Repository: ipsingh06/seedsync Branch: master Commit: ff2a1039935b Files: 337 Total size: 2.1 MB Directory structure: gitextract_tfp7f5p4/ ├── .github/ │ └── workflows/ │ └── master.yml ├── .gitignore ├── LICENSE.txt ├── Makefile ├── README.md ├── doc/ │ ├── CodingGuidelines.md │ ├── DeveloperReadme.md │ └── assets/ │ ├── logo.ai │ └── logo_with_name.ai └── src/ ├── angular/ │ ├── .angular-cli.json │ ├── .editorconfig │ ├── .gitignore │ ├── e2e/ │ │ ├── app.e2e-spec.ts │ │ ├── app.po.ts │ │ └── tsconfig.e2e.json │ ├── karma.conf.js │ ├── package.json │ ├── protractor.conf.js │ ├── src/ │ │ ├── app/ │ │ │ ├── app.module.ts │ │ │ ├── common/ │ │ │ │ ├── _common.scss │ │ │ │ ├── cached-reuse-strategy.ts │ │ │ │ ├── capitalize.pipe.ts │ │ │ │ ├── click-stop-propagation.directive.ts │ │ │ │ ├── eta.pipe.ts │ │ │ │ ├── file-size.pipe.ts │ │ │ │ ├── localization.ts │ │ │ │ └── storage-keys.ts │ │ │ ├── pages/ │ │ │ │ ├── about/ │ │ │ │ │ ├── about-page.component.html │ │ │ │ │ ├── about-page.component.scss │ │ │ │ │ └── about-page.component.ts │ │ │ │ ├── autoqueue/ │ │ │ │ │ ├── autoqueue-page.component.html │ │ │ │ │ ├── autoqueue-page.component.scss │ │ │ │ │ └── autoqueue-page.component.ts │ │ │ │ ├── files/ │ │ │ │ │ ├── file-list.component.html │ │ │ │ │ ├── file-list.component.scss │ │ │ │ │ ├── file-list.component.ts │ │ │ │ │ ├── file-options.component.html │ │ │ │ │ ├── file-options.component.scss │ │ │ │ │ ├── file-options.component.ts │ │ │ │ │ ├── file.component.html │ │ │ │ │ ├── file.component.scss │ │ │ │ │ ├── file.component.ts │ │ │ │ │ ├── files-page.component.html │ │ │ │ │ └── files-page.component.ts │ │ │ │ ├── logs/ │ │ │ │ │ ├── logs-page.component.html │ │ │ │ │ ├── logs-page.component.scss │ │ │ │ │ └── logs-page.component.ts │ │ │ │ ├── main/ │ │ │ │ │ ├── app.component.html │ │ │ │ │ ├── app.component.scss │ │ │ │ │ ├── app.component.ts │ │ │ │ │ ├── header.component.html │ │ │ │ │ ├── header.component.scss │ │ │ │ │ ├── header.component.ts │ │ │ │ │ ├── sidebar.component.html │ │ │ │ │ ├── sidebar.component.scss │ │ │ │ │ └── sidebar.component.ts │ │ │ │ └── settings/ │ │ │ │ ├── option.component.html │ │ │ │ ├── option.component.scss │ │ │ │ ├── option.component.ts │ │ │ │ ├── options-list.ts │ │ │ │ ├── settings-page.component.html │ │ │ │ ├── settings-page.component.scss │ │ │ │ └── settings-page.component.ts │ │ │ ├── routes.ts │ │ │ ├── services/ │ │ │ │ ├── autoqueue/ │ │ │ │ │ ├── autoqueue-pattern.ts │ │ │ │ │ └── autoqueue.service.ts │ │ │ │ ├── base/ │ │ │ │ │ ├── base-stream.service.ts │ │ │ │ │ ├── base-web.service.ts │ │ │ │ │ └── stream-service.registry.ts │ │ │ │ ├── files/ │ │ │ │ │ ├── mock-model-files.ts │ │ │ │ │ ├── model-file.service.ts │ │ │ │ │ ├── model-file.ts │ │ │ │ │ ├── screenshot-model-files.ts │ │ │ │ │ ├── view-file-filter.service.ts │ │ │ │ │ ├── view-file-options.service.ts │ │ │ │ │ ├── view-file-options.ts │ │ │ │ │ ├── view-file-sort.service.ts │ │ │ │ │ ├── view-file.service.ts │ │ │ │ │ └── view-file.ts │ │ │ │ ├── logs/ │ │ │ │ │ ├── log-record.ts │ │ │ │ │ └── log.service.ts │ │ │ │ ├── server/ │ │ │ │ │ ├── server-command.service.ts │ │ │ │ │ ├── server-status.service.ts │ │ │ │ │ └── server-status.ts │ │ │ │ ├── settings/ │ │ │ │ │ ├── config.service.ts │ │ │ │ │ └── config.ts │ │ │ │ └── utils/ │ │ │ │ ├── connected.service.ts │ │ │ │ ├── dom.service.ts │ │ │ │ ├── logger.service.ts │ │ │ │ ├── notification.service.ts │ │ │ │ ├── notification.ts │ │ │ │ ├── rest.service.ts │ │ │ │ └── version-check.service.ts │ │ │ └── tests/ │ │ │ ├── mocks/ │ │ │ │ ├── mock-event-source.ts │ │ │ │ ├── mock-model-file.service.ts │ │ │ │ ├── mock-rest.service.ts │ │ │ │ ├── mock-storage.service.ts │ │ │ │ ├── mock-stream-service.registry.ts │ │ │ │ ├── mock-view-file-options.service.ts │ │ │ │ └── mock-view-file.service.ts │ │ │ └── unittests/ │ │ │ └── services/ │ │ │ ├── autoqueue/ │ │ │ │ └── autoqueue.service.spec.ts │ │ │ ├── base/ │ │ │ │ ├── base-stream.service.spec.ts │ │ │ │ ├── base-web.service.spec.ts │ │ │ │ └── stream-service.registry.spec.ts │ │ │ ├── files/ │ │ │ │ ├── model-file.service.spec.ts │ │ │ │ ├── model-file.spec.ts │ │ │ │ ├── view-file-filter.service.spec.ts │ │ │ │ ├── view-file-options.service.spec.ts │ │ │ │ ├── view-file-sort.service.spec.ts │ │ │ │ └── view-file.service.spec.ts │ │ │ ├── logs/ │ │ │ │ ├── log-record.spec.ts │ │ │ │ └── log.service.spec.ts │ │ │ ├── server/ │ │ │ │ ├── server-command.service.spec.ts │ │ │ │ ├── server-status.service.spec.ts │ │ │ │ └── server-status.spec.ts │ │ │ ├── settings/ │ │ │ │ ├── config.service.spec.ts │ │ │ │ └── config.spec.ts │ │ │ └── utils/ │ │ │ ├── connected.service.spec.ts │ │ │ ├── dom.service.spec.ts │ │ │ ├── notification.service.spec.ts │ │ │ ├── rest.service.spec.ts │ │ │ └── version-check.service.spec.ts │ │ ├── assets/ │ │ │ └── .gitkeep │ │ ├── environments/ │ │ │ ├── environment.prod.ts │ │ │ └── environment.ts │ │ ├── index.html │ │ ├── main.ts │ │ ├── polyfills.ts │ │ ├── styles.scss │ │ ├── test.ts │ │ ├── tsconfig.app.json │ │ ├── tsconfig.spec.json │ │ └── typings.d.ts │ ├── tsconfig.json │ └── tslint.json ├── debian/ │ ├── changelog │ ├── compat │ ├── config │ ├── control │ ├── postinst │ ├── postrm │ ├── rules │ ├── seedsync.service │ ├── source/ │ │ └── format │ └── templates ├── docker/ │ ├── build/ │ │ ├── deb/ │ │ │ ├── Dockerfile │ │ │ └── Dockerfile.dockerignore │ │ └── docker-image/ │ │ ├── Dockerfile │ │ ├── Dockerfile.dockerignore │ │ ├── run_as_user │ │ ├── scp │ │ ├── setup_default_config.sh │ │ └── ssh │ ├── stage/ │ │ ├── deb/ │ │ │ ├── Dockerfile │ │ │ ├── compose-ubu1604.yml │ │ │ ├── compose-ubu1804.yml │ │ │ ├── compose-ubu2004.yml │ │ │ ├── compose.yml │ │ │ ├── entrypoint.sh │ │ │ ├── expect_seedsync.exp │ │ │ ├── id_rsa │ │ │ ├── id_rsa.pub │ │ │ ├── install_seedsync.sh │ │ │ └── ubuntu-systemd/ │ │ │ ├── ubuntu-16.04-systemd/ │ │ │ │ ├── Dockerfile │ │ │ │ └── setup │ │ │ ├── ubuntu-18.04-systemd/ │ │ │ │ ├── Dockerfile │ │ │ │ └── setup │ │ │ └── ubuntu-20.04-systemd/ │ │ │ ├── Dockerfile │ │ │ └── setup │ │ └── docker-image/ │ │ └── compose.yml │ ├── test/ │ │ ├── angular/ │ │ │ ├── Dockerfile │ │ │ └── compose.yml │ │ ├── e2e/ │ │ │ ├── Dockerfile │ │ │ ├── chrome/ │ │ │ │ └── Dockerfile │ │ │ ├── compose-dev.yml │ │ │ ├── compose.yml │ │ │ ├── configure/ │ │ │ │ ├── Dockerfile │ │ │ │ └── setup_seedsync.sh │ │ │ ├── parse_seedsync_status.py │ │ │ ├── remote/ │ │ │ │ ├── Dockerfile │ │ │ │ └── id_rsa.pub │ │ │ ├── run_tests.sh │ │ │ └── urls.ts │ │ └── python/ │ │ ├── Dockerfile │ │ ├── compose.yml │ │ └── entrypoint.sh │ └── wait-for-it.sh ├── e2e/ │ ├── .gitignore │ ├── README.md │ ├── conf.ts │ ├── package.json │ ├── tests/ │ │ ├── about.page.spec.ts │ │ ├── about.page.ts │ │ ├── app.spec.ts │ │ ├── app.ts │ │ ├── autoqueue.page.spec.ts │ │ ├── autoqueue.page.ts │ │ ├── dashboard.page.spec.ts │ │ ├── dashboard.page.ts │ │ ├── settings.page.spec.ts │ │ └── settings.page.ts │ ├── tsconfig.json │ └── urls.ts ├── pyinstaller_hooks/ │ └── hook-patoolib.py └── python/ ├── __init__.py ├── common/ │ ├── __init__.py │ ├── app_process.py │ ├── config.py │ ├── constants.py │ ├── context.py │ ├── error.py │ ├── job.py │ ├── localization.py │ ├── multiprocessing_logger.py │ ├── persist.py │ ├── status.py │ └── types.py ├── controller/ │ ├── __init__.py │ ├── auto_queue.py │ ├── controller.py │ ├── controller_job.py │ ├── controller_persist.py │ ├── delete/ │ │ ├── __init__.py │ │ └── delete_process.py │ ├── extract/ │ │ ├── __init__.py │ │ ├── dispatch.py │ │ ├── extract.py │ │ └── extract_process.py │ ├── model_builder.py │ └── scan/ │ ├── __init__.py │ ├── active_scanner.py │ ├── local_scanner.py │ ├── remote_scanner.py │ └── scanner_process.py ├── docs/ │ ├── faq.md │ ├── index.md │ ├── install.md │ └── usage.md ├── lftp/ │ ├── __init__.py │ ├── job_status.py │ ├── job_status_parser.py │ └── lftp.py ├── mkdocs.yml ├── model/ │ ├── __init__.py │ ├── diff.py │ ├── file.py │ └── model.py ├── pyproject.toml ├── scan_fs.py ├── seedsync.py ├── ssh/ │ ├── __init__.py │ └── sshcp.py ├── system/ │ ├── __init__.py │ ├── file.py │ └── scanner.py ├── tests/ │ ├── __init__.py │ ├── integration/ │ │ ├── __init__.py │ │ ├── test_controller/ │ │ │ ├── __init__.py │ │ │ ├── test_controller.py │ │ │ └── test_extract/ │ │ │ ├── __init__.py │ │ │ └── test_extract.py │ │ ├── test_lftp/ │ │ │ ├── __init__.py │ │ │ └── test_lftp.py │ │ └── test_web/ │ │ ├── __init__.py │ │ ├── test_handler/ │ │ │ ├── __init__.py │ │ │ ├── test_auto_queue.py │ │ │ ├── test_config.py │ │ │ ├── test_controller.py │ │ │ ├── test_server.py │ │ │ ├── test_status.py │ │ │ ├── test_stream_log.py │ │ │ ├── test_stream_model.py │ │ │ └── test_stream_status.py │ │ └── test_web_app.py │ ├── unittests/ │ │ ├── __init__.py │ │ ├── test_common/ │ │ │ ├── __init__.py │ │ │ ├── test_app_process.py │ │ │ ├── test_config.py │ │ │ ├── test_job.py │ │ │ ├── test_multiprocessing_logger.py │ │ │ ├── test_persist.py │ │ │ └── test_status.py │ │ ├── test_controller/ │ │ │ ├── __init__.py │ │ │ ├── test_auto_queue.py │ │ │ ├── test_controller_persist.py │ │ │ ├── test_extract/ │ │ │ │ ├── __init__.py │ │ │ │ ├── test_dispatch.py │ │ │ │ └── test_extract_process.py │ │ │ ├── test_model_builder.py │ │ │ └── test_scan/ │ │ │ ├── __init__.py │ │ │ ├── test_remote_scanner.py │ │ │ └── test_scanner_process.py │ │ ├── test_lftp/ │ │ │ ├── __init__.py │ │ │ ├── test_job_status.py │ │ │ ├── test_job_status_parser.py │ │ │ └── test_lftp.py │ │ ├── test_model/ │ │ │ ├── __init__.py │ │ │ ├── test_diff.py │ │ │ ├── test_file.py │ │ │ └── test_model.py │ │ ├── test_seedsync.py │ │ ├── test_ssh/ │ │ │ ├── __init__.py │ │ │ └── test_sshcp.py │ │ ├── test_system/ │ │ │ ├── __init__.py │ │ │ ├── test_file.py │ │ │ └── test_scanner.py │ │ └── test_web/ │ │ ├── __init__.py │ │ ├── test_handler/ │ │ │ └── test_stream_log.py │ │ └── test_serialize/ │ │ ├── __init__.py │ │ ├── test_serialize.py │ │ ├── test_serialize_auto_queue.py │ │ ├── test_serialize_config.py │ │ ├── test_serialize_log_record.py │ │ ├── test_serialize_model.py │ │ └── test_serialize_status.py │ └── utils.py └── web/ ├── __init__.py ├── handler/ │ ├── __init__.py │ ├── auto_queue.py │ ├── config.py │ ├── controller.py │ ├── server.py │ ├── status.py │ ├── stream_log.py │ ├── stream_model.py │ └── stream_status.py ├── serialize/ │ ├── __init__.py │ ├── serialize.py │ ├── serialize_auto_queue.py │ ├── serialize_config.py │ ├── serialize_log_record.py │ ├── serialize_model.py │ └── serialize_status.py ├── utils.py ├── web_app.py ├── web_app_builder.py └── web_app_job.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/master.yml ================================================ name: CI on: push: branches: [ master ] tags: - v[0-9]+.[0-9]+.[0-9]+ pull_request: branches: [ master ] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: jobs: unittests-python: name: Python unit tests runs-on: ubuntu-latest steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - name: Checkout uses: actions/checkout@v2 # Build package - name: Run Python unit tests run: make run-tests-python unittests-angular: name: Angular unit tests runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v2 # Build package - name: Run Angular unit tests run: make run-tests-angular build-deb: name: Build Deb runs-on: ubuntu-latest needs: [ unittests-python, unittests-angular ] steps: - name: Checkout uses: actions/checkout@v2 # Sets up build environment - name: Set up QEMU uses: docker/setup-qemu-action@v1 - name: Set up Docker Buildx id: buildx uses: docker/setup-buildx-action@v1 with: driver-opts: | image=moby/buildkit:master network=host - name: Show buildx builder instance name run: echo ${{ steps.buildx.outputs.name }} - name: Show buildx available platforms run: echo ${{ steps.buildx.outputs.platforms }} # Build package - name: Build deb package run: make deb # Upload package - name: Publish artifact uses: actions/upload-artifact@v2 with: path: ${{ github.workspace }}/build/*.deb name: deb-${{ github.run_number }} # Post build steps - name: List built packages if: ${{ success() }} run: ls -l $GITHUB_WORKSPACE/build/ build-docker-image: name: Build Docker Image runs-on: ubuntu-latest needs: [ unittests-python, unittests-angular ] steps: - name: Checkout uses: actions/checkout@v2 # Sets up build environment - name: Set up QEMU uses: docker/setup-qemu-action@v1 - name: Set up Docker Buildx id: buildx uses: docker/setup-buildx-action@v1 with: driver-opts: | image=moby/buildkit:master network=host - name: Show buildx builder instance name run: echo ${{ steps.buildx.outputs.name }} - name: Show buildx available platforms run: echo ${{ steps.buildx.outputs.platforms }} # Login using a PAT with write:packages scope - name: Log into GitHub Container Registry run: echo "${{ secrets.CR_PAT }}" | docker login https://ghcr.io -u ${{ github.actor }} --password-stdin # Set staging registry - name: Set staging registry env variable run: echo "staging_registry=ghcr.io/${{ github.repository }}" >> $GITHUB_ENV # Build docker image - name: Build docker image run: make docker-image STAGING_REGISTRY=${{ env.staging_registry }} STAGING_VERSION=${{ github.run_number }} e2etests-deb: name: End-to-end tests on Deb runs-on: ubuntu-latest needs: [ build-deb ] strategy: matrix: oscode: [ ubu1604, ubu1804, ubu2004 ] steps: - name: Checkout uses: actions/checkout@v2 - name: Download deb package artifact uses: actions/download-artifact@v2 with: name: deb-${{ github.run_number }} path: build/ # Run e2e test - name: Run e2e test run: make run-tests-e2e SEEDSYNC_DEB=`readlink -f build/*.deb` SEEDSYNC_OS=${{ matrix.oscode }} e2etests-docker-image: name: End-to-end tests on Docker Image runs-on: ubuntu-latest needs: [ build-docker-image ] strategy: matrix: arch: [ amd64, arm64, arm/v7 ] steps: - name: Checkout uses: actions/checkout@v2 # Sets up build environment - name: Enable Docker experimental features run: | echo $'{\n "experimental": true\n}' | sudo tee /etc/docker/daemon.json sudo service docker restart docker version - name: Set up QEMU uses: docker/setup-qemu-action@v1 - name: Set up Docker Buildx id: buildx uses: docker/setup-buildx-action@v1 with: driver-opts: | image=moby/buildkit:master network=host - name: Show buildx builder instance name run: echo ${{ steps.buildx.outputs.name }} - name: Show buildx available platforms run: echo ${{ steps.buildx.outputs.platforms }} # Login using a PAT with write:packages scope - name: Log into GitHub Container Registry run: echo "${{ secrets.CR_PAT }}" | docker login https://ghcr.io -u ${{ github.actor }} --password-stdin # Set staging registry - name: Set staging registry env variable run: echo "staging_registry=ghcr.io/${{ github.repository }}" >> $GITHUB_ENV # Run e2e test - name: Run e2e test run: make run-tests-e2e \ STAGING_REGISTRY=${{ env.staging_registry }} \ STAGING_VERSION=${{ github.run_number }} \ SEEDSYNC_ARCH=${{ matrix.arch }} publish-docker-image: name: Publish Docker Image if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest needs: [ e2etests-deb, e2etests-docker-image ] steps: - name: Set release env variable run: echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV - name: Checkout uses: actions/checkout@v2 # Sets up build environment - name: Set up QEMU uses: docker/setup-qemu-action@v1 - name: Set up Docker Buildx id: buildx uses: docker/setup-buildx-action@v1 with: driver-opts: | image=moby/buildkit:master network=host - name: Show buildx builder instance name run: echo ${{ steps.buildx.outputs.name }} - name: Show buildx available platforms run: echo ${{ steps.buildx.outputs.platforms }} # Login to GHCR using a PAT with write:packages scope - name: Log into GitHub Container Registry run: echo "${{ secrets.CR_PAT }}" | docker login https://ghcr.io -u ${{ github.actor }} --password-stdin # Login to Dockerhub - name: Log into Dockerhub registry run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin # Set staging registry - name: Set staging registry env variable run: echo "staging_registry=ghcr.io/${{ github.repository }}" >> $GITHUB_ENV # Push image to dockerhub - name: Push to Dockerhub (tag vX.X.X) run: make docker-image-release \ STAGING_REGISTRY=${{ env.staging_registry }} \ STAGING_VERSION=${{ github.run_number }} \ RELEASE_REGISTRY=docker.io/ipsingh06 \ RELEASE_VERSION=${{ env.RELEASE_VERSION }} - name: Push to Dockerhub (tag latest) run: make docker-image-release \ STAGING_REGISTRY=${{ env.staging_registry }} \ STAGING_VERSION=${{ github.run_number }} \ RELEASE_REGISTRY=docker.io/ipsingh06 \ RELEASE_VERSION=latest publish-deb: name: Publish Deb package if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest needs: [ e2etests-deb, e2etests-docker-image ] steps: - name: Set release env variable run: echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV - name: Download deb package artifact uses: actions/download-artifact@v2 with: name: deb-${{ github.run_number }} path: build/ - name: Set deb file path and name env variable run: | echo "DEB_PATH=$(readlink -f ./build/*.deb)" >> $GITHUB_ENV echo "DEB_NAME=$(basename $(readlink -f ./build/*.deb))" >> $GITHUB_ENV - name: Create Release id: create_release uses: actions/create-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ github.ref }} release_name: Release ${{ github.ref }} body: ${{ github.event.head_commit.message }} draft: false prerelease: false - name: Upload Release Asset id: upload-release-asset uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: ${{ env.DEB_PATH }} asset_name: ${{ env.DEB_NAME }} asset_content_type: application/x-deb ================================================ FILE: .gitignore ================================================ .idea *.pyc /build .venv package-lock.json src/python/build src/python/site ================================================ FILE: LICENSE.txt ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: Makefile ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. # Catch sigterms # See: https://stackoverflow.com/a/52159940 export SHELL:=/bin/bash export SHELLOPTS:=$(if $(SHELLOPTS),$(SHELLOPTS):)pipefail:errexit .ONESHELL: # Color outputs red=`tput setaf 1` green=`tput setaf 2` reset=`tput sgr0` ROOTDIR:=$(shell realpath .) SOURCEDIR:=$(shell realpath ./src) BUILDDIR:=$(shell realpath ./build) DEFAULT_STAGING_REGISTRY:=localhost:5000 #DOCKER_BUILDKIT_FLAGS=BUILDKIT_PROGRESS=plain DOCKER=${DOCKER_BUILDKIT_FLAGS} DOCKER_BUILDKIT=1 docker DOCKER_COMPOSE=${DOCKER_BUILDKIT_FLAGS} COMPOSE_DOCKER_CLI_BUILD=1 DOCKER_BUILDKIT=1 docker-compose .PHONY: builddir deb docker-image clean all: deb docker-image builddir: mkdir -p ${BUILDDIR} scanfs: builddir $(DOCKER) build \ -f ${SOURCEDIR}/docker/build/deb/Dockerfile \ --target seedsync_build_scanfs_export \ --output ${BUILDDIR} \ ${ROOTDIR} deb: builddir $(DOCKER) build \ -f ${SOURCEDIR}/docker/build/deb/Dockerfile \ --target seedsync_build_deb_export \ --output ${BUILDDIR} \ ${ROOTDIR} docker-buildx: $(DOCKER) run --rm --privileged multiarch/qemu-user-static --reset -p yes docker-image: docker-buildx @if [[ -z "${STAGING_REGISTRY}" ]] ; then \ export STAGING_REGISTRY="${DEFAULT_STAGING_REGISTRY}"; \ fi; echo "${green}STAGING_REGISTRY=$${STAGING_REGISTRY}${reset}"; @if [[ -z "${STAGING_VERSION}" ]] ; then \ export STAGING_VERSION="latest"; \ fi; echo "${green}STAGING_VERSION=$${STAGING_VERSION}${reset}"; # scanfs image $(DOCKER) buildx build \ -f ${SOURCEDIR}/docker/build/deb/Dockerfile \ --target seedsync_build_scanfs_export \ --tag $${STAGING_REGISTRY}/seedsync/build/scanfs/export:$${STAGING_VERSION} \ --cache-to=type=registry,ref=$${STAGING_REGISTRY}/seedsync/build/scanfs/export:cache,mode=max \ --cache-from=type=registry,ref=$${STAGING_REGISTRY}/seedsync/build/scanfs/export:cache \ --push \ ${ROOTDIR} # angular html export $(DOCKER) buildx build \ -f ${SOURCEDIR}/docker/build/deb/Dockerfile \ --target seedsync_build_angular_export \ --tag $${STAGING_REGISTRY}/seedsync/build/angular/export:$${STAGING_VERSION} \ --cache-to=type=registry,ref=$${STAGING_REGISTRY}/seedsync/build/angular/export:cache,mode=max \ --cache-from=type=registry,ref=$${STAGING_REGISTRY}/seedsync/build/angular/export:cache \ --push \ ${ROOTDIR} # final image $(DOCKER) buildx build \ -f ${SOURCEDIR}/docker/build/docker-image/Dockerfile \ --target seedsync_run \ --build-arg STAGING_VERSION=$${STAGING_VERSION} \ --build-arg STAGING_REGISTRY=$${STAGING_REGISTRY} \ --tag $${STAGING_REGISTRY}/seedsync:$${STAGING_VERSION} \ --cache-to=type=registry,ref=$${STAGING_REGISTRY}/seedsync:cache,mode=max \ --cache-from=type=registry,ref=$${STAGING_REGISTRY}/seedsync:cache \ --platform linux/amd64,linux/arm64,linux/arm/v7 \ --push \ ${ROOTDIR} docker-image-release: @if [[ -z "${STAGING_REGISTRY}" ]] ; then \ export STAGING_REGISTRY="${DEFAULT_STAGING_REGISTRY}"; \ fi; echo "${green}STAGING_REGISTRY=$${STAGING_REGISTRY}${reset}"; @if [[ -z "${STAGING_VERSION}" ]] ; then \ export STAGING_VERSION="latest"; \ fi; echo "${green}STAGING_VERSION=$${STAGING_VERSION}${reset}"; @if [[ -z "${RELEASE_REGISTRY}" ]] ; then \ echo "${red}ERROR: RELEASE_REGISTRY is required${reset}"; exit 1; \ fi @if [[ -z "${RELEASE_VERSION}" ]] ; then \ echo "${red}ERROR: RELEASE_VERSION is required${reset}"; exit 1; \ fi echo "${green}RELEASE_REGISTRY=${RELEASE_REGISTRY}${reset}" echo "${green}RELEASE_VERSION=${RELEASE_VERSION}${reset}" # final image $(DOCKER) buildx build \ -f ${SOURCEDIR}/docker/build/docker-image/Dockerfile \ --target seedsync_run \ --build-arg STAGING_VERSION=$${STAGING_VERSION} \ --build-arg STAGING_REGISTRY=$${STAGING_REGISTRY} \ --tag ${RELEASE_REGISTRY}/seedsync:${RELEASE_VERSION} \ --cache-from=type=registry,ref=$${STAGING_REGISTRY}/seedsync:cache \ --platform linux/amd64,linux/arm64,linux/arm/v7 \ --push \ ${ROOTDIR} tests-python: # python run $(DOCKER) build \ -f ${SOURCEDIR}/docker/build/docker-image/Dockerfile \ --target seedsync_run_python_devenv \ --tag seedsync/run/python/devenv \ ${ROOTDIR} # python tests $(DOCKER_COMPOSE) \ -f ${SOURCEDIR}/docker/test/python/compose.yml \ build run-tests-python: tests-python $(DOCKER_COMPOSE) \ -f ${SOURCEDIR}/docker/test/python/compose.yml \ up --force-recreate --exit-code-from tests tests-angular: # angular build $(DOCKER) build \ -f ${SOURCEDIR}/docker/build/deb/Dockerfile \ --target seedsync_build_angular_env \ --tag seedsync/build/angular/env \ ${ROOTDIR} # angular tests $(DOCKER_COMPOSE) \ -f ${SOURCEDIR}/docker/test/angular/compose.yml \ build run-tests-angular: tests-angular $(DOCKER_COMPOSE) \ -f ${SOURCEDIR}/docker/test/angular/compose.yml \ up --force-recreate --exit-code-from tests tests-e2e-deps: # deb pre-reqs $(DOCKER) build \ ${SOURCEDIR}/docker/stage/deb/ubuntu-systemd/ubuntu-16.04-systemd \ -t ubuntu-systemd:16.04 $(DOCKER) build \ ${SOURCEDIR}/docker/stage/deb/ubuntu-systemd/ubuntu-18.04-systemd \ -t ubuntu-systemd:18.04 $(DOCKER) build \ ${SOURCEDIR}/docker/stage/deb/ubuntu-systemd/ubuntu-20.04-systemd \ -t ubuntu-systemd:20.04 # Setup docker for the systemd container # See: https://github.com/solita/docker-systemd $(DOCKER) run --rm --privileged -v /:/host solita/ubuntu-systemd setup run-tests-e2e: tests-e2e-deps # Check our settings @if [[ -z "${STAGING_VERSION}" ]] && [[ -z "${SEEDSYNC_DEB}" ]]; then \ echo "${red}ERROR: One of STAGING_VERSION or SEEDSYNC_DEB must be set${reset}"; exit 1; \ elif [[ ! -z "${STAGING_VERSION}" ]] && [[ ! -z "${SEEDSYNC_DEB}" ]]; then \ echo "${red}ERROR: Only one of STAGING_VERSION or SEEDSYNC_DEB must be set${reset}"; exit 1; \ fi # Set up environment for deb @if [[ ! -z "${SEEDSYNC_DEB}" ]] ; then \ if [[ -z "${SEEDSYNC_OS}" ]] ; then \ echo "${red}ERROR: SEEDSYNC_OS is required for DEB e2e test${reset}"; \ echo "${red}Options include: ubu1604, ubu1804, ubu2004${reset}"; exit 1; \ fi fi # Set up environment for image @if [[ ! -z "${STAGING_VERSION}" ]] ; then \ if [[ -z "${SEEDSYNC_ARCH}" ]] ; then \ echo "${red}ERROR: SEEDSYNC_ARCH is required for docker image e2e test${reset}"; \ echo "${red}Options include: amd64, arm64, arm/v7${reset}"; exit 1; \ fi if [[ -z "${STAGING_REGISTRY}" ]] ; then \ export STAGING_REGISTRY="${DEFAULT_STAGING_REGISTRY}"; \ fi; echo "${green}STAGING_REGISTRY=$${STAGING_REGISTRY}${reset}"; # Removing and pulling is the only way to select the arch from a multi-arch image :( $(DOCKER) rmi -f $${STAGING_REGISTRY}/seedsync:$${STAGING_VERSION} $(DOCKER) pull $${STAGING_REGISTRY}/seedsync:$${STAGING_VERSION} --platform linux/$${SEEDSYNC_ARCH} fi # Set the flags COMPOSE_FLAGS="-f ${SOURCEDIR}/docker/test/e2e/compose.yml " COMPOSE_RUN_FLAGS="" if [[ ! -z "${SEEDSYNC_DEB}" ]] ; then COMPOSE_FLAGS+="-f ${SOURCEDIR}/docker/stage/deb/compose.yml " COMPOSE_FLAGS+="-f ${SOURCEDIR}/docker/stage/deb/compose-${SEEDSYNC_OS}.yml " fi if [[ ! -z "${STAGING_VERSION}" ]] ; then \ COMPOSE_FLAGS+="-f ${SOURCEDIR}/docker/stage/docker-image/compose.yml " fi if [[ "${DEV}" = "1" ]] ; then COMPOSE_FLAGS+="-f ${SOURCEDIR}/docker/test/e2e/compose-dev.yml " else \ COMPOSE_RUN_FLAGS+="-d" fi echo "${green}COMPOSE_FLAGS=$${COMPOSE_FLAGS}${reset}" # Set up Ctrl-C handler function tearDown { $(DOCKER_COMPOSE) \ $${COMPOSE_FLAGS} \ stop } trap tearDown EXIT # Build the test echo "${green}Building the tests${reset}" $(DOCKER_COMPOSE) \ $${COMPOSE_FLAGS} \ build # This suppresses the docker-compose error that image has changed $(DOCKER_COMPOSE) \ $${COMPOSE_FLAGS} \ rm -f myapp # Run the test echo "${green}Running the tests${reset}" $(DOCKER_COMPOSE) \ $${COMPOSE_FLAGS} \ up --force-recreate \ $${COMPOSE_RUN_FLAGS} if [[ "${DEV}" != "1" ]] ; then $(DOCKER) logs -f seedsync_test_e2e fi EXITCODE=`$(DOCKER) inspect seedsync_test_e2e | jq '.[].State.ExitCode'` if [[ "$${EXITCODE}" != "0" ]] ; then false fi run-remote-server: $(DOCKER) container rm -f seedsync_test_e2e_remote-dev $(DOCKER) run \ -it --init \ -p 1234:1234 \ --name seedsync_test_e2e_remote-dev \ seedsync/test/e2e/remote clean: rm -rf ${BUILDDIR} ================================================ FILE: README.md ================================================

SeedSync

Stars Downloads Version Size License

SeedSync is a tool to sync the files on a remote Linux server (like your seedbox, for example). It uses LFTP to transfer files fast! ## Features * Built on top of [LFTP](http://lftp.tech/), the fastest file transfer program ever * Web UI - track and control your transfers from anywhere * Automatically extract your files after sync * Auto-Queue - only sync the files you want based on pattern matching * Delete local and remote files easily * Fully open source! ## How it works Install SeedSync on a local machine. SeedSync will connect to your remote server and sync files to the local machine as they become available. You don't need to install anything on the remote server. All you need are the SSH credentials for the remote server. ## Supported Platforms * Linux * Raspberry Pi (v2, v3 and v4) * Windows (via Docker) * MacOS (via Docker) ## Installation and Usage Please refer to the [documentation](https://ipsingh06.github.io/seedsync/). ## Report an Issue Please report any issues on the [issues](../../issues) page. Please post the logs as well. The logs are available at: * Deb install: `/.seedsync/log/seedsync.log` * Docker: Run `docker logs ` ## Contribute Contributions to SeedSync are welcome! Please take a look at the [Developer Readme](doc/DeveloperReadme.md) for instructions on environment setup and the build process. ## License SeedSync is distributed under Apache License Version 2.0. See [License.txt](https://github.com/ipsingh06/seedsync/blob/master/LICENSE.txt) for more information. ![](https://user-images.githubusercontent.com/12875506/37031587-3a5df834-20f4-11e8-98a0-e42ee764f2ea.png) ================================================ FILE: doc/CodingGuidelines.md ================================================ # Coding Guidelines ## Python 1. Try not to throw exceptions in constructors. Delay exceptions until after the web service is up and running. This allows us to notify the user about the error. 2. Try to keep constructors short and passive. Try not to start any threads or processes in constructors. 3. Do not rely on timing constraints in tests. That is, don't use `time.sleep()` to wait for something to happen. Actually wait for the condition and use a watchdog timer to check for failure. ## Angular 1. Keep constructor of Immutable.Record blank. Any pre-processing that is needed to convert a JS object to Record should be put in a factory function. This ensures that the Record object can be easily constructed for tests without having to know the JS object translations. ================================================ FILE: doc/DeveloperReadme.md ================================================ [TOC] # Environment Setup ## Install dependencies 1. Install [nodejs](https://joshtronic.com/2019/04/29/how-to-install-node-v12-on-debian-and-ubuntu/) (comes with npm) 2. Install [Poetry](https://python-poetry.org/docs/#installation): 3. Install docker and docker-compose: https://docs.docker.com/engine/installation/linux/docker-ce/ubuntu/#install-docker-ce https://docs.docker.com/compose/install/ 4. Install docker buildx 1. https://github.com/docker/buildx/issues/132#issuecomment-582218096 2. https://github.com/docker/buildx/issues/132#issuecomment-636041307 5. Build dependencies ```bash sudo apt-get install -y jq ``` 6. Install the rest: ```bash sudo apt-get install -y lftp python3-dev rar ``` ## Fetch code ```bash git clone git@gitlab.com:ipsingh06/seedsync.git cd seedsync ``` ## Setup Poetry project ```bash cd src/python poetry install ``` ## Setup angular node modules ```bash cd src/angular npm install ``` ## Setup end-to-end tests node modules ```bash cd src/e2e npm install ``` # Build 1. Set up docker buildx for multi-arch builds ```bash docker buildx create --name mybuilder --driver docker-container --driver-opt image=moby/buildkit:master,network=host docker buildx use mybuilder docker run --rm --privileged multiarch/qemu-user-static --reset -p yes docker buildx inspect --bootstrap # Make sure the following architectures are listed: linux/amd64, linux/arm64, linux/arm/v7 ``` 2. Multi-arch docker images can only be stored in a registry. Create local docker registry to store multi-arch images ```bash docker run -d -p 5000:5000 --restart=always --name registry registry:2 ``` 3. Run these commands inside the root directory. ```bash make clean make ``` 4. The .deb package will be generated inside `build` directory. The docker image will be pushed to the local registry as `seedsync:latest`. See if using: ```bash curl -X GET http://localhost:5000/v2/_catalog ``` To inspect the architectures of image: ```bash docker buildx imagetools inspect localhost:5000/seedsync:latest ``` To use a different registry during the build process, use `STAGING_REGISTRY=`. For example: ```bash make STAGING_REGISTRY=another-registry:5000 ``` To build a tag other than `latest`, use `STAGING_VERSION=`. For example: ```bash make STAGING_VERSION=0.0.1 ``` ## Python Dev Build and Run ### Build scanfs ```bash make scanfs ``` ### Run python ```bash cd src/python mkdir -p build/config poetry run python seedsync.py -c build/config --html ../angular/dist --scanfs build/scanfs ``` ## Angular Dev Build and Run ```bash cd src/angular node_modules/@angular/cli/bin/ng build node_modules/@angular/cli/bin/ng serve ``` Dev build will be served at [http://localhost:4200](http://localhost:4200) ## Documentation ### Preview documentation in browser ```bash cd src/python poetry run mkdocs serve ``` Preview will be served at [http://localhost:8000](http://localhost:8000) ### Deploy documentation ```bash poetry run mkdocs gh-deploy git push github gh-pages ``` # Setup dev environment ## PyCharm 1. Set project root to top-level `seedsync` directory 2. Switch interpreter to virtualenv 3. Mark src/python as 'Sources Root' 4. Add run configuration | Config | Value | | ----------- | ------------------------------------------------------------ | | Name | seedsync | | Script path | seedsync.py | | Parameters | -c ./build/config --html ../angular/dist --scanfs ./build/scanfs | # Run tests ## Manual ### Python Unit Tests Create a new user account for python tests, and add the current user to its authorized keys. Also add the test account to the current user group so it may access any files created by the current user. Note: the current user must have SSH keys already generated. ```bash sudo adduser -q --disabled-password --disabled-login --gecos 'seedsynctest' seedsynctest sudo bash -c "echo seedsynctest:seedsyncpass | chpasswd" sudo -u seedsynctest mkdir /home/seedsynctest/.ssh sudo -u seedsynctest chmod 700 /home/seedsynctest/.ssh cat ~/.ssh/id_rsa.pub | sudo -u seedsynctest tee /home/seedsynctest/.ssh/authorized_keys sudo -u seedsynctest chmod 664 /home/seedsynctest/.ssh/authorized_keys sudo usermod -a -G $USER seedsynctest ``` Run from PyCharm OR Run from terminal ```bash cd src/python poetry run pytest ``` ### Angular Unit Tests ```bash cd src/angular node_modules/@angular/cli/bin/ng test ``` ### E2E Tests [See here](../src/e2e/README.md) ## Docker-based Test Suite ```bash # Python tests make run-tests-python # Angular tests make run-tests-angular # E2E Tests # Docker image (arch=amd64,arm64,arm/v7) make run-tests-e2e STAGING_VERSION=latest SEEDSYNC_ARCH= # Debian package (os=ubu1604,ubu1804,ubu2004) make run-tests-e2e SEEDSYNC_DEB=`readlink -f build/*.deb` SEEDSYNC_OS= ``` By default images are pulled from `localhost:5000`. To test image from a registry other than the local, use `STAGING_REGISTRY=`. For example: ```bash make run-tests-e2e STAGING_VERSION=latest SEEDSYNC_ARCH=arm64 STAGING_REGISTRY=ipsingh06 ``` # Release ## Continuous Integration This method uses Github Action to post releases. 1. Do all of these in one change 1. Version update in `src/angular/package.json` 2. Version update and changelog in `src/debian/changelog`. Use command `LANG=C date -R` to get the date. 3. Update `src/e2e/tests/about.page.spec.ts` 4. Update Copyright date in `about-page.component.html` 2. Tag the commit as vX.X.X 3. Push tag to Github ## Manual Method This manual method is deprecated in favour of the Github Actions based CI. ### Checklist 1. Do all of these in one change 1. Version update in `src/angular/package.json` 2. Version update and changelog in `src/debian/changelog`. Use command `LANG=C date -R` to get the date. 3. Update `src/e2e/tests/about.page.spec.ts` 4. Update Copyright date in `about-page.component.html` 2. Tag the commit as vX.X.X 3. Deploy documentation to github 4. make clean && make 5. Run all tests 6. Upload deb file to github 7. Tag and upload image to Dockerhub (see below) ### Docker image upload to Dockerhub ```bash make docker-image-release RELEASE_VERSION= RELEASE_REGISTRY=ipsingh06 make docker-image-release RELEASE_VERSION=latest RELEASE_REGISTRY=ipsingh06 ``` # Development ## Remote Server Use the following command to run the docker image for the remote server for development testing. This is the same image used by the end-to-end tests. ```bash make run-remote-server ``` The connection parameters for the remote server are: | Option | Value | | -------------- | --------------------------------- | | Remote Address | localhost or host.docker.internal | | Remote Port | 1234 | | Username | remoteuser | | Pass | remotepass | | Remote Path | /home/remoteuser/files | ## Run Docker Image Use the following command to run the docker image locally: ```bash docker run --rm -p 8800:8800 localhost:5000/seedsync:latest ``` ================================================ FILE: doc/assets/logo.ai ================================================ %PDF-1.5 % 1 0 obj <>/OCGs[5 0 R 32 0 R 58 0 R 84 0 R 110 0 R 136 0 R 162 0 R 188 0 R 214 0 R 240 0 R]>>/Pages 3 0 R/Type/Catalog>> endobj 2 0 obj <>stream application/pdf Print 2017-12-20T16:27:36-08:00 2017-12-20T16:27:36-08:00 2017-12-20T15:03:31-08:00 Adobe Illustrator CS5 256 240 JPEG /9j/4AAQSkZJRgABAgEASABIAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAASAAAAAEA AQBIAAAAAQAB/+4ADkFkb2JlAGTAAAAAAf/bAIQABgQEBAUEBgUFBgkGBQYJCwgGBggLDAoKCwoK DBAMDAwMDAwQDA4PEA8ODBMTFBQTExwbGxscHx8fHx8fHx8fHwEHBwcNDA0YEBAYGhURFRofHx8f Hx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8f/8AAEQgA8AEAAwER AAIRAQMRAf/EAaIAAAAHAQEBAQEAAAAAAAAAAAQFAwIGAQAHCAkKCwEAAgIDAQEBAQEAAAAAAAAA AQACAwQFBgcICQoLEAACAQMDAgQCBgcDBAIGAnMBAgMRBAAFIRIxQVEGE2EicYEUMpGhBxWxQiPB UtHhMxZi8CRygvElQzRTkqKyY3PCNUQnk6OzNhdUZHTD0uIIJoMJChgZhJRFRqS0VtNVKBry4/PE 1OT0ZXWFlaW1xdXl9WZ2hpamtsbW5vY3R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo+Ck5SVlpeYmZ qbnJ2en5KjpKWmp6ipqqusra6voRAAICAQIDBQUEBQYECAMDbQEAAhEDBCESMUEFURNhIgZxgZEy obHwFMHR4SNCFVJicvEzJDRDghaSUyWiY7LCB3PSNeJEgxdUkwgJChgZJjZFGidkdFU38qOzwygp 0+PzhJSktMTU5PRldYWVpbXF1eX1RlZmdoaWprbG1ub2R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo +DlJWWl5iZmpucnZ6fkqOkpaanqKmqq6ytrq+v/aAAwDAQACEQMRAD8A9U4q7FXYq7FXYq7FXYq7 FXYq7FXYq7FXYq7FVC9v7GxgNxfXMVrANjNM6xoD/rMQMSUEgc2Faz+eP5a6XyU6qL2ZTT0rNHmr 8pABF/w+RMw4s9fhj/F8t2Gap/zlFo8bMNL0O4uRT4XuZUg3/wBVBP8AryPiOJPtaA+kEsWv/wDn JvzpLyWz0+wtVPRmWWVx9JdV/wCFwcZcaXa0+gASG7/Pr80bgELqqwKRQiK3tx9xZGYffg4i0y7S zHrXwSif81fzGnar+Yb0Gtf3cpjH3JxwWWs67Mf4kvn87+c5wBPr+oyhfs87udqV8KvjbV+Yyfzp fMoN9e1x2LvqN0zHcsZpCT9JOBHjz/nH5qDX98zFmuZSxNSS7Ekn6caY+LLvLX129/5aJP8Ag2/r jS+JLvKk7u7FnYsx6sxqfvOLEknmtxQ7FXYqr/Xbz/f8n/Bt/XGmfiS7yokkmp3J6nFg1ir3f8lf yW9b6v5n8zQfuNpdM02Qfb7rNMp/Z7qp69Tt1nGNu70Gg/jn8A+gctdy7FXYq7FXYq7FXYq7FXYq 7FXYq07qil3IVFBLMTQADqScVec+bfz68i6CXgtZm1i+Wo9GzoYgR/NOfg/4HlkDMODm7QxQ2uz5 PIPMv/ORHnrVS8emmLRrVqgCAepNQ9jLIDv7oq5AyLq8vamSX0+l5xqWrapqdwbnUrua8uD1lnka Rt/dici4E8kpG5G0Jiwdiq+OKWQ0jRnPgoJP4YoJRUei6tJ9mzl+ZQr+umNsTkj3ohPK+uNv9WoP d0H/ABtgtgc8e9WXyhrLdVjX5v8A0rjaPzEVRfJerEVMkA9izfwXHiR+ZivHknUqbzQ1+bf8048S PzI7nf4J1H/f8P3t/wA048S/mR3Lh5Ivab3EYPsGP8MeJfzI7m/8EXn/AC0x/c2PEv5kdzv8EXn/ AC0x/c2PEv5kdzv8EXn/AC0x/c2PEv5kdyC1Py1dafbNcSzRsgYKFXlyJPzGILOGYSNJPhbntH5A /ldY62z+Z9Zj9aytJvTsLRhVJZUALSOD9pUqKDoT16UM4xt2/ZukEvXL4PpDLXeuxV2KuxV2KuxV 2KuxV2KuxV2KvNfPv56+VvLRls7EjV9XSqmCFqQxsP8Afku4qD+ytT40yBm4Oo18Me3OT5884/md 5w82yMuqXpSyJqmn29Y7de4qgNXI8XJOVk26PPrMmXmdu5imBxVSGCaZwkMbSOeioCx+4YoJA5pz aeUNUmo03G3Q/wAxq33L/E4LaZaiI5JxbeS9OjoZ5JJ27jZFP0Cp/HBxNJ1EuiZwaLpMA/d2sdR0 LLyP3tU4LajkkeqMVVUBVAAHQDYYsG8VdirsVRFpY3V0xEKcgOrHYD6cabMeGU+QRUugajGvIKr+ ynf8aYabpaPIPNL2VlYqwKsNiDsRgcUiua3FXYq7FXYqxrzxKRbWsPZ3Zz/sBT/jbDFydMNyw/JO Y+0Pyv0lNK/L7QbRRQmzjnkH/FlwPWf/AIaQ5dHk9bpYcOOI8mUZJvdirsVdirsVdirsVdirsVSr zL5p0Ly1pj6jrN0ttbrsoO7yN/JGg3ZvlgJpryZYwFyNB81/mN+eev8Amcy2Gl89K0Q1BjRqTzL0 /euvQH+RdvEnKjK3Q6rtGU9o+mP2vMMi61EWdheXsnp20TSN3I6D5k7DFjKYjzZPp3kyFKPfyeo3 ++kqF+lup/DI8Tiz1B6Mit7W3to/TgjWJPBQB9+BxzInmqYodirsVdirsVdiqpBE00yRJ9pyFH04 soRMiAGZW1vHbwrDGKKop8z4nJu+hARFBVwskHqGmW94nxDjKB8Mg6j5+IwENObBGY35sWu7Sa1m MUooR0PYjxGQdNkxmBoqOLB2KuxVifnkn1bMdgrkfSVwxcvTcixbJOU+7NGjSPR7GNBREt4lUeAC ADrl45PZx5IzCl2KuxV2KuxV2KuxV2KsH/Mr819D8lWhial5rUq1ttOQ0Ir0eY/sJ+J7dyIylTia rVxxDfeXc+WPNXm7XvNOqPqWs3JnmO0UY2iiT+SNOir+vvU5US85nzyym5JOqszBVBLHYAbknA0s l0jyhJJxm1AmNOogH2j/AKx7fr+WAlxcmo6BldvbwW8QigjWONeiqKDIuKSTzVMUOxV2KuxV2Kux V2KuxVNfLsPO+MhG0Skj5nb+uGLmaKFzvuZNk3bOxV2Koa+sYbyExyCjDdHHVTgIasuITFFid3aT WsxilFCOh7EeIyDpcmMwNFRxYOxViXnn++tP9V/1jDFy9NyLF8k5T7u0r/jl2f8Axgj/AOIDLxye zHJFYUuxV2KuxV2KuxV2KvLfzc/Oe08qpJo+jlbnzE6jkxo0VqG7yeMlPsp9J8DCUnX6zXDF6RvL 7nzDfX97f3kt7ezvc3c7F5p5WLOzHuScqednMyNncusrG5vZxBboXc7nwA8Sewxa5SERZZvo3l61 05RI1JbrvKRsK9kHb55ElwcmUy9ybYGp2KuxV2KtgEmg3J6DFUbDouoyjkIuKnu5C/gd8aciGlyS 6NXOkX9uvN4+SDqy/FT50xpGTTTjuQgsWh2KuxVkHlmOkU8n8zBfuFf45KLs9BHYlO8k57sVdirs VYB5t85QvdJaWKrLHA1ZpzvyPQqh8Pf/ADNZLr9VMS2W2l3DdQiWI1U9R3B8DgdcRSthQxTzyo5W TdyJAfo44YuVpjzYrknLfc/luYz+XdLmLcjLZwOW8eUSmuXx5PZQNgJjhZOxV2KuxV2KuxV5F+c/ 5yL5dSXy/oMgbXZFpc3I3FqrCu3jKQdv5euQlJ1uu13h+mP1fc+Z5ZZZpXlldpJZCWkkclmZiakk nck5U88STuUXpWkXWpT+nEOMa/3kpHwqP6+2JLVkyCIZ7p2m2unwCG3Wnd3P2mPiTkLcCczI2UVi xdirsVdirsVZHoGnokAupFrLJ9iv7K/25IB2ujwgDiPMpxknNdiqUapoaTAzWwCS9WToG/ociQ4W o0glvHmxxlZWKsKMNiD1ByLqiKaxVk/l1eOn1/mdj+ofwyUXb6IVjTTJOW7FXYq8+85ecvW56bpr /ud1uLhT9vxRD/L4nv8ALrAlws+foGE5FxETYX81nN6ke6nZ0PRhixlG2W2l1DdQrNEaqeo7g+Bx aCKY/wCeErb2r+DsPvA/pkouRpuZYhknMfbP5f3AuPInl6YHkW0205EbfEIVDf8ADDL48nrtObxx PkE/wtzsVdirsVdirzn85PzQj8n6QLOwYNr9+p+rLsRDH0MzD8EHc+wyEpU4Wt1YxR2+ovlGeeae aSeeRpZpWLyyuSzMzGpZidyScqeZJJNlG6No0+p3HBTwhTeWXw9h7nAS1ZMgiGfWdnb2dutvbrxj X7ye5J8ci6+UiTZVsUOxV2KuxV2Kr4ozJKka9XYKPmTTFMY2QGbIioiouyqAoHsNsm9ABQpdhS7F XYqlWsaQLlTNCKXA6j+cf1yJDianTce4+pjRBBodiOoyLqGV6EKaXD78j/wxyQdzpP7sfjqj8k5L sVefecvOXrc9N01/3O63Fwp+34oh/l8T3+XWBLhZ8/QMJyLiOxV2Ko3StQazuATvC+0q+3j9GLGU bR3nQK+lQyLuBMtCOlCrZKK6f6mF5JzX2P8Ak9OZ/wAs9AckmluY9/8AiuRk/wCNcuhyer0ZvFH3 MxyTkuxV2KuxVKfNXmTT/LWgXmtX5/cWiFglaNI52SNfd2IGAmmvLkEImR5B8Y+ZfMWpeYtbutY1 KTndXT8iB9lFGyoo7Kq7DKXlM2U5JGRQ2madNqF2lvFtXd37Ko6k4C485iIt6HY2VvZWyW8C0RO/ cnuT7nIOulIk2VfFDsVdirsVdirsVRujx89SgHYEt/wIJxDfpReQMuyx3bsVdirsVdiqR67pXIG7 gHxDeVR3H8w/jkSHX6vT36h8Ufo3/HMg+R/WcIcnTf3YRuFvefecvOXrc9N01/3O63Fwp+34oh/l 8T3+XWBLhZ8/QMJyLiOxV2KuxV2KppqhaXynGzbmN1AJ8AxUfgcMUYvrYlk3MfW35CXHq/lbpK71 he5jJO9f9JkYfg1Muhyen7ON4Y/H73oWSc12KuxV2Kvmn/nIvzy2p6/H5atJK2OknldcTs90w6Ht +6U8fmWyqZ3dD2pqLlwDkOfvePAEmg3J6DIOpegeXtJXT7Icx/pMtGmPh4L9GQJdflycR8k0xanY q7FXYq7FXYq7FU08urXUCf5UY/iB/HDFzNCPX8GT5N2zsVdirsVdirsVWRRRwxiNBxQVoPCprgRG IAoMB85ecvW56bpr/ud1uLhT9vxRD/L4nv8ALrElw8+foGE5FxHYq7FXYq7FUZY6ZdXbjgpWL9qU jYD28cWMpAJv5gto4fLk0MY+GMJSvX+8WpwhjiPrDA8m576m/wCccJQ/5c8d/wB1ezoa+4Rtv+Cy 2HJ6Xsw/uR8XqWTc92KuxVJfOnmSHy15W1LWpaE2kJaFG6NM3wxL2+07AYJGg1ZsohAyPR8TXNzP dXMtzcOZJ53aSaRtyzueTMfck5Q8jKRJspx5S036zf8A1iQVitqMPdz9n7uuAlx886Fd7OMi4LsV dirsVdirsVdirsVTjy0B9blPf0/+Nhhi52g+o+5keTdo7FXYq7FXYq7FXn3nPzl63PTdNf8Ac/Zu blT9vxRD/L4nv8usCXCz5+gYfbWz3EgjRlDn7IY0r8si4ZKOHlzUSP2B7Fv7MWPGF6+Wb3vJGPpY /wAMUeIFVPK8n7dwB8lJ/iMUHIiI/LNoP7yV3+VFH6jig5EZBpGnQ7rCpPi/xfrxYmZRnTCxS7zG obRLsH+UH7mBxDZi+oPO8m7F9Pf84zSM3kC8U9I9TmVfl6EDfxy2HJ6Lso3i+P6nrWTdk7FXYq8K /wCcnvMpjtdK8txNQzMb66H+SlY4R8ixcn5DK5l1Ha2WoiHfu+fMrdE9E8vWP1PSoUIpJIPUk/1m 3/AUGQLrssrkmOLWirDT57yTjGKKPtuegxAbsOCWQ7J0vlqz4/FLIW7kcQPuoclwueNBDvKHuPLT gE28ob/JcUP3jBwtU9Af4SlFxbT278JkKN2r0PyOBwp45RNEKWLB2KuxVOvLP9/P/qj9eGLsNBzL Icm7J2KuxV2KuxV595y85etz03TX/c7rcXCn7fiiH+XxPf5dYEuFnz9AwnIuI3iqe6TrxHGC8bbo kx/42/ri1Sh3J9hanYq7FXYq7FUv8w/8cW7/ANT+IxDZi+oPOsm7F9N/84x/8oFf/wDbVl/6h7fL YcnoOyf7o/1v0B67k3aOxV2Kvj/86NcOr/mRrEgasNpILKEVrQW44PT5yBj9OUSO7zHaGTiyny2Y npFp9b1K3gIqruOY/wAlfib8BgLr8kqiS9JyDrV8UTyyrGgq7kBR7nFMYmRoMxs7SO1t1hTt9o+J 7nJgO+xYxCNBXws3YqsmhimQxyoHQ9QcDGURIUWP6joEkVZbWskfUx/tD5eORIdbn0ZG8dwk+BwX YqnXln+/n/1R+vDF2Gg5lkOTdk7FXYq7FXn3nLzl63PTdNf9zutxcKft+KIf5fE9/l1gS4WfP0DC ci4jsVdirsVTjSNaMFILg1h6K/df7MWuULZICCKjcHocWl2FXYq7FUv8w/8AHFu/9T+IxDZi+oPO sm7F9N/84x/8oFf/APbVl/6h7fLYcnoOyf7o/wBb9Aeu5N2jsVUL+8isrG5vZf7q2ieaT/VjUsfw GAlBNC3wpd3Mt1dTXUx5TTu0sjeLOSxP3nKHjpS4iSeqeeSrfnqEs5FRDHQHwZz/AEBwScXUnamZ 5Fwk48t2we5ecjaIUX/Wb+zDFztDC5GXcyPJu0dirsVdirsVSrVNFjuaywUSfqR0DfP3yJDiajSi e45sbkjeNyjqVddip6jIuplEg0U28tf71S/6n8Rhi5ug+o+5keTdo7FXYq8+85ecvW56bpr/ALnd bi4U/b8UQ/y+J7/LrAlws+foGE5FxHYq7FXYq7FXYqnmg6qVZbSY/AdomPY/y/0xapx6sgwtTsVd iqV+ZzTQrr5IPvdcIbMP1B59knYvqP8A5xti4fl5I1APVv5329kjXf8A4HLcfJ6TswfufiXquTdg 7FWJfmzftYflv5gnU0LWjQV/5iCIf+ZmRlycfVyrFI+T40yl5NmXkiHjZXE3d5Av0Itf+NsjJwtS dwyPA47J/LsfGwLd3cn7tv4ZKLt9FGoe9NMk5bsVdirsVdirsVQOp6XFeJUfBOo+F/H2PtgIcfPp xkHmlegxSQanJFKpVxGQR/slORDh6OJjkIPcyLJu0dirz7zl5y9bnpumv+53W4uFP2/FEP8AL4nv 8usCXCz5+gYTkXEdirsVdirsVdirsVb6bjFWZabcNcWMMzfaZaMfcGh/Vi40hRROFDsVSTzjJx0f j/vyRV/W3/GuEN+n+pguSc59cfkNafV/yu0ljs1w1xMw273DqP8AhVGXQ5PT9nxrDH8dXoGSc12K vNf+chrtoPyzu4gaC6uLeI+4Egl/5l5CfJwe0j+5Pw+98o5U8yz3ylHw0SJv9+M7f8MV/wCNciXA zn1JxgaWXaOvHTYB/kk/eScmHd6YVjCNwt7sVdirsVdirsVdiqmYYzMs1P3igqG9j2wMeEXfVUws nn3nLzl63PTdNf8Ac7rcXCn7fiiH+XxPf5dYEuFnz9AwnIuI7FXYq7FXYq7FXYq7FW+uwxVmlhAY LOGI/aVRy+Z3P44uNI2VfCh2KsY88TUhtYf5mZyP9UAD/iWGLlaYcyxHJOW+1/y7079HeRNBtP2k sYGf/XkQO/8AwzHLo8nrtPHhxxHkGQ5JudiryX/nJmRk8gWailJNThU/L0J2/wCNchPk63tU1i+P 63zDlTzr0Py4vHRLUf5JP3sTkC67L9RTLFrZnpwpYW//ABjQ/eBkw77D9A9yIwtjsVdirsVdirsV dirXTFXnvnHzobgvp2mPS33W4uF/b8VQ/wAvie/y6wJcLPnvYMLyLiOxV2KuxV2KuxV2KuxVVgtr iduMMbOfYbfScUEp7pegmGRZ7oguu6RjcA+JOLVKfcnWFrdirsVYN5wufV1f0wdoEVPpPxH/AIlk g52nFRQWhaZLqut2GmRCsl9cRW6/OVwv8cLl4ocUxHvL7oREjRUQBUQBVUdABsBmQ9g3irsVeM/8 5Ps3+FNJWp4m/qR2qIXp+vK5ur7W/ux/W/QXzblbz70fQgBo9oB/vpT9++QLrsv1FHYtbNbMAWkA HQRr/wARGTDv8X0j3K2Fm7FXYq7FXYq7FWiQBU9MVeeecvORui+naa/+jfZuLhf92eKqf5fE9/l1 gS4OfPewYZkXFdiqb6bb6Refu5FaK47ANs3+rWv3YsJEhMD5asD0eUfIr/FcWvxCtPlmzrtLIB7l f6Yp8Qtf4ZtP9+yf8L/TFfEXDy1YA/blPtVf+acUeIVRPL+mr1Rm+bH+FMV8QoiPS9Pj+zbp82HL /iVcUcRRQAUUAoB0AxYuwq7FXYq07qiM7GiqCWPgBirzK8uWubua4brK7NTwBOwybs4ihT0P/nH7 QTqn5i21y68oNKiku5K9OVPTj+nnIG+jJRG7s+zMfFlv+a+rsuejdirsVeM/85QA/wCFdIPb68f+ TL5XN1Xa392P636C+bcrdA9I0P8A45Fn/wAYl/VkC63L9RRuLBmtp/vLD/xjX9QyYd/j+ke5Wws3 Yq7FXYq7FWiQBU9MVeeecvORui+m6a/+jfZuLhf92eKqf5fE9/l1gS4OfPewYZkXFdirsVbBINRs R0OKp/pGucuNvdtRuiSnv7N/XFqlDuTzC1OxV2KuxV2KuxV2KuxV2KpL5sv/AKtphiU0luTwH+r+ 2fu2+nCG7BG5e5gmSc99Mf8AONflg2HlW61yZKTavNxhJG/1e3qoI/1pC/3DLIB6HsvDw4+L+c9g yx2bsVdiryP/AJybVj5BsSBULqkJb2H1ecfxyE+TrO1R+6H9b9b5jyp556J5dIOi2hH8lPuJGQLr sv1FMcWtmtp/vLD/AMY1/UMmHf4/pHuVsLN2KuxV2KtEgCp6Yq8885ecjdF9N01/9G+zcXC/7s8V U/y+J7/LrAlwc+e9gwzIuK7FXYq7FXYq7FU+0TWTVbW5bbpFIf8AiJ/hi1Th1CfYWp2KuxV2KuxV 2KuxV2KvPfMOp/X9Rd0NYI/gh9wOrfSckA7DFDhCzy/ot5rmt2WkWYrc30ywoT0Xkd2Pso3Pthcn FjM5CI6vtzRtKtNI0mz0uzXjbWUKQRDvxRQtT7nqffLwHroREQAOQRmFk7FXYq8s/wCckI+f5dBq 09O+gb5/C6/8bZDJydf2mP3J94fLWVPNvQfK7V0K29uY+52yBdfmHqKaYtTNLEk2VuT1MSV/4EZM O+w/QPcFfC2OxV2KtEgCp6Yq8885ecjdF9N01/8ARvs3Fwv+7PFVP8vie/y6wJcHPnvYMMyLiuxV 2KuxV2KuxV2KuxVk2hambiP6vKazRj4SerL/AFGLTONbptha3Yq7FXYq7FXYqx/zZrItrc2ULfv5 h+8I/ZQ/xbCA5GDHZssKyTmvoH/nG3yI0UU/m++io0oa30oMP2K0lmFfE/AD/reOWQHV3nZenocZ 68nu+WO4dirsVdirz38/Lf1fyt1V60ML20lAK1/0mNP+Nq5GfJwu0R+5l8PvfJOUvMM78oPy0ZR/ JI4/Gv8AHIlwNR9SdYGlmWmmthb/APGNf1ZMO9wfQPcicLa7FWiQBU9MVeeecvORui+m6a/+jfZu Lhf92eKqf5fE9/l1gS4OfPewYZkXFdirsVdirsVdirsVdirsVVbad7edJk+0hqPf2+nFBFs1ikWW JJF+y6hh8iK4uMQuwq7FXYq7FUv1rWIdNtubUad6iGPxPifYYgNmPGZF59cTzXEzzTMXlkNWY+OT dgBQpk/5beRL3zl5lh06MMljFSXUbkdI4QdwD/M/2V99+gOEC3L0mmOWddOr7GsbG0sLKCys4lgt LZFighT7KogooH0Ze9TGIAoK+KXYq7FXYqw784oDP+WevoATS3Em3/Fciv3/ANXIz5ONrBeKXufH GUvKM18lNXS5V7rMfuKrkZOFqR6mQYHHZfpJrpsB/wAmn3GmTDvNOf3YRmFuaJAFT0xV555y85G6 L6bpr/6N9m4uF/3Z4qp/l8T3+XWBLg5897BhmRcV2KuxV2KuxV2KuxV2KuxV2KuxVl+iljpcHLrQ /dyNMXHnzRuFi7FXYql+s6zb6bb8n+OZv7qLuT4n2xAbMeMyLAr29uL24a4uG5O33AdgB4ZNz4xE RQRPl/QNV1/V7fSdLhM95ctxRegAG7Mx7Ko3Jwt2LFLJLhjzfYP5e+RNN8meX49NtaSXL0kvrylG mlp19lXoo7D3rlsY09Tp9OMUeEMmyTe7FXYq7FXYqkH5g263PkTzDCRyLabdlRWnxLCxX/hgMEuT TqI3jkPIvibKHkWX+R3rb3SeDqfvB/pkZOHqeYZNgcZlmhtXS4fbkP8Ahjkw7rSH92EcSAKnphch 555y85G6L6bpr/6N9m4uF/3Z4qp/l8T3+XWBLg5897BhmRcV2KuxV2KuxV2KuxV2KuxV2KuxVfFF JNKsUY5O5ooxQzW2hEEEcI6RqFr4074uOTZVMKHYqlOt+YLfTkMaUluyPhj7L7t/TEBtx4jL3MFu bqe6naedy8r/AGmOTc6MQBQRmgeX9X1/VYdL0m3a5vJz8KLsAB1ZmOyqO5OFuxYpZJcMeb6z/LL8 stK8k6VwTjc6xcqPr9/Tr39OOu6xqfv6nsBbGNPTaXSxxRoc+pZpknKdirsVdirsVdiqW+ZovV8t 6tFxL+pZ3C8B1PKJhTbBLkxmNi+GcoeNZX5GP+9q1/32QP8AgsjJxNT0ZVgcVlHl9gdNFeisw3+/ +OSi7jRn92w7zl5yN0X03TX/ANG+zcXC/wC7PFVP8vie/wAuoJa8+e9gwzIuK7FXYq7FXYq7FXYq 7FXYq7FVaO0upP7uF2+Sk4oJRtv5f1CUjmoiXxY7/cK4sTMJ5p2k29kKr8cxFGkP6gO2LVKdo3Cx cSACSaAdTirGdb82JHyt9OId+jXHVR/qePzwgOTiwdSxJ3d2LuSzMasxNSSfHJOWAnXlDydrvmzV 00zSIDJIaGaZtooU7vI3Yfie2EC3IwaeWWVRfWH5e/lxofkrTPq9mPXv5gPruoOKSSkb0AqeCDso +mp3y2Maek02mjijQ597LMk5LsVdirsVdirsVdiqF1X/AI5d5/xgk/4gcB5IPJ8I5Q8YyjyN/fXf +qn6zkZOLqeQZbgcRq8kvZtNbT4J/q8UjFpmC1ZgQBxrUUG2/jjbfDOYx4QkX+Fv+Xr/AIT/AJuw MfEXDyuve5JPslP44r4jv8Lx/wDLQf8AgR/XFfEXf4Yg/wB/t9wxXxF3+GLT/fsn/C/0xR4i4eWr Cv25T7VX/mnFHiFePLunDrzPzb+gxXxCvXQNLHWIn5s38DiviFUXRtMXpAPpJP6zijjKounWC9Le P58Qf14o4irJFEn2EVfkAP1Yra7Ch2KuxVA6lrVhp6n15KyU+GFd3P0dvpxAZwxmXJhur+Yr3USU r6Nt2hU9f9Y98kA5mPCI+9KsLczv8t/yk8wec51nANloiNSfUZB9qnVYVP22/Ad/DJCNubpdDLLv yj+OT6l8reUtC8r6Wmm6PbCCEUMjneSV6U5yP+03+Yy0Cno8WKOMVEJxhbHYq7FXYq7FXYq7FXYq l3mO4+reXtUuK09G0nkqdx8MTHt8sEuTGZoF8MZQ8ayryMu963h6YH/DZGTiak8mV4HFdirsVdir sVdirsVdirsVdirsVdirsVdiqXX3mHSrOoeYSSD/AHXH8TfhsPpONNkcUixrUfN9/cVS2H1aI/tD dz/su30ZIByYacDnukTMzsWYlmO5YmpJwt6tYaffajdx2dhbyXV3MeMUEKl3Y+yjfFnCBkaAsveP y5/5x1SMxan5yId9mj0iNqqD/wAXyL1/1V28T2ywQ73daXswDfJ8nusEEFvDHBBGsUEShIokAVFV RQKqjYAZY7gBfirsVdirsVdirsVdirsVdirCPzo1uPSfy31hy3GW8j+pQr3Y3B4MP+RfI/RkZnZx dbPhxS91fN8fZS8qzPyVBxsJpiP7ySg+SgfxJyMnC1J3ZFgcd2KuxV2KuxV2KuxV2KuxVbJLFGKy OqDxYgfrxSAShJdc0iL7d3F8lYMfuWuNMhjkeiAn846RH/d+pMe3FaD/AIan6sNNg08krufO1021 tbpH/lOS5/Djh4W2OmHUpPd6zqd3UT3Dsh6oDxX/AIFaDDTdHHEcggsWarb21xczpBbRPNPIeMcU al3YnsqipOKYxJNB6t5K/wCcdvM+rmO519/0NYH4vSID3TDw9PpH/szUfy5MQJdng7LnLefpH2vf fKPkLyv5StfR0azWKRhxmu3+OeTv8ch3pXsKD2ywRAd1h08MYqIZBhbnYq7FXYq7FXYq7FXYq7FX YqoX9/ZafZTXt7MlvaW6GSaaQ0VVHUk4olIAWXyh+b35oy+ddUSC0Uw6FYM31ONtnlc7GZx2qPsj sPmcplK3m9drPFND6Q89yLgI621rVLaFYYJzHEteKgL3NT1GNMDjidyFX/Emt/8ALU33L/TBTHwY 9zv8Sa3/AMtTfcv9MaXwY9za+ZtcU1F0fpVD+tcaXwY9y7/FOu/8tX/CR/8ANONL4EO5Z/iTW/8A lqb7l/pjS+DHud/iTW/+WpvuX+mNL4Me5Yde1k1/0uTf3pjSfCj3KZ1jVj1vJvokYfqOGk+HHuUX vLuTZ55H/wBZmP6zinhCkSTueuLJrFXYquRHkcIil3Y0VVFST7AYpAJ5Mv0D8ovzD1sq1ro80EDA H6xdj6snE9x6nFmH+qDhALlY9Dln0r3vT/LP/OMMKlZfMuqGQ9WtLAcV6d5pBX7kHzyYg7HF2SB9 Zv3PXfLfknyr5ai9PRdNhtGIo04HKZh/lSvyc/KuTEQHZ4sEMY9IpO8La7FXYq7FXYq7FXYq7FXY q7FXYq7FXgX5uWn5t+cb1rCx0K5t/Lts/wC5h5xBp2XpLLR/+BXt88qlZdPrY58p4Yx9Hw3ecf8A KmvzO/6sE/8AwcX/ADXkeEuv/k/N/N+53/KmvzO/6sE//Bxf8148JX+T83837nf8qa/M7/qwT/8A Bxf8148JX+T83837nf8AKmvzO/6sE/8AwcX/ADXjwlf5Pzfzfud/ypr8zv8AqwT/APBxf8148JX+ T83837lkv5P/AJlxLyby/ckE0+Dg5+5WJxor+QzfzfuU1/Kb8yGYKPL13UmgqgA38STTGij8hm/m q/8Aypr8zv8AqwT/APBxf8148JT/ACfm/m/c7/lTX5nf9WCf/g4v+a8eEr/J+b+b9yJX8ivzVZQw 0M0IqK3NoDv7GauHhKf5OzfzftH60VH/AM4+/me7UbT4YxTq1zDT/hWbHhLYOy8vkmFt/wA41/mD NT1ZtPtxQE+pNITv2/dxPvh4CyHZWXvj+Pgm9p/zi7rrsPrmuWsK/tGGKSUjfsGMWPAW0dkHrL7E /wBP/wCcXfL8bA6hrd3cL3EEccFdvF/Xw+G3R7Jh1JZRpf5CflnYbvpz3z7Ue6mkan+xQxp964eA OTDs/DHozLSvLnl/SFA0vTbWxoKVt4UjJHuVAJ+nJAByoY4x5ABMcLN2KuxV2KuxV2KuxV2KuxV2 KuxV2KuxV2KuxV//2Q== uuid:92c58e1a-0e41-446e-95bc-d00422909d30 xmp.did:57893048C9E5E7118238DD4578CC24E0 uuid:5D20892493BFDB11914A8590D31508C8 proof:pdf xmp.iid:56893048C9E5E7118238DD4578CC24E0 xmp.did:56893048C9E5E7118238DD4578CC24E0 uuid:5D20892493BFDB11914A8590D31508C8 proof:pdf saved xmp.iid:55893048C9E5E7118238DD4578CC24E0 2017-12-20T13:03:52-08:00 Adobe Illustrator CS5 / saved xmp.iid:56893048C9E5E7118238DD4578CC24E0 2017-12-20T15:03:24-08:00 Adobe Illustrator CS5 / saved xmp.iid:57893048C9E5E7118238DD4578CC24E0 2017-12-20T15:03:32-08:00 Adobe Illustrator CS5 / Document Print False False 1 500.000000 500.000000 Pixels Cyan Magenta Yellow Black Default Swatch Group 0 White CMYK PROCESS 0.000000 0.000000 0.000000 0.000000 Black CMYK PROCESS 0.000000 0.000000 0.000000 100.000000 CMYK Red CMYK PROCESS 0.000000 100.000000 100.000000 0.000000 CMYK Yellow CMYK PROCESS 0.000000 0.000000 100.000000 0.000000 CMYK Green CMYK PROCESS 100.000000 0.000000 100.000000 0.000000 CMYK Cyan CMYK PROCESS 100.000000 0.000000 0.000000 0.000000 CMYK Blue CMYK PROCESS 100.000000 100.000000 0.000000 0.000000 CMYK Magenta CMYK PROCESS 0.000000 100.000000 0.000000 0.000000 C=15 M=100 Y=90 K=10 CMYK PROCESS 14.999998 100.000000 90.000004 10.000002 C=0 M=90 Y=85 K=0 CMYK PROCESS 0.000000 90.000004 84.999996 0.000000 C=0 M=80 Y=95 K=0 CMYK PROCESS 0.000000 80.000001 94.999999 0.000000 C=0 M=50 Y=100 K=0 CMYK PROCESS 0.000000 50.000000 100.000000 0.000000 C=0 M=35 Y=85 K=0 CMYK PROCESS 0.000000 35.000002 84.999996 0.000000 C=5 M=0 Y=90 K=0 CMYK PROCESS 5.000001 0.000000 90.000004 0.000000 C=20 M=0 Y=100 K=0 CMYK PROCESS 19.999999 0.000000 100.000000 0.000000 C=50 M=0 Y=100 K=0 CMYK PROCESS 50.000000 0.000000 100.000000 0.000000 C=75 M=0 Y=100 K=0 CMYK PROCESS 75.000000 0.000000 100.000000 0.000000 C=85 M=10 Y=100 K=10 CMYK PROCESS 84.999996 10.000002 100.000000 10.000002 C=90 M=30 Y=95 K=30 CMYK PROCESS 90.000004 30.000001 94.999999 30.000001 C=75 M=0 Y=75 K=0 CMYK PROCESS 75.000000 0.000000 75.000000 0.000000 C=80 M=10 Y=45 K=0 CMYK PROCESS 80.000001 10.000002 44.999999 0.000000 C=70 M=15 Y=0 K=0 CMYK PROCESS 69.999999 14.999998 0.000000 0.000000 C=85 M=50 Y=0 K=0 CMYK PROCESS 84.999996 50.000000 0.000000 0.000000 C=100 M=95 Y=5 K=0 CMYK PROCESS 100.000000 94.999999 5.000001 0.000000 C=100 M=100 Y=25 K=25 CMYK PROCESS 100.000000 100.000000 25.000000 25.000000 C=75 M=100 Y=0 K=0 CMYK PROCESS 75.000000 100.000000 0.000000 0.000000 C=50 M=100 Y=0 K=0 CMYK PROCESS 50.000000 100.000000 0.000000 0.000000 C=35 M=100 Y=35 K=10 CMYK PROCESS 35.000002 100.000000 35.000002 10.000002 C=10 M=100 Y=50 K=0 CMYK PROCESS 10.000002 100.000000 50.000000 0.000000 C=0 M=95 Y=20 K=0 CMYK PROCESS 0.000000 94.999999 19.999999 0.000000 C=25 M=25 Y=40 K=0 CMYK PROCESS 25.000000 25.000000 39.999998 0.000000 C=40 M=45 Y=50 K=5 CMYK PROCESS 39.999998 44.999999 50.000000 5.000001 C=50 M=50 Y=60 K=25 CMYK PROCESS 50.000000 50.000000 60.000002 25.000000 C=55 M=60 Y=65 K=40 CMYK PROCESS 55.000001 60.000002 64.999998 39.999998 C=25 M=40 Y=65 K=0 CMYK PROCESS 25.000000 39.999998 64.999998 0.000000 C=30 M=50 Y=75 K=10 CMYK PROCESS 30.000001 50.000000 75.000000 10.000002 C=35 M=60 Y=80 K=25 CMYK PROCESS 35.000002 60.000002 80.000001 25.000000 C=40 M=65 Y=90 K=35 CMYK PROCESS 39.999998 64.999998 90.000004 35.000002 C=40 M=70 Y=100 K=50 CMYK PROCESS 39.999998 69.999999 100.000000 50.000000 C=50 M=70 Y=80 K=70 CMYK PROCESS 50.000000 69.999999 80.000001 69.999999 Grays 1 C=0 M=0 Y=0 K=100 CMYK PROCESS 0.000000 0.000000 0.000000 100.000000 C=0 M=0 Y=0 K=90 CMYK PROCESS 0.000000 0.000000 0.000000 89.999402 C=0 M=0 Y=0 K=80 CMYK PROCESS 0.000000 0.000000 0.000000 79.998797 C=0 M=0 Y=0 K=70 CMYK PROCESS 0.000000 0.000000 0.000000 69.999701 C=0 M=0 Y=0 K=60 CMYK PROCESS 0.000000 0.000000 0.000000 59.999102 C=0 M=0 Y=0 K=50 CMYK PROCESS 0.000000 0.000000 0.000000 50.000000 C=0 M=0 Y=0 K=40 CMYK PROCESS 0.000000 0.000000 0.000000 39.999402 C=0 M=0 Y=0 K=30 CMYK PROCESS 0.000000 0.000000 0.000000 29.998803 C=0 M=0 Y=0 K=20 CMYK PROCESS 0.000000 0.000000 0.000000 19.999701 C=0 M=0 Y=0 K=10 CMYK PROCESS 0.000000 0.000000 0.000000 9.999102 C=0 M=0 Y=0 K=5 CMYK PROCESS 0.000000 0.000000 0.000000 4.998803 Brights 1 C=0 M=100 Y=100 K=0 CMYK PROCESS 0.000000 100.000000 100.000000 0.000000 C=0 M=75 Y=100 K=0 CMYK PROCESS 0.000000 75.000000 100.000000 0.000000 C=0 M=10 Y=95 K=0 CMYK PROCESS 0.000000 10.000002 94.999999 0.000000 C=85 M=10 Y=100 K=0 CMYK PROCESS 84.999996 10.000002 100.000000 0.000000 C=100 M=90 Y=0 K=0 CMYK PROCESS 100.000000 90.000004 0.000000 0.000000 C=60 M=90 Y=0 K=0 CMYK PROCESS 60.000002 90.000004 0.003099 0.003099 Adobe PDF library 9.90 endstream endobj 3 0 obj <> endobj 7 0 obj <>/Resources<>/ExtGState<>/Properties<>>>/Thumb 246 0 R/TrimBox[0.0 0.0 500.0 500.0]/Type/Page>> endobj 242 0 obj <>stream HԔnT1 y @\q UAU<"J ޞd3ʒE&|nnBo(=%*~|N{9 IW#csz"ݨ :>pS&XZ֬)m6.'Ke~il#j>stream 8;Z]!c#!S,%#$4i1_es`;b9^u_fiQW"De/uCI;,_-!Dj87`di>5+_Rq5!S)bNhFCQ 'Ms9mk'OM%n\4]$N3daW@cAfrmA0/lE2T0\5AK]b8^(0XN@_UD]l2GYOe^FKKrXTW P;kuG\U5,ck4Z_aghB^K`TJIeK_i/=,J2*h(0M#QD2p+lE,26$TVZ;CE5gaUCPN.3 9MBkg(63_2GgZXV+i"8!QfD_^*m]YMDPYuUEQ(WM[IcD\50$\ESD%gSQc3Ir1OAHTCZorm*c%CqCO:K=M%lNZl8'd\J[)q' 5KR\Z%q80'g\u/Zl^1T`@s\"je^i7/Q'l7>:q$p8bY0NJ+4L&VV#;@AEaQ;fr$f5t F/A84mL=Ti41.Ks*OjF7[;R!J]@=4+VVf5\RD!c%e1gfLnJUK3G1GF;`TKh\h\pH^ 9:o5XFhKegaDXd(f(JIFdb"4cN4EL@gt?/NH?\gRrT^kd(#=;LUTHHiR>8YMgPUIS 3f*GX-6,3`q endstream endobj 247 0 obj [/Indexed/DeviceRGB 255 248 0 R] endobj 248 0 obj <>stream 8;X]O>EqN@%''O_@%e@?J;%+8(9e>X=MR6S?i^YgA3=].HDXF.R$lIL@"pJ+EP(%0 b]6ajmNZn*!='OQZeQ^Y*,=]?C.B+\Ulg9dhD*"iC[;*=3`oP1[!S^)?1)IZ4dup` E1r!/,*0[*9.aFIR2&b-C#soRZ7Dl%MLY\.?d>Mn 6%Q2oYfNRF$$+ON<+]RUJmC0InDZ4OTs0S!saG>GGKUlQ*Q?45:CI&4J'_2j$XKrcYp0n+Xl_nU*O( l[$6Nn+Z_Nq0]s7hs]`XX1nZ8&94a\~> endstream endobj 240 0 obj <> endobj 249 0 obj [/View/Design] endobj 250 0 obj <>>> endobj 245 0 obj <> endobj 244 0 obj [/ICCBased 251 0 R] endobj 251 0 obj <>stream HuTKtKKJI,t(݋4K%ҹH4J#Ғ(H wqyy~3̙g<3Y9El @ ]!O-@\+BVKK :OX~WCaiHKL0qY `5ck X]x= 8 XĿ׽>.f#aPn D^{y8  dp H st:Y׬cxc IV?S!:_9[YbQP~+rA ShHht^ '0߅™kYXY9Yqqpl'WzEE$%D>,^|t*K)%/`\ҫ:&D [7dplDa5|mb4,yy{e5 3⚅,t+whlA   m k xYUH&%Ȥ qO'Mz3KT@v[NUnn^\o]abTrtlmE]e~U+jאZ:zaqi5};CS[\_ۆwCaQ1;>L$Lz}4:%8M7l̎Χ/}XT^]X>\Ym[n!ycskkƶʷ;v{pIs0Xݯ3s󝋒&$WWW*)!$$%!e$cHNOAKIMEq ƕ;KLw@YX;ؚ8^+DspfKOTCPpJ%D=++O%$*8IZ\Z^UK_wL"dx]}>9=;s_G8/̹N!Gz[<=2|B}PQzlH0Wc(Een|Pds::5&89yFT"od䳔i/ZK^&gd:fgQl kJХeJ*+篍kj5U[ZUh0|em6]B@`PpH?QM1Msψ*iϛ.Z [JYZ)X-]R޸Ѻپw?@?5 ǖ'vNg W3gLC#u!MMMEvAms˔FVNA̝GLwA̬,llؿsݛnͽ+!B²" 'R&k?3?4+:6oT\ұڿ6VʝoF?LT;:>::>:;eqvx^sawݥʕ'_EFO\DKLtAnFF)F|ԭ6\`@z?m+F;LwiAhy͖)Mgw~_ @ZH_XA,"F)%/*9aZ:Q,\B^_AU񡒀2 *'[j o5[uR1uh`fm$1xJgBdrltlyyEe$feg-g#`dGbwj0TOC9; ܨݿxz6zx8IP=A!.aAxۑϊ}bG-ޒēx`G/Ԝq_O?0"۬խЮ˯ǰı²µŶȷ͸ӹۺ 0@RfzƏǦȾ *GcЀџҿ'LsٛFsM6+1MZ:{T?~ò~i~L}~cbA~Dad~ty~W~O>~\/~|~`Cx}%H}1X}%z}K} {N}׋<_~7A~-ψ||Dz|+E|[s|z} ^}wO@}-~ċ {Gu{Dz{]Ĭ{f{Zx|[]|ϕM?}R<}Ǝz]YzHħz|z={LNw{\|=>|v|ېI8z/r z;bz'sMzd6zɬqv{D[{0> |;|yyaIy?yazYvzݮ[{^=c{ФI{R*y߄yfUy`VyyuKzZi{ <{z%zȎ~+~}͇}W0}3}HtЄ}Zk}=~zɇ}!~Єd*s}Y<9wpSwuuVrUW؈|;,뇔{RsѲ;:8q)PCV:4.8Ȅ2񡂡?Up Vu9S c bփR.ՁNn U388A/ͬδz6߆өn1T\e7݀tXT)$̯̕6;eCʷˆ imw3SƀV7M \lGNػځNāa5tNzlߴS<H6*-N}o2ن N%է>w֣A}⇤\fXMݘ2, KԐ3g°[} 0e6M _1 ? 1ӣǾI^I|B̯dܪwLe1$: rW] 1S{z|diL g0\ U{[G{!{ ޔ`{&yE{xbie{Jr|/c5}~ ~:f#MKx+Ca|uI~.yW ώәߎ%¡唘[w!^T`^H*- 5GȨ瘎=Π4rv_ҍRGf,ދ̋|,ƕ{ Ҙtٕ^1Fő,;',#h%T,Qۥ{[s:9󅼓&^!Փa@!" y .Jl6mHju,bU6+s hܸd-ʥ}wi-sun=0Ľi-_*)U_ˈb$na+;ϧT;ppA7C4.*Iߥa8Mm.ACi7\j|fiԫ)]ޭjʄU]3(í whJch-4x7h׿*P0H됎L랇ڡuÂ,{Bz}8vggҲd[!XTZZ.vlAg {;Sm`vؿ`~?ga. 3Ì{L^WYe4]L7ok!wI~Ira^=C#Zh`Wu}p)"z7ff&3$FJ8Ҷ5m uR_,^VS&aR~PfLL_Dw*`\-9]q  TI6)>u6 D`e͢/xqY%9ʜ;åOd\˾P&eRz;].R<oΡ]P{?: r̨\ʻb Ҥ3|m s؟W9oZt]RnÅ\cW#+nI&gyAjsN06HiD'@J+a5V~cRI̫vwtUc[3+?F|l(iU^+O?Rs1Hqil$Wþh=(RE 1BvџnF/ BsGMY9>ܖ3ȗqI ڣ5V_1ȣβiJiX0WVH[8g_/ n3 ` 38A.|f|ј0I6bv%& ;Y㿜҄#dD.).p'3J12K[Duɥ$s8IƊ.z^48e!R6}vcMiozo0'=~i,3:?-?oS,9w#ROa; ?pB ֞IO ݟe#}ԯN$\l?], y,>&Рq]yh0AqK)ĝBFҍcH:-h-ǟcf)K9T127]qEjL<>h;|U dpG ƫ`&!8al`83>.qɂnA9 ; `HByg KB*k㰗2fF=#OM eT? mTm_OBۊV<ɆF('n3uG~Ȯ#7Њ9[١`Ns.P..콤 'KnpF\? B>-`NWOOWBlfxW^b-_x&*/(j_=߆󑊢zF`LdE:SNʔ@S 03|TOKokto}bFz$4-,.m'j*J|)J6BP ^3ewܫpX.*,07xPڳ:2XOT21|"7=0ߴy}ĸB)H[Fs V+̯+Y(I(x&9JAI'tXmyG=X[8TK)2<TSRvxlȓGO|g/{>4/gRFȶ&A52 uЯ*B幃AuFǞѧuD)B,*?n` 'qQIzK֗4{B_g68#ʉ2.A$69!̒ub1&D3Qx" >ɏnνxVG&TۨÓ)sxd-5KxߣD&1±jdGjJ|J{Z ޲f6/vTp̄ub PmBU#gBg˷)-*E ar>>Ƶrn[ɭF-IByѸP=ĶKUC wG D}"vN.p]]Q8uY{#qCv}sax_oyiNr( d8aw2CQ}V8UWO\g \yk@dcZt9$u p-1z(=f) vě92 w u煼ת#{P6+Dq3HIi%BCb!kc5&U ):X$܎[b2*@PkcӘdoTB_L1Uwi")=2#pI9,RO>T@>;bnDPuCfk^^\G~ oLRcHqܮ=-8^5Ońy*9:-\g8:T<?*C;[yX+I;lRL߭$DvYTQ6DyVmfy%/sIsmXP1Lռȭvow)QBb_LVwupeėO*|+](uHװ4WU.{ 4\m.QwR~MAiRz+%BKz?'{ k҉aa{H]sX}da~3_auQz VM\ĵv5I0LM)DŽp1:5,&4 %!$}ocޤA]R^xT◬M&/B:DwA24?cd&g]5b4a?iǐ Ĉ.OA 6vfvsd(5yTH/P=(a;zUs bWxDa)Eʼ $sgPJreY3w`cFo0|U[j5k.5J&eTor È´}I lpjC8c5J=g%Uo|L58E" ِ[Ak]J͆VBM"{NrQihЦ@Y?6^߫ZWٯ]ذc؋hKSLj:>O ɲ.ݰQ{5mm<ٷ?^v"}ъw9O&vX7km[ ,70nΒ7|eP\I;-wgFN cIP#qWI ;NٶA)H~7i thl~~dzY Cx2>*c&mb{9f1X*L #> V@g蒼]7n249=MK% ;,F\j 1klZi؊ΐ.|Q9а$_.!;̿lE,ɥDi}D3^a`Y5g{J=mɳy3CM'jM-iЦm n5? SJE+U~ ;q.tXd~~p*QeS%.Ћ"ưBsZ6-6[\d;^z4`;64藸ͱw;|+&AfLU3XTm)lF'l VɺgcGObbɜ9;v \CL, >B?KGCe"z -@EHILp<5'҉$>8#gL2m c1 c Fw)P+rkC qp/u8#!*g°Pa`vu@oH`"Ž:z_Q<,D>'ӅWP .`xW3|!6 5 El[",0 e[Oz0~lUO+&xkPc|u$k.?{Qp""kr6isVa=~@W_ .<7 2#h?c~m'rE_xs6aG+K 14L^kUp^^_mS^dШ'>}5$:τ!E[bJx&n t(m;ZsF5uqX.ՂBqKP *l%{ٓ{'f';,TT,bhUq2Z3;}T9vwRR;GD K*/@hUv$j!@ vyבm,W|-͢ ^ ~D_􆭍"ĉ#c禘*X/Ϝe>|XH;:)d9gƖ4aBQ4Ew,C ۯBU#>SV$L-5gV ϯ*B#} npþtdU$Db&$^\^&Z"/˺+-}%Z:}9AYu rTlP0"~! ͚*@5K?߫Z-P=j>܈[O?)a5 ?WUsy5^(ge${Cm> "Gգ+$踿ϫ& Xw8?g,'ō="/xNM)'EFqrf CįQ9ZY$r!6m)4 V9kJ$# FьX٥Cp[ģ)CS;rFP#ImKGɺzj>>X9,ZL-jIbkȉ8˚?vtxPIO}_ay@:|Ve6ubd/e3<֭ztea'cLaM lz&,f^_!?l2x2Xyń3D)\?ye ~4O+9$  EVDTSؓ7X?MM!ԼuOtP Cbt;iްa@gW#@4c9.Do z2>M5i~u0 qswQ9ǸLt삟Mz)>kɝI;io"U)]$YL >$$T:gUo$UK,C`sCMAJMÄKC(g]ٮ9sUG0?L5QM%0Ol5&`Ƒ1,x'{k+mY}-Js#\d:i/NK\8HstQ#-ND).s*Zymnf\1l{(E=VGW9s:?wǟQZsC6A1ƃ6K@8OUY^`7j6@9?,yt4&}"T- \Y&kVx녣391ٵqQ=beMq\`/nņ|2͌JkzDmͫIR4\~5NlօKɁZ]TC3l̅D3jSS)tWw$IX[wV WTUw^PeUhWE^ؓ~Wchs sIg`wgs (5mr] B`7JfAaA3ƓG?{O[ ?xj/Z*7exXz Ά})C?`KcMՌ&)Y5J]q':]$؞]Yv x(ıH1eU>_0b?*񸨎b¤،D;Wxm]|N7U13*;.=>SÜj)CM>.eI1/QvН6Tkk+Ɯn\\FFV#Xde&~WE7"bju^I@j@bQ Wk8w_D ^z xZKA _`T}] x}ЁM0S,rV+ KO&ƈ`;E{irf0F] w86f fm_8c3V<)r1p +hs|p!QP'Ղʛ2rӤej4Y r, r?4! Uq]f(*&umM+;1 -c8CjL=L1TDJ7>)BH*cHY}~xI,{7WjWާʇhg_YovMKiN> QRǧ}AQj^G syJG"?txt,L>֍p_>Po$^<%}KDS4 *S<ܖyd;éIJ~JMn>ȸcI6uɖژ䩊i77_5W2' 9t^}/8%wd0k)ͦF9kih3ShPBULzs'0$Y/L3ol|f ɪ\AW#siS-O^I+36xas @M A hm45V-' ѵ1S+ ~*%~k˝ʉl * lك=3_2~OgPs Ccd[aے{<ХjA {! ߲ۓ;O'9+wEHE&JV?fiӺ j05瀶bhWZxo=ƺ 0zhK5mov (YOut;e=R*yMVn,$v:QڳE.yVl;svn,Wi.[@34SD_!MF>J柣ND @$Y~-CMu (+lBpБ^#$~2è /@̣6 3nh ;۪.3Fq3\َvZnZ"/vNFNJ2V{#ΚVse_쑮Ta8C¢!Η>FL\M{5eH~7;F AB?VY=۩Q i9J.sӿc%FVbdեiL`a)kD=W \ne>NX7Ƒ†2IYf-to7/~Uas[`W*v3_`~:kjR("E * e)DDIss,f_n6":hmh+]AqñQqSa9{~8|~bh6GZĠםN\h+(E30~kTMGβ1:zka'LG2>,gt X&@?e% =@Ihs)HUOeX^m7R7~,, \jJԌfͬ8!*]JR:WR]Mɚ PZ;JN.8ɦ,[r*Α]MM"waX)Lbjd`>:?|:?u>^G$fa. ʥ_S%ED8 J=ĕK{6r zGG Ui<Kg"^ q I6vPWy^,uc/5@:ǹ+[N+li{P#^yv,ñ-NѳH⺣<֡gxV</nb6󴳜Ρ +nhB˾PoT(W##ĉTwZU} w-vT-9O᭺HIz) z9R'dI5aZGS˟agW=.P1ٜ y?2X)r4VaGXBe`9Q1͚@85$W?D}z2* pt +;Br\ܕ'> -vCNeʔL-ʌqKHr 7I d<BgNelB^փRγF2AqCR&t7߄{" D9u)Cw1t}?"'[7o̩~1{>Ru* ʖdClutqf2[l~{S4>J$.nQnlP#x])By`r+wLH?VD:|iUG~ժ+&+Rb gP>}WԹkQǖ]WSkqwZ DQdVd24KGMvU35KJ~4&jwJ*y;X߉˔O@5hw)񘴕o-9E:_̂o&6#V(ѽS-te$ פp}4%4mrnzhe4KX*KÃ29ʩ~'Ǥl|O5ÍB ;^j㛑Q`exH;J\*`l˴Khk &tF|(8VǡܷR:ϳoG*UjSKknRgl ޅ-6&Nŗ7O4rGmO[du_TvY{ ̏Iy\aRKy&P7ݪJ)l"W5{K S_j0WSW;wixF1^lО伴^'1b%OAXhq)L7j}=9PX=n`ɗKX#CùA *7{ jWܴTByufכd=Af]F=_u*`q+_i݋\^`BaE|S&%Z a8+QgQ[IK-jIKr2Tcju=A ʧQ"7{ٮם*X|,Yzѽ}ƈf:jCo[>]x^hlhNrϳEDkcCǪ ת9c Ht<)}z!hE~DBӳ2S͆i{;ouIp??砃46ٺ^"1R<-65sjpCSjqi6dzھİ紈 41.$5EG9:=ob쾄 v#[xﯦAF+T(C@RQF772I$^a$Eq>.AEbiO0]ТK5ΫPÛG ZdJ*$d ^}E*֤>?Ƅ$dO _tl%$^7[KSECqz"$]*B]}W zT[Rk"n]EUYvFUW\B6-RB^Me2B4/wͺh4Ek5˖<1U[tD>Q!.kR涧7uJc>c l/i^3;iڐ0sĀZnS qW7Np:([568ViAFޜ~h9Pldüj2dO +61--1Ewv =JCHW34܏&x8,&#Rc3Dvz6RSyu_N/nmكvT֥Y˼?RFװKzn9Q4gC^5l`P\ܲG&ޫ` 9PҞٲXr6 V4,{a؄\tcY`]lǿԾar鴯؏=b!&Yb ^[\aYt$w [R)i[{$7f"o Xp zBz'hO|Ō4ǐ|-j :}̴a%Tv5Y9QK d0 ?$ćH|#uD3 phrd@,@XmVKY@ou([8#!OM~.7SoJn%OG" Ü3N|/'O-R_1Vh&׺ NPz8de 勊ZTH;XQ6}+'h_|ȋCcuHjBA,NOS{3 L`]1> A rxӴ*E^.ؐ`Q5 v{`=W6뼟\9avGOXc& v1w~0W:ʎ~f: 0/˵%m KRKAcR% P#CSߥfmD5oEx17B0<&Yd8"1wܡ5 TaaJ3p57A>+yIMcu Zd?Bk1x-rsV9sH6p]DGgO| y5S$aE`$Ls [Ym ~u8p`6*I ߕ`S88sn9O3nXOE /7f^lbN[PBFO.9Z_.5>F S̉R'}ΪѬ`_dX|{dHXԾ3QlZe7PRqشO5OkZrx5u`aǂ:*`T), DPQʮdߓJRk=H+ *#u)h) )B6s9߹瞏HZGzGT"93hDͺ sr|b4y $TK "$I~$v(B#].qi?CN ~ޱ|ܷLcOnT~vxj̦5<.f\K<2p:CpSy,66>|zC E T)f/:X1}J+>_~Q;^ㆪvs&۸>.k7yZS:˩㜍rݖۜaKa!l.g57Kv0!;ڗfe %]"XT J3aժlwVj=v姠αe=bI/gH& :g,(y 27>aba88fVVqɌT0NɉB`( _"fo! t}Wg_0}HX 9,Qx=~Jٹx>ӱe9M2mFS)Vk-eZFF٥btg0O?Dǐ%7eyښ6WSCyeUS}l`a8i g"1лJ"|PKڝc,$+&PvꖴGBoj_t4I vqf熚(eC!b׼^SbYi1¨;2W`/7uh?4 !z@#(T 6 ^!R S#>E/Sq9z_ /G%ӈ0C9[ۼ@(٩P ,}XTOkpQȫUG6 x2e,> -?ϭQެYz/T5FL^`tީ3\#̬D:,vw[mDW)TBZ`0Ֆ`3tBQ˟kks41y `\޸cV#z`XHhwA0چFTyqӵܫ*F˪%*/>9 gS'"b'zL=N)cs*bR)W<#S 癛)K &L\9WtW!Y17i*%wJ_ 閥nWJ!p-0T`:K6B+SzlL,~J#ZLHBEe߈Eq1 ڸTD}bB;*OTCnՍl$OYQ0mz7o9NŻ|hDV[Ve֩b7YZÖHl~I)ܻJ5oOݑ%(,hZGҼmRd!/NEWutV57z;jjs^^lDǾ0-a_aL؁w44簍b^ppi&nX uƻ-݂ -cY4_g ?jGIfH %J҂[%ϩC6OzvWzoZtA$?z;ؼFT2/+0@@S<@>0bSuqw;j4S'/4sEթ(P[V^5ƊHkg/ۄw 0*֭ ajyB5TC J(_F4!m, RN ?S9 :״OfOV"յڇ1,V)S@._ #Q`K|ͨ%cj/&\: [Ft^Z"q٤Jm뙊jMarח`VCg w"~>< 8i}XT8dzQVY<p%HG/Û`rq;Nm~Ms\/Zh:(MXа^F.꜋.Ys}5`a((X0T+JS 4&~|iB!! !)$)ʰ WFY]E븎3x,˽}|dc |i-0Ws Q_GpRjy0׿tjT̎ԍD1څڍ›N:ka? 7ek_%]a;זF=9-b= &Mm0-vD'^j+/5(er^+EL F1$1KWE|fOFMKm::1`ڥfXЩM*i9 l?+Lw?-Nx͈wɳ\C0瑃f sM;iđ`$O0z*RٹB9@"k5v~.lB?ug]ed8JAj͹um.DO^^v:y;ske+,L¶vŝҼخd_5Z;q#k> MU\J{l*͟ґ3Doy"UDcu#H)BPit/ v`_Sʝ{e5mpPpy=-2[m+v6*.WۿSǔ] ^DMk,2.#ɲ\!{^I4Ԉ.~çlDcBU\b"c jvJG|H`_2rHѥ tHHBaG :Bf{'9 [jaЧe &hz6Fdy?>gۑx&l$^:^nx-'-]O 5@S Uڏy]Tu _,zWPT|BJ,ɕ}`8ߴy?p7gˢu\JO(_vOUue4+Qbi?A.jCxyRJ駥Pt㸲rTfdd$ֺFR>PaL'v2M*׵T]`W*cD*hAe#"ɆKO9JKL2J( KgK3jԉfZnL5oM(_>FOӹGi}<@w#Ndhoo4Y ̾Fٸ2YAz$W֜5Copli\ 32l;a<;S?B>zprjsm1tZc̥{s/J{c*#3ހfϡneh->Bc9SJ"չO8'8ހ `yHϤu-*` x[c')Oy\x!QS9q*;$;d'=NY ,|ܶ34qT=ka%hs䬺UX7Fl[ o1apuxf9QGk4;e ˸7荇5xB:yZdͫ,`2?_a[0~9iY Fs3g Ë9u<,yx87 1Ja,O@/gO㔛94 |.]16'^@1'p:XtwL,jVQv@wl{έ̱\?R^UV\GI+9D03oyd[R<""" .2}"!<4tH~(-r25DH@l"K濣,/S}"+~wF}V dRz,:w&?C~FqJ}JݢJirjzEgU#p]ZF%+[PjewVjlW7wR/*C%%jGx @EFH)&0_Օ|Xu DRNXA\0JSH307͛73 CWc+U#r# aQOL4Eљ?s~{sIy?y>ҒLָKd-ޣJ1v*fH 6hz+~BO:IQqZUՍP[UD#BM >$ z|?^!J0W8N WzXfщ@'h< %sdR۔e[$z,Z2H5[&Ht L UO 췯+52j&P6uRɮ! a+rk!o4 `ܗP)f%VQTF(Z]s,TR|O)O?ho# ]6yл)OU,F٠E})gsٴGyҘp/kw~˖I'Y;TdgYU'I8@F* 8 $I+A2((+y8OϋWȗE {բbW"@}@C׌teYgvֈHofE`eagbN_4!/e%O;mhtWv6[iyFy4ʔat V] au #QYm3rM/q{~tjD 7fiɷ  . =[n`4qShBrx_5wԐ %nQ~x'G[ `+qb]Q2Ըi=UGn~ڋJ(Aݪd E7Kz +M]!} jnh-Cզ_魺a٭Dfrj6$-4nUZF)Zpux'@]U/ٳۿ3Ug`iU}ڰULWu+SU[;uXJPvOŀ{$KF,qQruH.}imfZh~atMBb0*iWC䶧jZmn[nKfi c+.&oV.&ʭ{5_s9dmIA. *s5: 1Ů m!|fl'6#N Z>\oMkCZ8)*bEE@(27{I" $!0a=+vUZŁ`-xEJUǺ ~~7TSsV6i1=2J眆Jh@ Uu;7!0 ߽\醮%-;=.e/T7D$v{.ʫ|ZѮmcDֲ+-Cu_{>1H1]"D^nR ٺ:E3[h9 7TJOW+3 vœLimc @6'[c`Ǧ8v!bR{1_ӵuoPE2\@;4"mO m{ ߺE1dA}C=WB}[3']\PJG5VmnYG Xyahd'J[U~ vWۅWo]WnGnR9H7ѨAu 1vZm]lUrTVA sj6lhm,My4A*0vJR? Ĵ>2C!*#q0MJ!:ŏCR|dFa?2݂ch3dBzSIt?%LmF[AxYGҏ0m;GY1űh%[sጒ@9 q_8G>r Wn)jodEzC.qJviN&If8bg v|sd%:uTf&L0~p.(RU ; _)w%$/ t# ~#u`u[w.qsY_-*'̳ɩk/)2* i9$7fUzflc9}],툏WYCIkS-ty7>T! 26Kݲ m&cӣh' ..+upC6&@j5tdP0=I˂Ė C{޶$tR:(ϭuOR4$=jluq1?פ9Si|cqF!_z^SK}`d%DT wV>;<'V=(5H%jWMV#9YD2֓p~~J }D]gNSsjJmn->,vg&SLl#>^i8ʞ%4'RJDhRN0hBA0(r0K+aMY|"EGE_R^v4/?m[˨yN`K/5[71[Gؒ' '铯RGhqꭁ]>iIX 5'\GB ćd^ux+[^%e ֪pxE  6%!Itި@Ҿ#% :*h$r7שׁ55׈Ց'I+6*ЮwȰ%U#zD+Jt BaUؕ 6}uOr7dP Cu}FEua7RV"KST20 EN{^lkƕ$vW(,F7b ˢÞOy<"_).kh[n 9W?gڈ7yș*ӼuA@ OpIRrP($e[iVYR n#(aFq&mq3%\g?%ӆM5XD3b$ʁW ƿ5&͔D4®KcᏊ . 1Zo ^`~¿`6z q aXǰ)Ӽ܄'84 n"Db.yC<K d},{*h ڸh>wMv^ c8Iƻ(~j? eoyl/Dl5Żרpy1ܣܵ^004{ .%CA22dWuQ>okL<5.ſȠiffh7S-|^TjX[wCY*sG^1Ve֗+˃L3 /2y{+.;CtJ } ->٫y6q< WxA_PZ? Q y1>yK\.!OqM 0Cl];Sk)=RZ@[ɷ5JBeǐ$Ni"0 -úR4H~9.☫|Dϸah-)r~"eoMK%4 _7"‘e QD~0T.>"x*O>酧.Ey+HVy55RWsEk*PxEGB;(J X(8hiqmh^ 0`}_APWDLZ‹]<4zG֦`oyZR|u^gCF#nr)Va5ƪw9njyIt xI1bIy>}-AگOShKFx6xqqQ 3SU\ka椚̩Di~ ?{>J3mtߐZt]YNju]ɒQYlZZsNѴѷW>Sݥ0Bj+7q҄fU7m :8^;#eտ+*,_CY3MSU*LX.jQȖg_IWJ5a"9R'C\y׳qH)VU-Z.\+Ѥ/aen/|F[?SPkr" ^Y>VH9 &yaIxQfd}+] U.o.=q-y][viRgk*`/pLBu+A@[)&PYQ?im/K,Y*gu(i2`؀V"fJSs=RU@7+>dْsmY)w=U?ο3D qjv83׽} 1r@vy:{Eͩԡ.޸,珈~CH{ksv_l毁@"lOR."0Fl]]C˧Mfi nq˶Q{56ef e l[IuY_(i&;to 5kZ/ jjp~Ch⨿䦿iRs!G-֠5 &wa7WAƫXUr8+}E)oVӃIÌ}qZlh<gw A?=$6-ޡ|,)!<*ǘ*z!8߀ϸuPpD|Ŝe=sm4'ҢؽYaPOZ(vj?VGgxI=V-̹uMCJH_-C]B~2A\8*E8PTΔTo 9/whaߣby\'F,Ռo%wU/ժnM*T Ƌ{5NJԢT9L;y _fXD\uַA:x")V%V/*]1# )ԋ@X"SVӅ4u.f?Uչk%Nj;c~?]Pۺ˄WҌ=V듍1 E ֻqd{q׉; NYHdfttc #&vPtQjd1o ­R)ʽ@}<7 &8wyybH04͂@>o` ~M`Oi#T2"-!NSn\ z$SC%Q%;OzcT)!M.wf.Po1U=Bl1F#F0HD\u̞rڜ*ujQO5u8E$7:"І(UuANgulWYE*Z"cT\kTxlx)$8(YBIY`[}.Bb T$=U8Oŧ yP-x$]0_ j(sOH|/=wKR` ptl>f*ӡuU<=Ts(&zpKA?sLo`N0Mq+~*m-~F7^5惬H]${|-Ҷ9Y&=X'Vu+^ϖEm Y/0X cAdPc_X VRx6b|C6^FeC]o-F?f7Q3V>͝yFsy]ݯMF͊k^NնI#FZ.7ƆQfeϫCJn;AjB JFw mԗ6t(I5beElXQ͌ i,)6QS 1zJezVBf ۹ʹ/ HQ89SnE%o-4NJ``,)~utyQN]vحp+e"xN6y*,7$'x\CQL[8.d@}CɏE)1D?@晹b$?7 YM N| _Td'wa}0Z<9|3閗3~o=Y>l0Wb=P1jmE XR[louv:.C=;.a.BřS[nWJ3ǟN1='\Xr8۲:KXj6e g΀ap%z"K1.c1ɇzɭGTRiVBe-)K@iͬ!u@_`&2q up%P SЧ|NWP !o-t_ nyV|ؤ賐e`HʏE=>\Tǀ|cҎkIST!%Gu,%[IR'+#T}m3\/df)`n2#\M(CQd6flqGv첵).Z&wITe{JQܕQE\m`p`Ҵ\z[v7OVo9ݜQ}$SSFMWdnyuя: *o[3 O FRJ0ոl+L+&oE+d- @?^fEkoo\fyJ8zΰXmi  -Nw}OYpz&@>gݪHc. ]7Mz#fe"g\a@\qyºJc\3ܔ r'WQVE D|PLs\h_h#9Z-TdL>˼!WS/bniA3.1Fx@Ǡ3UNN^nPOZdtvWO&-8ךshveSȉ`wPU_cař=շ}m`<<$+UV66do88{ηzkG}ڻ<<7\jvg!5M!w&GmpfSgO3x? wZsLRq/~lK]QV:om<Q' R]AMXyu ^ȩ $}! 9LHaH8hʡrTtD-*fY]]wuu[bgg޼ߛ"ȹ I7HR7HBHudt *Ჲ=eJtj| #TI/W?{ΝO^'`v'$^E=7ITF2˵7-^'Z"[x ;[U7,QyWrr9E6cy'I gIRm2ZQ {0K,^H/>>G@l`T=FZnZH ѳ$m¯鵩KA3D;w7ŏw^J<`i$M_x8wU-,/h!pbP1|*k _U;N45jX_:]$ %ͫX+é Miwzz{7`fOE5FohX}fL}k%Jq_b_A54WK'h?:lTHmm. m&"X7rV7l̨b]r+ OpK[{0EuwrfӵFajCCPktMݻVw[FR(Y-VE8 P?)p>͛5 #TtF%3 qhk ;`LVOpZۓ. j&\Cʡ <*g!r)J;ȁ&xK0N\B&Գ$bԍ7fpt(0H23ӲG1d?ź bVֆ|\[w+tjj?b7hwJCmm#b.^VBDRb8E]4J 7LGc.Xd/a&ڎ @顢zQuֈ4Tqi˽èb˕ 43~,ymoθ[0 l} TCuLBt 2ZW>Eh@+[Řy0= sU"r];û](̏{e E=ma^2'FKv~.Оm0Oj(esߺ Pk*!3IBЦs4{^|{6k\* }XYǠD=A %$hǹWǂORV UBꯪr+Ca6 Kԣe :Zڿu6&?W&k).]%],lb7MX][H"}WL)RIrfr?AƁY&I~_IB${XlZXE&|w#؆`_vߢfu3fm89?9 ̟NՎ`jz1*.@爎܋`oْJ_+-4α6@/DWEjE}HRDl;Y+ z/1Dѓ(z)oι&;.4aZ#gsbZ+XWi;<~n"( M'b6!G lP<^\nM8--aG+dyXP^s:0q \p3bWu.,R&rm#қs)lej(^ ,=/FV6fj;ex%Dk%!FW@ao2QTvs 5h0B{UHiGCOzL'pbIq+'_1Lv QA%$[H~}{1fKٲ:HmWS ëd}2w7 j< O7i2G;SWݒ!@YsZ~*PƐ6xQܡ/9i7cGHVf3R>K2jZxH"Z")vHD} @} YJ64T(P_(*C]miSJqOZgA(ny8}wν37;?߇*x"D6HaeZ 5K e tE=H\ƒW8 72ym]Ly 1N<8͍@:> >6pӹ$.7$C$pA)hJewT*FmKg-lm*{{v\ܲsJa>3_*ݑہ>V5|WG_>RR_YL!RFjz S5fځO2< `}I\:XiZkRH*4[(xX$u|I9̺TkVzl_׼gC%*wXR nY)N.9+wZ[E9ľWJ%wp`Nj[.b|JOsdW,R~#* ĽyFdwCp*L(8OelL˞)A vfFʹ.Knd~A򥾺]Di(i]YʯJߟ?>w[侾7KK6w"!eDp5V* 3VEa{:KoEDcɾJ#oOU44lTjFk,>{S?ýSk>Su=|j}T SU.nk.mcŮ)RxbT<TV*yÙ<+`RC;S^0-itp<ȗ2IZ_0ȡVVKHWol9=fd jb%}DCy{sI*{ZL1r`n}+D_*Uz3}i779_kjxL+u ;FxL.mmQ`sKzK#>&ޗxiBV^\s3_XX_رC+ҭj|S kϽ|j|[X ΆBL.?\DCqߢ7nO(M&JOiݖw0IJLM,NCOYPoQRSTUVX Y#Z:[Q\f]x^_`abcdfgh#i3jBkRl^mgnqozpqrstuvwxyz{|}~ˀɁǂф{pdXL@3& ֜ȝ|jWE3 תū}kYG6$ڷȸ~kYG5"ŵƣǑ~lYD.оѧҐyaI1ڲۘ}bG,{W3qHvU3sIa)\ Z,      !"#$%&'()*+,-./0123456789:;~<|=|>|?}@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`acdeefghijklmnopqrstuvwxyz{|z}o~dXMA5)ۈʉq`N=, ٖɗmZH6%ؤʥwog`ZTOLIFEDEFHJNRW]cjr{ĄŊƐǖȝɥʭ˶̿*7DQ^kyކߔ ,8CNYcjnoldVD/h 2 R e r xzzzyuph^RE7)4=@?:4 ,!#"#$$%&'()*+,-./|0p1d2Y3M4A566+7!89 ::;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{||}v~oiaZQH>5+! ؎͏Ðxpjc^YURPOOPRUY_fnx̰߱ 8Ql»!Ceª9^ɂʦ2TtҔӲ6Lat݇ޘߧoX\[VL=*b/fMq T p_L7! }tfUA, !"#$%z&d'N(9)%**+,-./01y2g3U4D526"7889:;<=>?@}AoBbCUDIE~% ہ‚rW; ϊ}bG-ޒēx`G/Ԝq_O?0"۬խЮ˯ǰı²µŶȷ͸ӹۺ 0@RfzƏǦȾ *GcЀџҿ'LsٛFsM6+1MZ:{OX͙~ʹ~y~eL~j~Qc=9~|4~cl@~]̳~nf~C~لOiZ/gP8v}6q}0}>ϲ:}i^},~ ׉_LpK-~~,*~&E()D9vyowy=TS3wI!D)J%OBvwN64;>FVWm S^Di*bPkpة?%"1#!ϼK`L<n-e2*+) X䥂C@v2l Q?(=0q MzǃIz7MEY; Y@K (-\U&>rI^2IMe;Ya"VN,S;o_%sD;fƎ.R?l ;0Dq>8zDKG)3o+&<4@n͗0EO94#ҐnW9 b_7}B2yːv/ąJH삻Ȧp$ȫވy;Æǘfo虔F¨LsI,KhW2!AjHE^τ _wdlXggΩr!jU)[%B\DCfp <_\?k,.wȲirJRݐ=>0+cvZ{HllLVAc۠ ^{6oCҏSمbȏ:sz 7jP@Q;[wg|z30Uq`!P-~|X3+z2lIђ:_p-FOJ*Yr(".O'qäfrCRJ'dc~h!€?`}WzBd;hѲGϲmT SAij9< ߨ%@`8xLTqė=,Mk $hJdx_r̰gʱhtG,KytomVK0X?R=Џ ]ٛa`sʠ7g&Grŀ?>r&z`b>&z%sxbw&{~څ]"WR%c"zD zA rs!֝=jcf]rmANJl$ے#ؑ >wTfGFF699<׵.'SZ*˺#-Jl.ZZx%m*| o 2ӝ_TWK4eRsu33'jRFBWl| Fgml0L1, y+Hu2f;[T0BE{:qntoT]okI, LgV_R:Kϋ0dP?= vE̷փ(M4m\Tk׉o,H=Zw/EI-LQ[ 8F/g֖'$?[u~fghXjݚ- VImKՀ,%ibQ*e97WKMYiHtXTBUDw-49#iԗ/r]hGވ/ lD2 h‘%TTT*Fdw">GY?"[f r5ʊ4`TAo4H5rWS8Xy;$Yr'q vUPV&4m/5LJE:S7Hvy.. kPXAl` ,e: E$@BKr.!{A$A,CY[EA;| TJkU>41aƜdcT.Us R&BchR) Pd;ʟHbl?1;_:i^mMh9Ӝ+,x+(‡j3=P6u>a}&b (0=.À<2&m%u9_~zL!S`(6͟>թVlW䨸m5ypg!2< PR%wC>ubvbF.0UK$K;؂P,!rA5%\v" [2gwdxJ:_'Eښ_+^Cژ I! v,V72UJLNITUKɎIy/R+=+(֨v6!M @PB%R--3|4-)#ͯ w.ܘ<;b#;*>$eG >3"و~AZ$xOUx f𜓜x;٥Q h X(Zx=`dš 8b†id, ϐ!enZ b /޲І2P0~ +1baktT ?g)˧9 С`.ޓ`>'4\DRdPaxԗ?i|9,t Ĵq]"m-9OD'Ex>#Bz6Nk%tm6BDzVQGq,2O: y{iHcy[]vaZT5 ȨR 345N@qG!fYXr{3^M7HX1ey87ҙ;NP9tn/D=}*I:2s̋%G{7abTBm6ۺ4JZmI׶Fהz\FD*rEyք ̣V-8ˉi#7XmZLW:2 $Iⷱd`U+z3 8"}Y\E^\Qܵ)<&uZ!FM)V"ڟ}&à/ ď 5 O546PW눤0 fGlEbdc 'ƪrӬ[{K("M/y%0=zFBx}{w6{Y50%,40R}ԓvTp>K@fR$7HU( /10f<,1BS>٨RI3#&&pa5j19#yTH9cI[էjU̟~? +7NzM`k|-kqJ}(Ҙ2SaӼGi ; b:`uǤayU}T 2Ftm̔%OpuDU0m~L-_:qWg0~huw-] NVrP =<]x;Y1iw@8,n\(zqb !$zB&5dn61Q& & CuЎy#c%$7]w'z\0Lk{8 ;fGS Fx¬P~Km%t3MccM(bCB$ _ J,@՜ %ӸZ;.6B)PT~~:_tHNITScΤ5_3bO6-[o 7$cn:zNqnE2~7\NT' "[fTT^2F&+c5r~ԕ(jl 48mWDC]X#<n_ T 45 C0 V~ m&AGA7w@w;Q8Q ?d9#1yʕq_eS]y|d*&6Q30J(WG>HN vAg+[o:y1ډGmUV'pJ{"M@3X|*oƙޞ%sfJ<ߔ[-0R'G i++qNPF\&XT~ykPx>–~u2LX'P MOW rة Z?qU\+w>-q}y/sRQQJ@737Ka[t̷E8X,Tp!PVK$`Κ׵bu~*LlBz-f{i8DbMp/ŲF_<`w[Uq. Y!'i7L' Rz$v]c-ީ%HY~ٕ 鞀ws{)Wa˹ԑ`{[z ϡZ& z - U@uBP.8jz B{GtϤ1ޕq# ^o2N*`DZm錞c@QY@Oy`ŕ^ )H??s %J@f-H%{#}řPKn@u5w:=YX9(5#p 9#Av(~-"]Qb'䠡ya '£ +vO@%7_*Z-r*~z Ց4!wBpG-q.a+c"wmqk=WfB +k^0>npu5㞃= m]0o-1:ǒ~%ui;pVO/a3;0oKܼL6Ed@ZU%{ ^ ͰyOVNHLmu?uMBEQ1\IُOui@L7Nk\dd[i|lRܰ3"rW^  19~(VZQjsfb5~Nl, $LAE \Yv3k"*Ie.gj4uDk"*T~~g^ ~<|1cPx7kF84K(/AI\%HG;'6`kK ZJAFqKq$5GT#.a;1 p't.t-SSUn;QY(sў*M8= BHZ# GcDS{d',Utl=,}*vcr+](_1rØ@?A[KDlv'”o>=ԏ[?Q ôn!ܘeoiB]u3PzP'ߧ%44Qw L7@?;gSVjgohop7syR\7V%xL| 3n|2Q|-GotuV֘Gk}fd'̐yQ/;^+b#&~ي2(ɚpTֆ)$Dru:5zj,|~0T\~>*,6Y ]7E9!7;au*8Y?Ң#WfiA~\mB\$OwDhE16:_JqBR%*X3 !O:`Iok2+}Y'1%Y GPMJ{rK w_ L&N NyA'ճmﺾo4gz"v;L je %Ɯ{NS6U'*@djNcvo^=Bi 795l€Aⶫ627ICkyV_}B.I=YR2U^c~o\Ƙa3Ƹ2@eU*Tlmcӱ~ xnNU)o`Iχa]PFŚVTC&ϣ࿋Y=d]/..FBXs+$=}buM>RWm6Ŗ6ᢐFX 5x{v*j;zv<_~AVUJϐ^IjQxシuQo=lK_ՑEkZ\4sqU7vOa J?Q)4C^\k[{3y~M|J'g4Ay,$0( jHl:Q"V҉1X&e s)MZ(W |Ϲ\88&tcpҔa͔ CC GU$^fb|8u̸&A֍9ke7;㥦koAvՏ0o5y'M3q"y$[Y@SgÓ=ݎP1)L \!B;U!)/C$N$A³ueuU},3Y'/Jc .8_[ON-<"NawGm_+yj~P]ſ^\y X,r-|㒒ܳ<L^T},^eDR,nkqց%|r,!gJx=~p{"\eeEN;Þ=${q@Q_\?/иLe>u#Mp'Yn_e<q㼅Ra8pLB=(YK[l`BKB#4;c;HS^OA>Ʉx\+0lkOԼ`Fcfup.wlCnKJIi]&fXPAn1کFTKBoI!ӮZ f)~Xhy9 ݨOC5&|T2ӲnSLB5eD0:yP;(w9mΪnWhKu{`wk kH>*ڲ1 wp5Q݌$;LvvJ1f3n*Tg@oO#9|}?V0M5.ۀz{" NK?C_$ P&B̆e>(qIu`|ob|_0l2WꂝsCܴLTIa?f(/+PIwB WhgšH EiŮ(G6 "  "(H2̙dfr $xZEP>ţC~EF:}< \{ % rH6N$(߫Nᷘ_%1]2:$o-8ȥ I-qt;'kTjJW^}kfQUr\ulNkHn᫂H*Wd6M2 *{`V%VRoJJ`+"yO|s86Vy8 :+;9ɨ=.qqѝ=ɥ^ӏwldG;fH^2`zBȳ ŞO*{M2MoR0i:T~%$9ED~cj<}${.-+P]c=Vzpwz\S;!?C:GFIױqYŞ ݇>;]mS)yrEz_n˕aI"l|sGvmߵ_7e]֭>ГU)i:D΂G}V W5*{f? ($p\)9D$ZYr|(4D܁OHʳ ;ܫv۱jxLr_r ;Wi nV|Rudܦ;@YNl-QnJȲc/14C:'K&̕BOJ{ߴzfsW|F-q2 ?}Y[pXdY<\v+M{ir8~LJޯ vlL: ?@o[g`}>?UrǛI2Lk.}GpI8QRV%܂L0/PUE ?ɹTcۼfHs^QMC!)$ ; ej uIy W6#LMi9ĦͱP*HʘFg]mߝn+|X$Z6K'OQJq m(B~ljSuZ ťbhWP"z@UVJ΂\,<\HA 5Oaf΍C75O Uݮx7F>QL~:ʥ#][eTS2%c Æ~EWg9i%3W4ފ:}޼0_X|-ƣµVu8H{YF"qĔ-F95E!L/3zLw@"FRmOQ&[#ZO/xˤr~9T00bܬ 4Pߋb>_nMFY%MOaN$ʡ˖~ &($~>tBM%^i3ϐEf8UB '`-icIaͨ+ دR=ZȾŁ=5U#5HR>njky/s6H؃E oLyCG/?QE%FvMMz)=ZB.ϡƋ/•3O85&YKլ(ST eҝZVx'xaV4Ë*H]z~h~ i0d,K8CZy{jCF')b|xNJ>V{0e#|SE1b狛*_R"37Boξ(p3_<ݥ%-tɫBetƓpx HuRuɵ)H?mf@Iz͂qrgM_D|Ce ӯ_wCՄYK/Ԩ 佨/Y0y̸7.]*ѳa !d[m9#{-;W[ U$mb?ci3ؘsq6ĂT t֠} dlv{Fyt/ټt̰KQ8 N"4ʻc'׸Ns6I ][#?wsb,4U_ f)Eď* uä6Go76ɵ{'CGa+RUA=@5_rgs1OUG*ʚO&Q͡4%nlc=%Z vY Zeਝ4? eC` _wvĦ10KB/*Brv4όwM 0r `$CܝGa6;g-N_&ɰ.` `0M/s\PMf`p3 $A7 i c(y jӍ 5!UiMSD-rBFL&^:OF-T4w T3c q]2Rd/3U\;?Up=@b TYRJ3O)*+sWu.[L6ǼA. 귒hoN_=C|HW Gz}w\2h{?Ur_ס,[<4DmD〷C/Fl Mr_򑹾g"P\TMIiDw$=` IӐ }6.jYx^h}]"]l 8"ӽ΃ǐL"Hڝk:^֖Tm.^@1~qxTlU#U75:LE|4&W25exz*̖̆;M0do^lpmaIS7kD#'͊$"lL?bADINmEh 8Ԍ*"vұE݌5Z5 `z~x[MN&a|b(ǁ$ch |cq)M_Ɔw>bSО$  Dpz!G@o3a]PnN2);K4 U"p+q 7bLay$04iCc9(6>E3a{ R䏡0`?s07y9'`Lq`ScLr&MP.ڽ,_ru/F=܏=1ltŜ 9>1lם KX_t+ =#ثL uuWK̹ u)F@jR_$YuBśGbQl+$,o8qlg!) n2QήU>Ytw(^'Y! %GU9, &>YcwU Mj"Zo6VWF9=al mynqA/2AI̐i qAN?!9NxlbO{eiYQ̶>SZ .&sbj?1_ǡPkٟx`дY!n6fVJ?ffon06l)7BuyMAѢ&m>>Nj#4J%&|E]ۊ:i2g0io*6zXh +҂3;1"2ҍ+O?KjaY|nMHpA/LsI5cu*ΐDx!W {|mpq%qehrYbBt M7uA- w%5,x+ z!Ί}|%wpȩxeXx|Yy$M}yAz5{+=}5"6~{άq~p^Q~Md~*XŸ~,LU~S@~5 ~+f2T"P{pUIpf P[AE;Z1ٓ0U)Fj"0΂op~7f ![BPY_EE;T\1撠C)k"djpmfr=[M,1P\ǑES;`Ћ1')}"Ρmfni=pkqr^mtolVurX wtDyw'0|Yz>̾jqźjlr`ntpu0rnvgkbtgwWIv~yCtxz0b{x|bh|~j|l|^n|~pp|j\s}AVtu[}Bw}0z~l;fׇ i 9kDmh5})oviNqꂿUtXBEv=/yVǧeP{qgi卞|l{nohLp(TsuSAv@Z/ryX_dִ2f}}hƖMk/zmtLgdojT3rxAKuI/8xσ[c&5e[}gܞrQj.xylfoDSr d@u/x\ębp vdܫg%iwy3kyenbSq@to.wUad`RfWh-xkkemn)Rq\@?t@.wZtf4uhvjxxm0xyosekz.qR|{itP?|w-~zK'rp{sqԜu#svFtgwwtudxw*Qz%x?E{zb-}|Xpzr'zssj{@(t{vxv|cwy|Qy }>z}-R|~H(oYpq݃^s=uPt;bvSPPx <>Ay-|0m{opzrt?s^auQOw+T=y>,{¹luSmoou{psGrlatqOvk?=txj,{ @k mܖnlprxqؔM`WsNuȌ=&x,zj׫4lgmomqq0_s*9N uI_|2so|u]}@vLO}xT;"~z-*|Ly(x*yyr z$y gWTaˢĮkTd@D\dPPp-HG&]30;sCg( 1DE*n6ܵaz*&>P3ĸg| ,X񦁓`S$>BG DǕu#i#܌-`xJ!wم:(`[HWeQ2UFD`|:Cd2~TvkdEeUb2̽p ʠ~[@QdF!7H$ #dLt!BOK*G-iCrB.UlmO> ,B2W<+367ߛ@ )۠&KO 0ޏO igm82=D 4FB[!AIb4~Z *fz\OtF&ӝN&3xF[Hjz&3n14bM zB! |+ /hw{V\lsTjg?қ۟u 깮D}û.5ʺ(wM ұ=Ljeo(u\ yPXƢ8p2232"uh0 ;(3-ybݷ3WdsF@w ,8#!H*9)iF^ P7Dg3I33D_)JQNdOm2ta':=J.۱ s`d+uu- ǵiȵ\L kw/i&G1|91:H^gW@-Eif?QF?/KvřMkz݈uN0:ӎ3BJ]PU@׊VVzDPC9>RTl{=EY^ScyjN96b~mwj[ Zl'd}[YގM:tU9WI-#d=sѣS IKuƷ6i/JO{s{c@6oPU,'9cV~M6IQ1WwoT+mlF0\Od?oi4M4MC%HfM[r0p[p|R’/Ld/_c8]׍ YpFKM(Ewo@jjI0/kad[H>|/ѓL |00SVRׂV2Cæav4x,'L82'7&n&CĿf]9-f]i{Ta4EeNٟή"V_ǔ3tf65ҷ, jP6Ex)ͻUSu@6M6dFVSˬGŦwƠuy@>.TȆVOdj?#驺sycA)w,zl<ـB*7ij,\P#;}}~r4fxO"ZhNMBe@(78,iA#FaN}qǖ*lf Zۋ M2HB-7߅,yY#p9|qeےNYƐ*M}"A튘6؈U,ۅ#||(qW,esY!MANJje6Ç,}#5tPcjOf=_`rhTkHm=op2s(Hv "zbtu5k#jl_-$nnSjpDHrB=tytn2ݑOv)yL |triIs ^ٟtSuSHt#v=_.x02y)B{! }~st(o.w]^`cCcHlVf+;t)i0aldOȯ>tsw[-wnw\-_AMb0ke#SsShA!a7kO|o>#r -v0[Dn^aaShdL%rg{`j哟On-=rfv-vm3Zp]­ `܄cr f؝C` jRNnb=q-vBw~o`^q&ccrfBti quk_wInN1yq<{u8,-}pymjynlpptnRrp/qsr_;utMwv2>@?nC)HKс#Eu$%`^>[ (?`~^x0_+OËv&"YD>s5x']~-if~>NF" P^OG# ǖ0<7ӆ7 :sXL!kݱrx{6Rt"+@q*7k1U誘Y}(~\H`J䞂\ 52[{F;Onݦ *C{2Hpuw0D(MHOB$vKѻX{'V' 5c sh]T4I DGãTD(2BNlz9eB_ ݫ.#JUbGɰ Pc36߅!3?o/˼ 4Ta1l-vKWZApɾ<>\Щހka8Z5$GdW#{{ߢ! e8l&Vlu4ʚ@ԸQWJ"쎛)9(6gf y'1?JL)b쭢l]4LkۘPpuﲹ)nCA Ŷ+2dEH'Hm&Y3uѷkѽӭ1n]_Z<ڮRvӛpjm9G݂#j}dA-uڠ 0\C"dhK>مٸ:IFq\BVhF'$[I&3BtK\ D'`;I ["%#N\I |?a8+ş3"-Aש_ZZKO%u6`X{cͯw1 $+OM{'E],jz6+~ Qk a=_/E qbVk&S7fg\"&]KOÑ: %ijeB>%j:l=T1e~/ߪg I0^YV)<^ϑ% զՏQS-WGpaθD8ߠ9D֑ՃXM' UJ]I"mteuuE)-3`Ҍ SoO6Ju@$ZZǚ;oam>݄92)@m{>-V|WU>r$Ӳ]qّ¸zEYuɔ>GT@蚩\'}њG9mp.d.@L4c&,r;b ӂdlt3ݦ]Q<b-w Nk k bK%H@ j"W4sf|Aa{8c%J@bW\E':Ehsř=}9fǹTW !3ߔ% פԘ]YzĀ&XIkWdPيb]9gbIi $ O1wu_)xS$P)m/UI .mpsf5Uwl}oyh 4;=DUIKSDSjj:?2*w0P4o+G4O6jeu HW)ϛ=ݮȆs51 okaIӽ֒Wo0%>#}?V5N_r}%7 Լ{!`D}K_4 !Q\HҽzȔHN>uA-^Ჰbg%+k58W #wi+q0khcuTT[`5Z[`J &-v**cs0:-7o3G(Z!d  z Q}vx'E}aQ#*'viƷ|'in˵Y;eR{E1vikYT24o/;K |O c Rr_T'UtKyγzaL= zs#k)|OĀ܇:axim&&^cŽoIѓ` W82K/ױϬ˽^ipuO:JD:WtG<8YJ] ՄyiZP-|xm4rQe`dZH ;4SX1̚`wpu>7 H2%Cd>zES?+&e{\Q>+) ^T9ZPFV+@l@ A B r3L2$$x *,^-ڷ[]<**RInpdk ŻΫ :C>KXi<_TTՖqcs.JmZEŒ:^΄hsVIbm8tSX&^ a*Ɋn^m=A2s^mICca|k`K{"Y١:nf,ڱW x_n~ !f睥# Aɧo(u gįVg攷E)?n/ؠbdSu3QQIB`\C!d P,2QC[Pһn`RXYU^',|Y5G4-},V{:T5zGFdx|4Zٲ u'ʦ"Ww[f^'0Xcx2rKJJDJmB|CÁ=55oc/hNL9'0jI. =$!_3s^>pX0]ScԹ`gi9Q?+,O|ekkC)6bf!),MjQZF_Y[-ۈfiv&mH!`5oIxudP#F P&h_2nnmMsC?wOt[Pk+jnA ǐHځY*zל`L﵋TL01|w:44o(%j̨5YJ_|fyl00DO+/.5T"$8[g)T`MH?Ɠ\fިÕyL/\Zj@Ν(Wڢud>P"Yd'$$ʗVJ+W>pG[^Gڻ2|M 5kci{ZJbILFPCR7<]'wKÍQXb* $f»~ ^̈́:)]}pA(+RXzE;b1t!9ݠBj` d> !L7gh%7nׅ _Qg1R2Ǽĸ:@n\KX)'WIC0hݤ!XL}4l5 Vh2,?bLb#(sÀytk]:ibP_"2S&F ߆*:/~5l6fݻ Ӡv(l1u;8qi7mL[@Wxlg Y<#nMDyYZOEX;/C<_IfGuROM++c7S 4ƊaZԃu Mߊ]>]o/m^&=Nh̕.g*>d_$ ]koj-]wz`g`@XRSZ^6uV^og~XQ 濮a%{s Tp4{HLydW)YU&R?FD/'gH7yOG S0᪄g :po)-.XF:e*diG{.㯙nwn.tY<"`7dsSC!x$g:SX9Y%r_']4K . q cYv.㏢Mrm*ADbW냊M1Dqby9mT'buq7Or }yXK8`微.;~1K}wҭrB;ҏޒ &6 Rr*?j䆑lugICkM|vhZYHn8VzQ3N??֫zGP5|No(RGJ[5&Hs)qq}^&2n:zǰkFmP03;7Nsi+ZiӍ ^zs7Tm , zb@p22{96ʄ/= 4)c x t&83B-(;^SedSy7yG^H@Es7<AQ|h[\jeZҎy1|i-M']|k!3h{&m5&[KiK%}UEk̀u hT[*FkkOZ e ev]G ؼ;GLW[d;oo3xY{OEk[@|l2섐^򒼗F6a 9uUQ[Em'*uWAw:^WfAw:Rc$DZ9-N7~c ?;A34VfO 5*DvUe_Rqr_pMv]{қ[;f4( c5ڑGdxEjO-n | g8 KٶŲ]{r3J(?ұqlu;S7qWA}ǰ=o nxg|GCTpTaH͗O0U`llڤClt0jh~pڱY_,x',IUjn\[M zDBb<Ô]T7S0Co}2%sF͘MQ ś!7fSѕ&.!mFk(+O Oȏ@ W1fG 0JZ-#=qb>@@gIxFz|޴\E=Yg6atҺ*SY5T9vh  %2{}n}I90v zRf8kOʼjVo:*xH3_ 6WWx4\;5juK::i7rʶYAd~X:J1<;e (;MsrlڪU[y5vw(k -OlHWeG㐣݆L9sŠFp6i&xИp0C2}TxmCH#ѽZyڇm{+EAaWdVSy%ې8bש"SLL14$Bs&Bj&d@Y?O+82}-D^ݒD(PR{Ѭ.s!$4Pڣo\i(#u"D8 :]C>6ڒ׶*m@1GQm lìOrusg# tk-ۤ^G) yۂ2b+PgDWB;T+4Qv{9輵;!f6~/ė|@r~EM$,<`2+oMҿ$ȵk뤆)<$\nnu|LX+z-]:r"Xꗺ.KW;–YFC :Aǔ+IU u+U>.+͋;SN@] LUXKx6 ͑8=*U4^qݗۥ>S韒+Ż eLsf v?m!'粈Yv0zْ2GwT1e{BHM, &fr(y)% P Ehl% $EVDĶt o \~6-s//E 2<뤪t :mbpVn(Q7:ziZNl*3miИ` snX U\Пbi0^Kc=!!{pwpyKH&Ș/UDg#M@1&yf_sIrŔ\ Bc7HexXltbu!hI &) ֩ršbps;Cu GFq~~c6RbO'l"<͖z [T0}5y V|EWrф\2aAA0 /ɷW&aA AK]מ q\kPU"Jѻ?W{j#'rG^$U)~VHDTup7eÊ⚊R"I^w0^+mOXiMi-T5ȝ'N]~{e r5Ճ-wA-VYF~UgBOJt8y0.{KO(vlJ uS0փyk^?6Wc+ Cl]Eko%ݼ脦g}h0[[tVۃw,U^|}X?4:a<X s%هU)<@ZQ/[6 . 0A=fxIҗQl3\PBoJ]Դ\>[3?,ЛMOyIOi> '|2kxo6oy*Zo9XYifNP?1k𾠣 *_BupֲB[ 4Xφ}P73d"dٮ&<ăT>x4Y"GXF%Ngt2S 8.hpq܏#~2HleҢ(j =~n$ Y9PKC‰/q䢘&lrS1|8+ۺp5q Z(QӸAX!\$$$CsrL2$L%,*OQuOłBuUX뵊]xV~n,[|nC -bY@X?(e92"կ)fm6@>_|Xȼ L N+VJ2v&ǂga:y*=>C,꽅zqwΣaVbP$Ԇ3H* |tc^7CvfCUʆN\A X)MȊQrK{Fۏe"j%hCi24.$ҲɹDӮ?2]HMtaPZ+C9J*_r%QNH4r{W) |em}^e ٻ .v_.e'T)V4(FoUgzf0=rƣ[(hGjKҢy}%]ʟ%(y쭬0L1sR1w^NJO7 نyoxõO`i0)¿6T@JJL#״C[!)9!w+@,&TQ0GU5a 5\1(-9]s41y3yʍ/ G䇫~IĴ41_35g%@.1N§ N̡Pi'74@rz8Z? i;f cENOri@Du{A6.ѱ>1_:, Jf?/LCNN*E]٭!mq=p)ݍ cFMH?b;t% 7r~L&3>ﰞ~6slD'9?6T­ϙ^ 5; k[}gX0^hq$WKJm3qV/f̔&|}31sO[9"6ε6 9K+|dj8a&kɐ=9wUͩ?|0,lugzeU,}* e-^uGSoy77bC#Qşn[,( l^ 6!ʌ>":jbiq2$V1\$ǕwkGԣQ%[`ѐJ Ή `]+Y)u!*5(HIdaoElw17hYxЈrMyA39ScLYgBل*dlQ P/Džml)IR`i?ĞAY訌:et/ ysn琸M>dSG&HPe*p:vFӫ}9|%*CdڌTm ؍θSVkq~VQ< f CB'LH? 6ǍZWzjxA|+cshi#a43 KZr?'H:m2AĽ eЭdcM^k^Cj#,@DL2I~tHGǫJ̀e W`_qZb "pp߄CH I&d2L)xʪ*jXEtJJ]EZ_=@XY#>(UT#tgE UO4E]cDix`Ffw0b(U Y]sAvjfhw@A,bx#iu+E_Xx˼U-EW'_@ce2b1( h^EN `V[@-kbn_Pe:60lu-'\j|Dme;tHGD˪&աD!ߪ@M?B=rΕtSwo2Y!;DLž]򮆁˶Rf;˷-r0ۏ첸R}"?5#mk+3((.RxP{K$ ~?uX m(U$C[KIl9vL"F]C2q.OI61Qx 1iQZxle_)O&uZCj7$6} A~8zXmb|n^i>]fQBchJDj^ k]rou#Ih 8ЂTc1)üW+-*kxueI~PE:LR] &t-¬^*$M4-bB c鎳A9ZuKDۄT}pp;dzx0w 7 ? rlJU/3BK3hf@jm1RזD*p֓2O(Vv ndmMAO;1S`M-a6)N˛,_ l[c.Hі%Ŗش+#]lcٶ$ s~&b~In^Y6-쪸ʟ/FRa` Ei|o$Գh:)=kZv6g|V'E;R^t\"ZW YnN'⢒LiK[!6bjnf$=+ *.ӃKvIchP*%zډ,1-pGsD8DC7x&X8e!j5kL4Y &XqYLA)$]s_g^.[fx́{sHq  o݌ KFaa)1$PoגיDO̐Ńwq?0$װޮxYZN8$8 _ُ$`lcZ6ݐ?ȇY+0H5zቔkQ}Ö!~QQ2&P{BcH|7gz9^sylu^A ;RckU>)vQ 8:oVcsK68#7>^nNk_<w*>mڹ3"ΨŢl` D#ޣ7W-#hD:G"DxA4 >X( 6b-X>*'qkxOOX+{5| fP|~NEzEy?|S-2<3}=`[~#ltGPj_ _߷,cn$kaM=UlMQ"gɆ 5iЉ5M%7R%qvLSG[]]M vKsw>q| 7pL=#.[CjϨ^wUOlTvCe]j20uuFfձʪ:AƆ"E*S'_ !Z:Qpt47rv윽Ys9{<Fr׃d+G1 F~ /bm1&&x, ^ LtZnDz4g?x7o߽06m3fB|=ksΛ 4|K5~Xp%&(*,.0<664^?|X@`PsB#b$ PX<1A͹O3l.O IrOS#?UBP' BPT;} *~>22 EOL_~[ g ,v,cy]zFl(}FVύPq㫪J6A$*H$Ρ`v0;f×9zL2ٞQC|QM5xzAR+Ԕ k*xGjsH%Ť^Vaݼr~Lȡ3h5$؋#2'$ ,FP].V!foDc&2`* _'ǹ{# ݰw%{2>aQ*X SV*5r1V/\2dL9x~dE ]0 ^z[AKmILŤSK``;m\ojc{.]w{]}A][UT5䄚T9"#֑$-QJ֙ (R;7n^윆a:VVTST@e& PkLlvw6ԷU8{`>5#8-Eʦhc5Ij ɱUx(EUu=XU=ux}{tjG 4a(=Gr(nËqZTivU肝 F7 :&|ؾĮȬ8CLNlG\nt{Bvx~T2?]ъ?:B': nAS+w."nG%PBRBz^MLpz&*T@ mHh؇Dc΢&ZT_Wj 5yI5LOї5m һE/`v0;fˡp;ϙ־A}UlK8SQC#kדtYFUVErAF̾!b7E|{e wY쓌E8T@V4U4<7IIiA(R@: j:8vug*tE@EQ*r 럄B; !rIC@V@]_ӇQ5UW/)aY/-Ry%F2"  InK/i"tY{p8d|Q\Đxi'6ĩ/UUi5gԧyebLY(ke&\1q(h-Ev;wΛ6 !5kC(xH@m՝N&וy UFeaf5n\+#$,۾.wAڐ&T%_}ؗY6"s 9G&j ơR9aWLt~-m ANv$&! 2p0t{z$?5Z uTj]Ġ`9t& f,h؈!%gS$&T<6ncK /'z&bp`F*8b(@H3x!}': yo8IP&\P{C@Rt(ɓʌ*rH1𵐗&dx'McČ`$f>m|S~䃱ؕ$x0mq]Pe& i#eF6AWB~8QChiTɞ <|]z[u*nz!bg9Ԓr3lq Xr3" >4SPh=m@A8 {Ͼ+\Ǖ--F3a@4M6;ҩ'Z8JԐpjj6 DzQ0'չ=;Qv(X N#0-z#}2Ң>ƾ#Ahw8Vw5C/[r:mU5fYH7H)N6S PX'>}<5ӽe~y'NNdtOݗdjM Z̓x3YAdECM&-ڀjG ož>ْm\-u ZTS#%xG;Ѣ8]0^`#Hƺb~ںnA-9*ViTR8 `'yM>aATm#GђZVZ˪ݐETD_l }mϒdo8zPc)VdjGT *:YϪ z*MSqKP}W7K۫Ov*om;Czzqt}JeVl|eryItV2j)kb腳h ?|lIlN^mzQr}\E+ݫl([Xp1ٔZ[m@_Xi䮠pvfy?q)?GZ3=@W =T2lvsdrڰP챢ށzE     q5YTp yOCŻReb &l[Ghmb9M%>]8!p~{gkl’B42?ȩVnI6 e%2G-8o QP6ncN/J/FQ&= }-9>#, +>nƙ,Π z,>3'ЏԍI6Mo$GWdosfܐT:jGyhKڻ)k[Leٓ#ceA>Vl oiEǪ2p˪lMe.{J~IT"Cvnc53}-"ÐhI'ِ,kHM"D[YjsUZCM:fD˂+)U Naa␽Zfk@ 0,"IBLtrAlĐ  N9Vr:#Q1ha x!coDjԀE_dLqi&]8NLSNIS/)WKlƜ5==\[jTv]٨@(WKsm!fwO)iiLڤ?鑓#tɕOL=?ٯ9,o9̳t2UAP@C6-!d!@ BB6BĂQDkop94Mre9*ӍRMd0W:rB5*G1GRBd; ib"P'dh8^`B5yϕJ\ L΄*nW2b߭L)3t*E&' sdr* i@s?/=:Vh,~ߗ;{u15k}6EnA;xobhS$u,N%ɕ8j 'q/qO=`S)г ,Tרs=@o5-z$^˚Fk3(lUA?5(!4v(_uw1ff:w-}hXKvzqAOQ NϜ@:&z$B/ $Gc*8?z0;ߗ]/ZZV#sY]X&qzlKNCd P¶GFޜ=;èj!,z5ϥ+D`C^n"NJf90 2?}ɉ=yΝi*mJnL6M$_e A ($eEU Ȁӏ^9,>IoGs}YEHBWh֯յYTwL3rS1MOeS-)*d`[hh%؝jӣ͓\$|[XRK@-_JoЌ+כŋ8V"]?/&{d_$]B?,kʯ2xF5xun#s [oyDs?{how1,8 fL?CVAyE% K.?)-amU [5[ڜȺMtM0o?s}*Ϝ|-.̩ {JZVu (lIneC6%FQnj̍;\M{w 564q@p${{bKXQVx &\^fA{O򒻭m.B0b @ħ/d?4m/o y0wA6kloz=vVtbd.RC{,DŽ4]@Г zӁ4#L#y,xK|}]XÿC>A𵲇i6pD1|܎,HψP(@c ii@Rq2[eaU^FR6Jz!` {v' fQm)0}^(6Rc$5 (r~P,y9wM:(^։gDHDϡyl"0A4t!5F5bl ”#@ )ۚ+Ou`;\ mqׂZ4++'8bqu2ǬN Gt$ F7 G,)O '6bgSo/+WuQ.mlc`rj($oQM 0rIF?i#@I_S>8Z7gW-[ܫ J?&[1Ck\B"mф;[ 7qD $fØt;Sj͖%qzfg,;-^Q`-}"ҘGHv- 35Sl.J7oÉ@ 5pNgmwٱٙmu*ꊸ/#7H NH  @HB\$77!PxE.ov[O8bD>Π)Q6AY-aWjLGU-oF7k1Fj@3\=ۉ <'#Gޙ?uߎo qxeP IÉh1nzY=Wu Mզgԥ'(e]-gCGi.];^ɹ>~o[?) oOP^M!=aǠtRl69m^rU4\ O%%-,O]TB*s;?Mw+Pmv{ւC)#HܥO)ih\LC.!K'b1 HQs.w{ϟ/2Tp c6#s6"bI)i+˰exVz:;9 sYAnSKG?vOW{$a R*ը1o7l ˯WC^kh+qf7 :B|J+*u}B2#PCѦˋS%e*:g cCh܁li) `Fm5{kï 5!>s^sUXt9UJ厓7YΆ-P7 $*gz0W]yl`\:XA>s97<5'&cE=ffӕDdyix M8ZH6."4Fm Iz9)d1 ź F+)mju@a7gDfFiUcԝRڊXxi>6|XG/@@+$kaQbќ0/nMҋ]%:c!רZTxY jq4Fּ]Xyw?=5a'v:u]㌵u=,"@n9 $$!+E@AHGBBpEA."(hA P뷙ӗ}Їw oPEiԑ9qͩ[ q)Q<\Uh.gY}WS(35QEJYj)zS h/Pk<^~'?aS| A :8}F/R+|cha 4Y^HjZU7 [C1 ?w<}Aw{_Kyē]Pmp\+ؐ- TźˠRVYĐ[tX;-i(i7[9GPq4zg6@0=4kֈ\c-MANTij *A+7V |ZQ4fmld/ 5@ ݽ#]w̋Usri07mN wˌ|!WQRQIc fWlerU:Gg&{ q? n. |f0rg$u͚B869A$Vˊ:bVoi L,EUJ@!Og)Л@v4>4=A[+g $fy4"nv,9r1gJc:5J-AYL :J匞Y*ϗȭy5Zg!W6@@6,GDOMBӆF`+٘^-+*uj/iuUcnC9K)7hsz 5]Nٰ;Td~>TJ4& *ow} u?zXcΑggS+~P2u.3MV&*1Z,_e%I#\iPpYRg/PphmsY}~'kGs4Tj`ޅX~>3en؈24"y 'ʸq~tZh/5kofصOa8s߸F_$@3q˰>'n9;7^^^=1.5?jD'_X,D,Qn?t/J\p &w!ב0؋gTStZ*j| D„=bCB3WYx{ot}5[,w$ 4LBA#oaQQ\xąʈ}IHNK ȇߠ Ke's}*_};v$p;$p\,1~ ?$  ! 9~|?}SRwp^@YH{VDrqQ"Ş'VpoTU$VdDױJtzt *BM"{i1a=~oضR[ Q!q/eUV.yVH[(`IʪYL 1KWiE2c9rg0]DgQ])ܚd]ѯWiMU}:o@:vN?ćѱ@Fq?.[cT(y1oM70œh~8Jh.#lQDҭWF[3j;E#@O<~.;YKhk&qtd=rT}J+zPUX}Ψ9gTz<#8:<1)y/%O$yevUm:>Cn^!R$,@P18Qr .eFҺs&o|<#AD1@q47剜_NJ5yvAT8a@Â*2 hc^3~13JEi颸r!:Aj$U^NMrs!&xt~8ۀ>4@sWѴm)9PV-kQŸiP8SYFR4c4Kl] IC4<Q zás!{2 ЅfNxfKH~JμΟuF^4܊prfJ@г:6BRBd Am-[[ꍏm@Ch[kd+>~r`vS!CkBD+Y]d=a&JD;Dlw؛7c_so` y툈z6tk4 6֗7Z *-Kآ&%ת#qfB׆cʡ2 GMTC?.X [ZH5:Wt6譥dUEFIҬŋ(ZǗkxZ,z0= >=P~?Y9=1y~4tV$aix%A!jLsLdEԶrV!tZQ<s`i ,{߸?xQ#/Ne`%zyx+UnGz)xVY'iNCV`k"|FyT&`y'_z>#n/F\Lz2Cs/)Tb%Ӌ\8yU B+|Ȫ/: {7Ӟ޸ho;A[,8N(V'O7* xUzjޝ;Wd(aCV%l`PPyp<}捑^gՕBkQG5wa…g7pkŭYlhd˿L^b/IİK(9w} ۿy7S[Zh=(L0~l.}-ZYn@."@P gSDFd{W5d˸:n8 \o3K>^=ݻ_%%4$&8 j%| A oմĶ^Ƿî:fԌ& 6-LzH| b?ӑu[}U ^^_b6QYU82Tݘi-434o'iͩZRn ZoH͟sӹ?}W>ߪm7 b#1en ?#s"*aQ{u5k ixtJK} LjH 0}0:[gAM vtv3tљvZuծ]uC;rCDD @ !`BBHHBr;\BZPXnŋu ؇}f~/76ذQ @Bbh\Yuun^R! lQwLs6H-M{#RpRʒKʓ7k׌MrM'?gİkS!" q8@& xw3KsޖG!禼:􊑟 %X~H<齾vmWkaİu~AD (Dh>F,AC~I)o|J"&xŭԤǮ03bgF}PM}3-z[6|ǓoK@C' 룐A PtD`#c{xʢHjl80bÀ!s'<jc/q/Ӄ@ | 8- QMxFeU>iHR|/1{.K<['-<+AIgPW7 K g N H]iD/X"IYEMo( g]Ytd_6]8|pR~ =)L}Uz{@ yf4HsRA:VPRX[CYqDu*ܹr. Y%3XlsZ~=*UN^i\U^,t{gP5y - AEr(ӣAeQq>IY`<<)`?5Y^2]b+0gnϪn]T_\Vc/=˚%>x[@A#I=,-B- g Vm<Ǿ_%߭PfZewJ-۸?{5# %SryUC ݠ>Ф'XʂRlFyCrsTI0%ŭҐǞ݌!Wi KFMvWZfC?]>jqF-VTyl?d^6b#Sl0bYKO̹4KftDuE5spx!DGSvWLv|j'mmcUZգ_E&Ѕmc~0 ֑ܙyWk:nv}þv sv$4y4A֏K磻2nuJUaDG222qwQ؃RpaWPgM/ uLnmXivu:3_0%yN䍡I/ɴQ:8nj %bP,|Tv@^@q;$8ΐBOGhOtP___r:!͆i`=li_(x1ra q#Ь$ $v@mdx8$ F{8 ;("a)^STS 7 Ә>ɟAdL bc!3쨠bUom`kRS2i@1ȏlr>>^@=͚#K+ڴW+lc4`}_81CQ~u6hxF 0l? y;H !?)|$Y"3?iV徊H!fLSI̝Itx#{vMH!!M@0cr?H+e.%fNMcH͐/dLk V-I9wȫ_G 7^P6P%Ȩea-\`XL)jYFX| ך3"紒jro/&ꀣmjv;!NzA1 1+d)VasYV.o*X0N?'Tg<'TZs{ZI=yw)=?S4О\ p|*N{?(ы Q#eMeXqiJѳRSFz9XFRwOMnUzwOqKqOVgKx}E5qcu(:ʢ2 R^P)R @JHC"BE0 A\ gnև}99? ^!HyYz@-F*#1KcH9}b_Rh2/s/gf 97y7 HPa 0WRX3aA *v=A)%(j*5ybf?7 +@\MH@2 P7]APeB<*#q r|h%x\N/bz|VViè- 5(n@ ^$k $ub wkd߁zf0]1>F)\d7KheRUr:[Dx%2Q5I%euaYI+tJ^%(G-il \~NSyU0.FyaM𔋵dCPq d&؜L,QdJ)BJ)dB֋$SC wNyߧ6Ʈ6/> qJhMIlm"Y+q &WQ%+ŕm Tbs@@ӞEoܭ-~b0䤶2'rą >UepKyBBc^3XVVIqUz1 >7O;AtzB;~ICțF-LZ,8GK(^4#J]cz9@YA}O_\;nzGPLh%%lƲ.I*\Y(ؼX%mK$ik ^-!Bs@i ?lu?ov9цwD%HS2{31| n)c!5*!/Q)Hj&I A |sPsp3F>M/Gl|tĺκ>mw3ȭUNӑ98żbt,Bw2IjVs:L&9Z&9&^ MaݕɤvOeq'Ey+_hbh'GDzCȺB(kAzE*f5Ό0"4ӌ)ftPnjXo]+o?سB쨅手e36M$Po(u v02`Ry=0^G/z*TN k㷩a#3 sr%ۿ Ve ˴?si1ߓAԇaqIw3SY*v5(Y51讆to40xQ9rl|Wӆus^Y~mKw|NQ^#Bqsғi1s̈9Zn0/GϷ`{|{cn[:6-2vk-oVZm-FC q4Fcqƴ(c j&Rߕ}L{#}9,Wϼ3 , S!VCfi}ؼþMGNK?z8O.{—`bc?[BD/b>bSPo93){J<#}Yw:W@F4 WAZY۾[hΪ8,v ]#xA7̀}@a zZ`C? O-"ܖ#>65ڷ;2"{+vM%\ -ypI^vq2_gQMg9=ǥ=Gg>(*(Ȏ;Hd%| ,심@EERVOU0l*wo{_;Ci zCg н|_H)Om;ݠ0ʃ]ʬ_Y4("65p`63q' ܭc~3!>G P~؎wr+ ..:rN@uᎅEc *lظ zHMQ xzAԾDkW pN8t8@`s$@fka;PYln "b HQƺoc.᮳cً9 ܹ11?` v뀍5}wG!Bj/YD}鈿S +5wqY.棇xcy/q14o(v7kHx AAn8x|A e=1ı.${5pנq &+0ȋ9 55l eԄJtJ{UK?Mj>"k>G>EOsE7ڙ+2k1`0)쉑KxP{ ]D#؄t J2:xՙ&V"_8Cj71RuӲ 6YPsMҹ>jY,BOz;[Rd:MRhg75V]={__Зsbc kAENBv?k|?0j78H89PE -aoPoꤜYB#k 5*a\pP&k, E|>O<3KbXC㟡m+y~oߛ`b<&Uȥ\59颦lY€VɋTg*uũ 6cdJ3Ft@6cv`^GKq;}^] h;c;H N]/eS  VUfRe $7eMZYWF0W-3|@oΗ l1a ؜um%]V;B=vB\pW-%\gKERSy*ʐU(E_0}&79 @͟ S߮\tncuO:>hp{+!Z#9RM2Ǫ* KH)T*mN6M2յ4\DgB9_2?B p%MumwuL@#pBA^ ST::8iQimlY"YY9}^Pd9(R6 D)LI3 %8)|'r2$E9)yW ro?(}Sӑ) ֩ COǥ]%c7M5Y,iY!iFy-_RM-ϻR?{9,Rl|RRF$5tYqE7 )ɏ<ޑ)  Y4PSF5;/xWg-^f72.ԊU!AyW2*R/}8Bfzc%9gʥAgjĥ:NwJCrgECzu6Wzsmsw~a5eJmN qȈԪkRbWH:&*_V/+w_rDgfIkU[4Pe1vGO}MO@ٛK_omϕY' YwFHNM?x=G_sb:Uݔɬyɮ|ɭRAb/+զtU|J WmR}mNW)6'|cDŽ6%ňw3\Heܩ%w_J{1 GV(d2*uTnVyxիE5.vmyN5ҏ.b< >oDrZc}[-U$rD$j {.TB2/^#.SjПS3gi{ݒ>'Oqb_B]\~gݑ&ft{w t\ ꨎltz9)z68D WoZ?u#ꇗT ,iCzҏNF<,iQL?ЛO`S,W}ueyUL+vS;3$~S' j#*eߩ]o^T,7Y+O;'=#e4@ӑ/rdbO,B&xȏYhuX#wvݗ C3깢L!rL:{NFN&&%ST˴}P<4Mt /fVwWkS%*4ҩǡ; Ra:6p`F~ 0cFnuF##G! E$Ks@9]0D Te8v,`X` N70I>~ r>ę["fȱ2E>ރwf6uw r3W)˕ 0b WS $x9[LkpXBA{c7$;C#@!MO/ X/AbAh)c52 E0"Z+l xj=ir$5w« /Urc3\嬃hD1w!av%8?)b|Jؠs~S6$ o=OQ3MAdpm:f2ɷ@Hq$KˡS YeLT~Sz7I}t _(Âh#t! NuM5exuH،x1bCp = Ȣ{v)Ki5)Zޤw=@0A}N7PF,`Ȅݾr<`&OlX+m$9CiFg#Zd= ̠W5o*oQ+~(F{.0F0Lw$sD% lggEw:v/@2ڿ.bϰ=l.R-:{RUp#V$BB Y$9Y$0Baod(PW+^!,E^y>9/yw}qzP!qO( CT=gd W o#oŸ_F M"#Q/IѯȷP(7b5. 0w~B~`9PXT?9; @X\V?, !tǻ4̡Y%ԴjH#uz:~CCoX}:No\{5MU?ͯO+r3nwfB` 9HY}LpuD(09ZMF5M.t+y&A ?,'L2򤨈2% `uM%;Ěsy~QC| %'bzjb72zjRXMI\I-)'Kb mB\@ḨOH8Ww~rCsk 3s63Q64r6[!¶K&~˙F"D]?L 49.5%Y =7pH`1],Y1W|rTMOweC/0m|L"H Qo\JhKٍU}_6HϵIӹ{n OO?|{e/ʏU{Pu''L٠KT2^fq OhgK ^\RQ?& lLjwxѬw݂{"YMв֞\;Tw}˄ nʦD֤ctB5YN7)S92 C'NEEC,PGI1YR PJ[rY¹}'}K5Uv Y/Ηg1c|I'SCR(NYd*R!Z2_ɞ*!hTAc2px3H]}=@]_Y0^}gwt# cOU EttAVJNSrY&U+UJJE1HaU@5ikwxN|ҹk5zC'KԘ<^-j3$/K5u&-Qp5 J暒Qr4rn,Am@7dK[>Tluٰ}së otxՕ`ߦ*P'B2p5 (\R' G&w5\gZ׻^<|}WwVPr9꘩{.+a%R!(Pq9g83mRa. $rt >SWV:rk>WX}rKEGK 2؀9ZG@$Ub\TDc+شB-h.YK}6(E[%XӸ$.wBly; OU+ڼGr꽳ݳҚ7y(n)(A=Ǯ52:ZVf$+̂J]#EOP)=@/q֯/qxpoӡrΟ}=K+3FNȺ :VMi ӒLC5vDS7<]~QmP.rF/Pm`C߽yݏ:6Žў%GVg  uDЏ fB)7^^Lu)6Z2>u䝆c Ъh](VED$ *d/FI  Œb#ngT-.uGܷ0n B39+r?%RC]9˻RzU.y;w;l`Wqy-g?cS_iy=*| BKZJO6>b)MSXT*4VUj^cu:ZvctWn`>ӳ~˴[9N;W/9'%j:f8#mϲLviTv:^֚ۖǔ.[Wd1uV#eߴj%?Pbv$k4mv!&2yҶ]7tG۝8 /t)]8IWN0׵^bvWrRsLyc?=*˷ /m $KQ TL eP`F80+c_ĴŦXJU$& U% J>=r25j"#C##KnD]=q=ɑgDGw>ѝW!p|!ݲ7=^Jp|Rq^>(9!Q( HaY1!;BG.;QȞX?2n )~c3:Q/H&à r"d(|!/1B?T`GMG b ֶj+}<Aw#` 'p3nI`ǃѴ(ȦG@=# :d Ry=[9}Ʀ߷ V|aStD}Hp GP''C>i>ԓ}<9S|P6%_z=P5uv1 ġP/r. ܙIH@Z^(%Q| DJ/&8X`a:$I!a xa;{K!Ȉra93aӡ@ eqqu1Syn-\Hnlf裆XT?go"aHi9C crY3aaH @FVҖECm<$ 1n&x k&i}V3 #~{Pi کaa5, >.A C+Ĺ!<20DC:oe@Xu QS|pS\(nD{;rPo,'!6@f A c8Lש( _6 hLj] 䛙āh'#NwY3a)X<,a&Fc42Q)mkD,Bg_ ܒZTO.P&6+%_e- / _'E}4pR4Bo`,L\jV[x~IvX%=!+9x-7+__)[T-=YsSn\V/*G5f. 9sPl8PY^X#*EP.r`i^|onI)k-筮EҗvElSecM셦Y֓~G>A^W֯;8"߇UcPwGs-}5bc)pڳS2$kw[4UՇ5wtO7T]Kzuᔦp?VM63rz\?Y Brs9z!p2;ik#|r[a[!g=,Ʈlׂw1XWef ƫVD)tL^Nn?Γ8rFJF7qxg3Pr|UO3& S5`їƽ}/0~_5t<᳷9h[C䙆xO$_TN r0󖻍\g'9YߎAZ-՘MOd%LM59U}v!5J@XĖ1fGyPdвp.O80v9f< smOvcb8fZp(%-$T,,5K34HDuQP"KٗdZN<9\vupdi}{>Q `>7ZNHM$RCÆGda+2ZB'pĂp2SHr] j yhC_K^hyb5b=lО# pQ,[8XG*cE_ODNCVNš)i8GU;ۈ&_HfPHZ!I!q"EmB"~>"pg#!(Ohg3aNQ4NB8kC{-!v,t5J d @T~|p7c1?#HKAo*V"t@' @ P{}dY7` b u: Z34b(@,i!֡%`D(0~N} G69?CL  [(B[= q.Ш=4Bqq%xg`]y=;{5x5,k 2)Blp'0\Wx@c2;U ._ QM;#tp[\6scc~pG{ÜʘT e0} 5alZ(~'gYb.cny8=לOO11v {*D̿D[!އ-L쑾h hśH 1%:K谺8|H!rP6 ca=,(^%~wBx/[bE܋=!9a grܑN6C=ڵQTUnE/?%'bW/wsᓸےRȬCAIɃL;8bXɜ!|n>sZzs~Ē7 ѯ4[؝>sQSYr_?ߓߑǷKWҋayu!CNF ;ڢ0xǡؐ|Ѹ#i{KcmJqkjobMZ:Oo tgw%;y}w,p>zݭB/M6小\!8D߲^7ZՐUPq̸%5:=iszGRUgcefobEf b,g":z_Jמ 獡#NvF:unrsԱLvSQpxWZy}&6K&w*簩2yCgu9Irr{A"rYLtފ#oserɁ`{&^ɛu6LfJSdSy:qMP\Tee`KBE~Cb2isjrqؤϷ&,%!T ; (]@{:!PRB( R'DD H*" qwPagȇ99ߒs9I$(BVK S%> ~"^=7y^as`&ETSYAʨcGq'y3좂s‚nÔ/.w-XOlDde1%PD _*s:bhqИvN~Vqt`~xv>ǵ6Ç_TSq4Һ"މDnW49z)p}8EGדrlD@`VFExߡ³JdT=bH2`#7>"ak{?~л>;0y&6)!3)l09l:`9e̒ (FPyyX햅#`\/X˜pQ<cr9Ut(PZ=/2*PmC|zu;+lrJ'&I̩ZgTn$VlDt_$ X' ڤEmۓJper7ujRzdYgg穾P3Qֵ]SNA&&t.C#I.^hz-;XO#v>c>N6nkRlrk}xg.+98=7Q; pa``4ݣARP.F}CycJO$ ]ㅾjQPpav:MaC/ao,lfʹ%?wHo, ןDY\$o4(^U5"kUfJglYsVXV^ R x_md-;]:fֳ{l`^`h>jd~rgc" t^hXx@@!`CӘJ*䣃t'w9O~[=>*~fnsK;jZ|[=8t#42B/kd@su:pPQD-JSь6t7t䌞[_Ce!S "gf(`*`Tݍ=.ne4.OH"Q(D'P\ЈhCFG t}JaFK!k.:7ict5A=Ș0EƬ_lWXi?M12qJ$ވ:&$*eQyPEY+:긺 (# ~| G E 3N:8ͺ;8Oz@5!8&cǴ |5;Gk :{nq#x9g 8fӸ/<.ou[@1?s!p@3 if o^9-j y;Rf5@nrv' tR/2}e_^S\?zqfLxÞ7$>hp ANAF\2r6hjіI,[t;RZq3~.Ӿg\^3E&$ߑN_%| , @`iRkCٽV@8y5l 9H:ff (wĬMқ\?'?z u:Lw~v{ S?xJ;oe;5CB"/oSlKlYk3)Nd;9ut3{ܟ1N|ʸI/WIs >@e@>AngkJXO]%i2Bӟ֯eǤ鎣2Մ!n 1!ktkk:K7J?(}\[0G}Eb=l AdHQ@[!Mڮ{W{zn4yX)(6~;aj<ⵠ*+6EI>9?nj3qf K10$H 0<_^ ꝉh4 ]\ܒ\w,_!5{omwrqqQ{/3=.iH}!徽jϾ&)id`Oˬc6'vMUE]sz=H٤[ ע/Kj{FܕXRgkܴ?ZWLdUE7pQ=’_DőEQoQ3C:~AW= 1%ޙhFIiV V\-[SOxgWVS{zTg*|$1ZpqXqU_-khbOc/scs^r⦅sx!!n꽫QZM}y6Tvnj Ҁ' ;#=T>)2U>(I*ي.Q$]qWVS4)u߀`_vP@cMjM給`:IkOk[ lZ ϗΉ#j3I%iCibVvr/]$8)NIC5Cǝ/: ;/1n&K `ŏX4jFtM@- aPBzVYaLYㅘk|kObX3ٱ~&6r6ȻOOG6ɠDW9i"ӽQEhƜ ,0b*e9,'aՖS3c3{DQ4H0)ځPqE! <Q=0i` 4LOt=.a.ʰ"aDCE4TQDU8 cPf([ .Rn(ASxX9xG r09ACڗZ1Jj ֨IGբ8hJ*\'8(>M\'ot b`8dLT;YR6*q~uF.J=QrNި?(KGyR$%zQQţGC1 0Vg်Qf@e;b/CxbQި$D*,,  ]彂w9zЧ[0OE-z c LZ` c16\0j #ڭaMzo0|?@uDЧj*[>*/x}P~|ݣ|ݥBY0< }c% \*fS1wM\H tdrtqƽ7jCd n]7{G}^kNtiD/5D/4Dj=|f~Rc5uԙqIDQ⊈ȾCHrsH }; #xZʴiZԱuZ>sx9||񐊵n.5YMAJ"KA 5 *#pL6#-pͶz7ӦJWn]Rc&S٥";H+,%p jHVJbe)Qa^b(,D y)|Z)qn3כ X)a zmVoRG,K)kȫvٕɎ|3LV&V%XU?@Uw(1ſ!1Ő(ZeW0Wi x6}=A{a.'M6eKȞ&!>6!$.ݙ[+tOfUUFW#ȑWy{R"wypьÝs8>Zﵡ7"fi-hgMoKKiIuHl7Iz7QCi n \+k{'B>p6?7{qevCd]@?ߓv> eЛbw8Gv廝xw{S;|)W[E?r/~V迒g9jfjk`s@=aSN3w1_3"ܑН]QM^i@AH ,!!   aȢ ѶNjkkGfܵ"hE .qj3/s{{sfW/=4rl4:&eUԉU'br(PV_}P#>NW8,9u >K~i]ԅ܋/a坟ÝyDUD^Rj NOD{Z\oO#"V7ЊwXN)iQOͿjr˹jʺZ\25/$7'6}&o 7}״Gm:i=ic l:;wP^Ս Ϳ㌊|QMD[}fpNۊ<zǷ1tmk|cm_blԶݜǸv ?6OvwP;;ye*pALdRԩ3vΰOJuvuO*vt/v^^ٳK޳[s.=͐^cHzak=U>GhùwK[w@9(+JcԾ"_L+)qZ;@U=h̦E;ȇ#J$ëpKi נZV7n7ˁp;8]~QBi8 c>H7'""zBJ*'T"}kC]dR!EBXd/48pܑ~p֑ ͎,xx5quoC('u"4c )d $L.9t?$\0Q ‚̷C|n Pݠ}f>g#Ѕf!8w W(|!g5q ̤+$a.9N )Br=H$$(H-@TPiwgpZwl!_t1 b v{ cbh01dU!$Ą Va8*Ĥ@= >re(>/}K _2AR]`O!tZ WR`HR~E$bP ev0CKq'@7' - r\>&@~ aأ+{X>߀8rɀ7(qVH pIj*$&9f̙!vh7z+bMDbGd*FU'9oTת+-Πӧ<S@?IH䓐0)IO0M_=_3[|5略3h5gx/4x57xk}10=c ֟.)~ HEnZ{4:ML5y$҇V'c0l{nj]^An}SwQDMЮ$M|[:A8n@,ҘHB>#/~|qĒ2U<}̷;u 3+ޣ&Op/Bh3Pxtp_t=ᙨ*рK_걺I& (NBQ(e(:\ Ź77ǽ#g={ U[Zm7SH!zʿE-!ƚ+ƛ9ji&"N}} {o7sY Rʳj)s\ΞMoBVkNŲZД!cR֐ȧ̻$VqSmDcYi@~<4VJ' s<0,bK%!dW"fŹbR~]ʀs> *SINf패';Q̨<Ѡs,AeԽ"xBBZuh)MְBXRȶ[ȯ)\.<9q]QMi$((H*"@V,f5@ !LK@(h5x92NGǶsȇ߹~z}c)̓*u96Ϝ e^*3WuZM?YP2r}mob ZfkVPa~RM|%Qz|Ǹ$~(ŵO%n %ZnUSOPj8=G`ߡ_ҥhܟ)<fA%z)U#%ܫefeE䶉ò3. ҼMBZ P+ڰ¦9$P%+2-%&DqlZ`ߗ+ ks9l3k2"Z*?﯊"you@+a{6 }jKKKbA*huE!j iTo5&#YP>e~L`C&ZSXQr5\k޸qM>ʮSkMmeCJ)׻_V& *W"5QXN< @>Bsoh\!B-"y3$0T`½z5:<̶ɖøPFm[ÉZUGJ>EMʪ|oHY8T*Wy-$W6Ec-sFF*"odRJ,48X`f:` -ؼbt̡Bpק{+y~š@~,6<_ɮdUL2d tt[Z?tBɐ!䭐\oJީr1p {@0uָ]r]Ky뀨1dzX]ksTu BV&*)LU*CqP|Ce¬Aȿ!mpLp~Wy z{ô1){˻O9w&)HWԖu㕧4K3!i03"Y3JJTϑ+ r|ȭ^:OuW) {hshF}p|f+iZ@՜#H1\%wc홗.:3.f$p㦥NNzj5y˟>?}?Oq$6nfDgpG p =I=@+B;D7xxK>ؼ04+6g|`rŁfڵCk3eO=IW_zFοC#fwv~Qir os+ k cLV-&۞˲?f`;Dx; ejgA'зhv 7|fkg/] z ٿկ{x`),@ [ߙ@C 8`64f1ƳGm4c5ȵ4W+jv8N Z] _;{ z LpA8"4```"pP, RFC` l¥zb'&jA'^R 4TxPQ HG</chm6F&Vjr l&e #n#D eSNCCC@:*"=S,kP%;LQRBlt$js_%nsFΐـ޻9sG^xWSo-Tj}'润 MuyVMg/hF5DӠDdEa0$L!g*Si=j0DG3t9G.ߌzFZd-tm%mӅZ!?9rNGؠq;EQ=QGNZ (M4LfΙIJz{zX[3ح ټkqyVcW\YgCSǟ"8(s9~P~Tx>좸6xx!IM8JEo`iǒ7g`Yûl;x ʩg[at5#}!UgєPp6i 6-)>$VG7yTE_UF?UcP=LxI ds0<Z@{-ΑR.¸j8]ECF.-D ǣ_:N N&!Ƚ2~"RVws܏^ZqO%(ߓok"!dc@13E4wкXD]c[lظ ]lq|,úՙ3 \+ֹM.}7מEIRN+g^3?*I1ބS8Ä́!9&1<&_b7r2Wi1_ì͍dIUTfgT6k^QIɷ<^3{{j϶:-畅w_u+7nJG騘=C<R}ZVry^).jpdI*/Wy`vs-q-[ 5gdBV.YMY2O(g6yK.omZ>a"^.#NzK\ g8@U+beV%y:Ewn_Bu.Ϩ<PD H)#LQA,"tІFpF RD *1XQp]f%'nf=G}s=WR*x-^nAIܐ84wQSQQ;aQP_B61xCTT0^,p̕_-]Qךnܔm^`UfWH+v)OmRIޒ)ܤ޹oEDBLH$ oA26.98]pfnt.*[;hQ]&8+e6lDzBY[Q+HouSEg|2R>H{-H#BK&E20\ߖpQ )qXt)*+4W֕V"ҭ &ۖg:J $\IN^vNWFv -h[i Q^R"K0T꺭bQ#U+,-}).$)" &{d1pq5k7٨&+46r5 j:^q:(X̝),dEK9wkE5/snAph}OQQQF_,Õ2ڃJwfm4Յlț5{V5d7DbRd+>6)uSu墈&ކ.uCq~hН) Sxgz7.^܃ZZi>5Pt:2e^iRuI*Knm7rKs=M2 JnHC{p OpCpC:=zW? -4 ]@e*{磤ϖ)sg.VY97[pp֮(f):v!;ikw۪n{B.^R=lRMPzA]H-u̕IrbVύ>u4BcuGLBd.XPWvﰢqy7N}7{;s& 9:t}C@HĶQc$:2%@`u#BF6_s*ppqp5~'[-LjL.7h2h1=D[!b܍Y?.b/Qߪr#icؤ#7&s,17]Կ+_6dǁ\DU#c$&3+Y+&lU}'|2爦4SJM&-m):S]{ýqwx+}P2 d.W)6ncmm,m ib191 qBG|KV@E1aɂ:3jQ!9N,vP>'Sߨ־XԤN]O}&gI}D]\wa% R)i~=>BO͂82ٙ!.g.CX~خe6JlQ*\iTS@H.!y`b FdA@A 4 aJ"cD'( (
Pϱ+߱}`:{uI ,c`#ց]{I|OdE?Xc{< 8b¯37*535.ClU4-B 8۰::paQpڱX'v1e| 2F9#a[ lA{̷Sɥ,s0\; "_ h@ |9 fрW2:pb5 a|'&Gq b{̽D|^'Fa7BMhXt'=o) <_YؗڝW(5ܞdZnQcU!-[j!.z5{%-dp_jI:Pw1 d_hwWеL*D:臕fJ>Y)hץ(Sc +e&Ir2j}S_l_W- TC|)3I]':&ͺ(f^zLd/.XBVJ/)y+nd)˼hYh=w2٬ )vQ,yvi%)YaYYWCoadbτd`Π6AfҊ`u ؋M,hSbE nU/*H,X%%.ls>abAǐSN7=p w %!9kųx)-vbE8{`u,= *̃/ŖŔ fL7=[+"|WhW+BwK' ,:}mDss^R(shRX\)wPCTffU*'EL;mV1$bل-mWC_^!S~\[~ uI}q-v P߻`G)@N9@ΡKհSk km N3<:fjm0ormڹ55K֤Uk|YxWWENѨQ?Jw0%wrf@Rɼe%ǘג3Ly)P@w_wOqץu*N:ڕyw[6~_U%1/;{xb ؅>K= c.Ul&׮VsǭX[-uMu^uY~%U4uyqx"*ʡvlC5ިxxŃJs`Vm\clTf3iwNl׶ݴMnc3w<>]لmqN `TRiyFxs.q|r۵yyo띭}w8>9|nrolʖҵ-ˤe=UΧ䋜o)`"<#“QZ2\b$D+ mk ݾKvxr~Kqf(/]p6Q43` (; e /r*x> ].K< ^9e>gx:,fٌ M`tWDL+p`_+ǐ5|U"wxP w`EĄ+͸EQ"\!dAל8#P ܆Vk=!㼽ay4gTh֩ȑtG] ;z6& ,}sQD%IV%~pYJFii~Nu?V,'ZBsS` 9}yt{\T_b޼1zDw5Q]_Z|#x~sKn)$U9 48U*婄\C"⁒RX?"ZB =zOᨮFgyfG*˒V{3f{OBlMz 4eεFO >pZ`JUD/y:Ľr y̿_ # C{-4k-CF(^ԽfjppQ0f|7\^a3d{wUҕiM դ 0ь]}QNbWT.ŪUV^+1\"h:еg=Փp>j -b oЫ*CH׵Gh(MAcj1:QLtwxBOg tZf݈kVߙ^b]jP!SXIsGN/l7O3y|-0?a LYB6b>@p-3(.7RFvsնD7ó2?YWyĪw6vXhŽ]ٖ eɳјJgg]ȳfPQ%L^`}a`aQ PD0÷Q )Fal7Ls:q&3sޜ0e \[%%I8ù s>pٴi ]ʞQK @ ?IoUWp㠻6DC{=7ff:47BsP u~ڪ`v? lo>mnVGA '&:n1ߒBӡC U(| YO"$=3!Q2 @ׄBz=HfX0IF)_u@wPlP ( PC2hM? tB"A kSRsӚEs@a=2`8Ȩl3q}JCHb >$L$)^>8qZt^wK-uD'3Ÿ2q'vABpaRNH^ɛB~ CXHPCnnDOZu T 52^HF"$W셺=W3uЯGnj6{ΆF.f#W'~#{;֫e=֥?:CןdNvå_…VW.D.rah+i 8Jc=a} Xa2bak7lcwݿfwܳmЗH=_2p5YIr4'jqbjQ3o7>xDxG#G퍼y13K~{ԷofÆ_$TT.nTwR7\v43g7p$I42w7y Y<=Aߎ~m1[b~(h-hHh\`%li<?"ﻝAtWΰ)83Aq^aQYpZӀ4ۥWsN)LjK$4%Ygu 煵Dğ |1SPk_yd`ZT[0VFr2zeN K<׭EhJ3y5YxS}k]|tDP%VHEfuIcQؘo1}c%殺9Us0clƐfӧveٷ͙.J[}FG]z%WPt!A|BT*˗$S^X`EаtP7)r>0Oc m@o6Sm9`ߨIvV8ת\+Urg̬-l/VʣBYNxBKdń3_ ܣگ逋"`~ǸCBGGAӢñG%8XrӜ+wcNg3de7 }|aQ$G}%@~HJQbB'Ry"es8Ba+z|tٰ֠iEU9H.%:!_YW˫mks{H=%Qi/dj )Բb-in({HWFG'5ԗ25e;8a\sI}iqp)2t|b ~VA `T;!V.J亻r'?$ K߱!u="!{KsH_[p"$bP[*( b ݜB~xmuSv%2MYY^aS̃$0(8qKQ[Q&']%3ZZ:WtCY?֠ȺYwrpnvC}V}^8vw֕z&Vk}j15,(-aW¨/U V]uTz>+C4-(lA~*h7#};jdEqmim2Gi9%5\y볿_x,?:_/aa ճ`>GSʹ -]=m]]@^7^/dٿA0Xnb>/!W[cv 幷%ޮB:B:㦉fz~t.tV.=Q7![@$oGx(3͉OF"Ʋ9u5ctmim##?r>o<Y, Q}hPv ec@¤b=%F:ފ] gBgb=3) ΙmU?nqxkzq 7/ޜS'Xc@ v>ʵ sH:D&u9_[sc>oχ`|mq2oTh3q6٬܍~Ivl?ᮝn9~Wc2 Ng9ឋ@e.,x p iq6.a]xj_Ǻ%,e%V/YUKʥ#LyFr6#Y,/<爯1E#T{'trcIICm׀759`w ﮃ^ł_9}PV )x=χ_u1>FH}oM+@ lzx> չle D((`W% 1`A,H@Dņ(< C-O1D!ODQDĂg0;=;{9F 9PY0s C Yҿ#DHNb:D X ACFr<(g3J,Z=X=OZ8 `] h%+"6!j&;@:5ͣ1n@hm }k7jGK(]48Zw }0`W.e@~5Gn+jM :kRsG?:=@ON}DoG=b{}`]$7bu)bѽ5t?+f 5(F?C?b>`hUŗ`RwŮ`0o4H%$"H>)k xCxjth(m0k0QLɼLDcOWI%KrW߀|ExNyn@߅BV5ջ ]x)[,<:t6ᑬt7J+&BZ7pC]h2ehTsE9塡|(T7Tj :U3PŸ@7QMVen)wr{q]yMWՌCkp^øZsƝ{=fKm`f9/c)QDy P+Kz?'\z?#qnU듸c>;sC V}ҮBܾUXfLXD%L3lw`φ1H6G[g\qǜqy,wy"y_sW8-q;-v\#,s:Jvv:;9:wlqfLm|N:h{u A!8bnqm* [u_epKؐ**2,m7֛l1l5)0.7TJ6 W\:dk\^V2Yg`(vF#9. % }#cwJFscS[ŋ6-X f%YZ=_ڽXU9 ֥t'+mZ#PM88>(cEV~O8qT oDѺk6+Y"ʐ-ʑg{fzmS,maeye//L:}?>4sЬD}>͟Po ;`k@xry`A1Zѓ٣L2eit,ET-RBg}[~=h(1:ӑ (X2 d 3lj2|/N&I I?Wbиlqr1_5׸S3Xejf<.iSGCp" 80(ٯ[u^ȉ̘AȈҢ}QXqR9Ӥ'S E ]|j)ǻMk"-&1sT?pjPEq췍Ҽ3NZ,ҿqBj;(v<.@0wlpvL8!f)xy\ԨLȵ" uyGEuqwgfd`.誈i*e60 URUZb2XYK(nQ@M\)GO-hknQ999s{}b<31=uO\u]D1D[~:s[<ס='ˍykP0e P0I(HҜy2s&3.N#56CiuXShvNޠGGp>36o_kE QY|7jdYc?4bIQ4I\tl-4 6)1D")!ΐc/T+b۵ \z/NFŋ~>\3T`'ٔuy%&G,5E^rR!+ea򗤚a6IѶE $}LR¤r'Vaܦ 7w 3wY`%Rf5Q|'&`_ԥ;I 2ۭ^8cGbt8Nşi kܭz a5_b[7 W`=.Z ׆]4T[]Mo:`+@. L p? f' iA̓0 8 ׃S {t{Ȁ>-fn)Eϖ:4@ro9tXr0y TO&`R3`Q19*hZ]nusp2Nm U{0C{2OAy vP7A%PJ^uqW}@w&cN7sG80u p>-*ka{l(H/xArA$upup}DwPA;6yDt3=S-iw8O.ձ]#Zr_`HD)PY^K:_KFn )kp9}5O= G; pKŦ@ ؋+p By:xyDM?} :[KWO21 F.~EG+#ɗK q_po-~#nT]:˪^nb 8K!N>C<O}'iǠd[[k;ϯEf\ wNrgũ!p/394L`""}*/@%Spk6\KÍ8†NQp: jp2`9Nű yy9t>`:G}vm(/cH?5'Ip?P;2z4.c: 'i8ڍVW0.bfzWt[=h/ n{h˸_E zyɓTb5 O7?OEOHhq`t Dg)`Cʘ!]Zv{*vkphsѦŭ!CΉׇ7OZ4gI{Y*w}? A/zPg&2S:Qh MP3}:5<@SnT6hZ4uuqUҠ%YVkNq+5WSHOQZ*HyYITꩤމ&8biޡ'H}1 "'b{d86Gji`6D3-vv]m / %^^%^DR[- & +[v\^'_H {BWG7&3ҿ| )-F{lM16ͱhIbƤ2l}C<@,L\5G$jW3NpZzfyմyQG}*.*D=P̎NQ-xyO |JOi:D'mxZѬSQ7uOg x $>[TujZ*W+F^kSRΌUighUjP yj9n/L]ns!I!X2)!K CmJ,S,HNe'e%9ĕىeܬJiybLR^[(TĻ|~$A& \9 4{IF ǪXnZ㻨1b12h![\npI%\BZ_ +/7+)ەN}?&zAI(^s?dN~7_mSJ<ñ TMAeZ$Sa2s [jfKS\q7K]*Ӵ'Ԥ1n},)F??2 J/W袙h!kS.s(N9]Q;yIq#IlĦ3Ein8U(1} $pGn?cUk(b,% J,v-I.. Eu݊#ʘOʘA'GHotE,9g0@X3}9ݓT84ɬOOZqIP/y_,*ʷ8o{PzN-gߑn1>c ӧ#% iJ-,KRĦwIp^4;D!:gk{Re܋$$ӻ0 Lg6)C8cl7FgaTV?x B\,Il|ଥҨղYeY&rM"<'"*WB[+XIYIoR٢M^s=\wD\C5`0D"83ƹBqfL7JHCvKCviH#iȆe!Ԧ.e.I ^ ̦{~F`8[֘B99c@"u(AxI$ %_2JF_Tf!شzS۴Ne*Kv PrQ? _H \ la d5i!݉tOR r+ZJWޕeE9X 0e,sòb 3КB[m(xuQ!b#IY}XLa[8 l5N /xF6#n7LŖ-lڶ* joAe}u͏Dt##s*g16Jҿ< pnPBUNP6t>2 kgBCfQttR@Z| 01O'06 z? 'Q@86!=Õx-~ 0h兠V>xڸ\[ 9/G0+"<5`#Ha 8iAu#y㼖➼ ŜG;/"WX_B_-'{9ȍN2I{F(;޾^S@y\|N u'^5Mw6'݁t$jV; . ={\\ ry =f 0^-z~I8m|E&w͜>ɤDtح;DM"P2$ydIOK exJVғ;؀DW!-tUU񭸆2Gq?"G@ο\!/"o™89iͦ=zГQ3pkMrpqUFjFgъSm$3‘O"%Cpb.đh8!x ܒNuY"o$[ TY:Sf*/G|6Eр&E :=؝~@JDd j|<\5x]7\uK18 Ψ)ؐ<޷=||E>86pcQgvǡJ? `>e TNXI(ћ`Pl67HCNI6ܒCHrCEIίx̲\fimc?p}a2lEa$&4lLY(6COBao/}\)A55J .{]8..]n r[ۊ(%*XԱmSӦi3δv!mӴt2M3Mil/2f9ߞ>f&mJ`OfO-'_Ʌɍˍ "ܱj}6p/{Vp\qz܊5)hF+" ӚjLjIMs"fÙ!v43gNsCܠ"\4wYSe}~@DPCܦ+;t/m"hUc*7'sMέdFNfXa젶 i{~0ק=hgnK.UEg_  yyBrpeCmnNSgcDǐނA}ۘ~#ۥ悺\noӝQ ~+BY_٤+Ш>BIN1@QD,60aL@ش æ4g"dGٌt*tUVG5U~B$Zʜ5!M^Z{&Mpݵ6W&dw*&]g] ] +"\F5uWep2CiJi zE{RpqbS#uEuSnCw}jwςb_c٘B5Y3xwZ. וywy_sjJ`&FOy]7Gif-PO՟ f"1j=d\?_T䴼n"[n~i~-J#0GLQ;;ZPŽ0wn)j2@eE~W9tYV2s܁wyu65WGyu7HJxZ)st~P@1лoA^nhRqp@P>CfdJ U!#:¤zq65qMMKr)=kJu.ӞF D5-ʞ[ d!st#2ƶc8ia=R|+,a_؉pH0ç] M&)|II74eָLZqhcq=dLO ej=N'$$O`fbI"qH+FB3sH\3oFH28O1p#Mύ!Z-v87 dRLL=e\,'`f< )H\8شhlX|s/#~qxqo n=<9) Ch_$uh ПfIH^6]p) D"\ށX"vŌu+XEJʕA|-p~I|gėPG@pS%gi9i%ҿL/וP1M}SQQB_CRJSNhHER !|dB29>eȲZfǚ550żmw\]繟њ@ҚCeSeSE؈xxu`E D994|Cݬ`@c\ 0^_o !(`$' NRp>ٜ69mV<Z[9ɭ u;yr)ɘ+ƫf0jRӢ P676 }@R;Nl_lL:X;:8 u'F7yۀ܋ouQ`= PSy *_6XAEl<sDw' L7;0x0ZSלV/R"ȭN'w97?=G3sǼBOٌ<݋*%_꺑aуBFqd$$o+%9V)6 *5 Gp-'.o Y]> C+"/NyYG(2ꬢ:lΧq&9[<`_Gz)s 1'#`uQ/Z)ʤN`uSJY1ks4r.f~/Ȣ!ӝ7:WGP3ٌV"uC.b,lN%3_gpqoZ躙Y<8I + ᧾dJQϟ0ԊhI>K\P\͜E140M\ts :k42JC斒e.F` J.$A"gѦV84%^:e^.R/NZ*4؉zzu uawc3vE= 2,wwȍ>6^X㴱;MI(M"pX2 ʏqPze6>WNbOr۱t?63/QmvU揱-xN,+? bIaJ.l?=*q|]?o쵘ݖѨ)nyfQ%*W`U56YQ1 ^GXmnzxj3%Gyg{GFhh:!m3;m}PcjQevlMv`7v9Xgka}9VoE{X^nA+'C %bY(zԿ=}bi}z5 56t38zc?*ð1ӱ#9JYXR<,u*BSPۓ C%(Cg( <'bg$LCRimE/R. aǜ|+W㬰@=ծL?2ԡHWO,TcNiB:[H+~vHZENһ\b͡\l{Jza|7[ +Θ_!90IB|B\`/.> \/E(TL : <&JAigV29Rz)d>rIv!cv RC⎙!c1# !a ؐHĄ ѡIBTh0=4W -mSL '-kzaRYA#[]dK3f H0$h\FFhM04ᘮiBdx0%|09_#M273(Bj+7& "0#2L*8y2T2E$\Ct2_ۮfFmo^R=|yߔ-}ԋRR-)>Ϝ"3*{$efEim4%wW^zQM ʶ&fPndXVL#1Z[W,2Z2cI>&6j!<@ۖh!Y>q4M`,e,x 9*#fT{RclO8MIE,%eF+amڭku!u j[5Vm8պʭVU_Z-mߺC[[7䷦A-Vsy\۾HJ1eRl4^kЯBnfs nԢ:D~aj^)K`eổf+]M"ˁ샓4(-wZ^;ir) 㞓nwF[Zi&sMk.:⽏B8jijpkxju-HN~spRb]05g9#э܆AV xE{M\0pvƎ4Gh 1.::6zIù:bQG, r/ֱ>[#>AVG%h8ٜh[mӝihml҉GccPϡ_ONIt=.9_9%tzuR glf13] &;Jw>%}iBPWf2PWIU̫8rf`Db405nt;xZj~yl ҧp>HKo[ȝkrf>7vߐ@a5쇃L  B,$B&'fѿHi5\Buz}M=żtC:~5V)@C(M44sћ 4棱XׯuSDg-:XsE]>c}X+]`F>/jh   M@'_\h(Ac{)ezO=lK59cnE鄩zj>^TPnBHȅ@B.@!B- !"BAQDTRuκεgzvvnݥ]9o|=y2|k1;nmmW׆p%f.Ōb^pe^wqbpGX}qQ,MB!R}[;q+67Dĕ8.n0np̆vr|'p"~b!MX‘8p1Slӛ+,ejwѽW6\ڔsxiXJ$+܂d'wc.#2-޺[b_77 I}O0vG1QݟP{WH{1jm }=]8c,Pxub'k&j~GIF(}Ls1.è( Qv 0 .E!qbz]BgNtW2)ZXKM於C~ʚ%X$|@5敏)pS=e勔Ǡd#$Jr#K*C@ZԂvenx:) -f$sSk48?&"fE9OO5_{Hcq2Kc^2F9)_<Ay)(ѡ]QVE9*(Mp+Фtl ;|Us^lcQQfU=|ƌsdL3NY)GQF@:,xphRRW€ WzPW [jƙEsjN1Ǩ}|H@1jO'Pݛz49D&N7@9z_ЦG t|4j JQU*;,:7:?L>fԏ /1*Go6Gg!=GrO4\Q|̒մoѽ =?eiѬ_> Tx `5\@mj#5uiBuc:NVefZq1^Tr*L#NKT֬`o+&&uh<͔zSy(CC xIe_3LVe(%dtSV@uʹ[hUIѶ X6# D(ЯvB / ?02xmY/sd?q5iݧg&#?E^`!! rB*d lnFf2SH/!-|H }z+NRi2Bz'6@m W7Dd;灼i06@0{]1K%5edX+aXo/m` ƣP;612@:Xvp {KO"ޣbrϯ.˥*4q~d%dԳճ|$$;G؍=g~Iރs{Ecpnk*>'͓|]%5!qw4V BB%}lN:PBp$aIvg9s~DD$<Ɂ' =Q%_BfjDd{=YpN')|FLN3,19%]`aB.(}INPWʤ8xd8Du:1>J;돟Ч[o pp p&t;1({@}>np/p_b?'v5Q5M+[4[Rjcr}Ǯ{GﱷEsS{^ =9כ `.AFhC+qrCH_i!eWk2[EB ;ɳtUvZ+~~vuGMD]쟀F3A#bÎZ̙m e|??[#(FXI 5hHKS?(4HИ9hb4qR<_Zق5b.@dP+^?jFؤsbguC |h4)ڏ$/{;vk.rrkmOqR-Yۤk#ވ ?;@_e.hza}D Bc>رlÎر;ꔏ<-zUv5ZVJ*T)W x+>hv@Iy _bh4ICgV)B^fUllF-n~TTj{OĎ\봷hh{NC-U'5vk}?UV0īܐe-5LbCfaJh*w\*v:"*p~9.ϔbUnܜoGEچ{hsVDh_wTb-pMD(9IQ&1S9DqpŚjdt/ a44ztc-Mh`yн\g̣:0+*"EPEaeXT7( .ǚb&Zq_c5֥1xXҨZMD? \{0t^>|߂3s1TG9y%41W1~PŌV1V$ٍ6es[2͔-WJ3-WAMJ`?fr1 6 k`T78bEqgO9+h`U9Kq&(%a,pFIJHc0'+ ?:cx#%S3=|K!1'tTN쉽/[P%5)J)Iq$[d-`.s\ŧ<+SJM2ZbSI Qg[)#Si)ZdQ5DJH5ʜ4LiK+Rm9-QtzƦoԘТKj0;1Ue v~ۘ7m]Č~2g V|F2-cY1YVEg56@cehKoPxve G r+^eti)̇ ߶LUvyWMP*սj4ʳrUS99~nբf@ pWKXN/`^ @8)a3/ffl^˹-~**uv4{Wnuٕ-thFt2K` Py;Nn{7M.v77\ĵ7TN(WRjgpG˽_&h'?mM^!A918P~!0qGBAlp.|7ݾ\Gx`K:9:A$'1 G f:GMhI ކ* ]{.CvyH8ZZg8 U|J}'|/Fk~Eo#v{n;tk`3?M—Nñ=]|m--M< W8/t?úB9sIm|y=C魇 ЏF{ok:KkOB<u:=K[Dp\џDlAOЕp@F=+1ɤI *!q|@#q8մNjB)odJOXWGta(V2:h䳣:FGqK]k!*WmWxvjgvBm1<{/H.ΐ}"1++YO䱜LYvNATúuLM&آMG2ӤO<JpW0`6``6` $&!IsM4I&kf]zd=Uuӎv6դQҺN:mkUv޷dz{>I}R/xW%^սŋ7Zʥc:\G&dQqXtS gb"㙏5;e2|+ O =.V%?{ewV,Y ,de#l33a*pN79nek4y g((FsP;."7)R.JŎ].%Yˏg m K(dXѢV 2X4Lq턶GIPݦ2=Ke6ҿ7Q׾H_Ny5K/Ib$SCrM6MNJ)&X:@w8]eos[<7C_kҝ6GYyҾLh_Fͱ 3k6Tmqeioi⧣"D{(Uh:D,xlO}fۯ_\DVyFWf/k\2,'XL5v IM[aS4,d +48/QxKEDd'{VwQi> fѩ6n5zqmIޚNuk>VֶJTzx#f(-Q[仗G~C(7_eJ"(YRZ X;TvPљN3eՔ1[(80EQ`#.x O~S U..HgI*1'k*j;ʃ(`KO>=&z(쭥z MIv Y =DFۤ~&~OF'dDwK렴ĔDPKINA? L!w("d U9@pA҆GI#ydGΈ$ ?KŻ$ }*wJkYEHM%ZcUVQ[cȘ06HD:)y$OyZ'$bcxMćOb_O7xG?#~<Ši1"ѡ5UIJQ٘U!}z I$m8Ms`/68e|/Hu^dD~@cL<0""2 * 5"(Ȧ(( (8* +˩₩1n&DQc%i[5ǦMjԨI44>99=Ǚg}T:++Hϖs''- ŏ;q?>Əq)S&ժt"_u~uyzYWz+TXGO~>/~طb-v7R(=zB>C,N)V|^)P+[]G9DFx!Ngu%yab Qh@#`52yi>ZUƏq@Vf*%cDuX;;M,$ǩW5Ġ1 㱟LVUG$oV*V[rcգ_Ks4g [{/^g A' hEc)hdc)E ZV,""[.v._iswr# kG>>wpelwUSVw JhYG%Vu.ZꚢZL-q"|Y܊TVjZ֤y-*s?RwTcxJ1lD%G(1,V aAي /иrF,؈lCuR#~=;iAo m 1 ǽl09C"J (EӸъQ)5UkTtFF[4b0dǼa1|`!vS\7ya&po K#.M ̣>0dQvMvD}}GEepcĠ`Ԉ\Ƹ5qiFkUظ/MjzbNs5MSTk7IOsf`f{{K9YeIPfRIIHJWzrҒR%SJMJM]j%7)MG`A,W}́z@y>9JTqd2јTI)& `ҧ)1ݮEXgޭX`>x7e8نV7m\30*ǔ.SR3(;[9%ʩVdLE(}t jK4l)w)We 7v2l {Emg6k|m~sn0(z8E BװH~Rr_&,K8p.+*.]tqíAaa= Gw1]_5 ͩPFs([\!k\>ZiJɩm*si-䱎jb`;6{[ Vf6SDEVFr{ 6xh$2.c}cc}ǹ}7TGKH1Ia1y5빑oz v^x2 3#jrK y36 Y+0;g6~K8N[ u?E\vih2@o!ނ18I59͌# </W/RK ե e_&*F;Djǒ7pjY`\ U \eN>aFї2gl MVżuؠu <=w'-]U'mu}r uvxa}k}Ӹ_C<ω <74}tWE/JD3|t*Ш-6KANw}eE|y\Y"qyW(29?9<{=;BDzQDJ^Gt<ΐ))y|X5<\i0w|G'X4HG# |4J=ͫ O[;i$Nb''sqbױsqiRM6Z:umU]K+T(L\Mh B6&B Ć m0ډ3??~:w}~{cc/V0]b -|Q_75O op}$1s4WG :kѡ\i5ϫ~j%?L FX0i*\ъvif/hGɋ*ɒ5Q&>d eEi׸?-Ye,-5jԪJ-ЬyC =ij׌!ƔiM5a<NjĮ1]ר鞒ה0F,Yڬ^FzЧ}c~,lZsLf1;5mnДEami21˘F-Jn\U c>nzRqU Zju~?>./8l>Xz{f,3qږ)Q)&iU֦-,xwnm~LъksxUa WާyEit-<3M2s36{f 5dw*nנݧG=9bVr\Ym(TO5wU:koÇgZI"r=I8ce^FːH_mUPY^g8[R&Au׎*X;EuTo͉[=?kh=Rioޡyd,}TckDָTߖۿG.Ljj7T2|[/iW?ճ@su~NB/ ]m|5j RC%w{jc@霑sE՝GT*^eٻހ_p7ά濏YjN~#?yJ ZUVnTTeOLCS-isCO,| $[[&[=>Vy54ИVA:R#Շ}…rn1*bQe\HnUCe٢CFS]C;'Ḵ{Mb?9WY73hzϣ3N Aۄ%n໣RU_*KT>`RـUA6 j`#e )>s2/]_SIǴ0:tf|0^ B-'F)ՃRuPAED6$dLeHKɘJ 'w([*H^T^r*7C%~(cFgJ D H~hObl3ɘ*QIʤ&*Lժ ըT@aتuL.EL%*Pl܎3% Ce{ˇvLJmϳ?ݿ}" JH%:bv̠RѰ$H@phĈ`ּ>5&ym xyX{g(b5 s/w)1WΣ0JWAJc6ԔG1 #uơK?C"<˚eße.o-q3<{>Mzmx_ShB?ʹ |5[By=g®r'oϳ.0gK2{9 2{2r{ 8|oaׄnZr1xvfK04&{CYi>>椏 ~q>J%?A۹B>zƸ%9j]cF2ur9ACa?/~곟B;i8'U9@mcAg|FW(ćW$ ^~Ea{3ظ!'}q=/XRl $Ip.G&& ҝjKt>oKOlH1ӝS{7$ۘ~S M̫2ґZv>Ϫ@VOS;tF=ğI |ݞpOѩye \0]׹ i"'kL>RXf)'Z:%t,ev+-H|';!.'v5LqTa'&3iB/mt9.hXIdn9L?Ev( ,r5^qOCr1/$v9u&q'-[|c!.yds.3: On1.̓ي U2E|$E/"|,||\q7˺LOgTT2CeO8[S6[.R^/i8:4D# <4(GJ31yJ}P\M曓Tp$:`v [6 jV^?!=8-:qHCh(fSwԫMԡAS4>. Y2a ݩЃj =!vA@{ql5[=0fO53\6;ܠICtgUaɚR{Xi Tkh79|uq 5D,P}JEnGBTaT,5VŶDٜ*e/Hy&)7U9]N%}Ik2*#\gsó֣T= W|$^h)Ub{ Fʳ'+מle'I.;FY)LTc|Pr:#x>3zhL9eHc_#yVR!: qq)ˑLS,yJO-QZZRL#}R\ z@IGeǕ6|W<h5 ћȅL|}^d+ W\QhŔX]tȑ_$4(c,J*t=TO\K%7MEF4 gR]AQg]wEЪ(* -, BmăD3iFUi;1&ͤNkNc̴L56i֣c,d?Y罾}FL+`WJQdv|dȕQ Jv\*C ~;+ιOcqX^8V±`>( *id_+;IFYIJdT'[y*u)ڋ'/ыp| <<_h&q;(@1τ;~$J ~dʼnJ**@0 :3"$ * !yURxP JlL_qÿ~Llu1JXbPt|R.Fz#ìCH Njų#aKgpK-/p PH9ĜE̓}O?/Q_µEgKO F+k+:w%KF.(\/Qu`;ϰ-DMT\~vPBsy&1O _?f4`9VAZM.?Ppxs{Ez3r [d!m\@̳p}jΫ)$C7XlaX?X6N`LM6s6U|RMySpw+TQ"͡|ի^3uK a·A? XWY q/O=r, w}qKCM~'q~g<>,O ڙzb/ku?#|agD:a/Caq0&Xku7F4(8!8G䠿&M sA ";`4"hu&x`x?NsfO8)w /:r΄;M6HhD9pɈH#88rpu\,b%% ~O y.!MwAQj@|ν:+OQ8|H❧I~E?"sphBp;C->Un3o>$}|QX5=:7j ~{=Hj=k? Ux3z]W]Rt+pk>\P\fFi3[GP'^uz|:z:~CE0-{/J'i : A ƸE+Zd$,%ض㷋\DKè!A6]Tyxscu9/pޏ#N[f|a Gb]m;V]a;l/nvS<7v#dr EA+|2;17bۊtf.v#ʎ^DZ=B]F yBz}d%,ã%2vb\lQ*'a{:sυ.#U{~=7QBy5df'ީ~.=$8#`; ۓ=beد~ ?:CZEKo rzSL9q,Ǭ`#vpFHo~:b&'2B". 8p@wtұkuԣj .3HxU32_ Vq G-*3VÑG&ȃceTY 1GT5Ii De=G(\jycm+U5qr ?'L84^zJKXk'/SIF-6X3k,!K.l-HWMbHQuOzU&.UUfRqJL/tBEp |'6\p-^~w[62UcJӔjTM3Te|S**7WUV㖫hjͳUk}Eso*!=pm`cmzk.|q⛃SbUeMRŢ MI*NS5[ֹ*ZS;IyW)7urR٩O+fL9p{HC U |w*_ԖTRST:A575Kslʳ*VDEʞT5#}2.5-cD55,! ¿4`$|e}oJx  b I5AI*;œVYKfnVbUQyUۺuն]ﶹ]n9 d'y^z|*|̍W%Yety-Y*R OGrjU(Ek &-V_vl4~PVg~”߬8Ki̥*PfYI(/TzT) jhQjE'Uo@ɾA%;Ċs2T\*>W?a;Rԃ|ǤJ pϊ|THپx&')ʨLiԪP*JnRbuDŚXFwlU|^U կ+|DUݬmo W TP *1Q <|.HF3ńٴF4P(NiO;JN3X3.kᡖ&lAĵ)0(41{$f[3K7E,^mfv)##ψvl/ dx:4z0^oQ&R1&J ȵ Ny=/亭Mԃ>!g}6blS|s>imd7yp.]6E,`c 5YQ>9fq/r9br9c/[yfg0% .mm,o:HCYk7f-Pl,`'&'ߡOQ!zt~"'(~sbϫ5*]Msv,!{_3hl<&Bh-TlDŽ0 ň2=r?F(8 a:tPuVr4%-|4.F&1BJg蓳q\E?OAr3!pFpvR#<+;<au:Qx\(.A]6}fJ#+{^8i=syS~}=*:+G /P]Wiԟ%.~J~B.i\:ops0^/c_>Q\f 4G5t̻jL?~ʹy -JCxЙOEh47jvP}hũ߄3,ji0)(' L5{ #u̼M`pEWhT՟W<~`;۹v0Ŵi%mx} %rǘ as9jj=7{L`e R5:%.Z;}Q`O#6Zm/u؞{݌VlEݥ Te е/iVқbX1\G.t욱k.l{]Z쇰V+#]Lb Y:1~6ktv 5bׄE g?RX a2)snM?ӳٮ:e05&9(Fd}{\,XH.&=Fڍc~t!셱ۦv,n/f:z43UaKH}$A+oX&fp:9/:jQ6LC8JdRruaĉc;ǗN8NvlDZs:M$m״ K֭bBJAVSV1Dm0؀A h*h6&.ZQPG'e=:3Hì1V*f젗 c%Xz>A4lsGX 㰔gKH ;;Ѩ$:u42to>.& zg=;6%ʯc³x/U|8fwcniL".|5ը\nsL]:Yuv0-WxZ(m٣fA,ǔVr vM{RaG^{jSWKVZliDҸZJJ[;lWʺPɲ1%(n۬mjS" ۧ:\G8N 6 CC7]'caVDY]-vJ~%uJأjw)UԱ@ 1E(llVG!~*h<G W^k[KBzNUy9-粼u7 ;\MZL3v@gi%r1O5m ջ+rW]'OWT]HU+ީJ.Uq}\Kryr{oj'荓@.pm4$x#FE[תסץjWU DJ[զ~UT㟐ۿA |EFpJ偋rPOtk#Z!kR]'D~vy*婩&.W0#gGڅ2j<4)Gh/òRYcm݆]h44O#"YePP\u9rWɨUy}4t'[d"kdlUidJ%#DN *\d ԿEسA,$!=P ˀ91B4B6Lֺ"og4t@ mM@mݍ>T𚱮ib8d6cLll&qc|-0'3/<~w4\|tzFaɪ{Yנ6t-#Hb3ı8VjXCc1dOT 3oce}~z.hE75L\=5-Ch,I5$so%{sIFMı817v0&;XTVfH3׆A!s++z ))"ö[/:@ndwt/ ıv?~ޗ}S) kyR{꣯s"!Rt{^sk^nh Ƃz8K!Lt?I!q8feep#TxplCN.a0UXR|e>oH])a0K$SgX'0ٟq%=y2ղ1@ۏk#VR+{ @^y3xޔT'Y{.o?$ %KE&<{ŋsgW ml}y`}ò{ސ͚:Lm`VKs%O,~ccl:W {ś4썓dŧpO/yC/s /d"oGG,~~ͤyIKLWW^/}_%Կ,jg'ހ Ufyw?6sZ) :2qӺ{Esxq~&̳gcۼ8m~v|;׉8iM鑶뵵)F=Cݠ$@cL ILHCC􏩈C$PP}~{<$% ݜ73 0(_fѯ=MgP^ O߰y ކ!$=~7V!Rd cse e:#h$>+xyK+Dgt*sB?Lm* у_u]S25t,v#Wȑq?>2S{R#aCdC/6k*< 3ϋJ\;-[Cw6Н@wY4:0 Gt7)T 2d V9-hm[=c0g!X=GG xl'[p3=ѲЄqQϰǦ![[-&v؉c'vة`;fL$GS\VY<:ށ(Na |ayjiȓ*ʝʕ3ݔ=$[愬epf(Hicc{SP2(:x$!(*n?/UK/w6$gGL*)r F%O9s* rg}-ckl@%!4 AhߌM-]N9K-uma*$MG+],ljj@iCePPo)CН$PnNS!6J@e4U6]?MS'hu>[w4qu:@zJʱ{-hAz<2Lrr®Y~ΚE~A!ah66@A<0ǀfq&m&А ឦ ]` ta/)q ĮQaE{HYaNaV6 3]Qg6{9d7l[ Pb F *e(P*SS J/Pʥj-2 ʴ:ڱj 3Hm-ɞt;oel?V~YpYKbr5 c̉ջc,NY{&Μ&38]p~ᣴX,k:gHL6}?ѯ' v ?mI[-~x;gr!q68wsΕΒmQQ]·˨#rs[ 7c?}&{vdVĻH"8sIKi&xA;.Gd##h^e~WN0?HH3(qe3~VpNEj'[ٜ;nG<$H9X< WU~H<^W^ef\. euqDINۿ^p᳹ϏU6K<`,D$+5>>ɿJKb&>f- | Ol.>IQAaM2z 2zQ{u΢k~8 p ޿z]uq-l$.%~u9Gem~~|?D~bz":'~BiUh ^VXe]SNڟ&hq48Zj%v؝lj~>^n.NC)u}v!~D_v<mv\pǝ;vd`IЈ"v;;eZu&v;#bl/"Vc(p< 4z"%kЙcp_/;muiG:ў؊ @ENA{;ӱ;arXeQÛ rW+b f8S a@䩾";=}ll>B~ *YoaT1v|*8=ط{Lcz\cQlz+۱ݍ>l`o6 ;s:>GNU QuCt~1lEоkپ Tc ~o~;@VdjYdg:YG-e:5c_ ;~σaWuMC,lr2ژT2c^y;u£)TE G7Y.wmkUh9WJ4fy$;B5ur%X| EΊ}ṗs&o/E̻,HK}ܥx#+iժDb񠂉jO˓˝lSMG;lqf܆i I|HbxSGdQh- ϻ|Iy"QX+3SD~ & y24Xr5 9gϢ)K{caq+X³Yφ$/"\Cedj(fsI>'ݲ=&=#U0?;ӼMvū_nF5#\O&~mXflؒ! ||e6;A+h9/)>O&d\25 r73D V:HJW xmǶAlcoC%K"K+>|pN+=`hiy׀)ޅ~F5}faX5 ZZ" nUƱ3h:Z+neJ;=HYB6BIH@P !Ѻ/NT;նK2x:ɇ0p=!?}f^LRpφ`@Vr@G Aw"0<A!\ŜԪX<71 1 '#hGw_C0" 5m ṫ` ",B",BPGbP !BpS/ t3Ϟߧ"$/0` %:BrXa`F6;XApٕVb\r>i:_PK -:G/Ґ9c+.q|h"|X ~5.5uбFl 0a|x=u04.zE4)x C$Hl- yױ;'jn i\ W8tl-бk؎nA pNlEMlaY6{ר` Y;y80_w97=Ecg@Ҁ= бQR$Ή {P1j` B΃Vݕ Yk`Õ(,7U U+'F|` ^EMB@n/+iQ'B/ paT/D;C!XB"0cr>Q88/l0݊M?xy~n07|cǎ0q)SMs^(d^^2l/WYn_zWl۾ܵ{"ވ־o|#G?>3L6ğ=w>1)BY"D-U5ڂ¢CiTSźƦffpvv]|nܼu}ŗ_o~OD%}y1<\'_ gK"0X8d$ D0QPp)#`@L6-F8n#mO@zH(=&c̾dݽz~x FEyy = % G X'$`(,K?W-=C o"[ ;=Qo;p0ȱ4Ï?!Idr -bXwAWM1 0 z޻}_>xo=z;xɓOkMuDT__ba~CٖsJ:CR Z G#e&\WfHKi h0a@À 4 w|kfdKeUh_ݯAųs94HASe *g)AxӀ n_ToO*HSoTb.W]ޠZA Р%4(ײ3n膆>nE$YL!`*_mԝ/QsР 4y"ySIfuaƹgc,i0,5pCu~S9Ѡriȇ۝+]xWY"Z:ӸdM3^Dv 97V0N6CC4N۝#>1tdBG*@C'ie$5hͥotРUrS!\ʖrz$N:Ҡ#{脆ƒn#Hi КʷkJ A˱)sNy6K"cwgI=q:E+6 Zg Uo-/4CTРa;rV(ՕБu9'_4qbf՚ *ʶ̅ڸ|5ǢT۳,8Ȅ#Eƾt^鎗{<6XjwУ-VZzQQYkF}QLVנϋIh4X$&}49߻w?cW{YE˫}?Q ˱lpWDL|rV\`ƉмVmӰi4l6 m{Pdžg0|ǐ0aV]ց灡F!ʺ[Kn۹l{`?)`oh@lǧ"sf\޼-RtɌ)Nm-në= 5e'#1=0htHh#EAg"F Vh•Ibm0;;6 7`2>A :SvIQĢU]1W B% OXoL[n` `Q/c×hޫF'Jcs_+!DtU3(˗vjYy`xN+1™-x[VJf AƻC),ȗfjkۭTkëK/ck$fLGz(6lj;^i<)7m}Uɰw>&t%4aS&Hsĉe!e;l[԰0ݸ/WioƮOW}/>{cI_ᜲks,p!m,g9@Ov.Rgu6A$Ⱥ[5X=ښWǖͯslwrl$&";$&,aqJ'=ʲ[_vwMæaӰi7X?ښc˖9_ 0tJddD'%x:,&rA>'>\0EEh`NӽGWpkz^`x +Wc"R,Bq&<$Lci7_uA[=kV};Ǘ/ b$fǢ* ˱\PW@i.wEfx΁HmjiHW#-]`0(̩ IHL` HwEĴϙqrxsvB@E͌:yn8~ ^I3mfځ6MmҔK IJ qCwI֣yGﻭѾobKl˖%[^ p(t4uU}\?ɩk3Xb?<1{B1 )ʠ)u e;5+jK״4Œ^S5x{z~q_=a8 ie/ŴxXj(Q@ӨʨVf =[rSPԤtuEhx{~ {/ͩ0/!=k[8P&ڪY V $7yMRULMogn`##4n%ubD@tPf*haTIȚ^ʸ,oe>OUq x -"8g3h.PԗMЬ] U,*WPW2M~K(d+\+x{ڍ^o_=NioYz!pg'ئb Z(e^ik{dEDUۆa}B{_k_ӜB3sޔJ(6y %<$iCPMAcqd"mnf:p~0HA\^0K Ì*QJ 82Eg`*)=P3؏6r[h/w`}o羣=\[u᣻nj:|ͶoZp7ȗ|ImKu:mlB%a50as5ޱwDGI^{Ivx\/$ٝh cD,IFIdB#mZ47"TՁ>m3V?1Yiޯ-:B}Ky/eN(^, юd,A#$9Z6mtoJZmio=aqS5ݾ|OӂSacO0.v8hx'#TQ*LIHLʆt ޜYޖ~0˪a аm=ć“!A)# xB1B 3QFg2R!@ R`, }owYr6[iì+auc71'R 9#lD}qNܱqZӝNUzuuk@zWEAAP I\Bx $F$@BȅpAEVԺ9;m-ʶ?*9M8bɢv:jh"(VV@ߠTei4EJtLpavwk}n䅜4~1+=n*(NU <L;sYINiBx6 _sZfFGܰZ)HB':!TUr_JDot$ H\$\VQ"Fa]|VaG ^j2#(Q6"*r*&!i"$]0 k A]0ݺ4!>DZр/rz[IV-9~`qL45z]ECmdULDD](ՀOICVt^DA$"C V[+{$SL:Q 1hG 5M|CF^kʇZx3UAPi/  n҄di=ۊ~i+zd%C6@>k\OX["d>Еq]iB6gx;iذ% gd9 $*MM//uxUakfR2ȕ\o`*X( 0,OƤAq.<1*; O[T{j8lQƒ .3&Ba:A8/ W=hS g4IC΢/}ڐ:=kJ]* *8l]Kh-nH6j &_ciS 3Ҁir`xaؚDy]Mݧ 1M&o Zr-s.j)kjTAAdGO۸7`pHGܤM$Հ!o?f*wm2~\?h2b۩z2lnʯK @1'TYY0FG)2UhӲ4`^2nK֬f{}Vm&pҁ-ZwZܥ5UUz(ԦMjJ m3GrA A%h4 `Z ЭlVy1>g~ |y؟~uG? ӷmHozޯ'|%:WS 8#^87Ѐ`SӏT]=r{L&u~C*gN{i%8 dp?3 x \aheeh jOy`~RMOU!KrUh>Du38lj,J0pzT~ޡ{&`jmյk˦t˸("R(HɼX&QoAqq˓2,ah6EeX=7eNަ ;63e0uOɧ4]jnH"QRĀvߌ帶la,/1 G#Œaܔ>ehi3~1k<ʞ1tȧiPQ'5D^LRMl)l8q(˛G| 0#xeX+)z 9Ys{xJ1?o'ud^H2kq2,9ʄq00|hahe>o옵fϘ+&;jpQNj$ %h>鵐ifqfb5\Éߊys&``1k{ڦ4vTᢁ/AɩYL"2B5=+ v:̂*;\q`r!=\= ycʚqOZO:ᢉ/),duPbM97Fz\Wjz{Be7&H΋ ( un̬uyP>8Z?]'[E(fjY1)QUoh"^jN^l^$oGs4o-Ҁ28>u9Ƚyhlu^sKO3;(jzIyD. As\5KT1E7w>u>3mu *].NQ!iWcZDX ޲=7B^UtpQ+.hD2-hM;[l'Apd:d;,{OHgpj]<5jT:hCjJ ]QI%d@ [[ߎA;.}߆w[|pRB\G;A-٤}SKUT*K0)!D=eRoh`2xo.cxk{wt#;]ds=c?bv> k6`B:EM{MDZ"VE Ӽgwo2oM{ ireӇŢ#3PS }fj;8wym>3tE`uÅzAQlnwG6{xϫkE7]HH ~5_8ɯs뜀gq+>~?>].Lm`=acܜ>"ˑ~RJaiVUXaS/%(\bxa@ @Յ e nH\tzK?Y)ƶX f#fHvuqҨFą^DJ +a]XH:$?y.d_Y«ѶOo~~ZJ^]rrj[Eۛb.A\Ԓwͽ xYbN8ww`{-CplInF'LǬ/F>-/,zTB^O>{.V~1vtnYHI׽{Bc{C: >gώP:}$%_z^US~nˢeϪq%kҔIe?R˒6^L|,Oxri' ޥ^y/ >9}Ǿ+22AnB:@$dPɈğ?Ǐ۠d~u9;3'ܝd}/Ds;d~>O`?T.@WY4v,dG$xPt2\11 ЧO| @<(1>0nN\x??G )eUMuƥ6-k8b#S͢v횮馐J~Ү*`wo2`i(`!8):W@KD|Ъj){g3Wzǫqdq 1>, Ay-"8YhfNS%o_%B)X7oǶ;LyeT;- DA  p[ZT ͷ4zS>KkL7tDa 3fY`l^{j{~8 &x@ ?= R7 EUne2^dQDLr9I[M#D%@P؆~?VN8 o @A$o @ (pM@/6,qkًxդfu㍼*d %vk\Cn\ӂ9Xgh ?)lń(9 R7DkPPqKf9T$Y?. c(w 5A3xی{6gsv` ;llHklԪa *,ђY.I38aOr791fkpoui6ٶ0 ( KJlK-Xo;_*%/K8 P*cK3\iaY< r|^|ǐk2L=>_USI;İ 6mNH OHT$+U=Td웒rl+Z3! 6?9(zI!73`zѯP^e-'ڜ2a@d#LҖ*1:HFמӼ(/J pEHy,pWt:;7 ^)m.3ȷ '=Zs&6qg 6q[ͷOG$$_py"!hgT6! !E f_+Rl.[buũ@36.}"~'>]W6SL 1f񌒢Su<*qOhfuqi6gAm8%h?w=Oe4Ĕ=1a$P[k匭sH_g7)hv!oFVϷ0&96gtdul`5( _YT8PG]s߉5{4;~elH&{aL0Ejm<,P2|sszl e1- ?N٭s׏oPʝ~w8 JW14Gu'C0VЮ#ԫ%JFWV]R-fE`%la*2 & 7Ym((C U5XB~dgr[7h~ }hč87w*A?:Lڞ64^or]҆Xѝ&jL/RiYvCA)Tu6Ae} {48=?pkbPVg(3]BGiK{hnzicgXeTCP T!١} փNt[>59w#;vމ)/)+F $ev+Ӥ(󻒔.RPtSj]Τ eGrJc(D 5f&P}j-~&swl&n.Yh)YQtвE~Nkbr[iWra;=VCjRic.TڄjP E &P)46_.K{OkVW<>D:Ewa>r:lHd(qm6r[uKT[|ks+AutpP.0Vhaf' ,լR:!]: sep1"@L)FK%tەYݑ@ 29!kZb.zۖ7.nޭY["B>ߝ1cEGC z)?"WWc{5: DUՄ/ jDA?iW7lZ7ʷ;[%NJd&Dr'IY\hR60r-ʺ6WC`}UI$P,1oDAÖ/V:eņ-`,oY/ݱ)|! 1iTܽشDιt^73h0!-/]6(֣5~c#턉ӗR05nl:CLy! a1Q_sOq!)%5#03g!0̃T2^6:ע4C_XW L: ip='>sCa@Ci4kP z#T=saTؽ;`fVg  ;`xN@vvG! R\!pJCPy8Otغ.̾߄?m?.N8BpDt=~8+[Z!H[Ck#`X 0- - "dl2.b" >c @gaЫ\BXK&=ה%?}*_Ŗ͐iŢIbhX<" JFA0(&~> C e Cfpc/شLVbJ-?k.A7_"NDˊǣ%cƒ1;;AͲ^bYgT2Cb!,OK= yЫ7DvZC&3O&L%Hq1|4JYqZy->i':OJ|C> 1d#LĐ3ѫorٔTÛcM'M$cؚr]0IU=uf# ȮZT!΢<0ZOsjӞqkuQj-"eA` @XB$d%!@VI %$lj@AA VG;ߙuzݼ٦$DbRfw9WiQ^cUT-U3f5URmJ*0P 5ṗƝK@ޱ C?  ;61|3$-!xUF1x&(bJfX,tf(FނOg5p}o1(f|Sv/%V})$;͚F.MeםuEmvC'hQCݢYаsh],^trx77n97Lw@,Ddu,B %k{=eե:uS.uܥʐt*ڿB۷/7&V,tOmx} o*<^DAxbyލ0>P,8OkĸDT6.HO:{9F#OV{xAW~%=3ϭ/?ulmWۂ%/=J=:U|?HdeP2дpy7g3w{jd8⇃ȀX <&(Gdl1?Ƞʨ13?3vjjqBn8J:j`G'`21| ;7`&oPh1G a}C )ȁedD#O/6 P{]䈪F (䠀Kc.#KqgKhpu?ŀ׊@ؿtAC}"c_zAW;(v@ہ;\BPn  :w#-ya~ C'z6 UC_ B 9t ;{p?*NN& n nlw p?8_QC< Lq;FVk)+>eRƜ%Y8ωgz4Q0kMa?M47q1콌!} Xu;1pC:b`!7Ey!%x„LiRK33oT-"֋2$+Ill2_;$'I$ʻ厐7Fz, \ GN-M"EǚT`R%~BL&6.dN(&pG~H988l' +]mE P7ȌE2&GrpI/9iγ"Szx2*}L|DjP'^81Nh~ʾ}8K ii1U vp9l Z$N0gy4x2L6AT'f=$7< Kl#&s)' /S՗@ A N*1hb d| Q&O%xΗL(Ɠ+jU) QS4w75}M{Ҁ6D6%h'h ĈADA pm|("F-lTže 'Z88kaVmFwII7 i~~~}FY;A2 Πq@PB ^WfΔT! sF.JsѯzJrИk8W\+e^_4 1b ,oB! APw}A"NUqSJxBrR9aC۴s%Ime]+nnYfSV)) !cHɽ_oCP% I/ ֔J zP*5aniԚ>Z*|a98fkz.7q{ʹ=O@dA (F0aDY0H R'uJP ;-ִWSXmzNf+2~D]nt1k%~fo2 0~Py]܊?K ՉLMeQkj\rU[kתKmVHaыLzqWb1CO@s0 &߷uasQOԑLe-ZyUqR+ Ygԕ[j2ZkkU6NQt.bA&b#VgL{BPz7CF7}V3GvHwVeU+mŲ.5[4my6kR-4UN#rH|jx>A2 91PRo<݂x.NW@Ʋ5΅ʃvz!0$lŜ KHH"N_Ԥy=Hzg04Ay,Ey٬,G} "}bg}OXeeK'!vD _0Yǩo"ȋąs^kJ86׍z99`t2~@2ȓCByvK߿靐E?)ԯ&X׺5\L^sv:F"ed? ƿK \⇻)t{]ue5yn4nq2ueI 1@&d tGeɍRR؞Z`nvb, S!O" Hu rK}*e:.װ~vxcOѥ$Z"oieLMoʲ@[ F{^ ؙΜ.zD{@,D۵rZ ?8rD݁A bfL6lL0V;f`Kdp3% d 7 l+Gq@#[8ko G-x ,=j] bOrT!H4dT2-pSbj'tC>ZMISs?Ç k LDFr$j@#H$C!ױAU&46Aw'(vGUNkp+o5SB!JbD}ӃP*CD}qIE3 aQ*qGt7Z#`&gV[VpV0wEJz@٦ }}/DІ.ݐr%`U 0j(6 pUa/S 1f-u%o/&|E@j R|iA ~9_y" -c>CzϐBT0Bh2@EjpB e(;`uzP/R e@SWI-A+vw>o/e<{g@|˚]b={ǖ lMi24kp/70D'^' RʚBka~mg}#|%#3a&ϰ&5==-:+ZQԣuTD+ʅuBf! H,'$!Ҡ("e(U(Lx@e(λO}s7i /l>BG/`X/Ш[ DՄ.3#6'=0] 3ĉjқ:kci!i{JFӚ0#NI@Z ݀xr 9{"=qH{\v[laSBzYF Hz1|`D>e1̦X 5Q5P7y7@?H @O< qzܻ,\>5F})b_d < y`ۣpnapE?tݦ,p89 ٹi$,~'<=E3ch/qǘcӬ*h䥄gx=?1x~M\!_;_[ 8> yȷ/5 Yt Ac|bIo#e\=;0 cÑ͢GV\_͘>؇:Cɹ>q%y?h] zjPo4L A f~ 'J8=leC5Q QI^M|or=񁬊@vܛ|ܛ,`:jp!ul,Ap#@䐏bv/f<#|`l \QރR܎V^N9OJtQ'i= G,`Ow& iנ8 `ڹ} 3 ѻkJ&DD0 GMIT: wc;rjޑnct3:S ])lG en G `2w, oo~g1Ag[$KiPyRT'5kkCWlǷiYjl|(9Uѱrfr% 503o':M,s&[W8nR)UK]^6a֖ 6X~%dgEl|AWIg)E b K1F|q B̳(V=1mxCY0;̂c&εk\,č `rlLjxcWʴ|Yu6NQaK:|a6.ݮX:ҝbMf*7CIC<\:W{}w/<صSS~ՍuquDPQT(bIl$$,D*0:ŒZ;NZ:nǵZP*2)UdK9}m^|^K.7VzaZjBK5}F_\c<\mzGiafӛ0ܻ=|j|4쳨Ǟ$MW?l{I]voqf"k[եm+UnzZh|:^Eh[m[?QIT"bŋxFR.p\T*m?;1te!WrΉDyjx,k#]!ԳQ>ňX&gk *Y>cȎcd%rQ)#5Ңq+QhG3bwF-!?&H#!EjZQq_qY_iRH #ܰ8΋ŊhM\ sp1nq9fG!~%d͠3Y /RLtFkӡ\Ob ICo2 : Ʃ:KayU4c&ϜBp,4? #G2_%dBR+>a.| sxF=qs@ ݄Y0)։AXISQ-~bOqp?;"s;TR4HH6•%t0 `Hp\"b4GvnM-13Vw_,Q1_@? `g]!gCzztPh á.r=3'CM*${yCBEXtY m Rw26MV/z/钼vH?i3 lhS`¨DFf(Ь\_ܜvCrH1D%3O ;r,jߥh@aEvy7;S0 A1lz, 8HA6 MPnK|bH- z9DWUB𘂠z'~٨]BfoU A %@ǰlr2p`^cI<BW(w8 V)%$uWT5!zJ _6+_(ltrH e&f|U7h2}`t06 cP2A J$7?OCj!L0lSAG~DuAYgV\7?QtR6?I:?K 94d0 \`Qr$TOCl6Vh%o eLpq__ӫڣI7?k~"-ցjWuDd !I 2 hQP(ThI ǭ{{̋yy~y$A'b*37EmJO%\OŚx4C  b'iݑ/f F}KF-%:v22vfAi:Oǡs=_H`0Z:*J?,m: 20% qqChmݨ6foT?'j݆49u NU<*А^ _b`406YAP24]f2e\w|D x~j&TxXp%=6s@4j rѐǓ ) [`bc1` i,p<f;/_ |A;sT!5஘I 7X- eI$->CX?\Ij(cO3 4#76N0 Zd{߽\ml׷m#šC.9 !ƶ˜LV]Q[j6,KeDŽ =<Àd0 x9h@ZjKf{p?pjw˓S?+<ڕߡcSX8Z-PKj~!Bl0{R2Y:=,VGr=/mDP\s`z[k sBfjv,t^<{ j]7wZu@E מVET$xb%Rν)S $"B˸D5ŕhڷxHGz,߾ோ;^5YovYcS%]7+Îj~jrXUPPl,S.)Du2qrgH\&餢aH8, DO7"@@*,XSiy}-z.h umǟѨ1yHJ%e+f% b~jږʑ!K餈tXHFy1_d 9i9%FWa`FN֏oU6>\w1ҧ"6TU"Oe!<32%Q*f<%Ii#b|TȖ 8)GjD́dtm-,_tmkŃ]_t_w]|`eDAmLpfV"tnKR%q)yI㲇%dՈznLHK B@ 6X֬6c7WG0}wv]:֋5-a9AZRNV T#$Jđ%"\hrLǟ7J#rn<[%/sڥY-xg ~5=?Xt,S~gZxB/sI$4IŎ gj/C5z*4 F.!gCȚ0 -Em-xlۀl@З}pƁ}U7ܭ>"Ϳ{IŒ81k5Rji`MK vXQdbF0 v<[_o7l@陣UeEmz]~?hn/$%8vC2]$ow/4WԀWKկh!Ab;,å` tYk24cGfMcݬ?Q }#ف!'Gz6⼆pq^o 7}:Y0y!`XNKg j,eUL9or^!p]/?4$BQ.X=㴞0&+Am;2]>0GzbL;Z hk ,A}kPdk-[me{Vg]1f=Ϝt{jx&{9:jo|}{׉ϾGt~;߁pF:0Yc>:̓|ޖy9ӡ7Fy:-p.]gQMy? q .,* l!!!{ I 7kKGwKU#-X+:uA=zL[8 B|潚|w]=hil*5{.]0wp3GN RqU"֘[>asbOn"){>G6bڸ-Gx}HY|HC4ЄaX(AQ> a@TNq Gq2͓$ߡ(2)*%`8z dE!; qL.}6D3e|4|Es262'aqh/Ȣhf3 2* (\GAi,; <As Ru t:3ALd> 1y J ' JCʀÄF KTaP-!DXK/ldAV'ɺ.g Ivg|[xbd=xM4d'ѡ`1IgB'^9pGCI<ے!ٟ tNf@x&v.Ywg!>Y/yB t&xCȀ. &E [D(@/8nBܖ>BE<C!ρ ُQx /(#hPy#o1&BPPCUꓠ4 ʝ =GBH#3 KGR9 &'}HNJ1&QOn=[}KAݝ <Ϡ#4>(:qLT}å A1(Iy -|v{8TgP^RWhʟk4Owyw:?.)4½a#*}P23L}*QhAd$?ҵj}jzoW ˦QӅQ9g0"7x&XśU@|e渱jGʰs)wtuV+neEc88ᑾx_~aKyrpf.l=tГ|{]Ċ:&N'ؐ=ա#1+mWU]GF&K_ n[nZd(0[mmECSC-_zl/yAo"ؔ-Y#zY[|%+p2\+9TcqK?gK:-;,J/Y_8Z4h 8NJ),9yL~#d+ȷ.ͱLlK2ȟ9( vmpo]_JSMk{As_%Q{k7%γfGpYeM>'( dȾWOz4̣a[4;Yp؛=n[m .ѕ++ۗn)ztAGd9׉+eU|Yy+׾ʾݮ~.'0FfQC5&2%?1Ad袻[~mC?h9|{ɉǪ]]mK:j\]Etm_Wly8yƟ8H%CESf_˖889v!5dl!ҴeFiK4L^XYA@3AZ6]MDj+.;fw9&G7%ƞgTF.8M$, %tIIlb樒I^֥N{:+vxof:4 kRe i"anH^lYXVt/#\Ԉ 5=/%z*"9z&,9j649j>$)j%=֓0{"_B4{YS.uEp@ k%Y5_qOfKf|Pw .F &BWLxYN\;.v% #<{+UͤHߴzrLNM~jK ODdg%222YI„)x䇑 ~d7*a:<:~7ǎ.DDaDrxY~nSћjᮽ&ʷmZ_s2P"wZ~ܙ *d 8ᇧOq#Rgy)~[& `A O_B'=q/n&yd,@؆%`mY`Yn`ug=w4{@7|I:H5 ?BHI t`{R"n>|bf/s/m!?삐OV"xF`'!,ɹ 0z}OX ҂Ag,7{Ɇ_g"D.ǃ QvGlYMtBt"s+]*W5Fh+ !:i__#;?=G+b `>7ҁO=3@$fAb"h%[WWGmtp:f}6aי D @+5zq$X?r'j"Du"֕ S g8@> JdHJ[Q+<: D3q,]bk,d;2{!8?Ds3듀UHXAPAK },N&-*unH2 _x+lƴEwÆ؃Q7Q9/9}pŀw3Wq>&!?{ԯZ{d>@V#֊ArArUU=,7J$6^Z^%s^[%*7!q+C;Q 8/DN&A-d_Ɠ|Ň-֑{@w. …lٲt[R["WQT;KRgIO{[7c! qe#C1$WLhb- #G4g _4egy?YH_κs[+▲%kҞ+o.J{IEeW@ܩj$>đ|)֑6UTN-g7G8/yZ\ИNn}%7,ܫQ=V!Jy27ңv[V-@g_Bidg'=6M%sz_e_- ~6K]nt^7 r 9戞;O?O9$w&8|[ٮ]ٖ2h[ͩ㲦ԷƴwI dgQ@zlZhRjwZOkCf>VEuv$ٳ!}*$\KlWv#Ir8}`ZjMk귚}#ꆵVE}Ƹ|{[)!yDmH@6o<l&} ԭmݣFyN$,P}U.+*wWdS6g4e6d\Kٙ٫NQdsqYUDH$[G dΥ‘2VrG6O]m5n6;^.{vW6g?h䷙6 [ +eyU; jks?լ}0RiN0-1VU0.{$mJ l޲T͡ p<߽Vԫ{58xthWflYWf6nIY\#-lTWO0vZn|Z^03 iMqTU?(˷y{)L|28k݃(7x_h {YGՌF6Z Ě*yeNfSkʦԒ4Sb:ST41L a&.&{S͠|>rǔmݭ%"J};uʍbBf\.1M),,ոLZ^ُĀ>ӐX:)(UƔLV&Bٜ3(CU沧iFuh:'ʿ۝j[W[Ģx=rzSS nW&./fkIiViqUX٬5X9SY׺-CuyTe4\ѪuMBXEAaIXE@0qWzZD REAPAܵEܗ#n=3v cNUԞ,gg|~zy}?ѐf͂1=ŧoA4ӵV+ok2?mW{$QRYk+;.b}˶S"{qIyy%w,>{I@m˶\6E~у*!ݮ3FtmuM原Tյh'ly}OqOj# Ǭ;&a)*>K_X?+w᜜}md}=@V^`O2w  Y٧DN6 u1ֳ.3&sՒ"/jT6慮;TnuÛf=,=sӪo2/ UYeCswFRևD"_IUǧ M%S,\RU\,=㰽CQ>wݩy'G,iY5-yc\vSѬc{SkRNo / Æ/?R>*FGRGCo#zTFtb=tG_]ҡkT%^ 1MmDd+/d/>08g6;>'^:1U>>f6#9(TѰ臝Dw]۽j/qTyÈM{\]ۑފ_q3m,k |VS\1s6zڌ1יӣ vyŴ#>3D]!h`?Utr뮈ӖO}[8:>˼&<ت};hVFByCx]DFvAu:yDgD7#jnfʯӖ"kNkzżr =ZkCO]JOxVcz>Fȵ=U͊t2T8w(C@u752ω.4>/N͈V/y/eTFWfɯfOxdחa3/N׷!oc.܂M |{FD7$/!5Z!Dul+Xvv'_=7-)_3{p~jZxY4C UClw~d5IJAlbY?hGXaD|K#Q;#JÎ7n:Z(3 BHc?d`l.ATVK\_0l_Lj*P5˿C)EpVCԿ.4YEjE( "A% #@#r A("HM׫XVG+VWZ]gߝ/g|g]ך$i VcjD0!D hzG[Cq n@=_\r}As}F} ns[x ϫAy9*Φ9|f9DY@DB(KD*׌F!.mz?2a4;Na1vk ZC狰oR # ~H{/px*ٽ_ LJjٰb׻ͷ=o:~y_#!|\qw| $|ÃQ>P@)wusW`Qn2#5hyR/ף5n3Q-߇/5uM  N :!x\$hB6&P(APo8.S3)mOEHd`\iXf6iK'Ed Rtv阽';' :>|$l*@zg!U 4S V, =vS^jR \g [ͨ.Ǭ="w99)xOHKU|%i t D0^y(ewE&:bh F 0$@@)=Į%Ωs?A şS~+[ovlLqɥgr"2.GRIZYEࡄc|;+#vl6Knsc$SA j)0@7b-ǮȳCcSSfz3%쥓a㹱.#->J ;,3*o&e=d}06ߐp]PW%n 8r r`d0q-=-@Ѝ}M>*g./.qL'꒮O+IX") ]E7!=*nFgfONTF*=ERώ\>fP陕}z;D/*'Dˡ9a~5i(akRe -D}/ =˷Duz|o.5-Bg7߿f6x@ wqo]GSI:mu~nG߶a6޲z1hQoge!̩R^[.*KחkUM/+(L U~P^^Z6j`0pXWwT hu:yMt52-&bEKh}]m[UM6]e_Q*P+K+njQ@ɵgCe"y;B;9S w!!tC}fh@nj ՔUOguUMbW]CVm7ϐNԱu/ D{X[~|pL[V)DBwNc=fh rͲ]5gm[Gn˞YʆܠRzBNQH~T Ș Ht@ĖZYpـp{C |i/CC._-+aNn݉[S;mŴݭՌV"0G)js23^;B|3$toL>,u'{RFj+E^O?dr7 N07]X!@*Bw]Ad Bc _ݤt{+k/7ZT_ks76mDna-r[;~cx|D_|J>KˎEћԂEG->v8T)Nв@]n|;)T{s%35q0Ͷm@yW5;dd&GyS-<D6zvc_֍Yco,dYbjmt"\8\ۅHMkD Ds;^ ,4㹼~ocd 8= TxV{ .\;vhH5mL¯.CwC׏ma3>^gsX~G[BQ(e>*  MCraxayFc xGaw$xKp' l`3vog&_$*BM# |Ʉ@CBZ(( *.\,\ xH` X&c ࠇW!fpU3+l?D"\" Hų: Ix C =q?/8T 籎簝'c??g5|M˾Erb(xS(b DZDhĒT /j!8K"f5SdZm$=m2] { -HEbfy"z} ];ҏ|!iү 9ꏨbD2wa1xd] ԠkyXzLVG'zB9 q h( F|?b2 ?ɜgfn3~_r B,#dX,TzGPA}1a4{W#"f2ς友#; @vȨAKH?0q}5HpvE,UO ɯ)cI n e@t 1W͈1Ҵʀ﫧4OmbEۄ?+[+M:VHiPv}>dj3q]3r57`g0o/iK9XߎM9#sdkQ5nBN y\8 <; ?QB+ y#p!uNxʶ [Ÿ] X&wg<%ݫ:0/<8S6|n:9@틼H뉸Axh|KD~F!ZS4.y} |&t3I l}#fr+Ȧ0k4f,9nD$s& J{jUwQ1k n$o<.x:rVȖQF"vIv$5 Jst0k울 NeNEOU{JX( Z0D] (ަi0E&pJהFߍyǷ ʣl2v2&%ݵI ť3ɵD K%)^U / Es !Bh`/ {o. c2{WTKEV}9{[I rU:]M/6 %}_7[͖7[|ĒC_dD[ :U7JHu!ܪ5*5LNe莖˜=jС&K<\YH)ʨ+d nQnz 1!Y*bRSv10x{J.7[$5; לvU< uSTbt<%7GEϒ׳dYa$8̯~Lđd"412D Xp;O눠kXMaщԭq-5ǷUWFRW%TVzeRkYE;')O'̝{/!s[Y)(J"j& pk0hkZ1i8f .ZU*+{H˔Ԥj<|/_|b +.1]$[=gp{W#vVvYB{>bc'ٸQ9jU#'!@jYR.:S%񫚙'+|*'88|"*;R%S"h5[KLqf`34&w3T1Lz-#6-.Y(l5+ȼ&WdC#- n Va#FpV#ZX+*_ͿE{Wp ``#6ფ!ly +@N{Ss\»JC:՞A=q;mAԣ͈zL(Auy{oq`w0@-vвuq1Q -q/xl#GN *v:s9>Վiq\r@ o/"s;ٿ}52GpsgN kdӻ iWRX0o39jUmW;'2w(tێLݒc} 9. ra ut 4|$@MH3v;b=IQ>as7[MΦ[sf fjvg:`Kږ:duȎ1{\E+WwA'@?@ίXΟH m!f[Bਞ_l쫏^'1)i}g6Ky+wVn|8x8]Mh_ο-3'pC"HvY(9yѡY&/J9hZru3W/~,=A}ny;P gD.~gЗL{(m# a!: 5px7?ՙSa20 f`FP"JQ,X"q%Uc jtE=.Y{uƵG"%( !;O}}'~$~0Ofh#v^R+uBW e{; F;m_ x(6Q}اD֍"j)]5GPps`|(|H?-"")bϏ߈5X/v~nH>6J-߳* .C4'DD8?( А:H>0ZArCOY yJLX R`Ev%,M4/q-T{cDAD 38Ӆ㡽.Cw&]mqm{w'♯E^d֬QSzɫly]jyh'P=9]}GK4wV{Ju#qg|&xBSFӉПHD1v( Cjxm#TFtfNLPɮ+( }߆}fDTDDYaVePYM*X&FM0.59Ѵ1ihKs޼[ą3r { ʏ2hnڒ۪1Sb_ǯ*Ҫ=RDna_Y9sMF"",MB0R߯iPQt &VX) wj+\ټwIl徼Tʜl~Yv)(NBQj& S(Xlaᮔ^;4>#80Pk=uL{Ӽ/xE}ZhBg./c$18%#p0U$MK]O=O>d(NGQb. w`1JD}P:}'ih`A=hcZU4u kbMeՉV9iҊ~-FX_r'N>++D8E; QB`4ԃs5ԃz{vH[Cje-ZEM+c-$u))Y$TzU7 Uxm];xs6pk bJsS 5PH3@/*Ʌ.3rev.+k_ٶ0Ӥ{,wdh9(w辩KpBr_:lEX z.,^.Vô6T~GK5=Z)GvMw[n̳>\Q缮kD{xv;a="zNϤB 4MC rfh a]';m$gxF[bFl6_7 o7䴺)AU輺ɡQA5h8AzvV,Ns!eL83 Gx*NgLбB㐱Um kpooȱ>^AwP~1?OH1Łi=3LL{յ3OǨޥzZtnT!ACӷyFsh"D3\p-Ds8I?DMy`%6U" lBgE b eJ2L^U++fMOe?Y-k7g]ew+bG)F)O+a5Xs\3 )ς@x+܊f֟btRk(j/˔? 'ODT up~ `$lF򙔱xV2eы,?xO{*PuAo_t?_#?%7j`X~|0^@0WANx絔Ahieޞ`og?hΓ|9g|Ht7B|{`'  zh%hp440ppX%B0H1Bo FʗRQ>= X=Q[LɅCy+)hEˉH #[!`|E~\BAYpS8RB7(ˉ ro }bL x`B/Hb͇C<hƠ3̕A#z jAM,H`Z&)&5t>2L$U)}~D^ KK0hȠ ]̝ACo l`rI$! 2A%r|INeJvv :2hOZ1[•XB\RJj٨B: Bw,\'u}GEugqSFA"3u DPAd230 ",BK5ZWcM=hbY-b'su߻}9(zy'V&q_ Nq%]ev^Hihde-r8hQA:'hE"[|}mqBLb?ǖ( zŨ-,rw( e}ow?$kxo%7WCgҋ_w?=߷{'+E;oKQܒ(['e8s21E3fNPxpz]8oW.Z ?Y̬ Y 0/2]7\ g'\e /p@w$@/#@oZP/^z~>+]}A&ݙ;U'Eb;w>3_q)0JƧ(:@38]z~@Iw}҆<4{~ެ>;ܛs\Z&Uٳg7'dY>=x5qχ&G<ޚ~f

#z}b!\ C a ZdC_E yN68=qh~y&sL?ݢ?`xOn>A]gwd-MwN6]V@A`Wal-pM9G2p:ҋ},b>H.p ,ݨ?$Ev/6߹r{Z6A[K:K7]`'QkԱO/&f~e%<疈JGT؃q=ѱ{#4=]7nmtۯ6lM%YK#٪w͡hOPc8O7cq>_'d$8,d_۝P=>Ұ;.AԵ$lSlEGtmMֈ6eY˩1sC9z:N(#5hWұ0e7gRYp" S'g67c{g7'upKJFu=1Ŭ-![ܪYĕ6/Yn"UVѩ6̥2+yy]7Li :Ƣ8н}I ڍ0۔)oS1ņ,؛m ;s䬞l/^g\Pu1$U)&uMCR.־:acE|sejkQ)Wjvţ3q$2 ÍxAe Z!3|gVglnG^[΢ DY f itMuZ<ʾ$ɱHѩII'ܴI7r/Z52ĉȴI0.x82LcTe} AO)tX6eiʟPj=VٵuZaIBC]U(ReS*,˶I+-K5;w01E]#.BdSc PFF 9Pg?\Nay4;ʛfq+ Fuj,ĚqҘFYdNʊmLXŠKhLX9:RXU[<^H}ݍkW J8 (8g6NZ`jNmzN?f`afnMPEESkٺn]6eyZ(*X )JYYػہΞ;0}'MZB׋ǽ2-c$)nJjG%W?ō'=vpUB`J56<ցYki3d^S`gꪉ~E+߷bz + |NXc.tsȥձK,i)X,1$f=baoy-~KU^)5cFi(ޔmJצJGxiqoMnx$p̆; .X$lhIix^IUDnIcDܵZ"sVIdގ5^u+7r~v'l3`Jy*qEX[Qsl$S}Fna)kֹ9[V̭3ʮ؇-%$}0=5P-gťʁi\&TwWQXJ(W wݣwy2df3]/ӪKR\;-] lI6h )wHp8_\ɞ:P;`yVCNdQ7F׍j)3u{&կro7$1T(c1f`6ɝ.`2Wûùf6hXt$ G<gSFcwAUQ˴2-Z-~ˣQ;"ijro`R?PTY@Ƈ& cO!g|&_$#%;`?;}MCO"h-ݰ} `;+BgDi#3~n`k/b݅ F I'3@9=.ak[,m03Lv^NOй^6Am?tuނvU*3N5?evSO Hflo|oa1:w4;pPA7 -`s̟ɹ2;ك?e[V`'` x@7BdNqL9ćᇡ7\.,P.W/{rg̎ X>̽o,v$'ehB| CG{"$(C iJ0~OzJclr}jO][B 9 <9Sb(T/yf(ў:-TDA@'/R'yN[ߛ3?;nD$_}š-&¸P9U^x<~4^.0#;ߟi%G\ )PaI6Re١Ԫ֏ k괶MM6ѮAbN} :F9UrꧠǕiE`_PKufT :kA+i_ !7!q6Tt-? A$b@k"q$>ǫPZ%vٱDX}ب]ti;֨ڹS+D7Lj:##ݢ{-T3$88t%|t$ˉWӵ ki-Η=>wqڹ\wYsssy6%6{6&]jH`T$>5@| q4Ay@+#Wӝt[ZF⋴dډS5?gcb)+ )yLeKgMi4Hm5M'UvSUX*iIXgk{YjveVc5 Sհ|w cemyUWo5+ o" JbZE( K!@k@E(޸junkn۱vvt;ad?=s9s߰NJbMH k) ^ ك{x s%' 0!n%&,%^JR/5|ϹR3qS։ةPG2{4!xW!s΀e$ kg|¾Ct+J\V卵WI*9}V8=0MTL$[ƒۘI=!CCY=2/.H]r³ זDظTuYc繥ΕAt_fMMtfv<gTF0즎Їyj^]w!S[lϩ mn6gu4Caͤ&s>*Ie#YBCDHYCB>9Ήװ{^.p!g 0e b GP5&0z ޝ,}`k~ I_Zȭusf털\;')Yh?P[xJ$  |s×jߢ7 A R7 `LRʢܺKeM "]`Ȭ3VVs͆v~YQaIH?+)/n(|+)1"4#Ucpу. {F[UQyнŜX[W]_]j6BJj9%m|cqP4*Ht+rޠ5~#0t`aB 8Y0O0{Ͳny\VQS (2UXEj/-唞ה^dޗd3MD1AJ^W%fA=X4By#45Zѫ ޥ~E@C]S_kͭif!azSz;\Yu:\YHUITf"P _]AxkC?4 `Cz'f,@w ;kW j0\Ž-nؾ$mˉuY [uMeW/ة)ZxM* u]xpNA{&q38;p;@57h~D@t[ۛ NDn^>pW BCȃz`uP y2cc}8ܻy3itu` cOx>>ޏ;x}~lFຕ@Cq \֥)bJr:ɣP-g< <ܗ\;JܖᦼUp8^E' 霽:'8^vMm -,U)Q٬jifM~/-߿-4˩ŸS۟*p-lQ犓|P:Ma(UOUϰfRn1MPm6MWf7 l0Ԭ7m\keYb׭Vh %? Z+jslgXgzj~:J[EJ,6PnLW . )lڜk\]n^bԼfy\d\h,7W9aSs\ Nq+H eu-??;w WtX1QcJejtȴ* OY4KTh;7h.?~vP}^P}n#~zБ]N-:3.mKvʺ{:+=TFiXCEqYZX, SvfU6zY_L.4W:~Frǜ !{vziBЏdO%⹷7ubM7gjHwP,,ΏL떢u͌lsdvq);|a\NwYo _G=97Y#Y.{{3~,K`E=^&W{^VocvJ4yRp }بR=9$A_ٍCf =s c;eH~kZLtNr"}zpppc-4CJbe6%%ppj\&#}YI %)֘ꌉ!;_3T#R4b JIOde7 1P,,.V:,UHA@*`-k1Xb]QQD#UѱrԊ:k+ڙs@wŤ,F/(GFWύ8;jSxTQWc(a>_# }xk+$|dm8IZ%BN(If4-yYrR"!1ba\eLBUt|M,9"V6:p kv A>0^舶Kgųf] ޹>-)9;r=$eѹ~Ȝ9aّʰ4$Khz: w=}lIV|(fYb.sFx <%!e3˦˂KQ~-'-Vy[M(Yc^IWؒSڎ]*lH!)6=g;ؖm^!I.I}*$BP# `hKWjlҪP3yU UeXxUYRzVnQyTWW+>j a^c{s2|s@鎭WU[` |7q8P3kH̐ Y I{6+1n2w55w1lmxk:VXX\s;}FZ:K+* <moԪYG]׏[\?Mx,i+q1K6HVȆjdCLN2T+䃶^7τ={tW MDofm]2 kPO  3CwǀPosc6.C}$NKE%q\[Hv l#z,za ˞u?0 &5M:0h`<c=F`ӒrXBz\U3X>"$d382;s `. 00(лв]:!e mv0o E2 N?!kvN}'5) i{M'܋HDrA..iT5/Z\/_\JyC2h/`pB/뭐yO33OW:赦;X_*8kx!v7\[cی@77,]N)KOgͣp4x0mځ=jz/ȏI~"r~T<</qC.נ(++7&F,(,ȲܖEvvrY˂+  ".!xCEh&Fmc6If:i:MSM[vڴ}z<_9y>|e >X6e7pmŕOK\@$ dXqu,xFVe *U-])[kkݵMp={aj1drrr_w~ko7CfC $r"CkKGmoWkqKp/4 nRZ.GRZpP9E;}VC)g~֬(b}Bq}Lq==WΑHH둄CHW ׇG17r}G͛`!:)3aNi(-)>)wfi^Qg2z{88w}Hca kl!Mw07ߟWЧ>(U Qϊ귙.=CӞOQ[2 $<%b޿{?@ωlsc9ʅ49Lføv33 @fkזs5ތF~OF-L/jOJ[>})iNؕND"BWO_zp}b0L &tRݱp@Gt>ի/`wg[]6^g@ێ֬@wV?Ӓt3Fݼ^wKZVw#ơ#"$9p7\G߷`=` ci`@J0C1)Q0󊸞<+ߝ[ВrE 9{NÈaޒWޕ m'2H1D>O1wW9K(D}7 A) iN3X&{m.,5V4 ZE5=!8)Ae_HSGD瘃[xqz~\__z_ΒhlViI]lvcI>Yb9Jl5N-,+̃RaYLPZIXn6iH; \>b';(}-ügyQۼxQ}z ?jXxc^.=.vv)jdҐ0@+w(RV, Ư\2ZBm6^V{Nr1糨{{i'҈ߕ>j@k<ɃȣP]S!> kjX?7vy@E}eaOp}P, (q]ՠƂADET,NPXh{,G$1qu]{Xۏ{@xgygΑڛ%_>`Q2l]f(2C/)멷4y赌A.| b38~Z9P rxë;<+"Q1ír\\p4éUp,2!9V3yLYǻH?RO VF*gS݀cju#`WDak261ZCcIڲ*K%\@]+!=bԝC݉Eݸr6ԯ_ȠAVh6#GdeYPV: S^ jO-Pwm.߃k=?CIl3Yw8ߕF6eل\dikbR5љ&+"CV!V`zmDQ7+|; R@.Wtll]> 7 Lb|II}g'&w!h!y6N(F{;Q׋]# DuOrLhv/C?[7lO 1yI#_ҐWhv<xռmּExD3=桍i<,`!Pqk6@kA? $#dYM6RDJvRK!u/+~xI!쨭PpW;H32$t䐍dur .2i.'WG ƙ5H?2|B>N"u9RkC:k%2SVo>~CG7A8RWm! GzjXjMf|tX@Tjds"@# ~I p'4q7F \hK_hZG9&ۇGx}Lԙ0&He%rM8O_ŠS 8tZ%#R9SThgG8A'5qU˲h%|:bN+qJ'98̃UYê 4jpHՀ&|W2cjAxQeNW^/'7~}6}pV7lGX3`?`8nsWu2:AC=84aT9F@YBz7ˈn.yJ\C;N;tQwðԹGT{$aL敃*|Tx{JHfKi IA3!!Z=k, `;孁Pg} lʎcPR(bdl HVJ TX)Iy'e~LY֐՝FRK03Ov@ol=P4[Gas8OgHy!s!) !!9!5!U!쐣eOKC"#TțY?]8iG,=c~3XP7la(<`G`q8AZc"[eLeʳƕLFEq2ݸS^~EX(\(I< ԝn_>|r8nU =+LXcFie%-7e&2Lt\E)EjZL1S-0FlSϏ8gj1=6 ,Pe s :W|j {Kھ>XX? #e&5E\F3+Pydvf>6#hE ()Tm(O|ǧJx bca@OdFwƒ0XiQGdcNR̎LNNγ]mMV71.!Fh*a`+"}ccbuX2qH &̏(͍͎)K#"q4!SR4VhuGEyafav .D&葨((("0 ̌ (qh]\Q0.cMh4rZ=&Ic\kmm&A;8}}yIirjIjI4j{'JxU?3~F[6a>(ѠԦ*CPnH t$=WV^PVOW5MߡYgMYHc֋*^TZRES.qaQؑlZVudD9TfCi*LiPlJN]Y(_Yeƕjq&˸KΘqZqC&#CT ҏ}mf`69x%RuԘTfbK0ʬcQbBA>dbJxyhI%){rs~0AZy(R+‘R9HLT I\E4L6-U]ު^WY>J"r,JB2`y)PK8]LWA߳H^FB@̯}a瞂hĺ0ǝ"ĸ3ndX宖Ns/nO}M><<P{ ~u@7hYGo ڥŠ$;Fc@G8;#\<өG]  <M hw=n];G;65+P`0^ہN``~ jCp(C!EAeaqC1}C"? 6je6րv1.Ao8]@8B{|a#hB>n~psynu󘈿+27ԝXg&Qs459=@{?0# pZM3lF{p3,?gyٟ!a{(pm>/д.d/`=fC70ԧ'J"H5K\~¿ƍ b^?EnD|B]k4RCIX= (z%-BR&kOm?rw޸p0>&?62j4hGLAIþxq1GxPR*Ǎ+GsMԝPS20l<@?F-5Aո޸5ZxWXwq+0"<⢤MT8UKƱs qW\ %uW7hZpYӉQ\ňv ¸C8? ΍3n&<ĉIdGoN~:G ӊx0n11W&%atrF&0- _NI~GH) ^?`ST!|:lG0V#ӝ84ߛш3۱j웹 Cv`p>ݳ10v%%U'8V? _LHjȹ{<3&̬)8>'$r&cp{T`: 5cgP'vö~ak?|^ .l >/څCք|/@܎FcyG92]ұcq6-.Rlw/# û º]bO~qui;X\/=R}F4XLk6c9 b r&G/Ė$W|ٱVW.jձ*]إ[/vI!-;*ޕb$7SjU=c;3Ҙ?ov$/޸ذ, Xn}+Ra%=W.H Ƅ'NQ?RjjWr^\ Ekp4riӊ 2)I~<'yNGWJVRn0͐/BBl4ԉC6 rUe8T.j* w4eߓ(N*;STu˯lU' j^,^h71nFȘyP"\ֹ-B-΂d,NJ`/( bAAdHyղ֯dۆ4fi,5L}2dZU%3_S11׫=W̽H;Xx:O#c㳪EpFIB81(rБ-6!Q"ګ{dwY>ey&& MCMZMZr4;ej\A+XEq 挄2r˲S/dSYlN)ݹINuVR55I4)I%)RCm|GiJ%i"ߋk$UNjr!ۥɕ,]]EBLHuW ]򲪣r|ը纯UUT U+Wg`/*!mMVXE] k#ݳFz}IEDŽdAyZ8Z1~SIOrYSZU!ϸ\R㻤Ž@H55 IFJd$LEb<[ðu ⽉f`ׂX.omBw{P ޻bh'bh*6FU {'Za'|/^@,%#k& jU8"W-EĪdĢn+ºKuG|qXYĖ,$&niTUk_p "$DԒPJ2UcLUjj:Jϕ:Gr#y<꒕Ȭ g*]FhI#tM#44B3`i M7-a *tfpY Sa*gC~mw@^dQbOE*<7Ps#)7Fay 믐  ̟j_v;\y)`jcmAv3yf.fN5`={e!/b򥈥Rpq/R?- T@iڔʿ4A~kS>jmVҾU^#_WOjYQx?Vv&gR\)"K/ʥk%O<Xp1Pom$5qQ cXFTޕe) SM4PIYhx>]B IϕR)51JjIb۶21 ocR P RCk(b Wovm7) ĚzrjE oTK;$]++>v۽ c~ǏZZ}-ͥbbjW#0Gi%oFɺUh$/5?(G ~ŏc0$~b9EQ:_|F^}I;l 5wKa MchJV0E:\:Ǣt%B{ KuL/gds2y4]!T=AOI.?H+XMXPܥq>gA*KczM#c/v?>>~_zNo:ptp0JSjc &C0&51II1/gה_q0ބ10fXP+` -6ПPOls&\wV6= 0a&~j [Z=W^u_:Rtzme.4+k4xƠF )O ίu/`@hȉ+f7r}!>w7%,gҹYn!Kktv> KP_ ٤4*3ZzCǪljjm3S/`R _Z- N!Mhon6\[b6R\wϑc*=Vc=?jCZyF+n{>@NZ5/bF*#r#7i{YQǍԨ+ƌG(HyNd7xg{=Ê6Wvg"7*l.an ZЭ跔=Js'jvLbR53fg̈YaT2c?5b/ScSbM#I̯\%gˌ\}2|))enE1>=*U)@=Da)fn$[IcuhuX&L;by7q3qFzWhD{o͌ qڽ]iVK4+>B =5#>4`%ۆ)6ZSmD{d[1ѶИ`+UXj,m\Fλݳ 1g$b>ã0{ KHe K"I&L2IfLB&$$C!"ITBR, @!(}cVVc] B_Hg3s9|/>X$E ҐcU8E5IsT@U-wdґ G@2#Xa:Ŏ;BGYn;[ycq9.YK$_mqg.j]L,kc acSUU TE<',ݩӣgFsQ3Lw[,q+'+yN+fNj?g8IK+MuJ,Ty]̈Qif3ȝ;KnEFk\mǽvo[Vr_\GF9ƱlMԀxꉧ,'r`fE8;J9SU3GK= qɔϛ\o@^o1Mfg<`9={4cVy+󱕒c}fCV?8+/n-xBRgT7\c_-or}'w*Q?_n0#b&w[I^+Z\xm&}$=o%PF0 7f|>xhA,BeVVQ2#*RzTVj|&)ԥ`- V(!x lc 4o2cؒbی-͸"ۈ+c/bO~o&j`C5o(]k(FӜUʥWj֪"ͬFnԴnM=ɵ(zۚ\16&gJm<h|Pu<شZՃ4>\3 i7ѴxMmthJcuC2Mjӄ6ii|qkzCz[Qk`mD#hl#Yy&-)tS4s!&E:TKXܗ.S p8.jkaR3нAWe4ހRbc‡/L>e~>g|A`fNmh5@8 q P`%:X>qBx_]}%~1%ޅ&V#7B%B70vޯեh>g^}~$%zEs`@}xËWbCaADA z,EL Fe;{v0-[nrt#Lqjh8Чm>GulꖀiEP0'oeX׈?L0?gpjJU^lbPx;w@x#F7b;&"awQ\r㑗#G~-QXM7gQ;O-SQp2"G#q$*q`i9-2 v/kΘV#cak6X.#/a86`Cj~c>11_Cqy,$Ȱ31;VcJlYi$+6%90HAOPKL=PISA&ze?Z#tI %UoW9R2yWP~XaJy;RU496*pz9֧1ڌv d?}ѓݙCM7!Y'KG=2%|'>KL!rl/碗͙s1  ec [Do=9 V8PxWtS9ڕ{QNUy^ g#?¡3m>K,;&Ygɸl`\*cїyŸ_΂j ([ OaZ p6¥jJ4 ꚰ>ªzB$a-@Sf4(cCO# 1.aV-EWQ|řh/΃D*.m^4aS$E0 u3J$a"?JE>Nśi^t!:Q%,r\pVhЬEv6VZk`n&AaԾ& EQH5咸Oͫz4KI='=駛qfܚ%piQ)CSU6UhԕêӣAgF&}F F@NZOIB[%*%qX'{j}񻋿UZLXj`-P:FC#j -1tBo Ag}Bcr:#w#K V5HA 5Їf$&"p|wHdꓰb | .&7P[M`븪oR#$32R$uHYjA\, iD"*cDAtH8MENLm]'{LwXǿ *pʐ%DqEׁ /BEyjMl`֓&8֪16Mn?H}~~=~_Y⦎%( )Dn(/WS:`ʖ@Φ%r2mKʡ|2LhLcx, W<$Rk3`\r2#s͡jgQ[ ٙek,3ƛvsZ+*Pb[嵅OΗg S3`VjeSˌ[ۑCh(u:.:.Xn0g<̙+[F_sa SyH1g`^.@Us$z 4fp'Eg  m=E'{xK4bX94s퉮j#MCd;srÎ]ر;ر;h(|Ful]pwr߇ {)5՜ ݌>4Ap&B4hΏ{Hc_N`G#I#ůKuX4`;1'-cٮqux-tɞ%CR[1Y~ւ}8694.HbU(Mm™&>v ~fتnc8!;ݪu.4@W 9| -Mywt{>Sӆ#I? {YrU nGL_M%݁{ց 0=&&OVۃAcYp drXw@0C̄9P eP`~aY̍;ټ' K==⭇܁uޔ_8 l4r9 scxƎi )>s]u ~˯| | \K68ش +/cHgi? ؂.c*Zkl7ң49Y}]ZZ9flAMOŢ:#WϚdDeo{g)Q~hAN^Z0UiEUUnE&herU|w+Wrm]w?<5nk0I!vßWÕ_n/*}cJ;U *4X;<1*J,{T\,POfӌov?)E]C)!*mU2a.mTAE5k)7Vy~7L9ɚ?^3kz7P2VkJ6Ҥ3J &=UjE7%gaz<+Ŗ7fn^ jzM9X=G(eJSF,BW(5x&k\A yOcB+%FXZ(.EQ6XU<+Ė|l%69i!]552{+w >4)l&MPjX&и /SJ ج%E҈ CE#\ 06C9,{rhaVᮚɑJ쭉}5 5D㢒46jR'ktQ@#bjxFOCcNjH, Q-נ\ZZ#mPGjRRc<5>_b5&.R)qo%jd%ŧix|4,ޤ5^JأNh`'P-?<*?we1 $n "" 2,0QNHAA(MqZ5q4զM6mzĸ&ƚXa9}"ΡlX?m _fK1SU@Y(/ir'+; ,E&C2 2UZJRSdHPR>%RBNJO %$HrT#,g= 3 boeȜ6RpeE+#=ItsjLJ26(\MG'qA;Nw(4bS:F @QU5IVcwU^56=VS5Y!r>><ekzP )iluOy-P0yڇ>+7{4>]5o_pS$l7SO7=ϡ~z&j"9Ff3A(h ܚ4K}i~i<[dZ8ZL-P kygຈmlyh*^/|3Xs"kĚC 7IÚ%%m,1ϵxXE# C N01ҾCP"p8iFjz͚ 5Jc{Jj}?@?6f p뤰;YT':ppzybFcI7xZZ+ow^BmxBP\wFzw>{pbGs֏ ŜC9VM(gU8@xuq?Nx;AEquO/Nj[9WuqN ?%wŗ̺75f/NLN>V 1,vb{%Ө;[|;xR>prWJU}s_DщTMNΨ@Ə7𣋼:~ŏZu[8}D|E Bm'|.85go-/(t"шZ:s_lخ|l6bsll29=قldsۮ"G'#$:D b]Pr\l.`w,$ ϓTVWUD̮r]9 ];A1B9 (hr4*Ѩf,Ry ZDd+G#r"pvrԾѿ+`ܵ^ Gk4:ıT-TiL\Yn0˰/2,3,31 $vcxKʭk7V괪*RRU~V.Q*Jc;R{;G: rq+YȦcn:JFd)ﱰG}VuWj폴~UeZr6f_T=/F|Tg"S8%S[K8]ͱlsrx[}嘽,Lr fÄce.vLg2=&> 1,wvi9Tk%?k5t2Y$Dq"nG9orj8`!E8\IL&1B(iK{SH9#3jJCq'_vDSyʝi%SK,'r %pj6iLU1݌2ьQfL4-0`tݙ2y_ d2LG_d}>'.zi{XdU˜jɌf*3Y&w/4i'p y-0ws_c=om)]Ɲo6t=&ezX✖.kߜ,ȔjٯZ͛ٗs J*fwa=V|En+x O=ūt?*%o^ΒVGaֺ"tQޓDY3%R=V =xz1{GN]a92k=c`~53tRLrH[(m $`H#Ϸ\_!9 }ue1ӿH4)$(~I$ =5XE_Z#_t ^}Wt,RT$k$S @:;I Th$9")Obp/ yvOL\Mb&&+#rrC|ǥĠp!ҮZjBVCq$Y!6BLaCSl aTdo1'"lgqHLݢէ9(Ji+"J_1uBp:ع DSbsMa}aܰnBywx fkf?T#VJ٭aH=Aa+\89JI_4)ҟMDZYXI׃(ORS_US[Ƕ\[U\=%=@vP5,O8"Y=%]6mzI0H_)K0l>.wR )ZL-vj5!/Cp'V54Xք,(z۩g C|D' z "9&5xZpT% -vz'57` BcㆎS}&Tyi0(:5 : HtRwKc)j<)^xrS긭Mz[95YiGcݲ9S OkI7e.5ӍB 2{2ceey(Kk]XXXv]`9DPEEE-}3Ѫ68ƨǚ&5UcըʹMG϶&iLL9l?qg~e}yG㣍 Y&FaV[O?r&4ݑ Cƹߢԩ~?pҪ 'Ki.g]l穋 LhݷS c)+C7`?vj $ur.{gxhV.37kznP7I7M;*D2f;y6U+6S|}.UXzM|;]jsrE5zH]< t9}" v@ Z3a,tS|=t}M>|\sOzO BVȆB}24FQG@Ǘ9 3ЕbA\Ru!u>}p?^0zݣ׽C.RC('~n>_~fb/%||% x6Otk?Sn)qG>H^WBԟqsϨسu8Mhl6uF*ާVc%>V2e e摍ϯilJfSQM49αͷhf x%{Z1p"ơ4-6o,P 4jИTmnq_x y-5+8{wn}W0zh%KdhVWfvWc!޽LӺR8MI~Tc&X[Us<1=/gjjb(Gˋla5øph?YCif5iJ_&U}M (Q]HѸ8C5 TFU[UWo<=ߏ2pFqZ#-93#gjҔ>(BlQmp֘~+~e_UC,dlְڪ!T~q >O ИUEktI"2¡9*PJ4@);҃i Vgd(͑TG9du(9YgdrF wAF2:)SB}ځγ$:P^ ,g3_Yٲ5(ۢt%ggWk̮*\Jp5fuoQeEຬ(]EgS WgђL1P%PR]e`Yr#+sY<9Sg<);S2Ez7+;y*sIaSDn[X,4&rP ^pV,o4 P!2WdU薡PE#Q4NE -ZE'̃>8dY]p9dj FEvǮ"!fRzD1j56$HӚ1ZsL5Gڴ4uSv~LLJ=}}}J,}Zҗe-=/kMي\!iZP[OhgJ(~ԏ0h.CM& lXoӈ`]~񣇋5顸{ ٽ ]k4N>brK $B])f:[`ki8`ogg~rr9H};¥{Ev$9P,z)YJ\BcU?t-=7L0cQq-)8ť|?ct$`]9sMxB@w~DŽ q` a2B5XXQfezE|^&WT_?xNDH x&@QGՠoLNי1]e ?>Ǐ?Y>c2D|oI9d 88>//w@<)3̤NL ?>ď0՝?{wgROyN9%x%cd5^ \{%e3)/&.lخlln应wEVوvϓ:^<@G!.b(?hDcy2ĶuDڄ]?Sm+_qх*?J&v%} (nC D:\fkmt*t3zs7]/Rk3ɰZ嶥jm\Lʤ"iʒUTZM8K[T`T}wj9ME$QnrvJˤ3i ƗR-gE)v8T:Lũ#5-u&0PiєA^MԬI_ ەO_ kx G҂Ҋ. `+ܔ9"T0k#Qi*7eyG(AuY`w]`e]]6xM0xD⠉hhԦ:M4=$ΤvI۴;^37{y{wiJ>SMM,)O"t]-)n~]6pDo}=׿%؃ M|!.oNP9M1#U3&_,UVSSE嶶i] u.XwzHb=xpgيlAS!|(^UEUY\QYM29m^a,-<ٗоY.e|9)-0pvӍ*-M0 &]*pĪ̑,Wi*-5,TRŮ:&5UW27j{/h*u]9rFc3e.KFB|P e;GYli*.RQyVe(S^&OF{f)ӡQ]FV>L+y>FG*^3T͑[|oF׸[SQ5SӨ|3kWo2|ەۯ4 \UZ FoTQ=Fh.& B }/P06Fk[yoHCY2uLRz` XmJ-ocB2)f(= &rkxȧPPɡf%*!BCC݊S\bo+6znڌ5]0Pp]W>mĤX6&*%p¹J[4,p(.ܨ0/6&|f,4b96Dx5ƌ@=|mA{D'Rb45AC[R@pDT#EMaP<0iBSBca<}P{{$7eh6ugrј?v6ʜMncښ 0mx9c8GXfH⽓1[s)V)m)nql( ".ɣ=åM$wc:<_O&(ӧ &⩒iX tSK(kRˆpp [eg%yt2'9drcN/8&s-[ֳji'7UjCm^0}ƛnr ]"W4y&걙ztG7B=V6,Ԣ\1ovaM]QD:Ro ig3tt:~͍[`+<(f"$#I̯e'{5N1bhof=Cc@~ Wad 0*r޸ΞqM&:$fϼɀ$`8dA>ؠAd,-=qB~#M][}wuO|ʯ~g ryAXzEa N n2.SY4yy]C4b9eh'{̻Ja,#tZ\S Z!}5}L>U3 xG;h^ms{V3]8 Je|INS4hTO[}?#ĞIybZg)W*7eƻjArz}}Fwㄧ ShGV4\ԭ~b&Tb n_}ث ѫmM-v,ϵ'`| />g,ƒ8B-^T*G_L|7{٢mӉ9:w [iɨ Fܤ`< W;k,ExNT2yg?fۈ_FtA7 Z9#9NZ֓I:Y' \9yv2È?#KBh&t0UjgBF5׏p6XfƱZtR'e]o;v.p8qNlp9&MNv-mvJWrT+[v h5[@QZXA\1&  !:'}<_+Qˌ7ъFQ4$M,c]OxaYEVW^eN{{J;Q>!ctM:^FݏNtf6R;Iha:fmMEQڣ^C")RQXs< uM!}*FjT tj"W5=dƲ7k 7,jpV7PE,+s_ܴb%4J{JCvJ.Ym\)Weʘ&+W*ߩd~ Z4ZHAD˜*@#S4hтk"6P:MAZ]ƴwRIJfa X<1ٔ0974dnҀ9~sb}kIieF˜, WrFAeY*]YzJ9ZM `<ĉeȖ" X-V(b ֣.:c 7(hߡ} Skj.7-}G\ܓ<:B %CYXc)O/;Qb-SOYrʫYVI6+^UrD~ǤZ[ث&xFNjr;^Ɋphƒ8$:j0.kQmRD5jq*P mZjj9Ffy|Bu|A5WU| qOPٍQ4` z=^0>Eü^P.Z\jvW]/ۧv5GT_?(g\.&9=A6Á23|43 #7964U_.4&ab1۸zsVW6t-ribAL"c+ǶDObIcN9TYPs46LJs;0c`w^@nwUf 5V1!a5&9f6Ԍn&لnތc+Z$_-xIL|1yyuz8c}:`?GgisyǼO& ٷ=0ۃ>9g4OdNi8)<|.[O+q8O^aX\ūÚRZ#u}g\"ӿ30+FHaW MqM7-:uqSu_qp?a`ә>^h^; s͌I.1_^ }C~w=ue|>r,!݆!ytVLX1K/W[e2c̦Z^ً@'~yy_=ͫ O8v?;NDZ%n;7zImvݺ6[E֪] Bҁ( ʠ*kT.T`@`m2sQS>w{y+8!hn X_8wp@7)82}]c^e?̫ <߅o79|NFb-COoc\#Wp9/|^u¹|<~KhM^#80lO1|e.c_ރw&2؉_3/^P'=C%i/pZO~|1!} "zARBLy,|>Osyx4c$csyy0;G{ W=hϳ<*wnY6e6NY]adZyN8K΋eT/·df }?edNaA{)tg"֣=Sqy9 ܯ3 OTnawj~ m+'`6@;vahO0y$>f{;{n2ŵSϰހa<_֜B~c7EE]TK2/{]B?J5hgI8Zrs̜y)u&x"<参 ǃ-hQt1"Eo2KnB>@6X=GRff}6CKc@ ZbKI~hyYE6VhF~ , )Y}E SQiT]ILkiS;,>Dj+>V%%9ib"6N&!ˀXf2ez˪cU99Ty:;QޣeJZV2V˼jQAE-\RrU!u9Eqs mohn0*]7t ԵTiU)CjꔴXSҤZnicƄYEyj0*h|JTgX>E:'?'n݅=XK۳|ý49u_;-s)(WaQV+TѦhEJgάkpnr*[yZsrW^Ru pSngN5pwoc-u@[>C )VB.1WljtU(r+RW:긂 ܫuonkUsJ5_#9ϫ}*s:Ļv|OA?tbqo&FxL y,j+P[ZޠMyu7)6U>(g9|'e="|Ruޜp-ĺV|e:^$Z"s~|KwF@܁rҪ d%"#O Y_%xQgTxSJ}NBw3h&hڡ{Pa\a.UF|rFBt-:'kt,=2GWY!D.5Wi8{ 5سly Cc~;7zUSr cN1d5OQy|Lj78SEc*hct U 9?ހڣس^V>7CPǵIrag+Z eʖʚpȒp1xiE%ɔL%I$1ILY( IpB'Po+h{$qh~\r߅uc H^"sʢ!Sʩ%) S jV `V4NaRRRl֝Iّ]hϠņd6h3//LH/S/9t5Hu i࿏{vw #A<0sqsc,1|rl7mF1f#+>i<>]s 4y%lH*P_rsN0+8t&ܧH()^b)e`.ɐц?:񣛜M'х.;q+\̎ ')'/aS} 0Pc7!g y?QK9d% .5u!}0klVO)^ތ6)`{`5=0XI}ƏuaK}u\kWr@nzcyGY<D^%-ći 5M7a&X90Os6&j>괟d+V곟Xl!y}K؟6Sϟ!s=U|R</hr뭁H(!0 XBqaC*5?+8plI>| L}_cuM;9`ͅ9x7Z}k -Ǹ(dD\,FN?#'')3q˥|xCulb3׻Z>W]yD&Qp$ U\0!Vs%q@ϓs ,QTg~)!ĻeW9IнFsƺFA}pC&k@&0@>r0u-ys% kTmr YWIe{1܏-S׀CX5ȵG6(пјwUH}r:~\eM! s?ΣijOZ{$U1#1J ]v[>_mpmu })'hЗQ* 8fy >=w$) t9Ïa|$љY;6f_)b 5خǶ&b{gbx O)5dt;mG/Q'*;)p XUf{&wѺИPn.NVjd ~E=Kx:\?דDtO`X >-`U2K@ +\gQQH/5?z ORz^Nl߰Ǩc~g;h$i= lYnl WP:l7`{'!n-n_%sŃtAzDwm_P|ت]>`tdJa7#u:IDQR-4,MGZYmbÛV͵ǐy,G1C,E'9 'F;5:KQL䥅L>f2H6lq~+93=\ssAOǾٚ5E^8BpDJ8jnV_D*P#ĸc7|8V3pw1wG6Q)pd‘GnRx੅M؟Ld4HR1è:g(UW^1DwAýxK#>6s13:mԐ`ˀ)𔰓U W\ᚈxC v~nw]Al}`hi`, Y̹ßI)&;U^U'4$yRTRLPij݊.V8uS_T(u)G9%RQ}_ι_m>@xV˻JP=Eՙa2åL2TYrEm5WEA{kߢ^y䶝UrF8Xʸ HjFJޗO4:Vđ,Y**U" 3X|WF\~96>>`b:ɧ20ہZyVJ)7WJI*vf*RRY N@9U*!k]S˵HNZe^+쮳9ALk=YHI;cQ5WR> BK?/M< *ϗ'';A׃AA?L pew^g;n ,$$\ T D8V^Bq2ZN6#2Ң >q;9oyn'[鐫%g+{ ]Il% e VP!d"Z "8<<]LpUq-@ԉhܛ_! c> ) $Uc֥ &Sa (Hw#)<as29C>q-JFP .~1͵~L{ MHi 4 -JG ll+@5c`*o >40E70cۭ +W/pdK,1!a7fF3bll m B&xjG؄;@ |Ro.<)> ?:cm9&'4^6O/3JκsܺbCg\o3@jiy\g6^g8 9@/}o1DNQCbwD&AzkN# -:Ncnx_`='d62!'#FW?r&eRR;㚅ͬU[uv ~&v6IZFPI`%XEnE㮥>yl>7 ,f=F5\3KTh\5!%>擤!s`1kD- ^[csX|0>CMlFY} s$A n–Z5d||X`5?b ߝ%b&&ϰo_`aM<~H{xy71RX:8{WYIň%@#_&A%WL$u8xWXQ>8B>0uUJ &^pKd|G|FwUV3]6KD_PLpoFp*%U~/N )ڇGNvSO8jQv85mjd+~Ɵ9D!Mǧ);Lr+jOU,U*.V_ZUbRmP*Q!y7^'q~&>@^gUcS;Y))*wzT0TjĞb{*tT(Ѭj-JsHOU/Zl( "Tr O%*HV^SSSI++5W0*7BMJ3:eK1VmlxRS4Na\ɑJb1ǐ' A9J@!%7=A9$e(ϗP&#}cr?8ʫ o6$,fwI6l~vIHBH&@J$ $AkJJJRZjŢXZdZQt:0VvږaV;0/۽=s}o |ӷCyd}Ke+{y3݌slP⼫`M2|ey*(!Uh+ت`>9ò7RN-F 6`|KZi |A|lr :)wr(4KP Q~RpBpNˆ/B׏܆hDѲ!|PNc%|a#hpJF0ߕKżbxLq㤸Sn~ǐ Ѐ (%]%\a8g|JHlHOq4di X65|ՆoexhE0W!Д./n{y೎Y|< scZlŏv0 :rEE0&u٦0k@3nIk%e o!ya|HsvB!'KA#KYd>`]*Y Ճr\tuL-1GlkKx_ o8I/9kA!h.\c 2ꄯMS~w9Xeqrđ&fNjn q/X6,ao=puV?&kyGC&g3dL(9!Qjgky?ۇG>-})wžk) !#6ko,c\ɊA(fC~yCv&ړ{OK߹F*JyW=烀% qe#3pH\΀j<9y{@&/|N:gT "bc|'ku4Jg-_-__߃?[mrrz{ҿrGQ -@t%"}v̨N|StLF8$P3\PEM/3y^b}"3ɹ#LV92l+C 3l3ԑ)eX%x?<>j* -tL4&qXsh^x_xONjqJtR{L(P&˽v+p^Z!3Ne8qY Squ sa(C2M~] {mf{Lm5:'`6?)=|w|_APŎL籟.Li$_y=Bz?"kzDZ}p_!B%}'] J)(<5kn tb#BŅ9!:NwpCȎn$|_)nSV"xfO*xlyxH%k7xxSspd;I쯇o9r8+[@ ԁ8cҕhC|E\ ;{Tn6ٻ4wX܍*ɝyyrgEo/(3?do%3$$`BH@ Ud(Q"EED TPM(Ȗ-Z""Kw;=4s3_sg}ߖ6(M@rAk&Ь%vJ^ ; KgM@ úZ|u9I<9v,}l+팸 O8:C_ mk<b$J.!)A-A&.~OHtOHZsѴѪŭ$NVi%M["VxsRܢx:{u>4 gqYBzt}N}.ѽzDEģgq%+ġ~ŢNy}d8/aKbrg*bNE} A3A "M~K[4[<~M [[%VA[rNj?]༆ey|1G@(h`*]S@K!M>b Gs)4 N_(| SC,u%7$ђ4ų `6уv-dwCaآļb1a11NSaȤϋ8!5F -ESw':{U!<\>y0?*>⎤3&C̙",\&57K?GӚ 4JC96g! -dSCI23!rm3A{Z"%I cfqdZ$-*<|6xcw!^"0Å("qkNƤ5!96"M%3 Cso#:24s4/%RLZJkM[f ESjhrٓ\gRGB %xPEWjI],-VI^T0 GFZќ^IRkA#VN,c.'ZϕW:g/\hL1$iRUkkuRG[bNڱEAΚh˺نTIn}Rɻ@S3$( 4[f\" h /\4DSGwΆ݊+yލCF3gDv6gTT a[>u 4UFhF4B@?hI| baX_MbHДt%0>BS .z$f*|ըj@8:FF0'YPyH`pΪ4M LH9:KFf T{|jHh#OS4puddq'qm*i~RRB7 ೛%ljۛ ZoIq@>CRM}jP~D&%x8(D笚|9*Ŭvh֘Ws/wQqN2,AI5F(SB)RV$M׍luxUyd./(ǘn+ДЬB݆j>׍l[ {T38؂r99<cBvOUdN[`(Y9y 6izEqe-Z^Yqխ暶U_WݱSv7٫w8vC 1r17~¤Snco5{gκos~?.x?.|EO<䩥˖xz3ϮZ k^Z~Ɨ7ymn߱s[{o{>Og𑯎~}Ϝ=ϟ.\JJ]IYWQdg+/RKrR+kAݔ7) nƃe-ʅpa2b"NLUV܉w+3~ܘP~< y G+K<Ô+/b:&e+8fMy[sP>| T}<:I?().^J>\ͻ%~__b2lvǣaO G8#1<\|LҚ7?3' t뮶;f[mq&O6}ƽfϝ7 [xe+.j[ȤƟILiLT*Nhj~o'G~83gϞ;w?]pƶehbm֮-;zW_5a3Жj꩛sӲZdOG. 54PCC 54PCC w +u%^]/9 _ԏ-~kwe ܝVOѢ'~HPG VFծ=Ͽ1}C&|ww>/n{y /Mٵzo.Ѳ{_d'װ6Rk]/޳̾{ttp|gvzffgi;fiNд2/AoF)TLP. E["-)'-߶g/yޘᎀN"_ )Uka"a>W~ՓĢ ?J!&vezzc-̣>$CkSFkCe\ )j3J-)|ʉ鱶d(;nZs>w'C71}fDmX4 >J6 3O;F.^hW}F]υ {E"f(/c`4Ѭ7h^!}w>85KhʙHAΡ 5.-F\ |{Gq{im=/ZtUppdK*8+#KD0 3{717@AoPa.57d0~DK2qH9yBCOmHLU1tՔl2"8dX2, K%e( %6@du= םj^A\ˎ*$,!2.KGv[=ZP $A=]+#zw^ XaF*bZ*SRD &^\4h`C͏@v~'x<\վAGi]8^@(WI<}IXA-edYÍDVa( e Zg:8QCHz,I^PQ%xI-02,탡2Ad jk.s[g-HQ$Xi}yJ&E|Ne2@ZFO AF# zw/؄9Ќވ0RYeJ($9J;C`M􂜇}0 P/69&Il됼:y*LYT~?W@lͥt%SYBfaC*Pn;ӑW_ހ}]M*'(bN)%O2qbzȅ)}^KgX $'mo N_u_q y[r=P"fUdVp=!:qܴ.K``ZJ s^9z5-&i *ઑ9u(I#ObDQHnƖP VKE2jVwn '\G&ǼԲ֠m0NJ\ɰuUH Z %4Y@" 5Pog1ip`䉳ߦq/g(MEɱZ,T"V $KM2TjA})Ï9;g/(Ė>Ȝf+Z5b m @92v~ jl̆q'G%7B#~̇ސt& n磛#XB. xю?S]1a^‹w>LpP8:H"Ol@H4@D E> Eߧh x 3{ =p~@bIb)y`o%65~) }OztGr( yĆ_ x {L|@mT+5s7*Nȁ3GR]xڅ)|9x^d\ \dyb.pPdmkkm_"8'q*)c{&B?P|5 b} \hXL35j1|%/h`?b!4У( ^@9 Sa6r%'Pb 5A(=)|FIS|F!'AcIn#V4jBkn    {C.bE aԞ=ag*"tVP*GhHLf)sUQ͚H%PY~5[y6V!zgkhpwK Ly}\DM3 3]WdkijJ$#d=U$yA 7B-P%P_6`ׅ3$}N+5AIL62U`#%yWFSE E[V\Ks2[nh`ԱCbx. 蕽|Đ Cű-NV>ߊ=jVy& ޠe=ή0ۯk@G *h\̰O^LA m(U42fwO'pxa0|YPa47(t%1 Ο;\0^vwvgԟg oB l;e^cdUd ~Pb07(PfG?^;87rzĸ~_|)8Wх5SSrITdC!b@ As/=s߼tD[gnyQAA$LN'3 c8FBm}.)u#=g4b4~noUc[Q8ܾMYBy͇fBHOI8t8:K |H{ȠLdT}ūoݸ\7gMӫ[e`Gp=ϡnO"@EdYs@\4yjlflV/o.=ٴBj:*n%}lNmg[SeJKn/T^P@QQQ@@PA/],YinmyRؽ e[ C>yE@)H;a#ssEfKϖ?,?_V3|~lEfAx8䌃XH?Br_({Jh3[^^0_S 3`p^AkOot7$磐dmÐА P+6/ f^ Gp1~)$T1|RB.玦TMI千.YoA777SCkCɦC ?#4wPO?Nfo0qZbNYLn~(VbOKmkZeu8MB ̵Օ??#Ȏ,kNDs:#:'>V\9Oe t̚R2'L掞8Nhmנ镡1!?mb#~GPqEfV 7UO$K.R+ \f9\cLu%uMXyߪoNhkGWF 5Ak(9ԁZ$)93Ε4RfiNb]6*MZInAӉW2v -&Ġڷ6[ɐiuL Ye>3YK@e6rU/"4 SjbMCb"5b@ ЇIUBEI*w:SW.+Է[i>9Eߔ+UKr7Se@p^*6A3x!Y!,!*y-vANEM&ACӫ cuԆnvC$e^X_/.bm[rĠyb g \)ƴ&} IQRVmz;hgyn46,bhغ ;WB;b>tv/KWt:A`jNmg\}CEŴ6NΠ7Ҥ}4E111t"/,1AaO\4FٴC AJd2ȫ(8Um3R HaD ذ$[n+A? cSΙ# ͺvUD֜Zא )F/ (F;Lj! 14! rfס^w Y]1|6QHUU5gvŹ=utsԚSͳe׈2xRKFEMP81H|ZOgk15zq4`.dzXFPV(3Kd&Z/asbP+KVPBCǎOf{~4]=|RPIZ-` I-|M)z圾ЂxME os?߻IocA?c)ґ]쉗RtIZbAe򺣕EHE)B)2*AW8/1/o\#=s#ǒF#l{t%/NՓ % ʸtM~aB4|}MG 5 qtl^44ra8((>' ) 1tIDIz*5+ @};XpgӻG\^km'#!_ٴ_DTXrR,-s4A8K`qqY9UX[~M6®QUA۠]tw \ԮUGg<6`**9{".#u 7 '8́j');m{k&h ho''DX_A!NW1K)LY[FfZy on: L㆚ P 5ۗ@+@t =  򆶤YIVS~AƷST*e\W;^^'cF:/n΢wt@s[\d FPEt$H3>eOsJ0)/(kh@>Ӭn fvhN1{vgo'h9_ >u6EgZcin=aqWu7h4(N~ ="l'h0SFI721MGMl/ 4d`B mȠ84'<@sgV;5Q9Ts ny*rQ(FRiھ,&#{ZsJgK|ݶ ۷6@|ftgB&'=TeH[H}U{˚6˪Zs}I/S9K 'h߷ T7P1!O I=;scPcƪQm%WY.(IM7ـ Ad|LgGX"nJVx+9J%rS}Zd5LV%THdHm/6%^AfdP] ={}-*[[|SJј*9(#6a"[R)|Υt9Xe*\of{{8 o@eЅ փos{Ȑ選 WtEi2ȕMe*icAfa<&ZOki*QCP:gZd`"/zn߽ʯ'mytSqф̚t^s4{g%wQ5g,lmEڬJgd୛ m?gz݋NP*|3I;! RʼnC䖸 5mkey_goMi#֊NǺ_ъbZԊ"( D K! ـ!!@BB "ua(nXQHU^h?ܿʪ@-g{~o٦w7iaݯ'^ EQG TŶȟEa|1Hx]&]-E"͚`^?eܡaWtLmqu~)~?;I _)OԄi_hY=A.3+ݑq+rnW-n/^2禰gl}'=$qϛ2 &[ftC2.L?+N z*)ShSes s )[+,vU%8Zyps T z;v6ُ.3 -#0l&. . x D ꞏ|ces`"5^Y{JOD'"E5 2p!8 ;Bq(7n\v{>F+,Uyv.5ก .I- vA@Dl 8p}!:.uXb,15(`ռbYȅK2Uw ׇ _ yC7^R@qX;o;w]8|6s\FV[a4,/Aqp[k=;\ȯ f+7@!H26:C6 pm mlO +8{q\ׁD!P@rԣag BdIR&ԉqMYϢhc.ƓޜG{a87Nso5dX݋`J#o;A ccx⇿LNHVvL7(I sU g F3LcL.SITS󞞹's1{.bXܛ|mz^y3-9D^鮒d L4T ~m8d{嘳@_h[;V'(PJIki)%F'\WTonuwWIeU'z+fjol/c}I6K m+ΨU\f\VnFeoÅwE*I> fh:֥ݰ˧֚.n&(袔[1Z(>D$h $k+{*%KGuS[o{d<5mq|셽qj??gpzB9(RF/oeV[׃ZE-M]5rI@.i' 7crk]:_Szp؜q봋sd 2hD Ӌ>n_ҧʫW\[1(\Ax*P& e~Qaўw5rr,` )||dh2e"1!>LpW1CBzmM><\( WyPzѿdf--hxomP1^R#g¦iIIVD6)/D~R:.rЬ,N/TV ֭],[y )u߃'/RBHoƒHgyQ0 L=ED] DI(>݂]+Q[W_M[f1-cC\ÿDa)5:6`5\,+E$Eq$rV7z߂jZ'W ۄpN%4X`E3@D߅P Y &( Nv /Wm FI,ˣF9"/*J~ ?*\8 'C| X d}51RL܁i k|s'Z8B$1"۳ uod8:;r8 8DC+D8CN@;)/S~l ZCDݑA5"*{&#Ud8p@>3Nl ]80 {`ma CVsX@s`5Qd3c&?.n)TC*בֿ$\)/]N`,w[V6/SW1F@g JUTC4$"1 Bg $*AHP ' `jAj9H7Z $d;A, Q^jC>Ak6*8$l(|g<59jCo_j@nEDioK 0W, `{kX3_*yBJZJEZ n $/F.D>y;5?5HoCIH>,1Sd{jC<, 5ujS h5<:y)!j@Ky4@ݿ4$ig>3MM$" q:y;*CaӇ,.1ҽ?'qL.\|Q&$NzEseP ONM%O_P@<Ґֻ<eh@!Z jYu&o5C\D.S^GU$ͅ`̐n1p=pS4\:9x Va dYˤ~Hg/$'gK'd= iN%  t=n?e5&S74!i HCJ9@ї^\Z;=z4C1O#  FI%>aOЀ~9x,&i<4А͛lG68`H7K ANmtaLs)JM .QY2%Y")u"bb] q@$8@50Eڿp0̿bv9YCu7DrV,8dI6Rr<ĵH i(ߴ׵G>trÛV/,أO[C0L]5SI =V E fKt XBkUSjOL~Wi_  鶅 ^9k>=퓝#ۃ͑QqMzZA(VIXb>Yʙ,uIr- RzV]@j 齒B  dW'`#g! ƽßo=UǹC;|{Z#"[o]MIjRE6') oTWV(VX5֝U[++h(\Aj|l>m/^7K>fs,ؐ'NYJdXʮK2_f dzh F<ߪ!zA=P4;A%r@Esw]kЙM? _p8#,-:Ym+jQYņ<YK G*hez}I$jp^ixo\GJ9t[ivO=W{y{7QUmqYd ݄/4s b)R$Ī8V}pKsةUu_8t#Tov4t_6=sꋈw]`X ҊsdU4a|S$$E#LMaT! LdN)σM~kcK_o}vvo8~,x8<⻘4j̒byƉ_Qy"Z4`k0mz{i4hހ&ǁqܵy 9O"n|@F 'gR> ݈6fYM\d2m&Vp73pp,gnWA8vx2 5c/OOp>D962{"1>m"S^gEYQϸĐ҂z6 v|yl@jGr j`bCo+ o=]ޜ3a93WgC3"㧉1ԸkxI!/)uJTm sE7Cj?EXzf?l^s_);xًٳa'fKf`cfKʂCgy!$a~W}g{e(]P;# Q7la 8 ` xd( yBr&gp>;~`}juBR*G!lu))6<קix A;/CԞPH i =H1= bPz\Qoh<-^(eCUM-tZ':-"%,X8.λی-~^m灴\qJw֮ udO~q|2PG( ac>lpNj2[Ke8vGEłH "BH)$${$!H*(( X.l"(3{9W?p.Y{ofF0  dI_}Rb/Wx"xW]^ <|s8ųq0}<Nb8!cVx} sw[1,b]YHLelQuҬ`ZdY-IVQ¾guDY@< ␋ _x:[9pˣ!5Q-e'& 㤞-Pq$FDk (ܚ|9ǻenkxn1P` #|"Ȝ̍j]w{MEP[[L('1F:רgKjJPER+.4ŧ3M7XN-Kt_>kDP"ga^c'qqK>˞^=SgsוGŜ*OJ)*JS9ZSXW tr]:ǐ,ȣ+*,4EC>UuӜХKe"[q`/j, IgMk@76޺|tŦڳ1ѥ|~y M_La˭LQM-àͥXL$}9UBȹn&;t$=)Y-0epD`anj{9vZkgoaGQudC g Kv1[F?!RS zdH4%Y0aN8u)H d9s8g[lIJŴ\u]I㚚G1hhjsP+]ռ\}xn~sDr^G{TAPI =uz:ڬ5>tk7ͤ﾿Mw/uk zDs7u#)y)6YAO9e;푷T֦P{@!AsSwZCRs?U O>Rj&;ߨ}M[Z W}^w76MUK,Ց=8RAbyڰsԁ ֫ uTqZV"D '(`A^Iyݿ20duQ琯UPi5w["I^OjfƵ<ɕawY9*y;zZ]k^wiT4لݔxwg 10dBVI۰ՙ!NG|=xa~iw\p7?W^)m,m*m+w4XƋk/ TA!GRpn .q.qst [q3c>VFn=RRL{ e"Ibw:C/8hΎCBh$3b\PpJx֮CyK2c~q_vdrT]ptInpF"Sp},t66!l"FMl I3Ff9X!AN{H+RƍQgSwy*wlHڨTG= cpsdX"[,G\Z $7@t6KH  /im>MKH{ D6XkBA2]d (["O@~bN{@i 9Z F?x9 ϙaciǩF9ݬۜ{1HH@?!i&Ri#g.Qi@sTnBz!<y1v> eJ ?Vެ#Cbhw/e=(}z,~L:%|HŞcdA@6}GnRŨj=˱j*n_J2}rcf32gҎ$MIٔqQ~7לG᜻ vfOJsE]Y*HPkPNaZnZweCI~T~%c"7*kHNT$Kc͢ܧEw/ sGxvt'g@B6$7ϰ&5oT#G:i)E edY$oH I؄ld)C@+R>}/Ç}07H^0iƟRO~O$,i,K0)QN|BȢ`z@`/A\$%%!5v_k]7t 7S.lt9nF f=ް|܁Uǵ6[B!ڐgiznT[$߮sڀxoʐ8dgWos0f3iApZ@bڢ2Mq? n.a~DX+"UP"  "d&R0 w~T]sJ/)h k0x QbF-&*E jQQDkbaĠ8?R9Q_3kỵ@` ܦO_,Z_t86]aɿe?'#a dëcV[L:@?}b#o$`W{tC 5@; A_m@%kPkj~CfeK0Jjn@A((g4:I?|kXyI*x $%Rtfffjj3Q*jS$bZ\uab'q(\} Tp0zSs( 2A hBmT‰c4탱!]~^qD:M"!#M)Xag 'd&n,D#fa$ȋO0K!G>(B'j m6s+.\I9MD*9N` (7Fx!$fツ^kx]2_K,GT-t@B{,Z_]$$)\Na8XE= Ìzs\2Nl숄^ RMVBd2ePڠfV*`u}P\\cB瀿<JmQۂG0ց~Ӯ^ 7Pz<^O$B°7yi}>Ǎhc'|>B':8<WC𼍑2M0,؀oNXׂ䣮wvkʯHH|;čWH^: 4.x#=h1, -GrR:" `q'yq~=@$= &j ڐw c!o& 0 *r4bY0CFsȒ,!M[t=NU")3`^D`0~폰I𱤡-YI|89H  #D:#eDN3afȎ\E%+!_q U7xw܎ pN}!kH) 'c-uh&W؁/1("B`p7Yke9⬐˶ETxATGLroc?KO~K RSS?{i_c#:u6ᐼJUOjoSp 'r"Rqΐ1QcM#)'XkF/%mG{~B?d%KD.vX5u3Qi`slBE|q볢0H+z/}+K~?Hcd`v1n O ?dh=L-6kq=NƙLR =d,GJfb *`[%ƶh>U^ذl`=>ԧٝG0odg>$nޤ__I\2s)pFn[lu4v?5c5vwֿ) eG+ ˙%5oDH:2p~LξɹΧ}⏰O2t.KC 7:y, TzdVG[ʾ=VF9Pis.)/w"`8P#~}bC:1J"n!ȼ=sSbMsF])c~.SPnrn(W%3ʓNcžQ[=T ,BBxyɮht9e5hp[Ԋ˓vja̭J9jsT}vi.|oC#$ $Z-2dAC.q {'i:&C D,{ږ^;2(+r9gJ' *GUYs[eu-FvAdOse}N6V i(&A;}_;c䰛p­s}Vt*YIVe˯W6嵨~[TsdJiujejekq@H[2YD,%!iAGF>s:jh[i~W#J:Q#gW *J-eٍŻuMj*UgirYB?uz@Q5B];sk:~#-4)A)ԡZ I - *HDP]@?8*~Yѳ}ssv: {:,{~'ŠBzjZƲԨ3Y i%9ՙp0W(D/D)췉y@4!M?a{g6-—KR.~4qj4h^\:Y]_^+(.*N/gd)ŒԌ 񟱒@:mCySn~c+ǯx`q_8ΩZAd ёTaiEfFajŜ¾#E%/KiG $I;8-88 w>\/~e*=3rpAO^?۶[cK8!)LjFU^Y\Q/?//[>_LRZbٛ$8I rԫ⾶oý؁{q{nvh䘽a^k]gOhV0qјĴdL^M0TWõO5Ki1oxU+q@6\ET)I}QFˣ{*޴!ݖ17ݶa LjiO$3#>$Mr,ȅ#q)MU2Cu6d7m\N ;~8I jTDfl\o,jo: {FeDzS{“zbb#; WקjHjNgv̦_ -8$t%diF4;$ݑFSh䑎T^Ŗ86_p& ׄ!q쁊D$߁!c`V761=/{5JqP)^^ >;JBf6gdtmB᱃F՜ACƾ,ǴPOhޝ6wBš(whñԉ9mb%~cPI _}8-ۤН /οQKrk{5.T@%uR=w1щXE_R^K>KC  />/iE%FoZgaAլ HkU-ɫV"WCW9FHՔ}B[Z~Z/9})gOrveNfB82GuRbE| |#5lYwT [`wi} ӣ}x={ɏPcMeC0cy^a[I2ކP_B-7:=P&\hΆ80dWPh' bpN't/}hc{6m@]Ĭ Pet7||ϔA$י T%OX黀 >]4.#`\5ƨ LϬ_9,P-R ,?S@5"Ib-adVp'EBP>0O]QM^[TzYuQ Ɛ9! I@ @ A@(rUZPE *Ȱw;9]笇ظm@X  o#D/a$vla,|Fk~`We0;xۜ{!p .B i+n hkrx[6?nǹQ'q-[FÖ8āh=н0 D Aq1O#pR=%~h@m 3@ ҵ@ez$|e[IZ؀ ws*CfV=zG%v?&W0e 'waO"w6x   Æ9~/ϸnl$;C8 T7[ځ|فa䮛 N!F#{4i!U!_ٔ>oA%W;9-<þ$`ODfhl<%n|TQG wN::>r~u.;Zm`.W&<AȍԄȾD WA3JJb;D|IoKIyO%^H>J3*dS 4p1:?,y7s_pyP]yW~1GyFԦЎ'XGE6&Z,L( %;Hi{I 2+F,,jB&)&:Wn*J.eW{;i4IsȒVIT|(0g-$Z.UíN2 +TU% iC nE>rSiT՟՟y z ߾C7u27ۉZ5/[|ٲ[WjʙN[QM*jmU-M-4cR<7U>42_%di> 35LA,ty ]lrxOgwۂ_;CO=aCAֶ2fS<֨ThX%B}0/D=$קUR:U)?RHӎ1thkuNf Gkf,ںkyWS:s-=hUR*S Y#0deIU+*"0$N%ǘq5@:g6t1f_Km^t?p#`:Ἇ7R4ԲwVW e2raS(hr+F$!ˬeHP<3Sd[X15@ cbfq݉Ř]K0W4t QrFVXY_aS֗pRK qb\&-O+=JA]4_8̗-,qi O 1P= f<^g̍Kv;φڻp;iSe #ݓ &ZJMr`:/2O_^*yNi7.*a^\W<ƴH=;af~.al̽s0W0}Wu8/l_t.ҳWl:j~IH (" ;BBB@aG(Vԩ#.uSw ,0EtVEq=c= bʎϼO;~|}&GPnj'Stbo~~́ՍҴ껥? eUe>6 S9Gg?2ɃVEnvK7rk. ^H /usYq[[ 7sbMFuaӶ9Y k U E┆u9&NMit tIn3 .3^9w^SǰVՑ|פЭ37\X%XQ"L{~:ܮԶʬymdmC9TeZl7$chI-if+ qA3$MScCV{n Э _}#/zq|εIJŕJ*-A#HOr{kJxBrA7좑ܭt~NZn2I##rߒwYQ۞7<{7ú{cDfknNS2KR2LH.$ &zrX艨Y? ^3C \?bY>.Ԉd#hݐky˰qooLlqd)jMlp-:2{-O)zIH8!J"HX>--t?x9g#0 ee:@i5 !e96̰p\C8hՇ" `Bʂ,R)!*Bk ;[s@\#/x7){4<ǃ.fqq!cBE1"ST,B%W"=U^jo3v+w)n࿡) >HmW] f{!և‡ T\cMPCj1de6C!>Q DHhu«)5QʤL @JM 0&jTًH CָCR6 Qrq Zلq鄏 gLQغ|AACil2} fl)2HϠY_8!e+<8!vCKľāIp1\h"G$wH .HpAFڕLw8(XahS-^dltf',rw6&`FQ4%oL::!Az;׈Qɛo%Cҹ7I{-GK+ D4ȝP*yLa5%B )mP1oL6Ɍ=~iޫԆ&7rGQo1IyCWW/dclLg)BCBt!ő,:飔c}!"LPƷ:̚ bϱMr6s_`kcN8MEqkKXܪ {H @ b B-@"D@D A^VPֶ^u9ߞ3)1 ŌyD>$ < {>p}n0 1[ n32+$lw} b:XۯQ)GrU`0kq&LxO1 Q f#@O[ a#O}kTm=0}ډ}Bs"oףX}i$6hWѰV0+ s~1e9XET2K^Q9A~E6fLH@S@V@((C^ d!o5n3aNKǪ]/wt9Q~uqXN-Q1J8yj^NB03S?*?g$z$ {s羚P?llew]O~{GI=VkȉŔe&tir:NfQJ8Y9$Q)JRR^y:m<$HX|\^ԯsv-K.O}ݪ7rV>VANfgf1ӋjMOu^>/NPJԷ$?IIeH\@\k X :G.5yionm>KWg(Kɔ4m#%']ƍϬfeiƴ$:7<-3.#%. lb$ zob0[=\&:7;/j;E W_v_U*є(ɧB-K^P•Djω^H;)ʋ*|˔|J d!"5.7 Eg0ri={vUM-ݥ :gv_cĄr5-4uFxb$[ܒ\A?LQGl2d=iv'poٻǩѕ7w6qiD>YP|]>&Q*4qw mWB-PA;?k#t{h5h0phUw-uHy^/;,1 0idҌ,M6ɍOhoht#$1a-0 pF;0r]m3`fouw)^lw+{/J#E] J ˒B'Q:*(v#-3>xJ÷!a m̝`,߷A ,hrO-i~%s0ɇv9-t9(ax@!p`9 l ,n0aߌ@v;(ݎ[G%];1MοÞOʾ\O%(wƁfdlfGmrޟ~n^BL Ѿ"4 I\dLCpfbc!Ń5RlOh0P¡Ej9Nh8b#MN dBBgCbrDd9CVI;hdFo۸O@p꣞Sc>k ᳐ENCP@^ᆌE gސlB@|<:S!RԨ/Do/G [1|l hf;U:A=*$(j='os背f2N/d~~C]'^OaM)^Rq|m$ y$ rȋACrr]3CS2.TkyP~@ȏA~6dJ |:a9z[ gBƄ>c8i80 :W=79>Эvc4ۂnAPAu,lȗQ!ѿ)^E*T'* d#d@G4LX( aaV4D{%1K튢|O"Ə y%~Gnwv?DsdڐT ߳`F5}E=z&L`dcn= '\y0.+\2lZb憘gMOsN=ɪHӞK{*+y&O\0TAtLi/vNuĂ 7ucżsJ>?.\0s}Իym9,?-wnog\?]}oD|$u0R.`ḁS.͇=/4[/[$ɿ(*n)1wƎKNw=Rv<[7E)F$z".5,uL,Xo?T48˽oҐ17*fhiHRRF酒EMs-3ԧL4'L]c;ڣϳۋd)!J~r}EoY{|_y"űo2ksk._YB|Ʋ.͊c/5K9P|>wϹ%VKedZ n)J{4/#/x b}_`߻FܽR|~vLU k,WTy|zߜ_);qc2i@Vs%dM}Q ỐOsڷYVp3? c> tF}i1\Ci`mrkU{*7iw<狹_(o3~n9h2m:oHXնf>L0?I8XUA)ғ`B(@( =jjA@P((2눸zQ 3{f{vV|>_NnMF*1&8xxot~ |NΌъXQuTY٭9.}|gWF>UVJoO&51/'&Tſ NZ 4D#/C +2TVkrRtLgve뢰%1Gz ;ryr)R~1)ܿ>YƪLa&KEļus->TC"{٘p#W7 ưnA:hO6zIw VzE':J 9U*%IeQ'Nt=h/L@ TP![ ד`]tk 5]Rwҗ]&ok7BۣKq-IM79'LWBȎr0yL, M1e?0Y~rD#CdUV&z 0_@]=hxVr⸁pD0`ƿޟ:esZdjJh*dAC1b)VO(P T{kn~x{oeFgC5='ݼios~)wC,D쏹k5t$9ǐ27zO17ml36E|blՋ6<ȕ~WNP0- 7HP5 #(^C}lgIqLO΅sd?8{ &`V`ǘ9f32g̠촋#:JR%n+Wq gC5(~/r!Z Ɯ% Ygf~,"/|&x6dtmGUnߣCnO6p`sY9P@ -HUY.B )RyLR7*71[hP),SOeNwen6sew,~^p\O;Cde.|-{2!aYb3V]5+ꊟJJMWӌV}(>o;6kb6ە/a+~*p<@k:> }Kې_|4kC:(r:k!T 5C^pZ>}w <H[_Hh \~:L:IvMQ" ְD{P9Jڍrw2Iu|u &9+m8)@ g)kHE vȜ` dYTg;Av5&@ $$$6!)67,E*n8RA[EQ}k=ťӊ֭Uq3_ۙx;}srpig0 bɴA$ ZH2E1ʴ JYec'6PT9I~(“Fp [83ؘǰ' hiCzu%icj&v&ON߃Ѓ`O B Jπ_.xzǂ6p0~b8A\4uxg3O>C|x\-,@0Yï {M;H3)W;=%wÔ/x0ȃ&|<BDd,H:τ(} Xict `)$- ?&^[?i >Th\H>D,Ku YB !2m@½V"i EknٓWB_ a5W\R'H#ݘZXc!F](#,2S֛CMʆd͔dpG#vg&W߉ڏux!px.S lH`8G!ք`gr{Qv4bgمD)t01&*4 _c3fE;v7{^u~%;4 sI\I>{7s~c RV$4ePͦ24/-!E: >&ϵ|3So"j9O=w60G=/xߑI;vM +3 " D[1`@*&D3>+ߤL_$~YT|?V.z}nFs#b{=bwUywTyߥ7 Q> v&c a$7d;3,!7#ўL {g c NrPչ_Khȹ&v߅s1D <}Ip"Xˀ|ăs8Ép'4!ιtl2K %dsobɬ&?$3K/w)|*Ι=G :Pui㟈&LUw( dx CIb̀B R2 {ƛ*WI5GneЌeJѢDNgN˽^w05@rp_Rhhɠj_Ȁ꘴?lww}Kop[b RH~6[EFVQlƯuNV+.Y*Ns:v(@Vo?,r=K%};;RC ˷W$VCؓ/M7&wУ(#Qo̩G06NX'3ZȖgaks&%C>_GT䷫I+&gRVRZfQ6Qmv>-"{9צU[^RZ*^Q$ސA.o9wGxS#VD5\j}\?!m,DPSO;!oQzR[3~:7SohUڻR]\Qo+/]%n.Y'k*ڤXUMYW0Y?U)aDm{gՕAP_&(TLf ֦CnAlMU``|wBmMIs2<;?n-_[])XSY/^],+oWԖ~*鏬(9]VrUQ.rՕT iiQohjl5M]_M:4:-p|澮PѶ[ZT.kzlXпBаliTW}" aQ(B @ؑm( ""PYdY(.Pjg: eLm 8ȢTEgǙ3=a>~s>9 f?pfތ=!)B3&w7- :íT~!3߷1 ײ~\btqjZVWdx,ҬdX]~at,^}w:}.();x{Br%FeTA:']Hx uE:L8˄M|j2$Z%&EŹ;itK+L)(I:Z㟘75Qp|o~ ^BD&}8jh]π4\nAG byэWYoP|&lmM95qU;U.puVnTUy?? Ȫ=us5:r%j8Of.-lHa^ttz/Z}=b]Zfx;:ƞ͒G7(=:}B|BF:V7^:{Nx ۵W:?yO^T]ׇ^+F5);ǃѽAOﺴ4BU\ۘ{>ȔF|rT^^v=#}#mJ6'jEVH]r.;hHO2pl`)0?cWh-KjэcǚD&Zn*Q<ZG+2[2Q)'9O7^F;uߑ.> g_E̟d+?Āp{߆L3Npp~; ơwRL2,X kmb>E !T*ք!>8^LI dlT,q*+N׶>~LW4Ӡg3s'|:Dx ꩻDa5`ɿ | 9_!(_SW"y֑vuc8aڽ԰ye=c\\tm2YYpO'-Ba -|P&(Pq%HђBW=iS `0F3 1 Ә+{XY2kY|uS}.?@O߁2``Aa1 5ZJTP+Л^4#h=up,08XKt0Ļ75LU$;x #0%uj}4d/K P@領F)AWJ) (t" EƖF;R!"Jh`8 AhyǵHsq'*.Zoω7r,ɖXD8(g?E,A660G \ QJ\ Y ?ψrȝ/j:YB(>\E t1QB0MƲ!Y 2Pͺ(EQK  gijoĨ;Kx,(RfBҜ0SD% ݂5%;_t߸Jkx(i ZG>b9 z }z3u< qz:O|I~`V=.h` t "7N qQTM ֆAH$2Ī3l5c ~[>`-c5Sfe~ #Ɯ}Ƃٽ0L ƋBF cJjH}1@LIJQz#r߇zVM#ٳn'AF}xiۻnzЂL0X^W6!``U8ߐ4|3-5.!q1GQkG,7[޸VNa{rq&ՐΗB;_ y4F=֧#TX`̿6M{W"H7Bd]沼5_g.}ʎ{fk"f|*k1vWZ~޴b.#" t?>1>Kg O.Qx,ŀ^ `38xػ,|+>Iq峜DKm%8opks=}2f43J>}pIʡ^~3G 4@x{;W`2@_VBkcڝ"_!y٦9zw,;XýȒp>IdY>--\!BEݔjoBWhN(Ԏ}ɂWD+,z*2m;xBa"4hR~/C);ªțfc?]Y뫊]H[@Y1BVaߩSGB:j&Z4y/#g$H;$l61ױTW*H}$B%M\(/,  nOY xb - ;jp:DUʎ0\2ckY,ͦ&k\wUEbʊ:NFQ _r*(VP~[|(B0l5g>BU t]*щvutc%߱0Nkc,l 3R5n^Ԑb]P+uHd|&3e#30x=KvHU=ki;m_0ptn.>'n_Zm}W >H[4@oƤ`GLspf=1Ze_LRs`6`a(nQ5 S≧3JӾU_zk;qolcoLc9a91 5Rk)C:{PWX\(nvW(hdDzs۵ DQ:8[3.O08ĵ}ݶYٶG1ֻ5]ƶD\"ΡKԼ */= 0"&\Bֿl!:!#ԑufq:': ;#;?:kXݵ&$F$W1h|~z=}?o ? n3b{˜XFӛ̊VsBs>)Kk ~ׅBy yV?Kv@x D? ŸBG0qLd0%c Mc{ xgu7v+{u+}u+5=Jp_A_F9\w`l7@0JttTa4F1ned8 Y8ebIeI 52@cs'?25P@` EԔP\ 1l1 l01LF>ba c/jwJRC,Hn!? ۞ Fz0EYf&\TOal>/r* QD('UDE 尢O\\DEM‘0Qn ƹ28_BX=&P3] ^"°O,ߨ[VeJڤRUUG/Tgs⧪+⇪n}Sq[{jVwF@/eP`}\zi=u\Ԏc|p<˘VtS~U*~QV@do}v5uv^ͥQw5y2FkHWɕ(az{tGh.R0#3{g$inuD;nݽov%n=N7coK;bOIǶˮޖ]}$7,5ƣSk<:OQ0-mH8&dհd'dXdYIgs3e]~õ5NW7HL4\rA׭][ަm}^9U3~fݐ{S"6&d2:HF'6Q7t̓МЏyTbq~t[]͙i{iBέ\ΥM8t2&}w }q_GRJEϺ4tKGtfmL,+zwNq›NxLdnG*/ZN~I[rMuG5}qIK@H &8L 5! !!!!B-D~AumγMzvqwHEӒԫ|90;Fzx5OռQQBgC$kP|sKMgZ;> .=w$e@q<;eb6tU!ŕFQ=.Q#U"tGr VMN|D#2fo+) ;i Dokvpa2v\wz`Ys:P{qG2ˬ6d:jH343!ZHXi\:!$;D0ut [8?^|WA ,9ai;d8У;^rji&F7Q*Vnbh CJdCJ,K!).R.>H_RT DQ!5({AW ie$J/wwT_ZIqae-Q(w=|kXhރk}twKfWvzoUk%ƜyTe7J| EueTam5-hftd֌W RUXz͏eDL5;hϴ`!@[+{} =L۪_9w^ܧHr+pڸVwU9)ECi6P3&4l6m&lbB&fBdk*=[\?ޅ;`@Iv0ʾd Qџ\ЛvPԓ}-bnV>h0-<m[8u{\r#?Z_#3M/eȐoewbp8S-]YrNaUy,"_M|Tn'UԐZ\&MaSހׯT%=Oz{U z9gIO1!E41C׹z. 銌hԯ-iw {=k aC,CRDNئC GgsYg./1n_nߘ7wϰ_d8"i46[4X\3 嵄 37%_A_Kn^ ,Pisc ?\tN B`hDSEoOu̐&ʡ'Q <8H1&FTȅ?G|&^(=7ت.Dv6ltvS!{lOVԆ!aP~`*ZpӄqtL:R͠LCsR|I_EuqӱKO,^&?u-Ñ#w6N/►*♺2jw*vEME4ʈhn ҵПƃL##Z3p'S2'2h,ď,Q ;mu\ݾU{w+igaˣlnsۧ.eqhWD>F` }lAmstl6+Bx4sPzd\yʔŊBb5e Λ]7w+w_cmBKI"KPUvv4 t6L睦y 4,B8,pc7p5aFrD{̔=jG|Ŗ6\!uSiz)kN>*ve]]q+aMLtX*b72):F[\B:qP?Y@PU/bT5?F37Y-gUYv|cVtbYk25X2}ۗiӾ-K;4NҌA^6 ru КHW?w|98/#j8g> ̛fM -KeKr?R|Z9E5.fo/u$F!@n ulJA@5@XYE#.`ǕxZjUlF2Z;sng.8g>>&u)1$~*2~DV]Y$ILLl'bopk9@[D;W"!+H^૫QMmG3ӭ>ȵNSWV:u.NLVH:.K$Mr"91{b7P.8EC`{Jm]inTśXVʳmʲ Y咢LSAFK^z:kQ;kiZ2E]\PUnZV=CU`QJV$YNM.)g')v'(ds8]ٚ&3&I*`Fga4g˨}=@W:Eh[+WV(DVerی"ԒJiRqì5Mh'*o{DZŜb'z|K}jpvAH{WALl:O^UniCan4:vfGɖiY6I55تzv++ETuQOL9DU0DR/i?WS (l BԶ۠rW~>oJn 㬖u1D mRUjNaug}Pw5n|5s U3b7%zCt}v=7[*J5PjfqoEz S:BL6ψiO3ڐk6V*Y%]ֺ)s`7΁-Zòf&^l'~L !߽PvK'^e.=ӑ#Af'OЏl=R-4+Y֕mYdbkufM_Osak%[F~mf6zP(hU((H"y5E/_fm7A|oi=3zj,}{=e{g7>=f>yz LwoTy#@D rH!O.tȇe|5r B<x GhfL{'0yZLA8 c< 0~2$o򏁜a?O@ ([@z0F=7 30z ׋z9cQ- 7ö́1j5򄌑+)?W $ïK(ۍ(0` 2=1 |f Lmo08qLJ8 ƍcGg 0H YG6.Yc \#'+q/?dyTSWK¾!/@ Z*,ʾHHXHԸТH݊8mک^ENw~NrOqw¢)^/  (sHlCbh&M+_<8 xKKO>Nctf' HbGZdAC6=|Gwѽ8}|K ߑgcr?F>c!SMA\=iOcOg#SbH5|'.5ԘhZz%BSE]3QDtNSEWG-M1fO u?Ax}aM>nۓO_)S 'zRC*}I d+d[}̛~?$'7$XW<.z60s;pv*f} τp-#k ,IK`btV`Y.u˻w52U&߽6]n|ѳMF<My~ozIfC޿Ʀ76# uAmaG-Ƽ\%9Zy%FjM0=favo}OOՀ)c>׬>~?l\Ȃ aha߿~}'l_o0'([wb+`50@hlFcYqeF9 3Йۨ(֑p< u}ֽK{BVq nn +ogY^G-=w[k [?0^c_t-ě:aqFbe=-V؝՜vkNo o ;(61'ǖg;͑ض9 |/=7SЅt>LCO!l/5ѓc;)մ31bGں=[n[cm\L_&X'\=* 1X쁭! Bwa0$Ѱѡ 1&T,ҨCh-Mfٚޔ$7'V94%6ҫVovZC,nKCQQ}܈.K]¸c]"0Iiq' AR :Jm(sEM^ğPи(Ƭ Ų\+gjm܂RZW#P BUQVW-Su=#*x!Tc' jLޤQ8' •\CT" RFM.P1ha, 1(6-+Id,[MQPSV ;ej׻ȵe=nRq4ew8],X\iP%28Z2 XNvQSK髂X& Z:\Q-Qg9(J+FuiWIcn(YS üIwypB{a4ܷ 7XFU7(oE42 ÍbrkS,klEV^UM*U.$W4 *\ݢXq04k 3!A4(ADqpU֩Z X+ 8"8KlUk+.D:Zu}_Z?y}r?hL1+'Ǫ$;LZ_3~Pec^:A?iÖ8g~&h;+Wƒ&^I>7AR9{u{d*`}¬4=f15x/j\jѫ;|v G X{EwϔLmn5l%$ ݓWoy?8lJeҁ529ega:__qڋQq=C89NwnXۚ]2xuj8QgCls4و<+al܃?/ b, q=0DGcr504/"~}Ts{re.r,EvܜGD7H}zQI;q-ri9Ѩ }>mmV ⭱5^tWtSYNq̟Xbߢm_6*m管;k莿+gs' v}8 .B- 8Dz 6PF mmmmdۤ}hͮnyc!xP:շn+9 d ;H΢l@@ѺEA0[TV%=вdKƠE4++JDuIU>%Kſ+ T Oﳿ&3{_3[_ wE .R.uCKɗ"ۡrAWY E ] ombj/e?fSXhf? rh^U?mwfpػ>pbP٭P؋!vI/3xG@S` j'hjXU5@¨#񥤆5kxk ^]zB/Hf,d~Љٖ@ˀO&|P:t^;5o @] a\:$dwXNR]% RJ:RpUu~ߜ%Hx]/dϦ{̽j  ~O9^D.Ue纍.O<Otc BTPwk`w%襸MtlVGKf#d<3#Kwa5,Ն!]jr}va2v7Ἰ}[S-.R\ @!nӀ\!Cu~a/ZlEY`<7"{n\$n q͸Ah?J ŀ2EyuŹf)4S6b*B:Ul| 2ۚ0#Zŭ i4UT$wT9Si̴MuڴWUb*PԅJc~ 2W :b\Qq}nE%󖱢2YQjPYl:e^Ɯ>iNs8ar8.hvO820|aj|tmGF8BF"xY;ը&(n1PO|3Bq"zQt8/ǃKGhE2 jĪ}Eb'{c\jl!B!$K@$6Ibر@ 8X$vl'Y&vL=i&I:Mm433{y9^+b?uUL$L(8/~?b.JϤTJ> FI l |,Ki #ޖ_LAYB e"dDG_ŞvQe sIZOKB/yȝK3kFJ0}n3уL̽{T rr\9fW 9eAEEr5 z!֜l=+;ŝ\2S cr0GY)kXP!JEBlkdJ'+RRD.0ԓy 5LG aŸ!5Gh@h@DЯ$a!0\̜/񨻍(#AḦ́˚Ͱy 4iӥr)uQL6WlzUDQs=\,+ {xw/\: >ulG<>G~=<𞍄r98 z1iVIEזƌ3SZPJ\ܞx"Y|RԡZoS'&>Ij){|K !hQ< 0*A-3^d hE0cJ!MaCqG_NU{ʭ.m#۩mv4BfFԤYJh,]KNi~ɦ[ڴ߮%'7؇1wޭF0w]żcX[kN&U&VfG TF4nQZjFn5r]FЮk/'Yt6~5F_EO,g50_.|\}DW1y+Zu/iВFEtUG9*=QYhc5T4sm&Z@bbR&# ӏJ!@xU&K>zpu6͘79o=Flc 61]ASF4[˩ ʪnX;恸Jqʟp|&]ՇȗIz"CG/_p3u8mx 8لǚyRHG"^mQlFY]gTֵL}qq:46ZGHX#*qe_k%xa>}g6ּ::1wvw ҠMζm֖Ljn=LnhG; -Zeijg[nAYè>_b9Qe5^Rs|^b;Gxa}x&ּ+?1s [ܮhhw~{I6W*֕IvFVvǘ:˩ zy{-ns[ [gDdZ$E,,siJg|XHCBr<(ds r뀯hf'07!_R:WƊ2B_}(VM* 6U M Me0?;ϋgnse@@Ӏs%`-TMP^q7W;AT(ĉff>XxkU@c^_ c?\p/0Qz:Ue@+ n:ԤnZpC͐7݀3!/o)ca؉?DZڏCKswُM>0U﵀&Ѓ)yocܤQ}E.>o9G윸x~Q`:ϞWXx}ͼ{~⦆5i`M󞬉"CFQl`.~ <_ @]Q }Fi ͦIٴ66*TL';1E;w<;A&W E8>UQ1=H?y,NxdJ<2uQ-R.iOeEBvWjz/+/ x=K{+~rK NX2Z*L-!Kel%]ϒ%#/X |* })v\UlSl}Mbc#?4esZ 4tU\q/Q]}IEcdOΔﰦ)[+ZW(7[sUͪ #s5oPtU]*60>kt&T Q?wQ=F*Nm %4N)h"/_WfWdkr6hvج o"nYo̠6ABmАc̿B$Q~<)p0EaWHiCxڰܰъ_({NV ^ ]dLk$d>=H(aAha^S}ZO#=vn4ݛjfWpj/s'Ϡ?FJ׀7GbCdr#H91Pf蛤^'Ygi3lz2 h8;8R}J_#6{܎~f췏l:lvژȉ醕1aRVtYFtbaʅ&-jiّ" )+G7Niq4%CrcG ;ғ=FYcP'pFnXoEF|O v"-6Q͠hfLΈIM=ߐe41zWCR[c@a [5{砚}>)8 |`BV `)-,5!Z>ʔULM7]?1nݗbWq\>r{c ;ғm|/#Y.h=?goÌX<5/e GAkТ!#@ Az@TBt]OZa]-3umn~L _|?~i扫t$))2k89ǹ0ՒJT2k7gk[=LڃYSL^&3iH$%QS{ Krٻ>5`:d1UKkR$iAzc~97⚣[XVu'4i^ԛ4#uNpK J?sYIjeC?14LӱظP\!?kԜsr2\ VAZwmꔌ5I^Z Iz-Y/(bkی8(bq1;¬Ay¤c> xc&;b|G:1SYQ1#:As9|ҩw X=|}鄓2v q~ x́GO4=ˠ5½ PBEE(z<(O=޷z]ɸ-w "N! t;< Ji7N}7PHI2$9CԿp;7qBƝIS0"@!tIeKo4pe" WX0/#tpL.#?o05w1cbzx;~~ 3 'MJpT,=/^`Q|9Y0y\t$o>r|O~|F!Dϵg/PdcE]cAnArKĂܑlX Y,?`/G|b‡hEE>{F)[6SDϣ̘.c x6o>&w -C}1<%ă=&YEyCp m49q42,&$ Ud=LZțNr qO?/ z%qx:)$D-"d% d+APg?u1q xk%w~AE?4tN"|G҉Xy8&>y;uvQ ?uR8ۃo>?pnA+r7Fx@qnT\9C41$[1jlf4h:Ӆ/u<;HT}Pem:X5$p 1$$"B"QmCToB ~ZC j]FҊ6\lU~\_qQBYOU"1J F* !zR}/&4w|kuWa\QƗ#.hVs|يs=1|Nw'#k"uqb  $?-2zp۸%Wb7;>ŹU8ӽz4Þ8ٳ'z^m8k:CO`kO]"_ǘ٧1-O$.E&꟏Q8ÑEOg`f_BfOr2lav lpxixm71Fd7w_AB> ' ]8\Q|L|4h9'6{6`ٮf:S-ڙLv.Yݤ]nnbkwrܺ!g5CV_>T-ÈG<&w銿6ZY=[|0,Ga_pҍ6 [tyجs0t%zmu:vMl*[/m[bJ,ѝ.ݒ6x,m!J? O?$[FM|@380J]b^q!ވ,fM\2]WʶUqKiB}YHҤ_%o5OW̸񢔐O%RkL!jy{Io$('ClH&$%IfQmNpM$2BZ P )Ҟs=n#ڌ2tŪh1hNMaf3sRNaC1,36 K5e0j*` G11E٫`Sg~+Ofz^b)K29sG1sӐc 7_k*ԘҐi:A/OL_LoWUPQ*L,Ch>rp:>iBzeE6l r1M4侍>'d[PJYҪ2 ܤW(6uy8ƓuE^W(6ҜN`g!XK- 5?OY=1#?ov` UyvΟ-R%(ZBe“LUQݭqZ>8,;9,?y™'ʝQxɷTd8GڳX@~*P`ڢQ3a6=$fb+ rٲWZPėX}5 + .ka][׫m]NVM_jUTXE gܤ:![G-^]4:u&rDiי; ^Q%k}j_ooUwj,\ub3^wY Gr`C3}Qye1LȸfnowKlE~F/zGn)\)\*ܮ6,x2Js KtNRS*4~$'j+텒x|Q䋃7q2 t7畖Kr!Yw]Q{;TiޣTďQV"_ <3:S P4vNO~%npUFQ9FXҘlRir* J$?IRF*ErVe*IXPT!*E9!{:;)`Tҝui />aB0H1șldBLf(5\ZO N$I2Cp0]<^PU T$ QSo&7h"i4L#UOs: {\?a0G!=p:c 066)a ~nL>\yTƟ3, ʦ0 '-Dk$F5O465"eE 8Hpj%&*.TӨ(1> &y{E 9^٬IθI&9]hBm^]u KY+ǢVwdX'!-'Y00g#YT:Gaf)r /lV&TƜҘXe\*T%R=PC_7f1&yeVr dia=H>}BR8Ο,$}oɽX{c?&ؾc~RĬvywR@Դ`5GQk׋WI%0PCi4K+MA/@t Cc4b嘆HG;rX/usRغv)XHk}/q ;z8x@Mi3_pz"©G3*ViDhe B*"r8*Ǣk$T͆U[U}VRS0\$1θلyY&7Vlc<.=c6$z =08WO] Թԩy$&ߓBwp_F;~v[.vB-ӎxJd"%"SB ԩN 5j{q|˿C?N?D_/b"Od fRg>u p6Q)\s;SU[whWp}+\D ZBӅ9 H^!M?Ө3m&SǎXC56sjnݸX|8%:Uj- @oX ^zXHo2L77Z3X Ȧ 󸮥F5*phf,Nc'Y@*o1zuAS;hvcGbl ^;CQ T6`sQl n?Jp!! "',Y<8}hHBmcj"G:rĦ lZDB4zT픊51n(T{GUHOic{WT^o}kd4hg7Pih2X8 PbxņiuBT#'Ib9/a2a"axFq-ENcEv:Y=k=ן@|U߶^pĦXcBIXcզQ74QZek!0}$-3-rPe*S,1mU,65*N*$Pf)盅"JCơqs5>}{`%v,iȵ2j/e[&IK-Ŗ兖 y%[a)-%yjťeyޙ{D K] qDpFf`fD 5.Kq-5zXTkĜ4mz5m<96ij4Iۓd1w= |zemA6G#ulI1kLslJFɄU&3-X,VUZMI[a(wcm<+1Vl y+6"SH"?7wg:xuH?6#<MXmE%4X2EZ S,7{2 ۼZ[b~^*6o]BeKa?LK^Ze}%s4kahEI٦*t۲mPj+KlbͫqֵRul:lsm/ԬkCzu]˸9Dq-빮l-#QW eʔ$#JLHY"8Xr]+~)W$/U~Q)ʅpEy'<[!܃Yż1t7|ۊQBRu&T@j:\L5IRԀXݭRYVxO^YՐLܢߗuJ@o/K} J#Pdc:9pHG#KPX&.q5َ,Gjo2;uq.,q3l>P/^0GO4l^\NGV G3 w><\$丌X]9bCJLWcqҦ6H&gltҥ^եOuo4gH꣰+y|'{X[rzTB^i$1qO➉ŞdyRaX,|!S$.TFO&ͽNkpoRrFIII>KB^ޠgS@-H zdW BVHX+' ; <)XTeʆUVU(ebNL,n{OKqޫ)ڸʐ6'.S8\>84ʕ] \n ~OFo }HYSs >Ź͚پSX[hbBڟ8tf`5 |?` 4HnX< S1?$0o.f0fi8Ycc 1Qu@fύC PD3I&s[1efƌhLoiqڪ3fL ((@Lс<؈ =x*)`|W ~KwQ{s+=o^[6 Q1LLjۢ0m,&MSX<`*30`1FkZن;aX"FԎG=a֐Qe۩BFT'%`^ v>ۣ0} ڣ1}t'i;w,ǠF ؂ h?[?CAzdݛX'$b_f1G Dqrº+RW,] Lz?]  |d8paD8vs 0CK77[7E. ̹3_oI}^3vi=EWGA a:-Dr:0 3G_l]BG>Z{#=`7ԧ__ DQԾI@!j{r aCя =aOpaȞߓ{G{]E Ybj٬5{#|Dc1=GO>g`|C x/y=dO4 rjbE 20*;o!"\>'ug_KH2kDT} ** EZnnhYDQA@B"2bM01rRV&NRV8ff\*5qܢo~T{=缤O~ld!Hu'3enDٍ^ӉYDdd"d3AvtS"oq?xW?" ~ 1 1tKlF3`'5ڨqssg#>mj O9z<&ȿ?eg7N&qdOT@EꬤF5j8s#5P{8g;V!}i_2:2G;C5ķQĝL%_AԌ3sӨCBjF%5jH-_'QB//} Moq~$7 /DÁc 9}r]*|=c\| urQDUԨF-5>V9wd4o鋫˴wi0Z"6;ٙ0eG'\;kBq5JN&gͣp y]U Dh9YВSG|kwqlCyeÆo$O^17x Ұ,\p9bu,ǙU85|z6S 9G#qGF^Qߠ1] sh!ȓx吻|!+ȍpy~.)DpM1lt-C[ :jtmD6toO$xm}qoc<6WL7OfRߛ70L.Ot%wW􎝆|=^ }`M.Ůk:-ScJ O9Sylv M=D+4xB y4O3 : ]&s6L*gsf2ϴaR4{bW*UY[Q?kP7S+}]s_\uS^})ZO.;v{{bs%}4h1' >VlDoj|P[*BoηUX3P>G\=X6rޏb|Y,yP<\{-]~tS\ `*aRJ=ʔXLAҎ"eBY$,W ˔br+-]--YtI#e?!,CG߈.10vƲ1-Zָ. Uc6C}PUT:(PLy!E_H^X 1cx@k[Hb[fB+:q#1&.헥{Rh2q<3I+s#kvxa>Y=DlvBP&-~,"d%ĞXVjI 5bԴc1ZiCvZ3\o1\r{y{lb>Kz 4&Vq.]#4"!RhX0&>'dӀ~M}̽5G%]3G%>4G%VhdeT>` 38E<gTJ&;iHbR48%LSh@jT6Q}Ҧ+:mҲ+3m),)tUShShdArCc#˰ Jsz2gکOzguStV_ٱ ώS ˙МSPE9kS+c͹,2L/RXݲ|އB}0 f8*]A , V@a Zƨe|,3mIU7"ue<-\GacFgWA+%r:!-;klql}Q3dcMW2UP #[yL@^RE7_W?7Hq؃R)`+5okTg/S |!adg,@PՑXuw\ xº2s/)kS ܍>iޖloaHa1~R=Ci}_CP o,^Ç<OXI-A GhFoz<^ÒsdwT2GvNI8Eag0?:Ǚg hrM@-H| -/:'֣?<ŕldllj֟%hMFg&9GEq\#dG(+t|+e`؛=vEHrsh@:st4CjQNFi-9c֋]DNg:ЙCGaoA:N:K(gJm5b>i-mP՝ U|ǴUl';cWC(NzM=~WO2|u{7W ?w1ԄZY?T}40VEq*  zM f*7h+;8WYEYy!GsC+-)%)a_ڸŵ7+x(0fl#Yik͊P- %,@=# ^+eOiJWZxR#2Q>_ h- ZE%Hy!@$ $BТmN!Zҭ͵{3nu;֞vNZ!~>Ͻ`͊O= S&',V iw$uLs0^5K>[R)G{Z 6g-=Xaڌ pŸQ ?|mX o^:"YDX\f!U<ຒX`d?|lΞH)EkӰ:;9:rLh)GCN9u]Q-\ʰQjIY̡TP/*IT80Tf?گ>8b팣E5yhV-O j Q`e<y [v&*w _4#2]Y&H4cO79rZM;렂Ʊ _39j&c6.N:tpQæ_/'EYQZ' ̆wd%["G+ ?Xu ;i& }60(Ӱ9lT4 a+,ƕ(5`1h(.z^ɊLC2iTVh#HEc[LyB~'Z$[s8ܦIpf bLa5eXX0QRBq*[`4(0zOd:yc/"ɴfIJG=L+s3Y&Pa0JR si:K0PTf̊ʽЗ?]yX}"z\loTBe(me-rA/{"z`-]c,Mb{,KQhO|+UA[YJ*WC][:l1pYUD+~g9 ۀ}M.G}\fN*KqC0TGB_]"hj!&y5*P9P:+ZlW3 tu WH=*gDjUO!wIBFs/QwZǀ'Ɇ5y0(Ȑ_{8CAGL V;V߈Vx2oR/#{Z$y HDGqKU(=C$ s[*e^ Oo*2}QHGZ"țRڔM*,o6`YUHj"ѿK['?m$4CB$!s;ڹ'Zg[#cR3 Hi@R$bi,i]G[Xܪ ;u Fl 11man% ¼"EB̺1q}~ux@s `3]9 ;v#%L-[Jpmcr60%&^JI$"|HL8x˥^ȩI @@ \*""^b2T@W=j>gmt]36v[NvݦsT|?D~;K NH#H3i#ϑm%1|I1G,Cy|G3y~g_2)ѐ,O"ƯgFCldbOajWL#>[_0o69aOƒ #5 &$dP/:jTι_72~w1N.~vp:kߤ0ڍ>$%qαRgrragaoj^ԓ24jZ}\ q>)tvpgp//^_ğYopjG708=]O͙xԓ3I<87+]Jjbf@FRcn)C\vV{k4Wy? C~9wyD)B8%3/ DQU^jM]c:ut='ye&I-`SGch"x^Qy1H}^Y:9?"56qj66LubTZGKB<kW)hVub]X1eG;Kf ?6I:E1g ~s7ڧmFeV 5f4`Up>V.X6!QҌ%!X50<_EugxG|Lw d*g> Iǚl)X>#"BTGP\,SEsajTϭCFTmCyTG@Y̋ Qo O}؂ձ!|u iKd煕Q~X=u1cQl2jPkDe qN̏,Q⟠Hn (D •>.SL >{Hh%kS'F$ $Ơ\*C4 z$d(OB܉(L\|2dp:F87`O9Ia0x'29gIigk譞>'B>e`H![BIFlp&9H*F^r%K))w"KyUȢ(S`n:ظv``볎>VG-}+Ǣ$t(#R#O J٪\Rݰ#+F¤QsDyUd _Ads6x:ْ>(}T"O :X5)hĄLm6KaLAn6tۑ? HcNAF?V'.w/Zd=F.V}0,9ԋa+`ԧ!Ð }Bk\q=LH5|Q4@A4 " F'Aj?xK1#MQ2gLdL3a Yt ZL ԙyH!RU2d"9k^>d{|~y0Bc{?wp$(^ J5ři h!Pۢf"Ֆ UlRHqCSyRHoԾsGBl$va3#{/u+9Tq/𹼜<\z1:EPyCYH΋D# rGd $:m:!qAEk-b]g|A#% 9i?wQ{">9*VK!G%=B$A ;y @TQ `NQ#"7#xŠO!m9B!H@+9Κ~/;9_ 4s]QQXWeueߑE=-fQ(̸ `T 0q8QU bզAlVMM`L6{bCRc4how{ xmcb-fJM`PW`ŘՔhSE(4\ldZR[_yE`oجloCkiYΧ6B}3UXO|)uF(6VvЫ dά<Ȇ3D$ͭlJabWS2mzAWڄf_0'xni]' )vba'luPC!d|R[Yp156v)40wIwvjQ:jXG .@Z.Z}-Kbna14,ttN_tb\KK34@ o/uahj!j6pENc$\bq'-%r?= kgY,zA&Z@q.IX4iÁ=9]lix3o'3#MF{- ~FK.wuNĐv>Q@$M1p2 u1(}\4׸7qț|m‘3}ldIƿ>_{[4Ү)yWP(]%| [6]?>FC#c61qF./l~ `0)`(bJzآ~d|isY;}/\pedZ AwH0Ŵ}k1˰_}- :55u]|gu N|OCx̹7T} c ضa{.0.S0I v͏C8 Zנ"ZIJa/`߈"ih~1/Ƕ \M?Ч<~b*-a8k7刦NƠABc")}gcfcߪ^N*ȎSD2P-T+nKK_ϡ1L4ʓIg#?EhXrc;YvO^Ö}51%;JUhi#:cFg1v՜\; keҧ.]:6k 8qW:Dy{+ePvw9] ƧimZqGiV9hsV8s\ eJ]*TNuA2_T=z6k\FXᷔaw͆SUnZ=̣U9R%3EiI *4,\JhX|~C9>5fSvVfspN_FъpuQ7N :sh@ h;3bʹvK| ]9ʎ UVd21fF ֌=Vi)=MSf)kRb5)\c7+9Zh|qݚy};`sآ#;EXojz\kj|MV*%~&unS5Qr5.at_7W=hTbF&6jdѳm/uT@T@S2 0 ]`H䲨1 `y ^K$Zf*hY)=Zֶɶv:k%ִܓ?>y}}˚ƎL}%q4bb\9\0 -_EUG$+7ª\eGڔYQ5ʌ5EJTjl,5?NܛEra #NJ + q-z )?zrX͎1*#&U,*-ήԸjYR)JNإ2%Șx~)S'FNm[q88GE9^2LTaJ3D)Ր KB JI,Pr\%ͪiV'4˸Q2ː.;?^b911AaaV٦0QG%#dɔ$cRf%*1D ɏȐRfřE[*| w)<@75؇~gא2jSHdd8̓d4*yS~NC@SKoޖt/*zXlȤ,bI&XP,cR4QE *(M+NTV) M-Ճ%4CJɧ䔼K>yC6&35‹JzYQ΅Zz-X҉oya+>J+)5I0=hD{&3SV$_VqOjlyʳ<˳:8e ,\~Zʹ4\SHl2y1!P&JOոJ? HyU%ʳ*UcQe{"n[FVKիUF.wZVmhȠF΅ǩzr@LI1Z(7T:B(GFe遺 : ;лX_mQg?ߎI~%g#=Rb|J cʥUÛйM\\k1>$mIgiໝSQ;vMG'$]0P`C@uQN w+ }|7[ًO FwJ]#y‘PRa#> eԥ8 t4v71qzjiW|?-/҃ ܏WO1xNA^SIAN$'gR,Yhmy׵u/`ͅ35b%Ұ>Z ҅\Opn!p8>c"5ec,ýKKf+ų`ߐoO!|z-Kp\uCѫ 7RnWosܦHv;; PeP hfh(MEyEևb7:󺮢gKp>5HCax$q`\,?Yu !yaMZ`{!`{9)E h̏Qh;:.iofp^'Ѻ7/}J3G~1`9U~ YKm@k6Ӣ?ڵzGOѺѩlS$8AQn<r_ w_pYX;|r"𓈏4-"el ֱc X:V;؎t^*ׅ5h 9$ V,a߆};plՐZ| -]ɳ|kyF;lݘ؀m@an_L b -M&kk^5SWUv6ҤjTiViӤݴnUNC}>}}.Wy%z"Y/_{Ob> ۻ3>wiJ>EOOUE79𓣛}!\+q~F6e;K 0"WҽMyޑ`HRsxx/Yڱ8]c~9Xze TtOҢQB|c29wxz8-RLSγhqyi'Ooi=lff1s c`4!F?јop4Vc:Wy,=|`oŔ>1 D`1*u`6ƎjLza"ΏnhF0pC LÒ{_CI"%M{MlbdK II%LTc,QcI%:0,APڀ!i H;0 EldcdsI^EwUtmDG{+3wLYfV 37C;1"##) /ՆCГք6tw`z?:GϘ2іyYwz ^ω?9B x6`uKlfiq'L) (R‚@v9NgѦUGޜ!4LE4?-xo s@Ïy uQD\, Nݿ{xmtMc[:oCxhdj2q@FV Z' ܨ-ZKg1TΡRav+(7~@N<_&-7p%~X Rud h,LGz}jTpp2Ԣ؄ c;E즣(3@y6uX-/>K%"Y=r`wps:T:&9&*Mp a7Qn.CŅRKlm$iXga]GQ"}opKcm*q-$ RG7u2VP֊&E&.wm  li&IX9㡭BS5uv۠T!ӆ^(Fp Ho"!R䳈=%.p$[;xuwIE덂99r(ݬ«CׂL:|Hu!7 yI$lBr ҖHlI-_Y̷۴?77s, ijڥZ Qn Y8H!ůܟd2pAф;! !3 OqF_|g|AfY㼓#VA}FK=J} io{eD$ B D !L`0!J{e7#bɽ6ɼ0Xa,L|qzJ] PSSm$;8D'!b8 -,FI> d0 1y7ȹf{5"Iq[\9 N98|_%~ / .) ._\Z!,8 ]u'0B(5wN FO3朜>dPg\Ҥ}jCtrt\\ȯkK8D??8{=<<wrx\O &5y vh}q- t=! P Fj0ؔf/TdV [=v]Ku_}K7펝ץc+ XASZQvg+tB-l7?ckncgX>Ntho+|+{n* ^k踂?t\B{lum29wtt"w71pyG\Vx塿ۏa ]x: k %^i optDoq>!;p(cv;i w|-88,~>^rlWaد@Z=ZAXf8Z_m&:-D`kwp~ >szK?"'f)X~vcN^F[4Eт&tl2!'*,42"^q6Q{rר_\1a#bP Gcᨆc2pӜ.E(>;Fvf|¤n 3a#<H#8 -F"t)rkKwմZ)eZTmjKU}*{lVEσF]W=x2undy>уnOun5W&h{j3T힫qCU1Bc=U9^= y.RJxh~Km**qtS>TgYB*ݨ|V {Sy+0w^s6|;:rT ?Oj_J#5ߤb*ꛪa}sTw+?\Co50hv++2*3䞲B#u jv}L$7TA* 2 (F588CB)7P9!_) t21RJq%ڕP xwۜC^_IqD7g C"De(#<[JԈ DNRRl٣Zw}L֘Kp(щCp`<9k6d>FBr<̨~J2(-*F)QJ+):C ׀r%j"ǭٴ]&gzWq2}61dZʧyo":*1CБTCd3F*'k\T%b.TLje΁kJJ%QC|`jK0~a||}XzfXd/,`0oJfJj_5 TE WQ%+@3T.¤Vބ;@_Kڕ|VKH F7D`aLr_hvȣ<ȣ<iFF6wh es-**12eXI3b3Ism2Q"@zcn^NVN$)I&O/\T ypײ ~ h x Nw 8 ^o7h:9ϚQL3xm\|pZ+>V4X9np 9 %pb]79E|Fk.=tqߣp_ ~ @z! 8d %8b&qO, 7G;[s}F7}#8>oDX׏xobE.!}F'W\G8?#} y 7{//x8xB/?xxd!]ʥ?8 Jqq`2ϓ9cʚv鷈uXi<^^G~_['228}@-1/i z]@"b#v91::f)d̲%8 )=`A}`7x#vL*%x[fEA>Nlb=Ӊe2į~xVav];aA-63ڧamFnf:iyZG1cW6!~>gbE,C %F3QVXn8ױwl=>t 3mIB6wh=X)p1b8{V e5YЕ(އq#%Y/>`ݍ.F($ p< G 68jȣZӴ<G UJ\ #J7á{6h^b{?v[!{8v !J$D @2-Dʂ xPW`k,@9GY?[ԟ0G^m8rК.5~a_\0A O:YT W*N.gd m$VM{Mn+rޓ+}GXo|/DA]U9fy;kfTW5-hr,lSNCݚ;d\%X mh#aǕ~Iww[~8:ZڲE7*HFyb=41\ T3f(8NYɲGNWfd2"+Y&YMj:.,/>R+цhkknтҖQ|k9T(ƛm,S/My2̣d3[n\F)%fĬШW#^wh 8ӂ%mhY y>̠NSikTF.+_l# 0 3ΰl (0.D4Dwq;hc9&٬i&VLlkXSi&=iZcܲUt=}kPqQE! 0ԩ,+7lFSNx1WUJ3nUK)rF7r+%.^nrn-d߂Y?=N#_<&0ҧzs+&OQA1#RVg&),3]Y *հI2dPp<(0C9?(8/<39AM֪lTi&Sy?;pMgq$*rkPHnȐP`~˿̖qHA>+#C *B ܼg9G0s%\*(EYX'btTP%b_qq-OI,WWҍ1 %gحNՠ:iV4x i|U<}/!㤀A+ Р2|ˌ)h`yr\rYX 0嘵rCƠSW.jB豉4/Ɩd%ӛ BM 0wE=\.BD."c'1!Mdb61;-s8KpG`O+yɇK*a@ȡ3$x \ \5\t5\@5д!`u+-M_M;"88Lum6{&P\ U jbibh[6ҋP@/DG=lyC2D-\X:` XX G} 35ã9p5XᲈXĢ.$ml||<[\ nm 5ʡ]ֱ@!H]/Y@ & VxҏVx£%RZq.|j&UL+q4+ZOX9HfF$|6K[w+(݋ < d۹xvzю(8r965]@:r;zgAK2>Ab{婋t} A===uh $߽V3u"o%9KɓFbvI9V#= u̐ǜc@E?eb(Ea.^zCU>_Z>QA\%!Կ_p55AGy1~ [/ g?>q&8Ǣ%Yzq]9@"g 57<Ǽ=f/΀w9Q|P5Xr*.S 8yP  q-M\׸p@E~_).: x~B>G"QUmpA0ҷx̯5c=U K+<.$;?1?R>@k?eέx ^?ni 53|5ezqA#_L ^.{8 3w𗿂8#=C=:n$2y?t,Y?8VrEr?أ8G:rXD^]M2m~A馲.= ݠ&救GZq+YȑLLV8DDRKX%_"6cvv'iP6Դl_+u:~G-rE.9ϢB1į DWc Ğ2 YNy: ߰Z_j%yWx=19v-{E'{Cf$Ilq1 BjrԑdkL76`0`n&&`CbH'@B(HB[Fi.K@%Ye (mfi6AZN]5mӺ}m6MӦM۪}ؤjڥ4G.S =z?y99『w f 8$7el{W('ߡܿ$xqÖ8 1Ua#f<ߦg3q;cX5#Df= MSw)h5졅p$v1iL.x 8K)gYBDim` $]v>NK<n'2LY%u )tY='e*\v/q~J M5+ɢmIښQ{rڒ˵9%M)aES՜USj61m"Z~D XR(j ?R/1~ b:m:r"8+GS IږVQQU6`(VBZ7֫޸Qu6Mݪ5Ri3)yNU VUX>T3SKH'bCطEȓ&K"L3TgU\Z_5ZjUm(hک_Qgͪ,mUb{Sg;>S#{Lo&Yg{(C$;I!Qk,ekͭ*[*m媰W^2GJ[SI39OQsWޜw6 %33IcuKZ~vlF9{IW3SNʜ.*8'"WH>涩 Gyv?ʬQ5` Q)-[J&RVj0vƟ^Dw;X҃][K> 'dMSfI,kr@ ʨVz(PRB=J )1N0uR;HfbM ~f/w_ዾKax e"'q!a$|:xĞqbMG#a{i{sp mx AY2`͐ѐb: }0q8k]A(nbL4n"LvLavL"&i0bK4A<&?åC){1ǎJw ցJ9>c;cܘɋ9.?7FҳB_Hx| :;_ U:G;0\|Hv,bb,R(2 $y{8G^~;?oسEi㗩WH*_%p p Ǎ' 67%X,e 2X&8ҫ>_{Ŵna"r܄*_a |n]M>gVcB~PW Iʊt9c/ggTW6\ۏ_ݛ¸oo=^I/G!R6\{tƟ6%inmzK4IIKKEZ.E\1AAȠ ás)`e2q2&sӝYiOs~/<Yz,GG>ۇ;h {mf5*c?,ks51#ꋚ b԰>_8?@}^Gnx7u6v̀/b@2(CAw6ڦq-gҿu7g8?R<7{{BGeER?.jK?wvT=:uч踂89,C%tz gz@{཮kz _>/߈M_p귪һܷtɜGG8qyqa6WqR6K'Hz0v]_p|ܟ>ݛ,::)tGsc88#8Zܬ}d/ _R@m!B#_y \b3e'"֯MzGek=:Bt5JR=pt±nѽ(\Sݰt*O.r?b̘C"f'Q~mmIG<4vPAo ɠy#ynsmEo  8,OUB$P]*,Od_ 2\G{?vX-s^tSsd+\x )c:h_P ~/k$?fOyF>OqmrѺ!.sSc>;\䱧"p᪇pMdptvZf^w@dG\ȝ -a4uAL&cjHָA9ʂ͞*P}LHuª4Z59_'`K0\RE-U$Fp+mw_ղqlI&&cqjHNQ8:CjU`b+4$JUI $5ȗ4VH%oUqOxBwU`BSDEOƮGm%#P1i(bPɨ!Ft94y4T*Rjܤbs *HW5r[)\ʱ\zD $#F#ϯw泥8!7#kȍߚ YJ-*RBK UX):Jn[rm3/T}RcxGi3-»[1nŌsJnFS'R*U`cO۞<{r%r9|I );AY㕙>MNgҝ+*{^2^5MlkH=Sl-~@ Fy24+iSө̌\P 93*=+4L={l]< K7#L_O̔zvx75RxeXՅ|vꤖz P#6(e3Ǣ49\#L*Yek.{LddHO*sLIs#>|o #c`;3 mcHrSCn|Ĥ* Y|vY ke,K)EmJ.+x U\Qy|;rȻ chiCG3#t27^RL%VJe,u) % VRYH 2*ۡXxK^n"/˴2K-pg]9]m jF_-CF2֓b$&@>*-JLx_b}^ SH~gCcc ~cUEp>4q*=NsaXFh11+)`bA MhPi`0MA C?Vj)x6{LzӐأVBV7q7 $K%l\xa0t\x ǸcBHuhcC걓zCON0yy@0"dF\1RkRivHMdM4pġӄ&45GoLLk.Khhh k\ni)![ 9<h#;?;: 6+Xy#tp 30hs1 ; 9tG7&4nrхU]Gy,AUEpܳ:^J<a<2h6ƺ gGI'M/uE賏FG.Y'ṿ; 1pa0p{Lߐ {%W@Ca!WқO c *r1@_RqpfLtLRl`ut^o$6hVӐq -8.sfp>rFqخR+_W.0Y āt0Rοgjs;pH}A#GGs"^@ aG>|Tp!X4T |pƲ~kg88K8G<N]zS'u/ >z:=E;N*ңn<7U#` :._ORܠԍp/h=k!G!^7YJgz\hDt*bn 6^ 489x,؋h2GM>:p6Nv4#ԥY EfUR0we mXu8# teDt2!Ue/Z"\B.j(fmV]O{ jȭ7\~t \χc9)2xYŮC-Z@泳R\ ,F}9(48ĵ5xW:EiU5YJϨ.&j$ n1 BxS(fYjC(i>'{ogG;k}+l$n9C5rxxK;\p%'/\p k4\5hr#{#PN. idgqedY1@3zMaL$?r2C&X5>ȡ1A.%jTPFgiD a!w+'tCV:7)5C)O( 1|!OwDt.Xm)1PANO!ǁ紆*dT^Ur .eGxGRZySbdSd{< =ZQ1]!2YQ,jLN\r,rNi 9~LSeLȄ*W*,qB=9  NUHO]pwL,Xߕ|VLl)f9#'CNPEyLxl2{2yeLJUD0(-U3Ui0v|:ɮL1v ͥB+tr)D]᥊y ݸ~0)\*ָɽû{Xfmհ2V|ߵ=růĔTMT۩jEZWj^vqq*B˄ΆKZ[µo5c[_U`8,G bK^2ٓ:hh5i|1/jZVXA>ך_,N7Ѧ _\[=_iu`xD@yy_2%ʹx>r؏{Թr`jf>+Te$9 `cU: I ~%ٱ/袁/h _s)qqlK3[j ML_>7\;ֲc4QkTT((kx[w ሕKk4U@{.J1P╢4 ŗqE`ƎUn\ɼEi]l'${.yǵ1Ja} !Ϛ:mfG3m4I3]4E35q^'$;i츎[u r@ 1T<ȸѹm a-߉MKvǀz(j-|BL9~3p.Q3 xԭGn߶dN;|ܛ}6'Ѷ$3'qR<%&4S|qJ~DzR>ދx/9f |ʸ'yj= kâٱ ]0!,ڣp~ӳq0rN<Qٗc;ޥ`|<\^\e>PF<?WOcq|xiorM_a{ u| =&RK忚6W$dv}*1?X߶i{#_\Y3Nmc} 6>|d)];__/9Գ 3%OlOI' 3d,mB=E;bW8{; ,g_^U*IltBtl x( $/g :{'iv6l`gv;8hûCQO)͠s'I=. \x)9)#+yJ9ۉxs'5ۆ Tx>)3tSI/ WB)t~-vk~ƻFvNZMsEp]z>Dk;ddI8,ybi|ENbWVf{crVրco5(Xe1/sSG j+GYvꎣ7b%8pTȊ*J3LJY–ٲ_h9 ukTz.?.7i<%oD,!`R8\)`. .jȥHB@H1%폎@TXb/&f:.cK4#1wsb=8|LfҖxxCCxt $2N(mt 5&j0T?CpmG2aEh9K(U/0q&{@AkX = =Y&zfͺ uЭ>HV^iPfPwމЋlxH9,4ٲ5f` ,x808!qM٠)]I l"10BTI##P$Ccba܍2Sc5#&F&G;Τ-gҖH#D >[3F5b( Ab${izз9&l^}p"F;b2!{asE D&x#8j$,byb!p,dLY]ّ!1CHlp\q .U%NLH-Rdch ^@D3Hvgxq|Dp*жhcHu}67jʌ ?R#3I3< PS,> ؞Eq\=-R'6;9IAzɆٜI6|XdA,@W־+Y?[ړC iBF-(ӊ -A[(oq@j ȡ^s8j$,AE$h~?Xhڊ>ǁ-•a0|!St+R)5D@*zmahCFnlV7qm͐pnyQњ+{O#Ok R>5y]Nbs0 ;P^84~EJcil)%dtUY#Wq€rFtGz](9dj_8`]భKJ7HKwsؗ1TT..(rۮѵ}4f>z{ϟࣵLAϻsƌzfzkfL(քC ~h?j}CJ3E%/c_TVJ*pT_xEy\_^Hڨ;Wi YA"ҭ[l!Iv^يR9$Vd2nqy>=/<y;s+Nw $ ӟmWy0\*c<0gלuN@B! +G[Yu?R|^rrH/坑,~$K]Kn`l=Z5[7q|gUnr"~F8ߛ-cY đ\ೖ-K1Es)`[>zyH]PF(볫ܤ;dqFV Lk-zPߔJK{wWy~P'C8d,ߴ. :J@7 dzqF@` V" 6X ##  ZeWŔԃN~a~qfu#E".lйy.?Xϊ ;m HK=`(tu4G!gn_:^!B@zhCLZ8l$@ + @ e!OAx C8~ⷎNs]=/I֣3ѡM*{q6ljK~!}9Ym!!_7Hlް(Qppj`0GXs,D`+/xGF@ҚSШ s=t##URuMT?|zq+[:sMnִ䂹33o\P7.B *OEtO1o,N4GO\ٞ~pc݌)GR0XQAl(f4 M)h@<׹L"]NJYsr,'%hݹv  ݆/U)|JnPW x kFEQ`0|=t[ 1x}fpc3A&ŽpJ ~ 7%1,۰PRND,^HU0uf>7웻ñ]zQZVq6 S d`0XA#GVJ[(9 RWvHo^0x3 bx p`+gQ(^1ױ>9ږ騬*^x#qb ,Y2aHwcVMOb/f=-ȁ/} - `=瀾}k) 4`" C!)p3:mu@XoQv ngn3w:s+*qBV- M$NreO{}v R` 83JyMO4)XZGyQj{DM {_πY ̸Ӻ|)weUefᨈ.A]]dciI~\w<8/t Pg+e >*7E`S# 3\GHpχHn aKS[K 5uk;mɶcVރ iEHD_+߾U\'9GVXJ¬9M<~̨փI+qijL9%A0pcF"((`77Q#'q h[:-H,n#*Z_YXO =Vy!pLYzY*K;x2}{"w7er"Iw:GSy\V[<6'Rչn%:溬'5mDtbZL\&$ ܾ~vן{}߻<%E&gINDHJ"NƄdD] Q!c@ d *>7 8PW% \ h`3^l:93cM|;egA :܂8XJ[7XI|0|N7w[{EkvcJȬi%J-Q#u|FBѵ<~ԠVTw|_JvV{J,͓ɯ)l/` R|Vxfm 96pL1c3Y0ߜ,/NP[@Qt+eKTe9ۏ-p Ȯ|BpW$ %IHO޿y:~0?_(gD,rE}KcШ+)J_*=I,?!4l=Å[Pծ=Ğ [ }g OZO$o!xL=5dbBC) Oմ>RIr\r"#;@V2[kclzi5a#*Xm?;62.#:ĉ֙Li_8L+ endstream endobj 243 0 obj <> endobj 252 0 obj <> endobj 253 0 obj <>stream %!PS-Adobe-3.0 %%Creator: Adobe Illustrator(R) 15.0 %%AI8_CreatorVersion: 15.0.0 %%For: (someone) () %%Title: (seedsync.ai) %%CreationDate: 12/20/2017 4:27 PM %%Canvassize: 16383 %%BoundingBox: 47 -442 457 -61 %%HiResBoundingBox: 47.9487 -442 456.2666 -61.7456 %%DocumentProcessColors: Cyan Magenta Yellow Black %AI5_FileFormat 11.0 %AI12_BuildNumber: 399 %AI3_ColorUsage: Color %AI7_ImageSettings: 0 %%CMYKProcessColor: 1 1 1 1 ([Registration]) %AI3_Cropmarks: 0 -500 500 0 %AI3_TemplateBox: 250.5 -250.5 250.5 -250.5 %AI3_TileBox: -46.6401 -636.6396 546.6396 136.6401 %AI3_DocumentPreview: None %AI5_ArtSize: 14400 14400 %AI5_RulerUnits: 6 %AI9_ColorModel: 2 %AI5_ArtFlags: 0 0 0 1 0 0 1 0 0 %AI5_TargetResolution: 800 %AI5_NumLayers: 1 %AI9_OpenToView: -1359 825 0.5 1747 982 18 0 0 73 117 1 1 0 1 1 0 1 1 0 1 %AI5_OpenViewLayers: 7 %%PageOrigin:-56 -646 %AI7_GridSettings: 72 8 72 8 1 0 0.8 0.8 0.8 0.9 0.9 0.9 %AI9_Flatten: 1 %AI12_CMSettings: 00.MS %%EndComments endstream endobj 254 0 obj <>stream %%BoundingBox: 47 -442 457 -61 %%HiResBoundingBox: 47.9487 -442 456.2666 -61.7456 %AI7_Thumbnail: 128 120 8 %%BeginData: 24593 Hex Bytes %0000330000660000990000CC0033000033330033660033990033CC0033FF %0066000066330066660066990066CC0066FF009900009933009966009999 %0099CC0099FF00CC0000CC3300CC6600CC9900CCCC00CCFF00FF3300FF66 %00FF9900FFCC3300003300333300663300993300CC3300FF333300333333 %3333663333993333CC3333FF3366003366333366663366993366CC3366FF %3399003399333399663399993399CC3399FF33CC0033CC3333CC6633CC99 %33CCCC33CCFF33FF0033FF3333FF6633FF9933FFCC33FFFF660000660033 %6600666600996600CC6600FF6633006633336633666633996633CC6633FF %6666006666336666666666996666CC6666FF669900669933669966669999 %6699CC6699FF66CC0066CC3366CC6666CC9966CCCC66CCFF66FF0066FF33 %66FF6666FF9966FFCC66FFFF9900009900339900669900999900CC9900FF %9933009933339933669933999933CC9933FF996600996633996666996699 %9966CC9966FF9999009999339999669999999999CC9999FF99CC0099CC33 %99CC6699CC9999CCCC99CCFF99FF0099FF3399FF6699FF9999FFCC99FFFF %CC0000CC0033CC0066CC0099CC00CCCC00FFCC3300CC3333CC3366CC3399 %CC33CCCC33FFCC6600CC6633CC6666CC6699CC66CCCC66FFCC9900CC9933 %CC9966CC9999CC99CCCC99FFCCCC00CCCC33CCCC66CCCC99CCCCCCCCCCFF %CCFF00CCFF33CCFF66CCFF99CCFFCCCCFFFFFF0033FF0066FF0099FF00CC %FF3300FF3333FF3366FF3399FF33CCFF33FFFF6600FF6633FF6666FF6699 %FF66CCFF66FFFF9900FF9933FF9966FF9999FF99CCFF99FFFFCC00FFCC33 %FFCC66FFCC99FFCCCCFFCCFFFFFF33FFFF66FFFF99FFFFCC110000001100 %000011111111220000002200000022222222440000004400000044444444 %550000005500000055555555770000007700000077777777880000008800 %000088888888AA000000AA000000AAAAAAAABB000000BB000000BBBBBBBB %DD000000DD000000DDDDDDDDEE000000EE000000EEEEEEEE0000000000FF %00FF0000FFFFFF0000FF00FFFFFF00FFFFFF %524C45FD4BFFA8AE83847D83595958592E582E582D342D342D342D340B0B %0B340B0B0B340B0B0B340B0B0B342D342D340B0B0B340B0B0B340B0B58FD %44FF838358582D34FD060B05FD2D0B58FD40FF837D5234FD370B0C0B0B58 %FD3CFFA883582E0B0B050B05FD390B58FD3AFFA87D2E340B0B0B2E0B0B0B %340B0B0B340B0B0B340B0B0B340B0B0B340B0B0B340B0B0B340B0B0B340B %0B0B2E0B0B0B2E0B0B0B2E0B0B0B2E0B0B0B340B0B0B34FD040B58FD38FF %A87D2DFD1F0B05FD230B58FD37FF832EFD140B2D0B0B0B0C0B0B0B120B34 %0B3411340B34343411343434113434341234343412343434123434341234 %1234FD060B2EFD36FF582EFD0E0B05FD060B110B341134123A123A123A34 %3A123A343A343A343A123A343A123A343A123A343A123A343A123A343A12 %3B12FD050B05A8FD33FFA87DFD050B340B0B0B340B0B0B2E0B0B0B340B34 %123A343B343B3A3B343B3A3B343B3A3B343B3A3B343B3A3B343B3A3B343B %3A3B343B3A3B343B3A3B343B3A3B343B3A340B340B0B0B34FD33FF58FD10 %0B341134123B343A343B343A123A343A123A343A123A343A123A343A123A %343A123A343A123A343A123A343A123A343A123A343A123BFD060B2EFD31 %FF83FD0B0B2D0B0B0B34343B343B343B343B343B343B343B343B343B343B %343B343B343B343B3A5F345F343A343B343B343B343B343B343B343B343B %343B343B3412FD050B7DFD2FFF7D2D05FD0C0B34123A343A343A123A123A %123A123A123A123A123A123A123A123A123A123A59835F83595F343A123A %123A123A123A123A123A123A123A123A343AFD060B7DFD2EFF580B0B340B %0B0B340B0B0B2D0B34343B3A3B343B3A3B343B3A3B343B3A3B343B3A3B34 %3B3A3B343B3A3B343B343B5F8483845F8483845F3B343B343B3A3B343B3A %3B343B3A3B343B3A3B34340B0B0B2E0BA8FD2CFF83FD0D0B3A343A123A34 %3A123A343A123A343A123A343A123A343A123A343A123A343A123A5F835F %835F835F835F835F3B123A123A343A123A343A123A343A123A3434FD060B %A8FD2AFFA858FD0B0B34343B343B343B343B343B343B343B343B343B343B %343B343B343B343B343B343B343A34845F835F845F835F845F845F5F343B %343B343B343B343B343B343B343B34FD060BAEFD29FF7D0B05FD070B050B %0B34343A123A123A123A123A123A123A123A123A123A123A123A123A123A %123A123A123A125F5F835F8359835F8359835F835F5F123A123A123A123A %123A123A123A1234FD060BA8FD28FF580B0B340B0B0B340B0B0B34343B34 %3B3A3B343B3A3B343B3A3B343B3A3B343B3A3B343B3A3B343B3A3B343B3A %3B343B3A3B5F845F845F845F845F845F845F84835F343B3A3B343B3A3B34 %3B3A3B343B340B0B340B0B0BFD28FF2DFD0A0B34343B343A123A343A123A %343A123A343A123A343A123A343A123A343A123A343A123A343A123A3483 %5F835F835F835F835F835F835F835F5F123A343A123A343A123A343A3434 %FD060BA8FD25FFA8FD0B0B3B343B343B343B343B343B343B343B343B343B %343B343B343B343B343B343B343B343B343B343B125F83835F845F835F84 %5F835F845F835F84835F123B343B343B343B343B343B120CFD050BAEFD24 %FF83FD080B050B123A123A123A123A123A123A123A123A123A123A123A12 %3A123A123A123A123A123A123A123A123A123A59835F8359835F8359835F %8359835F8359835F5F123A123A123A123A123A3434FD060BA8FD23FF7DFD %050B340B0B0B343A3B343B3A3B343B3A3B343B3A3B343B3A3B343B3A3B34 %3B3A3B343B3A3B343B3A3B343B3A3B343B3A3B348483845F845F845F845F %845F845F845F845F845F5F3A3B343B3A3B343B3A3B34340B0B0B2E0BAEFD %22FF58FD090B34343B343A123A343A123A343A123A343A123A343A123A34 %3A123A123A123A343A123A343A123A343A123A123A5F835F835F835F835F %835F835F835F835F835F833434343A123A343A123A3434FD060B83FD21FF %58FD090B3A343B343B343B343B343B343B343B343B343B343B343B343B34 %3B345F3A3A123B343B343B343B343B343B343B3A845F845F835F845F835F %845F835F845F835F8434120B3A343B343B343B343B34FD060BA8FD1FFFA8 %5805FD080B3A123A123A123A123A123A123A123A123A123A123A123A123A %345F5F835983593B123A123A123A123A123A123A125F5F8359835F835983 %5F8359835F8359835F8333110B110B34343A123A123A123AFD060B7DFD1E %FFAE580B0B0B340B0B0B2E123B343B3A3B343B3A3B343B3A3B343B3A3B34 %3B3A3B343B345F5F8483845F8483845F5F3A3B343B3A3B343B3A3B343B5F %845F845F845F845F845F845F845F848384343411340B340B3A343B3A3B34 %3B34120B340B0B0B83FD1DFFA82E05FD080B3B343A123A343A123A343A12 %3A343A123A343A123A343A125F5F835F835F835F835F835F83343A123A34 %3A123A343A125F5F835F835F835F835F835F835F835F8434110B120B110B %120B34343A343A123AFD060B58FD1CFFA834FD080B343B343B343B343B34 %3B343B343B343B343B343B343B343B343B5F845F835F845F835F845F835F %843A3A343B343B343B343A5F845F835F845F835F845F835F84838434340B %120B340B120B340B34343B343B3434FD050B7DFD1CFF2E05FD070B113A12 %3A123A123A123A123A123A123A123A123A123A123A123A348359835F8359 %835F8359835F835F83343A123A123A123A125F5F835F8359835F8359835F %835F8334110B110B110B110B110B110B34123A123AFD060B58FD1BFF580B %340B0B0B340B0C343B3A3B343B3A3B343B3A3B343B3A3B343B3A3B343B3A %3B343B345F5F845F845F845F845F845F845F848384343B3A3B343B3A3B5F %845F845F845F845F845F845F8434340B3411340B3411340B3411340B3B3A %3B34340B0B0B340B58FD1AFF58FD080B123A123A343A123A343A123A343A %123A343A123A343A123A343A123B5F835F835F835F835F835F835F835F83 %5F83343A343A123A345F5F835F835F835F835F835F8333110B110B120B11 %0B120B110B120B110B3A343AFD060B2EFD19FF7DFD070B0C343B343B343B %343B343B343B343B343B343B343B343B343B343B343A34845F835F845F83 %5F845F835F845F835F848383343B343B343B5F845F845F835F845F835F84 %34120B340B120B340B120B340B120B340B12113B3434FD050B58FD18FFA8 %FD060B050B123A123A123A123A123A123A123A123A123A123A123A123A12 %3A123A125F5F835F8359835F8359835F8359835F8359835F5F343A123A34 %835F8359835F8359835F8333110B110B110B110B110B110B110B110B110B %11123B11FD050B2DFD18FF0B0B0B340B0B0B34343B343B3A3B343B3A3B34 %3B3A3B343B3A3B343B3A3B343B3A3B343B3A3B5F845F845F845F845F845F %845F845F845F845F845F843A3B345F5F845F845F845F848384343411340B %3411340B3411340B3411340B3411340B343A340B2E0B0B0B34FD17FF2EFD %070B123B343A123A343A123A343A123A343A123A343A123A343A123A343A %123A345F5F835F835F835F835F835F835F835F835F835F835F5F343A3483 %5F835F835F835F8434110B120B110B120B110B120B110B120B110B120B11 %0B3B12FD060BFD16FF7DFD060B2D343B343B343B343B343B343B343B343B %343B343B343B343B343B343B343B343B5F845F845F835F845F835F845F83 %5F845F835F845F835F5F343B5F845F835F84838434340B120B340B120B34 %0B120B340B120B340B120B340B12343AFD060BA8FD14FFA82DFD070B3A12 %3A123A123A123A123A123A123A123A123A123A123A123A123A123A123A34 %835F8359835F8359835F8359835F8359835F8359835F835F5F348359835F %835F8334110B110B110B110B110B110B110B110B110B110B110B110B3A12 %FD060BA8FD14FF7D0B0B0B340B0B0B3B3A3B343B3A3B343B3A3B343B3A3B %343B3A3B343B3A3B343B3A3B343B3A3B345F83845F845F845F845F845F84 %5F845F845F845F845F845F845F5F5F845F845F8434340B3411340B341134 %0B3411340B3411340B3411340B340B34343B0B0B0B340B0B83FD13FFA8FD %070B34343A343A123A343A123A343A123A343A123A343A123A343A123A34 %3A123A343A34835F835F835F835F835F835F835F835F835F835F835F835F %835F835F835F8333110B110B120B110B120B110B120B110B120B110B120B %110B12123A12FD060BA8FD13FF58FD060B34343B343B343B343B343B343B %343B343B343B343B343B343B343B343B343B343B345F5F845F835F845F83 %5F845F835F845F835F845F835F845F835F845F835F8434120B340B120B34 %0B120B340B120B340B120B340B120B340B12123B343AFD060B83FD12FF83 %FD070B123A123A123A123A123A123A123A123A123A123A123A123A123A12 %3A123A123A123A348359835F8359835F8359835F8359835F8359835F8359 %835F8359835F8333110B110B110B110B110B110B110B110B110B110B110B %110B34123A123A340B050B0B0B0583FD12FF580B0B0B340B0B123B3A3B34 %3B3A3B343B3A3B343B3A3B343B3A3B343B3A3B343B3A3B343B3A3B343B34 %5F83845F845F845F845F845F845F845F845F845F845F845F845F84838434 %3411340B3411340B3411340B3411340B340B340B341134343B3A3B343B3A %3B0B2EFD040B7DFD11FFA8FD070B3A123A343A123A343A123A343A123A34 %3A123A343A123A343A123A343A123A343A123A5F835F835F835F835F835F %835F835F835F835F835F835F835F835F8434110B120B110B120B110B110B %110B120B110B341134123A343A123A343A123B34FD050B057DFD11FF58FD %060B34343B343B343B343B343B343B343B343B343B343B343B343B343B34 %3B343B343B343B345F83835F845F835F845F835F845F835F845F835F845F %835F84838434340B120B3412341134123411FD04343B343B343B343B343B %343B343B343B343BFD060B58FD10FFA82DFD060B123A123A123A123A123A %123A123A123A123A123A123A123A123A123A123A123A123A343A58835F83 %59835F8359835F8359835F8359835F8359835F835F8334110B110B110B3A %343A123A343A123A343A123A343A123A123A123A123A123A123A1212FD04 %0B057DFD10FFA80B0B0B340B0B123B3A3B343B3A3B343B3A3B343B343B34 %3B3A3B343B3A3B343B3A3B343B3A3B343B3A3B348383845F845F845F845F %845F845F845F845F845F845F845F8434340B3411340B34113A343B3A3B34 %3B3A3B343B3A3B343B3A3B343B3A3B343B3A3B343B0B0B0B340B0B58FD10 %FF2EFD060B3A123A343A123A343A123A345F345F343A123A343A123A343A %123A343A123A343A123A343A59845F835F835F835F835F835F835F835F83 %5F835F835F8333110B110B120B110B120B34343B343A123A343A123A343A %123A343A123A343A123A343A1212FD050B7DFD0FFFA8FD060B34343B343B %343B343B343B5F8483845F845F5F343B343B343B343B343B343B343B343B %343B345F5F845F835F845F835F845F835F845F835F845F835F8434120B34 %0B120B340B120B340B34343B343B343B343B343B343B343B343B343B343B %343B343B0B2DFD040B58FD0FFF83FD060B113A123A123A123A123A5F8359 %835F8359835F83595F123A123A123A123A123A123A123A123A598359835F %8359835F8359835F8359835F8359835F8333110B110B110B110B110B110B %110B12123A123A123A123A123A123A123A123A123A123A123A340B050B0B %0B057DFD0FFF580B0B340B0B0B3B3A3B343B3A3B343B5F845F845F845F84 %5F845F848383343B343B343B3A3B343B3A3B343B3A835F845F845F845F84 %5F845F845F845F845F848384343411340B3411340B3411340B3411340B34 %343B343B3A3B343B3A3B343B3A3B343B3A3B343B3A3B0B2EFD040B59FD0E %FFA82DFD050B12343A343A123A343A34845F835F835F835F835F835F835F %83583B123A123A343A123A343A123A5F835F835F835F835F835F835F835F %835F835F8434110B120B110B120B110B120B110B120B110B34343A123A34 %3A123A343A123A343A123A343A123B34FD050B057DFD0EFFA8FD050B2D34 %3B343B343B343B3A835F845F835F845F835F845F835F845F845F5F343B34 %3B343B343B343B348383835F845F835F845F835F845F835F84838434340B %120B340B120B340B120B340B120B340B120B34343B343B343B343B343B34 %3B343B343B343B343BFD060B58FD0EFF58FD060B3A343A123A123A125F5F %8359835F8359835F8359835F8359835F835F5F123A123A123A123A123A59 %835F8359835F8359835F8359835F835F8334110B110B110B110B110B110B %110B110B110B110B110B12123A123A123A123A123A123A123A123A123A12 %12FD040B057DFD0EFF580B340B0B0B343A3B343B3A3B345F5F845F845F84 %5F845F845F845F845F845F845F848383343B3A3B343B3A3B348383845F84 %5F845F845F845F845F845F8434340B3411340B3411340B3411340B341134 %0B3411340B340B34343B3A3B343B3A3B343B3A3B343B3A3B343B0B0B0B34 %0B0B7DFD0EFFFD070B3B343A123A343A34845F835F835F835F835F835F83 %5F835F835F835F835F83343A343A123A343A58845F835F835F835F835F83 %5F835F8333110B110B120B110B120B110B120B110B120B110B120B110B12 %0B12123B343A123A343A123A343A123A343A12FD060B83FD0DFFA82EFD05 %0B34343B343B343B34835F845F835F845F835F845F835F845F835F845F83 %5F848384343B343B343B345F5F845F835F845F835F845F835F8434120B34 %0B120B340B120B340B120B340B120B340B120B340B120B340B34343B343B %343B343B343B343B343B343AFD060B7DFD0DFF8305FD050B123A123A123A %123B5F8359835F8359835F8359835F8359835F8359835F8359835F83343A %123A123A598359835F8359835F8359835F8333110B110B110B110B110B11 %0B110B110B110B110B110B110B110B110B110B34123A123A123A123A123A %123A123A12FD050B05A8FD0DFF58FD040B2E0B3B343B3A3B343B5F845F84 %5F845F845F845F845F845F845F845F845F845F845F845F845F3B343B345F %83845F845F845F845F848384343411340B3411340B3411340B3411340B34 %11340B3411340B3411340B3411340B3B3A3B343B3A3B343B3A3B343B3A3A %0B2E0B0B0B34A8FD0DFF5805FD040B12343A123A343A125F5F835F835F83 %5F835F835F835F835F835F835F835F835F835F835F845F3A123A58835F83 %5F835F835F835F8434110B120B110B120B110B120B110B120B110B120B11 %0B120B110B120B110B120B11113B343A123A343A123A343A123B12FD060B %FD0EFF2EFD060B3B343B343B343A5F845F835F845F835F845F835F845F83 %5F845F835F845F835F845F835F845F3B125F83835F845F835F8483843434 %0B120B340B120B340B120B340B120B340B120B340B120B340B120B340B12 %0B340B34343B343B343B343B343B343B3434FD050B2EFD0DFFA82EFD050B %34343A123A123A345F5F835F8359835F8359835F8359835F8359835F8359 %835F8359835F835983593A34835F8359835F835F8334110B110B110B110B %110B110B110B110B110B110B110B110B110B110B110B110B110B110B3A12 %3A123A123A123A123A123AFD060B2DFD0EFF0B0B0B340B0B123B3A3B343B %3A3B5F845F845F845F845F845F845F845F845F845F845F845F845F845F84 %5F845F845F5F83845F845F845F8434340B3411340B3411340B3411340B34 %11340B3411340B3411340B3411340B3411340B340B34343B3A3B343B3A3B %343B3A3B34340B0B0B340B58FD0DFFA8FD060B34343A343A123A34835F83 %5F835F835F835F835F835F835F835F835F835F835F835F835F835F835F83 %5F835F835F835F8333110B110B120B110B120B110B120B110B120B110B12 %0B110B120B110B120B110B120B110B12113A123A343A123A343A123A343A %FD060B58FD0DFFA8FD050B0C343B343B343B345F5F845F845F835F845F83 %5F845F835F845F835F845F835F845F835F845F835F845F845F845F835F84 %34120B340B120B340B120B340B120B340B120B340B120B340B120B340B12 %0B340B120B340B120B3A343B343B343B343B343B343B34FD060B83FD0DFF %7DFD060B3A343A123A123A34835F8359835F8359835F8359835F8359835F %8359835F8359835F8359835F8359835F8359835F8333110B110B110B110B %110B110B110B110B110B110B110B110B110B110B110B110B110B110B110B %11123A123A123A123A123A123A1234FD060B7DFD0DFF830B2E0B0B0B1234 %3B343B3A3B345F83845F845F845F845F845F845F845F845F845F845F845F %845F845F845F845F845F845F848384343411340B3411340B3411340B3411 %340B3411340B3411340B3411340B3411340B3411340B341134343B3A3B34 %3B3A3B343B3A3B343B340B0B340B0B0BFD0EFF59FD060B3A343A123A343A %34845F835F835F835F835F835F835F835F835F835F835F835F835F835F83 %5F835F835F835F8333110B120B110B120B110B120B110B120B110B120B11 %0B120B110B120B110B120B110B120B110B12113A123A343A123A343A123A %343A3434FD050B2EFD0EFF7DFD050B12343B343B343B345F5F845F835F84 %5F835F845F835F845F835F845F835F845F835F845F835F845F835F848384 %33340B120B340B120B340B120B340B120B340B120B340B120B340B120B34 %0B120B340B120B340B34343B343B343B343B343B343B343B343B0B2DFD04 %0B58FD0EFF58FD060B3A123A123A123A348359835F8359835F8359835F83 %59835F8359835F8359835F8359835F8359835F835F8333110B110B110B11 %0B110B110B110B110B110B110B110B110B110B110B110B110B110B110B11 %0B34343A123A123A123A123A123A123A123A12FD050B057DFD0EFF7D0B0B %0B340B34343B3A3B343B345F5F845F845F845F845F845F845F845F845F84 %5F845F845F845F845F845F845F845F8434340B3411340B3411340B340B34 %0B340B340B340B340B340B340B340B340B3411341134343A343B3A3B343B %3A3B343B3A3B343B3A3B343B3A340B2E0B0B0B2EA8FD0EFF52FD060B3A12 %3A343A123A5F835F835F835F835F835F835F835F835F835F835F835F835F %835F835F835F835F832D110B110B120B110B121134123412341134113411 %3411341134113411341234123A343A343B343A123A343A123A343A123A34 %3A123A343A123BFD070BFD0FFF58FD050B34343B343B343B345F83835F84 %5F835F845F835F845F835F845F835F845F835F845F835F845F835F843412 %0B340B120B340B120B34343B343B343B343B343B343B343B343B343B343B %343B343B343B343B343B343B343B343B343B343B343B343B343B3412FD05 %0B58FD0FFF52FD060B3A123A123A123A58835F8359835F8359835F835983 %5F8359835F8359835F8359835F8359835F830B110B110B110B110B110B11 %0B110B34123A123A123A123A123A123A123A123A123A123A123A123A123A %123A123A123A123A123A123A123A123A343405FD050B7DFD0FFF590B340B %0B0B343A3B343B3A3B345F83845F845F845F845F845F845F845F845F845F %845F845F845F845F848384343411340B3411340B3411340B3411340B3B3A %3B343B3A3B343B3A3B343B3A3B343B3A3B343B3A3B343B3A3B343B3A3B34 %3B3A3B343B3A3B343B3A3B0B2D0B0B0B2E0BFD10FF58FD060B3B343A123A %343A58845F835F835F835F835F835F835F835F835F835F835F835F835F83 %5F8333110B120B110B120B110B120B110B120B110B34343A123A343A123A %343A123A343A123A343A123A343A123A343A123A343A123A343A123A343A %123B34FD050B0558FD10FF59FD050B34343B343B343B345F5F845F835F84 %5F835F845F835F845F835F845F835F845F835F84838433340B120B340B12 %0B340B120B340B120B340B120B34343B343B343B343B343B343B343B343B %343B343B343B343B343B343B343B343B343B343B343B3434FD060B7DFD10 %FF52FD060B3A123A123A123A598359835F8359835F8359835F8359835F83 %59835F8359835F835F8333110B110B110B110B110B110B110B110B110B11 %0B110B34343A123A123A123A123A123A123A123A123A123A123A123A123A %123A123A123A123A123A343AFD070BFD11FF830B0B0B340B12343B3A3B34 %3B345F5F845F845F845F845F845F845F845F845F845F845F845F845F8434 %340B3411340B3411340B3411340B3411340B3411340B340B34343B3A3B34 %3B3A3B343B3A3B343B3A3B343B3A3B343B3A3B343B3A3B343B3A3B343B3A %3B122E0B0B0B340B7DFD11FF58FD060B3A123A343A123A5F835F835F835F %835F835F835F835F835F835F835F835F835F832D110B110B120B110B120B %110B120B110B120B110B120B110B120B34343B343A123A343A123A343A12 %3A343A123A343A123A343A123A343A123A343A123B34FD070BA8FD11FF7D %FD050B34343B343B343B345F83835F845F835F845F835F845F835F845F83 %5F845F835F8434120B340B120B340B120B340B120B340B120B340B120B34 %0B120B340B34343B343B343B343B343B343B343B343B343B343B343B343B %343B343B343B343B3434FD060B52FD12FF58FD060B3A343A123A343A5883 %5F8359835F8359835F8359835F8359835F8359835F830B110B110B110B11 %0B110B110B110B110B110B110B110B110B110B110B110B34123A123A123A %123A123A123A123A123A123A123A123A123A123A123A123A343AFD070BA8 %FD12FF830B2E0B0B0B12343B343B3A3B345F83845F845F845F845F845F84 %5F845F845F845F848384343411340B3411340B3411340B3411340B341134 %0B3411340B3411340B3411340B3A3A3B343B3A3B343B3A3B343B3A3B343B %3A3B343B3A3B343B3A3B343B3A3B112D0B0B0B340B58FD13FF7DFD060B3A %343A123A343A34845F835F835F835F835F835F835F835F835F835F833311 %0B120B110B120B110B120B110B120B110B120B110B120B110B120B110B12 %0B110B3A343A123A343A123A343A123A343A123A343A123A343A123A343A %123B12FD070BA8FD13FFA8FD060B343B343B343B345F5F845F835F845F83 %5F845F835F845F835F84838433340B120B340B120B340B120B340B120B34 %0B120B340B120B340B120B340B120B340B12113B343B343B343B343B343B %343B343B343B343B343B343B343B343B3412FD060B58FD14FF7DFD060B34 %123A123A123A348359835F8359835F8359835F8359835F835F8333110B11 %0B110B110B110B110B110B110B110B110B110B110B110B110B110B110B11 %0B110B11113A123A123A123A123A123A123A123A123A123A123A123A123A %341205FD060BAEFD14FFAE0B0B0B340B0B343B3A3B343B345F5F845F845F %845F845F845F845F845F845F8434340B3411340B3411340B3411340B3411 %340B3411340B3411340B3411340B3411340B3411340B34343B343B3A3B34 %3B3A3B343B3A3B343B3A3B343B3A3B343B3A340B2E0B0B0B2E0B83FD15FF %A8FD060B34343A343A123A34835F835F835F835F835F835F835F835F832D %110B110B120B110B120B110B120B110B120B110B120B110B120B110B120B %110B120B110B120B110B34343A123A343A123A343A123A343A123A343A12 %3A343A343AFD060B0558FD17FFFD060B343B343B343B125F83835F845F83 %5F845F835F845F835F8434120B340B120B340B120B340B120B340B120B34 %0B120B340B120B340B120B340B120B340B120B340B120B3A343B343B343B %343B343B343B343B343B343B343B343A0B2D0B0B0B0C0B0BA8FD16FFA82D %050B0B0B0534343A123A123A34835F8359835F8359835F8359835F830B11 %0B110B110B110B110B110B110B110B110B110B110B110B110B110B110B11 %0B110B110B110B110B110B11113A123A123A123A123A123A123A123A123A %123A123AFD080B7DFD18FF2DFD040B2E343B343B3A3B345F83845F845F84 %5F845F845F848384343411340B3411340B3411340B3411340B3411340B34 %11340B3411340B3411340B3411340B3411340B3411340B343A3B343B3A3B %343B3A3B343B3A3B343B3A3B343B0B0B0B34FD040B58FD19FF34FD040B05 %34343A123A343A34835F835F835F835F835F835F8333110B120B110B120B %110B120B110B120B110B120B110B120B110B120B110B120B110B120B110B %120B110B120B110B3A343A123A343A123A343A123A343A123A343AFD080B %2DFD1AFF58FD060B3B343B343B345F5F845F835F845F835F84838433340B %120B340B120B340B120B340B120B340B120B340B120B340B120B340B120B %340B120B340B120B340B120B340B12343B343B343B343B343B343B343B34 %3B343BFD080B2DFD1BFF58FD050B12123A123A123A348359835F8359835F %835F8333110B110B110B110B110B110B110B110B110B110B110B110B110B %110B110B110B110B110B110B110B110B110B110B110B3A123A123A123A12 %3A123A123A123A3434FD090BFD1CFF580B0B340B0B0B3B3A3B343B343B5F %845F845F845F845F8434340B3411340B3411340B3411340B3411340B3411 %340B3411340B3411340B3411340B3411340B3411340B3411340B340B3434 %3B3A3B343B3A3B343B3A3B343B3A3A0B2E0B0B0B340B0B0BFD1DFF7DFD06 %0B123B343A123A34835F835F835F835F832D110B110B120B110B120B110B %120B110B120B110B120B110B120B110B120B110B120B110B120B110B120B %110B120B110B12123A123A343A123A343A123A343A3434FD090BA8FD1DFF %58FD040B2D0B3B343B343B343B5F845F845F835F8434120B340B120B340B %120B340B120B340B120B340B120B340B120B340B120B340B120B340B120B %340B120B340B120B340B120B3B343B343B343B343B343B343B34340B2DFD %070BAEFD1EFF7D050B0B0B050B343A123A123A125F5F8359835F830B110B %110B110B110B110B110B110B110B110B110B110B110B110B110B110B110B %110B110B110B110B110B110B110B110B110B34123A123A123A123A123A12 %3A1234FD080B2DAEFD1FFF7DFD040B2E0B3B343B3A3B343B5F845F848384 %343411340B3411340B3411340B3411340B3411340B3411340B3411340B34 %11340B3411340B3411340B3411340B3411340B340B34343B3A3B343B3A3B %343B3A3B34340B0B0B34FD040B2DFD21FFA8FD060B343A123A343A125F5F %835F8333110B120B110B120B110B120B110B120B110B120B110B120B110B %120B110B120B110B120B110B120B110B120B110B120B110B3A343A123A34 %3A123A343A123B120B05FD070B2DFD22FF83FD060B3B343B343B343A5F84 %838433340B120B340B120B340B120B340B120B340B120B340B120B340B12 %0B340B120B340B120B340B120B340B120B340B120B34343B343B343B343B %343B343B343B11FD090B58FD23FFA8FD060B123A123A123A125F5F833311 %0B110B110B110B110B110B110B110B110B110B110B110B110B110B110B11 %0B110B110B110B110B110B120B121134123A123A343A123A123A123A123A %343A0B0B05FD070B58FD24FFA80B0B340B0B0B3A3A3B343B3A3B5F843434 %0B3411340B340B340B340B340B340B340B340B340B340B340B3411340B34 %1134113411341134343A343B343B343B3A3B343B3A3B343B3A3B343B3A3B %34340B0B0B340B0B0B340B0B7DFD25FFA8FD060B123B343A123A125F3312 %0B110B120B120B340B120B3411120B341134113411341134123412341234 %123A343A123B343A343B343A123B343A123A343A123A343A123A343A123B %3411FD080B052EA8FD26FFA82EFD050B3A343B343B343B343A343B343A34 %3B343B343B343B343B343B343B343B343B343B343B343B343B343B343B34 %3B343B343B343B343B343B343B343B343B343B343B343B343B110CFD090B %58AEFD27FFA8FD060B123A123A123A123A343A123A343A123A343A123A34 %3A123A123A123A123A123A123A123A123A123A123A123A123A123A123A12 %3A123A123A123A123A123A123A123A12340B0B05FD070B057DFD29FFA834 %0B0B0B2E0B3A343B3A3B343B3A3B343B3A3B343B3A3B343B3A3B343B3A3B %343B3A3B343B3A3B343B3A3B343B3A3B343B3A3B343B3A3B343B3A3B343B %3A3B343B3A3B343B3A3B34340B0B0B340B0B0B340B0B2DAEFD2AFFA8FD06 %0B343A123A343A123A343A123A343A123A343A123A343A123A343A123A34 %3A123A343A123A343A123A343A123A343A123A343A123A343A123A343A12 %3A343A1234FD0C0B58FD2CFF83FD060B3B343B343B343B343B343B343B34 %3B343B343B343B343B343B343B343B343B343B343B343B343B343B343B34 %3B343B343B343B343B343B343B343B343B12120B2DFD090B3483FD2DFF83 %FD060B123A123A123A123A123A123A123A123A123A123A123A123A123A12 %3A123A123A123A123A123A123A123A123A123A123A123A123A123A343A34 %3A1211FD0D0B7DFD2FFF580B0B340B0B0B3B3A3B343B3A3B343B3A3B343B %3A3B343B3A3B343B3A3B343B3A3B343B3A3B343B3A3B343B3A3B343B3A3B %343B3A3B343B3A3B343B3A3B34340B0B0B2E0B0B0B340B0B0B2E0B0B58FD %31FF58FD050B11343A343A123A343A123A343A123A343A123A343A123A34 %3A123A343A123A343A123A343A123A343A123A343A123B343A343B343411 %340B0B05FD0C0B5883FD32FFFD050B2D113B343B343B343B343B343B343B %343B343B343B343B343B343B343B343B343B343B343B343B343B343B343B %343B343B34340B12FD100B2EA8FD33FF7DFD050B0534343A123A343A123A %343A123A343A123A343A123A343A123A343A123A343A123A343A123A343A %123A12340B34FD060B05FD0B0B050B0B587DFD35FF580B340B0B0B34343A %343B343A343B343A343A343A343B343B343B343A343B343A343A34341234 %343411340B340B340B0B0B2E0B0B0B2E0B0B0B340B0B0B340B0B0B2E0B0B %0B3458AEFD36FF7DFD420B050B2E83A8FD37FF83FD410B2E2E83A8FD39FF %7DFD3A0B050B050B0B2E5883A8FD3BFF830B0B0B340B0B0B340B0B0B340B %0B0B340B0B0B340B0B0B340B0B0B340B0B0B340B0B0B340B0B0B340B0B0B %340B0B0B340B0B0B2EFD070B34347D7DFD3FFF7D05FD2F0B05FD060B3452 %7D7DA8A8FD41FF830B0B0B2E0B0B0B2E0B0B0B2E0B0B0B2E0B0B0B2E0B0B %0B2EFD070B2E0B0B0B2E0B0B0B2E0B2D0B342D582E58587D588383A8A8FD %48FF83FD19A883837DA87DFD0AA8FFA8FFA8FD52FFFF %%EndData endstream endobj 255 0 obj <>stream GM6xlvBo4Pm~G=$]?l`欒oJYrSA<9- R:)-8Nl8~T"j7qshisB>SV֭woX{6&z7VTW_U"橲m Y N .{^=z?Y}3鹧0 ١awVRvڳG?5uUbf*#_*X`jѥXr^4TeE7xEVMV9((6j>|ɹKpMҊmW*Tɷxd-t T[|ZxX ~w+t0/WN 9{хbQF`q` طÔsq ?#*6UJHfMYzן+#鼚3Y/^C(M<_p(I A rX>hה8F 6?iSO1,YF(#X;kzۚXhƝgyڬ X Ng4ؒ"+A,OVӛIi=-HKCfAb#݇}Ii`u+SV?DSs]S%(:Ň0YQFRv8pDSxUBF1ޤQ?)R;d[Ү}}mgdy/D ^x,4eKyk}t^)"+Q|)($RB1/4׋bI"]+W?Et5?po*2J\}!~s4^D9%G E.SNvе[ n~}QJc)*JF 9EDc{z>»iT^.V_B)raOkw_?7їѷnog?C|9@GS|}Ĺzˏs\ߋsjMQӃޥ87vb X?' Љ]Ȉ ;'|%w_׻Z}U+_~C\,=9w䇶<~=j[,س[^,׆LIB(-΃;Ggr8a8_YAq#"|MS~%}zuyx0y}g?l~Z['Phau :̊;0sح6yIHDžFW K4KqrPׂ/rZ[$/H5#1ɧ#=Nc*kFᄒ킶OF;QEvLs -sy򡛼}n~! 0`q*g9_a} nt/<&[HVO:9%~ɞ-GywWggykq=Ͱ|t4;1FRw ЙRgnXǽ/'X/n^mع/q{sq?B $.l;bÝ g4o׻04{Qyd`δ9{`[=uڽm@~lSwlx|0R *S &s2v_7R_uFzVڋ)0yrYygnAy#ta)8\ G}׻iuMg3 {;ח ujh`BIg;Ʃ͍s5 uϖy* U6= .ӫ}Vɞ 4q2]Q2||TDA}z}$BF) 6}<ܦ o)r=t5f/W+,Xjm00,m:PE2~鰡DKd&toUƍ' ;.p.7$@zpe (KN7UC]r _ G3E:ǿ[k/ f1NmN#C}?+O U<7h4J6sÏQ#] x\)v5ZEauX+`A5}=+T{-},c6td (yr^?ɱЎQ+"G&\z_R Zya#ڑ][Ulo^v˨mv+Q2Wq :3y./_" y!q_J2I`9= !כjUn˳t|[O[ĥvn불}ssM&(sMS wt6=/e=L) 8i~j X- 8TW>--Hjw, 4Y Nw%|V J]`ҩgZqx pl6w`_]#g-mtPf&%:$^Ȳjf8N (݊I\Pߝ_ +#ZZ'٣ yEGJIOJų4S|k-2bV> ҩ|Of}\-r0[hC{`/*W7R)  a>;Ŧq1ټ*^ϲbzE`oJՖʭ| D<*=S:>$>-X`n"%>X,5^%3Qׂ@8ߨI`rwÍ]Jժ}/HϽf;D|j .jʤ~Vi݉GQ4!j)7$CZ6}|oSk򊢈SJʪF2)a:\QĖ;l5?/[B*c iŷśFʾE8 Z,vm ƹ/){+"HjND )U|yڝ;IR_ }#%ݥ{d;GI.OfQRtQ;Q`Q(W)r8$(a MYRz^ 1F9:EHjTWng&5><~v Wbќ 'Nȧ+==##U0JQ7J8JZ%m|bX?[S~4\R-|H0!޻/A/ A$2 vЃ|?a|n>~c >ib`1 1P墯Ԣ܏/pfaj|Bx^ f3|<`<@EI)?Q׷c~wrVwE륻'!c.΍KussfKHφ,.Q5<~.桩xՅ2nBnx(rtZe+/W- 8NS3z{>Z"=鰯umep%M46=JCu޻8b)Jȼ  z^zZ]灕dx=ܕC6e.sp?0mNruttxsrHpƁm~ ;XvAw7[ll.C{ԝ<4O[v߫OJ5"#x<{l"&\бxۚc,n Ѕ%ǥ-hzש]`ݰ7=_>;mK5aspq8i\|>l~)Ru͍/h|w,p6y[22<2@ -,sٵ4=d\M/09>=Bζ~w+|fSY_ q~ ~oz9xk|z˿Ga7GbЈg;P/jVNjo^Ed})w6S3fZLA W.vZ uGR}QG׆RaZ/qNG5ˎS\>>L)iŵn=ы|-=˟=-:W`[]AY.g[x>m|6˾wvlDrnK;Qu'f6լ˛ M|BCu^i݋W]s<.pW~SsSY31I؜ކnAzmtߋϡ;w@x6 䵴ԙrEnEDy3].2_~v >FXj=]2HMڕ+ZgokmO*~ZxsfчY @봥b2-px;pIІ9z 3^EťlA-H ZFtggZ lh!gq&CU[״*e]VyP)kd=]E K&e1#|Mdw]/IЦA R< םl.󲠦Lc= %@^{gwt9gVL}h)`-! bj;-pDgr߉΂Iem~IZw+\]Sn  7UJ^.{ Iwu1]n֎؎iZ-5|5HXnT'"&b k);"g(l#PYo]ks.Ou6k\-ɷs-HY)olJLec9\am}^nK&:$R1 +NzxicʣxF\ G\gN3\8f6։@g4tˋpMDd;D7lU8@)K!ck1LԒPZQlNyK4N{Y]K=kGZ7@gnk  x&~]]n>v0Пd\BFFF}Oz^(jYr?*дZKE5z& &yÅ@m=;\-C?|R skT$5k ]G&x0F-\? 67rY}\QQ4NS<91 ]CBs h!`ahL͗w Їcsf?{,LFrz{P*43Ze "I"o;RF8RFȽS*湃5گOCX 3H`cpn? HFƲ4S}pnHR<h= IUWV9)ݛ!),vs(nz{Z䖿>&WwOcA):@ PۖHu)I|Pj?p_y!ߩu[$w{k]Dײ|O N?pd%J[D^l<=_aCʃ.?ոJaSu\t9ۿPxkkB cF'ab?s]U%ڽYPI$HQ,f8s?ϋQTV:]f{Y&iTw5Js&fWPP$iϕʂVc_L \.5TT`^`qo/ki3"jmnn{\-Kjw| ; q? oGHyj8stoqViq9⏴ߑd&_ٱrޠL,)JBnkQ#/p<%\?K&pU{!sϛX^$_>K]Gʹ]SKr$KUO?Mu7I+-R`- Y3hx⪥s{4f|Ws;^[';|S'{?C^!qKɛwKh;>vqנ~w;[VPk]e'^p)<Gݜef}*lթp!QhtVJYrb&qh$_p/}pJ>C|{9U/gnXc8 { ;Tk7C|]}yy> ۼs>Z5 p(j, /[f(>ۼ}VvB7Rnn }oWiWPT|OSfk5n?qĉ~2=ulp3Ι~zz^v0:mml1ktk }#\w ;.[`/Ny5 N{ݲ>jPv)h |_avz+֖[f|Wl5jq`[=ᄿ+UWaAk@}p T rWzkL# l8ͯ}A׮wYO&5|8n$󆦐%3IޕNJNN봳oU^FZsU5sVqOr75d3{z0wEDzˉsu鞬EIhn9VVҬTZEJydi (*%B+Gs tQ2ԏƦ՚u&z٤GԐ{Epb^S 竢 OOӘ` ^鼠z{_yk_w奸 $0e3G(M1 cp;IuK,a$` xRd1H1b@oag! \ezI+ Cx M*^])UP &GT]&?OOԁ;'X#Y` {F BzAژc8ŁCHWW}jh9q ~cWVʁ9& n9A@DRGC v 1ls~Ư06۞׏sFVzO'E [@Zz(G{! ZuП[׊=^#R,&:h:C>+ܜ8RV߶eRy>۫o-/PƙnKح/ͫ;;GSS0Ҏ?s^:s6+>7#|ۜZO2DwhC=uG/“-fYEPWqf20V;~>qCLb{Ey /껮~:\n272A 23L&@·_hßMW-8pƃ ay3z/ K )/&UX/j Y/xJ7 ;כjYh_цn&}3I,󦎤 ni,-h*HW:Ho)_X1?h6װ~7!г ۽{Ʊx,QGq{MJ*{%;)?z'q()[}[)ZFCͽPA|FW5W;@gX^ ,K!ߟp}Rv `}u`Co`O`~o`s7 3["IRJ_Dw)fzeebrN,f鰙3 eӟ-ׄ\W<|_7+ʫ3h ;FG{q6v?!Ö燜LN;cZi|]}!I.)S4N~k4WA_v i__k>`"^C0Nr{<N򽫟fvfF|ͮv^bYȶ^_HUCO>7ZM7'&wpJsW{̒D&*|x ǮW %w5iI3Ro:[p_ba`;pv'6KQiV6ivL8>(忀7Pu]O7'ACiMJޑ%omO(3=GF֭k886/8ήOǗ>x~p*a4QT;wm>^.EXs;Vf{pMw|O4ޕ&Wҟu3&Vr`ws5e*×3 £=nfa+(Pޟfe~ `Xk=?:uW}ϩQu!j/O.Sz@QAkj (W3hjQŊ&.}>t41z1=@5rkbNhs}ZpZ;gUqRUaJɕO.݈.݆17GxAvP!RkG6g46bm2 W˨kf\RzѶXQɔہ~j'}`}$NI8 W.E=4jNH6y#QM1ndge-HG^)ʓY/Vc֩RNː){7S^Ê{w$DGsEm1KM2}$дthg( \xd, 7p!XW9Yu3tQPPa7{Efӵmm~nӡ G8N'yse?Bһևp)jdDm$JG M/@ꘀԙ{‹:}G~n/tzTWg++>mSMCЅ$ץ9vz0'L)?;U"8OfF6016~}<8elt6x NV

<pfԾnĥ;Јҙq#v'ߕ<:G ̬6~]), #0:q15,A3KL>u\>wG.n4XJi>ZaQk-i'-h-<"R6ӬdCb^poꭶizܮAXc |}Ջ/W/<0K&ӑؕ;f" T?)}G?N 7|4bNqA޳Imyeek[UxtVbխ`Ĕ梕0N狂(-jĮ%i/86%)VIyWDԔk׻v+yфb )>P2a3O1RKokx@ Rl{uj xEFi"5a+g.?jSI~R&n {Sҡd31':DjPA]pYziWYK~Ύϫ: j#V`uS$k&k5NX L(0QXi0¬1/e<}9y>f79WvMDjoRU^tR9WR؉tF9bOqȝ+(p4QR,v)>L KA??m>+ΙlA<#HrAǩ%U7M뇯# (*JZ q y`A`&.8gGcX-6s |pq)s VwIζG֢l]#3ESsFəsc`O9x8]_DdNy * e %2tHUH(dtfIq7[繕ӎA LoP!AT8jGy>ֶAA5 Ok YwH6^woVﯶpxilj3!y/5y|)I$tMA?$aZA5IJ.NqǓoWAmr\_n^[([1I0$ 7qqS3LRl42;Sz4'n $9.jg:V;kaOȽ?H;&$''x5H#9mUjMiQؤ4T~@N}]g䳫G^ibp> DvT& Z$$+/gNbvm_oqw圈8=;}N)*DmS9M#Lv0vzyNooF‡h5׊>V C=/FvZ4^9{i+UP;£ -^Nw'@']Tw]n-xUp05`AM|)-,o^&iϛKCI;nzQ?ayV5r3y& JUjJ}ˏֹBu*VJnU?Ñ?c.*,E}w1|y9T*B_olkPZq ]WU M:wqa.˙ϳW5OٜELٓ.U³uvK;qlq߼ua-RM AM3~a=.V3iZpTץnr7Inêd"^*WpӚ:3807=QQ}ǎjrΏ2:5)~rimE0昵ЗRncz}cbm\Nj/ʋ'0*ۄRKz<;w=3ɶ-9db8zTBY2hl7ssZu*xom->z11GxUK&tPt)ڦ&4/úpa&%zo 'z`0d">PG*?f k43oi~- r+7 f:'-u`v hjjv(8ހkwe%.a=YNzLIEiQ S5L3?4`a3(HKDpמf.xl.~;eMiZQUϓ4s "˼/j@]N+S+SեHlmP 5ľ Z|P+7??^[K}8f%r[Xϊ9z DŪ~C/+Oo*'uy~ٚDI0g(b¦\5y7}ujz|.p2QvK|,t[Mw4Ts-jxMەM c9Z#ig%ӽ -GE Svm[LP{H~Hh >?.U}Oh+DgϷ}ezq}'m>A,3XIE=-̷\}Kb0gD&@Of}!P6)dtb-л~ #&#&g>[!!VzJ^y JzOq6kb8,Ӊ=Y*X9mC~;xyc^|\ۃ*.;;K6cmV.5r{kj5-B@]j{S%3+=&q05^rױRvܷvETkYp0lȂ<#-͒CyÙ[9Ǎ񽏍7;8*=vm&s,]!%ܕ[R@/X5cC57ı~gH2bY:Tpk/`OHc.uܜV%U\Sy?Ga7gwz4Ap;|̂LǾt%VXӻTuHKQoO]yNBVλa7"[kRAyq56Te2 Fn5V2BKg8 X,<:]Fwj4>T f!F_ gV C> ܯ uE:3C6æԹ>½ym9^>0oc۶7ƾd%L5Dj3Bvr\"4lUlcbhA)#o!sE @t,8~|/%QՎݐ~ݺ퇏Ԭ|Sˤҏanà8Ps26)KՑBӬ8UY"ޛZA_ %t4Q",ÈWAX$ *ުR6U,U&y. }o#BrV& 75/rdI &D-i6804v6S Ӑ %AQ@`X9iOWA+i8:};mwdoiw.N[~t5CR͎ɹPP7ug zʃy0]h Do%+,,\åW5Gw~m438tHW)-I;~vvͣvFvF~9w%dI#l=(tӨۼNvP:] Cؿ48 ڒtٚOM x%Yuff~޸WUn Th&o"O_ǻ9~ABXC?eГ> l m>e6 VL6 {'>-oZܷijS* Au=kժQ;lC` ѷUFcgh&ޡcS*܂ [7%4mkE7jݝXݿczZT퓮R'_BUD 2,OY/xXƅ#??T} lY*8^lA1]h`_iVzGkP^K e"JͱK%Ģ5_V.}.rŭP,YaE]N!7rB%Beʄ{٫W~uOAk)v_x~$>#aO:Tfk/'B6>ZXoB" *p!oq:$F& Z=bt/ЭQK<@'>*,sy9qNc_rɶ;iR+[Y 2Z{$&>R]y_/|h<|7xϨk C ِ-!/Am-2|!A?Gn>] fќj3l"ԉszݒЁH+Ĥs) 7쮬ﺟ~U&g _KᡆݝĦݜ~LWxZ&ζ\PU vD1 U ( sݽ|jDQ[ YqM?vֲHmv}?܍'[OZ~uަ Ћ7,F۫3]'~ }N*][.juB^CCuWgOw\k^{GW=oCKC^o7jk9_NO7q}e?w{]=]0I'Eu\h ́|}r4 1v!3liqwtVqn.xifQw괪7N}NUo ;ac(5M-ލjf}}~mv׽bsM/zA0m`Nksξe)IԎlE1ݦt=ڇʫm+CbGyͲ>.˙=sg.rhTűOTM:_nAEM=J+3Ze u݆jCRc A*+ZtE7by2e\y~B˝E4Ӭ_ĻF,/3' Wbsch$N0WmMa9ojk@қ˫7JR; \˓aQͫi{@){nY;#|~7S%AmtzuZ EnRˮHw褽ZX] 'j<`zU* wъ󛰰 q2wq3Q 5JXH\Ҵ5էۨJT{¾NjҘ:m\wl7Yg;]~'Z3QQwوӎR6]6V-]e@&^mϭQΏv )M|99w(TeՒA)Uݕ\jI*9ˋic(hfuӧfZԑ,U>{7umѯ]̉^4ڮ0gΪi$lHm=W̉+lՒ8)*zƧsGVwWrrÔS[Ftv*mEUL5Ww$=L-_՗s&sR`8bi䦝\͍2|2RHh+'"E ?rw=̮cDa:=a2{"-@UnVr `QZFXu󪚺Y!5]e6}iJ 0M6Y{K_uXtR8eo-:EGC>1/49!k܃Eк~Q>,z57W.=k9k]ֹ!dMjr^,EВ*ER!WX]F%_?PH+BnG' mq/\z"*'?^\wìVWX H/ n)j`y<98)eJKs_5I!s `J.b夆1T8>VV;N+}"pFsq̋ō{^:jDBIdM9âoS'[kd#5LKvU3նq')V#3~J HyRTժ&g ^sչp%L9ַ>X>dKWI;)ڒ’:!U1_;^}Uk[ѻ׃cu[d3jUOQC4NOUc\y⸔k>*lF̬818arnfEWNHL#j7D6ߤF^`'~iR zt0C>b4(oXx Mc)+ndo %|:iNպ0cb^n$]m$%bGƐ?#~G܍`=¯lBR/]T|k[q!txq7<}1`.sWZ-kO'0bAKE]+ͦoYt4>Zޝonp'0.,0 qt Ei*Ь!ls RptG |u0cuaUsupi,`h}&%Ts8/ krmJ1rr`|;_LFJ: JhN.4=qQaBmo ;g{NPɵ0DŽCRj m ~i/vBg<6ED6M''AqUd^ɏY:%yrn˝DٶcrZ.J֙Ïn|C`52eeFܬQ} $fFQf?w3{Ey7}lyץd]. LťU؞.pYgl_F3H~g̸3,dt~CdI?OM'm) ?Kbm!#p߀8Y ΅ h*+?bUW8^ղK33HG湎F E3L>c/9R,L\ @|P4TJ&U@Ur(A'yd&UD.[(F!&4Z]o夅]HMBѡMU5UipSp{ CS[ %E#!G@ IS@ b p 6dkSxi;fk2͡t R.4CCΑ)ìw]W~e[qC؎QW&N|M*b"38 HIA;@*@jO@:k~yuIp-) ^f:[v;ּ-Bqv4L!ywn6~߅5+Wޕ@.` 4 oӥ_L[B.Ů?}C^V6 Ώ)z\ !/>B;,쾺nq/~ɚ__{O&Hr&/+Kg?IWFeyoiՕ!ܢ(hyje9عg|Z{m> _K%(i$Dm!{Gc!uokud.Q8oK\S*'jO:>]Q۹᮵q^?@fMe1NU2fjeEs&lO?C$ Bntbw f% :|*k<V.[[h_\t!wRQtɹ|~:HzÐ2 [F=/%Dx3VmJE k|3N[rqu[!5S4TPidx[ }#:wXxF/dOA[A1{w!!hbgw8k?< +Dgan2 A^utͻ /#POa.aӭԴ"k`޴gR3.U+:6PJcԬfP9~9}6./:L34<T3YgWH1^jS^p0MZ9w#ٺp?6tk*6h8 ׷uqEѩ6p`PuU,9C;ѫ:+kKO+kCuW^nٮ|q3r~npsgjȴKoi&YuV}XoYuN'?7՛3{V <RQ(U#C.aŞ6mQ|dg߻7q \?Y _N>,KBQ_Gϗq_~vRե{V 9ZU +)޻rFE϶k rh 1f֌)BNydb]:>|rF Zw(&&B_1z^qstkkh..-t@URY6aڜo.JhUrdn د'Fx:0 ʒW]WAV[{6uhljilj첥ױEDžP4~nhRF{e^F\)o22^Ң9^8|F_[";{Af jrcTϧ1Irhtpz1d R?@[yi0H%5&ha C|k +IxIxNTm({m2~1ƵdH%MPQН&Âl l2m]r[Q|!,7Lp꾯A4Xe=GVEz)dGAՄ[i1KdSA7]*WђP+Y.O ˜1w¸Q>&~ǓP&MI5ø%vB0p~w mI¬^|>8f_3)_|N9ZI_^J 7z_'JOYGNeG >+JLN9J &Tfrb6S6|8[O/Q**ՂuxgU|mh>h@)BU+BZ>it[V 7fS_c;~&WaU}rTD<@\*@< # ^RW 4г bI`f=,.5`alOJG<o6QiLe9_+1󕴲Zzrsf n*>jkk,/ի?ovOԂO⭣s'?ߜOWJ?鯸$VTdbz}Տ7c߮v,|.P3ޗS,Oۥ#;uC7UG5hv"k}%Wr|b̊?ORǏ^UN ~:(5t[g?9tagv?´r־I`sк>Z*6tyd%AEU4z7]vj8v O6Y&*g?<-_L-ݏxYqk%}?4ѹ4sxdgāxk^1r9w?|0?RM瞙Cݹ"J8(ٶ}ZSp ncyХgrT}U@zzpTsɳ D:zc;u}vWɶ[(7hfnJN>>w3s_^zp_i6&(Ѵi6ck-gڛv;ttⷚӗw: cgv7ۢa[)]-x]YO |P UY_ްQ?ѐ~-kK9Mm y:օe*Ү!#,lKck &e+w^ڴj<~PT:Zs;G9ofg!`ۭKQPpO>l:  υ7atp;)3ږBjje}[FM/NWX8N˔"rƄ ~9okŸKf66a$eFݭy9-7S'T=]k/9ٞbigoȩU>鮹t߬;cgu&F3X|\ӷZ^XkVN@tyJM^Y- zXH*P៩_=_f&;"*uw(mi[*]4K1v@6ݙˊTM킦:H;AFjYۖ^g7!JJ[T&BSR1B9qA%Bƕmś`Sx]cl{+okNv N7w˗'G*ANNY00TVj^sRR FZ2,l%m#*-/,¢.nf$n,OZK/VO;568◩=wq^mBJ~fPL]M}3Uzg\Iu[29uY1>K>U>Y?Mn^m=}t%[)j9&Ɗ\@gioq{"Q59S9K0<ܭ8qMQ Y0ܛXz7PkcQF]0kwZ~X/[e1lP]:TukSY`:ߔ^YƓ8mz)wfףǟƒ-96J- =I_t p5:'axd8:GjtUǗ'WoԹ|=)$PrSJdٷIl"S!YL:7TE'cݝ4w{RoJVl.ii{}߰CbS~3f&d=Syc8;Y<%-fzsɺMIQx7#Fs;H?BTcǔ̪pBpX00/Dn?QZy[t%f3$bU1ڮ>zpvGLk{„3S||hdb(&ؙP|!qWI>)kcoxXkRӞϴTaA8jl׌'%Ѫbt\qcvKq;ph3TJ.f=FGWbjdLsy: M HUĜ l :]vX tq;[/oFeO,4ԂVd;+frpzG)y9w(恎4Z@gDWD,}c"98,,VX@:p<&LXk/%[3f+>;1];ҩaݤyGv|lZ?co19@T\<⁚F Eh]@v1i|y8s\M(娴Ww R78蔊3 r&BnAs:Zd[ rN9lv̇>rf8kq@օUgwRYNg5V&2 ң^GQ[ld( E~E\8jəqT*TlܞqiS[_1;d<6d$ϓAeR3O*)ttCV A9@RƤ:TAsL ;^`$#&x& 'ʠxq?a&ߵ[csϟ G2-^]<_(j<Ȼ ?MFBK(m@l7c5!N..dm .{}S^vnk'Dj t1ڃ:hmعd-elk[s=T?huA,3m [|H2qߓGZ*AXH b ӹ Ao Hp O<1 +I:X~4Ҍ)KibO؊hAZEkGF dn*UWg7@Alzs<`@Y* {Y!>T2LPmj-Ӏu@߳䣌cɮOEP^MV@] %P2hn2*OAv4G U5> Ӆ @t0sU0-C 1`0y00 bKj .[ًٌ7"ߟSX[etЦC͐3`b|qN\aQ n:`#kzΩ<7&p;wpexa\ܱ(FaC7iKu婗t " OA5n&+62!w9&QX0^azbNqV (+-.ip|r e ^IMSB#~'~M_W|~S{i+\z@@ 6i*\NOBo5o@T_WPp!σ}T>Cu~۝վ8H7XvZ]M |cz9@faZGoBeEhSﺸ71\ô~Y1֥z9t[<a;"7lS}u#oVWԜ9-_\k{!UxR6:{3z<5^j4a'.N"͸Q,GqA~u89HMI-UZ?>{ @uj<@#+sТ' :+FFXZEb?<5=֠AGP )4#kM_s ZT轂<7h_ԟ?jd4SOԸ!~a 1C{fʏUv$}h7zfsvKoT ސ{ﷸJ;sϗnz]?Qoq7g\ABkʨaRWtÇWxBJ/T؝k7:w+>u*UױkB喝v݌41MMx~.~_\+i qMѼhqt`jWНkz@7Ah}TPau 3}e{3CЄW\\I-\߯wrZNsҨ^A٦|ĦG4qBՙt,N{F]I)UlF7if-(/N}jaݹ:Nt5bo6zszW;b!_ "!6*`VN9{|4[;uk):MT֨´Y]ɱJ<lNhTL^kcS_`s%aʺ]ujnw ˥8*d(mY6j.hV3clȨ'SCIoI_sW3(0׭튶]/z҇ܩzqs}f˯U`ɰ.0j,Vj{Z(s56p_vѩּ+Iil45n.J1̖k,[ zKi65:ujcTNK/)e魌LsCI<{*D-ɑ*?.0!r]b d Sdq3]7eMoWGa9|S${1ݱ E'`D|0L#ܠ ێUta+`xvzwOOl@U O1Y+ K1;oDi@9=Gq)Hy=;3C ;Y p>.}rYtlѢA %tPUZ#F=B`f)C,N*V1©糗f/k<ڎmc"e瀦4?2&G$>;Xpku-C##G)tAlz 4[.ɵȡ2ߟgFnRMF2^Mwqod+#̦sRޑz9]Z 4yIC~{HڸȞk qkՔ%Ha-ǵ`oCk(U]^?dD8C0Hg|l} Χ{Z>~ !. Eule#8X^ք1T$Fx-Yz;h > uwx<+: DmwoDlb H667t? ڱ4:.s;_3w pcZ<5cLƀS"w.w2w(ouېstIkM TMZO->6TnyEGggLb; O$#ePۊ@:&U  ǘQ@% P+PԌ*ZQH~9wy{Q>~e-~\i/sh߈&>#>{_Oos4.sZ_ r49T gI H $H $1Io՛"Hpt{ {k>~{sGZy[ Vu~z$!AMލs}m}Rѿ)USYc$?ޤY0<=gT}wsfnK6%0/\C].a^"E^A9jOÓ.7kw8*: \&&6DMM2]-&oSPߑ:߰?D284l{:f>Σ<c;B d'8p(vmvep!v: "=w >wsL]# [+vWArvd]&2P_XYխfVZ-]|q3L .6 i-hN(>Ly|C㯆:]?U~YvÌ \2[--Z-uNTs0ty¬i{쒴3:;\{$a 榃g޹?6Nx$=d ~۞OݰeG5[/m(MZkiwњ7e$C܎u4C~cc o8CfT2 BKnU0p_EWȳw 0OMt"Q< WN+Jt%驵8>[*2S*e4e:i%̺{iݕVpBB[Uh|N.|泻X.vVVVչRx Q^ej!ٻQ [jozN+le;ѡ\G{d'M/pŽ^qA/YwZ-| rB~x󋼑T}:?_nI{x)ڝKc͹ib>5UmXLD|30XyDv79(&_ytNNDcthF2+SU-\nEfKLD bS}\o|~x@\;Wmb2] n^F\Agg@l@K/ߨV1PZÁ(᤬GjDcKqwQ=>n8+t#Y GËt Ŭֶ1McmR-ճsQPzThiZkvT G2 {fBisNSj;{=WS^A3+`7h[fTY=AGq`LHok~AUYsZjXfJΪ.L˾raQJN|M]j`:.o+ 8{,8El}A>,3dCm1. hs1wnBݹNl#h:EIt jʸfO16d6XVۺ# {By˼sfkF gG|{>iǦi0 U*8K6=r&rXaT 1K=b"5$iI1@ }ay [kUgF̌p1AmH_wrRsi8jb`N_čKw У5%uE2=gwr(RdH*21qe|^x`M%l hfeu 龸W&[ xծU$J]D';MTj-N.WWtX*YmA'sݐL91R[-}|txyBq XWu3lP4<(,A^GI~ k]߽-V>(o0 lVaY;MN6^e̱7y({cLGJ6nWA@wQYNy9=PER)iqY P3V]O{w[jcN<%rr\_&Mg LhwW -mLYvBz ]6Z=@L- {uccb^bAя|b/[юOPa_YAY6I={?%\S^e3WdW_ ؀5 9%f=FۍȭbxSy.5b|&܌n gt=a ܰAE|o}׍V%R {\5lXP!pbIPExizdQ:gR=)ݍ bT'1eI[@x4ʀz:{@my3Y{Q^GBiơ)WyK|mo {L7ga1|%~W1  0AXYI6`'Fx1q'Feq:5i{2"m浘_~hBl+2L>Ӆ# @>',7ɫo3~@gJ p%<nWK+x3x2Nmşq^/fUCfJ?A;ÛKxI=aLtE2}Τ[ xּԺ>= ; 7X {\.R7l""d1j跐:0Mt ,0X%ݛG^o $d,__[#b#7-qYׁܔ M 1 H- @Fn bƋXz>V"MiGDn9c,R{N$DL$1~W}Z ]\7@AF(lQFՁbAO(o3J{d h{i-|BoDR{ '"\e?I~#&~ZXKo7'!GL:6@C{bmnpk.^(Ac<@g) f ?!ILoxC}S:O(BRdlX1#V:?.Fۛuz^noWdԾ8XV7K7n&˕Hd$71 'UΈSiG~;ͭYs %}%; v&}a&>Ȁ[\N|jtwb?=gy?? _q.Hs_.DCݟbYBrnҸ99g7Saԩ>݌;ħփ[rxo4+]DVYL|v_bd>y)ʉțd4@1Kko֣8 8k2GNݰ@]rƫU\fu-8z':1iA{}..jRomT$*_,<7|72AdAe:|\Uڰ>Gk"T˃{ }9K7.Y:%datmGg#,NXƞ:6כf6zKR!7}w2=/P(sҪ5.JV!m3˺7N`yQi9_zQ_Բ1V-_͜{a XL=}xϧ߾{(DÔw;;Tk Y4U=TJ}}cUM㯊eQNd@Vy 0H+#P_PQZ-GJٟ2R;FT\ ל)NoR\,ǡQ׺5W`0[zj;,WY>g*mzHJإ2k HݯY8Lj-QۂA_faG{mwEZSw}(L+ƖYй]{KrYZνT z34WoQl&q9Dfs_w>Ks4f7SFέ猔bk 誡VjJ%W "{M^+tԛM;>ktĭW%p ͥjpW*/.l@tm|wski~bH5[}ʧz#>EAhle4tަT[H*qNm( e[O7kHnO#U_1_LwtZuA8Yɒ"ٽfm{eFL ֖>c}l=z65ƺFJD(sT6 ^|㖑,헭JԒJm " >M4DDw$]~7땛5R~Z&?D`Eಮ,2UZ2tlY,Ffxmߓy8茿h$4XԖ%|[@uƓ|< _DRJGPI3g85YP"+M^Ȼ_EsN{&9 i֞m`Zj)e"?NzUp C=9BSia{Rjɂ|fp=R_Tgn1K&Ʉg&Uđǖ*!Tp>* [vmm?&}8~ڀ}W+V]?tk KA|~ Dݪ9SF&\Μ?Y"|'Pl^vK'D3m&$UnvÃ~ݔNCZ-3Mʰu , "6X/t19 re]HODGhv!=Z(Yx[c=Cݥjfx:*>UB FևdkkQ+fSIݽ**qߝVL9I7f"cSzz6*Xm0 i^|%s1nVZ1I~"._'^oAܠ|F H9}凔9,LmU` `:րXлۻU9ޛ_@%Rhiͣ˹oޚ0J,3+4V2C7SNe28/bO8ƈ8!c%gS5 iY`]Ğwk[ }h(y>IijERW|JX?VJj$(CeMӽ()ʥP7A_aR?91F X: Zc9_-__GtzƳգwQu9 eAm 0|/6[krA@aO.G`hEٴHYUb_iX bc1& esHz$b;8o@34 BmEd` Q9h`ř*3fA!˙cCr! >\T5mTbL@u^k YlcjP-j@`(Z*)17?̯Qq.u2d!EC闑%l2l{u0hQv1?`(q1q*&cƀ宀,wwЋeCh<=\+~iq;Fg%ɗGkQf \c_1>g:r<@aXc `giƸ;`?Qy ⏽^laHM~20jQ̿~ؽdjL3O! FxwdO$:0z^ S/C(:TBcl_Vf21n#';s %P%w8fõ^eTPQaaBM_XNրhdU =+ʍ1d= @m ^+ n7 FxGj *K"&,qƆw[P9 @~WXS>4R,_Y* cKnA9|+'>ּۨ>|9)B-;Z{^ B<ͬg&V̇j*G݇b:M;Q[v.G 0>Om_o-_R w3gZd7gGlŎSaFuFM*Y 4uRɦUߠ5Wq풫>:)t\ix ˭f Mdm c5#=ԙQs7"sަ^ ЩE`iy椷eKKo5kja x k-: ;7/Zy:ۗwVzkʴpآ_?m*8_*F:zXc+X.jw_Xiقve3Y$rhF0yL2:@rA Tx]s*S! %X^wҊH{03ߍ g&2AaZ(+UĬI^àz.ȟ''HX9rs >LMJT|Z9X*~1; VHW] a< 6kj_wbmX/u]rm%k#r-;sh4{f N;6W?aMAţnNB.,G3Q0Z1HTO,:{:L:Fw9ym`OVke>Ղw ^{3rl)OnEOVn0ԪU,C+I%r'aIb癕:OŒ}O|x՝]6y=,qI u|Y~qkvc[I*㌷M'\N)y 佋[ r7Z)GbhC$1smua#aw~:.l{RwvYreKS;ݖ uA"F2;xnс=,Q&oR.oS~@jxݷ)b8"MRbkOWk;|wZwrޡݗp~\ӦVA m>qb5̞VC-pZQS +pZ'$bÛ$rA"R/ol!Îy ]>j%.%:jK%n" =wNo498Ū=j0es0g5+7H.[xR19;B]d-aH "w-DjD%<}D“|ʵ'zؠC?P32L~">ぴ8y9E)5 eΝⱚ$p!մ z"5A,vkXICB]vgU6,I@−. œ1̠x4q5e^W̠F q<ׇ>^+k*-TZƱrZIuBwMGl\YI z7,{W[F@@N>$g ;"l,rث9m,D7i,r}zI )QV=r9d̜'d] }[Xf#ܥ9^rZ0uJm'rΫ#&Ρ!G!nN;܄q2(ýB<:ip@=ry9R,ϧǶFnPJT4@@vb`äb|FSGغ?:AYUe |Xf༘i)<ݥ8G'_ 4"r6jjf*=j`/Ρ] t1.;c wd c$o>lQw}Ț7.W]<9f~%&z)Qjxe) "=}d65 E`^1QcLFA0Z$ʭ8į:]`]-ޠYV?i'3rud<2te};l_š轿ncJ\m~ B"1j 6x?6p{p /_ p6 \bɝ ZpX؎en瑕[R}-Hs ~>vmi뵴N)B ΆI3D,bU1A1ASn "H11zE@[V!w: l]KU?cL}E`<9Ŭ1]ei].D]Z^ *Sb L)FގplX1 _Zc~@4y #@nw@NmA|0M?R@5ɑ#1-$ϳ!hsA0JE{#*OT @ۨ#(b4'1+@cЅ#bɈiZoZS⏱L@I$UhVlmk-!=PAC-IO.& d=`voѹk`v t%cw2r&L,Xl=`>Y -&`Jy6Zy9|v t{+ULD~\ljٸr1x\c4ހ;.Ӏ{#ѮxN<8C{j G$9_EU,p~L,<|*5c*&&$rPj^e7H4_L|= f 7K@N. DYр 2 ,O,9<}7 s5$TRlc$p;֥¶7)=cE":կX% '_71B+o9~ ͔ի,,|7M xc p>S@zM\`<瓲n& +8{IDDM~-ݦPrbWC <@#q/ha&X{P[, oHo˛WD=H7vo4mɢ:r5վ*H);fP.N.37w*}{?N@W}h;JTpZ :eTDM1v YӭLnrYnXNd+UHvu<;0UVYE%jA ,aY\v3QJ=Jsh<)([ 剗w1fڔ7m#K6@̳ѻ}ny *2sLsV&ʩN}ݍ0A[ՙKYsŶ}{wxVթbhk\ʳVSF.֫bB ]F֤3ѡGibh1NT>7Yz< jΊ]=jvOCuۖ^T rN20,y+](9GQEPs9g=ݫ{k-@ĪH7 :qWt9r 2gw0"[BZyecgUj%Xtru\s}sPKDv^ۺB Wj{Aa8ϣ%N |}r#vyl*YKmYnY3IKzX4t}.caN:L hRk\S-Aќ*?YZ6wַEEB)uqfbn^ 3rzc*W3Nb\\YJW[UjT /f#O¾,בֿxҒӨP)s>XbI, iB3eP.>3siԒc?ůWYSaݝ(7o[l4:F jnCf5h$-A;Q^\s։70F~飣aJj_.=i6q__ [s!s m~M6h}Z*P#?ՅzO,:h(Aǯ>m ~20xW0kwv?N^52J8 F Z7[}S{aL(Fߟ5V64G6?>\@uWL)Ko)ov[ CiT/J`NNR?U /,UZ&ZF d sHÜDs.ӈzOvihCkBTbX`$&@IGwzȤio71,XI#l8y[էQ|X%B*X뱆`fl#sAΪ ](oʛ6ჲfƓꇄEpMwfd_T2N~ml=̰R|XCCB+} Mv *Z{ڒh6jJi/E9vʓVn/PX&7=@e1]gرN ߹+ j ft`~V)*t3R'R'4"_|mps(!:D?hjԮ!U R<sͷI/V?C8U#.=̶^8߱2~`}Ъ"斓Ӥo!u!cL&Ý`N{BOӡ~_I},M8Q]FgOt6B bS 劭)U8khܭ^35EaًPhMEQ Ѓq#gy@QZ'Q ܜ K)X Jajw)WS`O$b|qG8.vп2^H/"F.gm|% #>mxLgipfTp{<}A/z=OTjxEwݒ 0yΧ^`>ble2J60.xvǵVOO>\f-{iY|&Kq\}htK8$aD!(\S)Ζc2[81:#x.DEQOthN51CB)]F>èdJd'+B1ٺV'pNoVwFG0I(k 1q_4 I}Uc<8 A9>+8m_1x@t!G@D{eo ZgG]v+ ,Fyv4^lEeUA[Ɂg3[ecT^ab2ßĸ0 Wȷ;Nt37Q9FlO@uMF}f5j[4-|n S.4bKY@VfDN7oz1-ct_Ũjg hd7ٍj@qxv̦֯ ToiԼ7Ek!@+U42`S_H~ k+^ڌ1N&`l/F~(0; f:3r1>-f?4`|lo'ǥ~Ux/jx)ޫ $ /5q4Hj9Z} Uf#BLv!72n-R<򷕢^aG^| v~;2x-~_k܎@2!_!1&& '׹f qT;*՞dy> ?*V0Br+ OP9_/K [_&?K?/%$]86:"Zd? vRVV@ BH~w!}ef|yD[x'bۍ'b"Q$ '%-l`_e{@ ,tx?IbUaUԀ*VmI"6Xg 떸_OZV"&#KMU[#Ci=%g/bLQelyLOrߡ6ppJrkI/[/\q:uԀ֎:=+ 7&H$ |dȼ,\CO(s+•{s~]CFJj(?Hl>W۹r9lVǚ/>=as1-neJ_|aL[N2&(&vO5k(OYyxih9rFgvM*me cѦyk1{aY gP7O'MӚ#?c6[{v$ !fU'Ogs咵41N 48x/}~l"w}]9S3F窝*.ZzuҶZ`Jܹ ~[6(E*佞ukaqOʛi="דY|n;V[,ϥ7ʆ>P3W =cPAga9`Qp~CΊmzęRM4h5]Js=aJ9sB=O3ǽcGY=ONVLۧsf^X-FUԞKP<*FÖ :JНzݮqS.*S6|RJ9=3 OeU';SжbI H=LjM5rG}XL:nۖG^m*Jmm3tYa1}K 6S˛Ul.c܊`[vh˶n5cbUW*m 4J(* Uvq,МBZdt 1HsΚ~!(޻,lWB'KYq9 ҬW!^ZĚ'8<^i=P'Nv/ U|vT[SYիFQm|@Rz.2EiRA@Qɽ׆+oGFa[Z:;qҿM&oYLO ^>F/z37 is6>BCxCCNʤѻm%VOVm'Ţkm?P[F+#Z#62$A!,]3' ljmVaѯ{ 1 3DM 2k rxMm:*U)V1Vv/`*l)E~Wwkܳkl5z찠L]bUtܰht/澗.5$#DLN%opSd`m@f[5Vov nQ' >翵s L\Ը֕37KhPLVhprѝ6[cEs=FjRFdPXrk<@WYMj*?ROb7fWzduԡ"]XVѩK),nzi̵KVCK|\D8;11+䄜6%\x+I=: ZNCm@So/WNe.^* 7T 0iQ/k6l3ya&r6*k@xFnms tZ_ W9{sP5]Qjt]j\|']U 5@)3,n4Gb猣$V aB2]gvRL 0eI(k#~A㷺SQQM 009F`a#0$ƀOܿޮhzego3SX'f3+ dS|B38{]RUo7uF;~}M'y/  vXv㍽䲙cY6C’/{= `ayy_O;hsƆcDot?}کQ3qWӤa_?B?FDCMhI򜻖=ΠX 97f0e)xwOS75EsgJkܖ^&wF$yzCMk})ΊzΣT K"dU 3݄wiCq)K}@BAnk-zY4_kDdjn!WG[Yms F,@{+ʻR~]_{SJZxyGT{ψNu~K\[4~²"UHҶRޗS77o)zG% JhjI)CzC?qvdo![B j᙭Jmh$:xEghMߒ qwYF= Lvī,-,x)w؝KkgU[:=維==o;6q#6ro6sJ JA_wҶįeIaۼ%0:!Ǒ7og~Y.@Pcb;w 6pywsv󐋲*53<2z8,:L6 'sR+?Lچ OSQu&kV7FAa7DXh`(71̯|S&H4HxAl{OV#3P*īs~ҟLCe(ggx"08污WW B^9/ݻD`I6 /=PG5fyvj1^y4ts)WQ:pW(vk64|+{1 yRfOܔcBfXQR=F5;h5PhPU}w$3!3go3QnZeR]̥vM~TtR6zkǭ F̱25/Gn,AkQNILs#D<V^'Z)|x~Tqfؒ7 WBKMs,3+~üzpnyc7?|i^QZ=4FN0k\%4#W2>4:[yGyD_S8ʼn*HȚx+xm|cvB7OTDe ^24TR#'7fO)/Wp{v>>(=}wfU\%*j7;&3K4)>uxp|vG0_r( t3F#C/ZnHΰ\qzp}օ3ZQ_8f*V0]_qȉ)>G_PPzyӲL%`siC˜@B[v>]OC&Ye7hQ?ؿQN#pDۈ !|#2wV>RPԳN!HP*tFnY^^-K}B18ReZrq(/SoL2Ӕir7!>=Awg[(q7~i]Ubnk Zu/u00NSK1Ln:ʕ. ,_o2x3_m`ߡ)+X;'[Ȩϑ&5M)P>op37nݚWU-Wk[jYs\N5\ 2H?uitY'ɞ5އۓ 6?(ǩ~Lex; ל3}Z/ԍB(t8 othڻʙ-bNlyN>F2ȼz5PޑsL+8^c5G/Zoln.wCKxcB mcW|mc.1Pr4bܧuwP-8M*^QJBHXUBX;<Tmrޠ{ٯkHay39] iG=p-`B0|pqze6iz1-Q2 0h( .'ч3CERVt'v_㋞wY/muSJ/Q_c5FHC7ʟ; 'B<d7#%c<$=32^R/jL[j[Ou> k ^{럨 mu~%dm )F |6`$d &F 5:O@H-6_AJq1zMŘO~pY ߔ(H5ɞ4[?>MmJL,ōxٱӧp~6qz}@  ]absd B&^}9+Xї8/X@lp7kНv+uˇYb;ܚ(T[Ǽ\X^tv4}+H_:>Z1hPG 0P6İL@W~v>Ƴ (6;!@o Pآwcȳ-Wp9^3AK| לdF|fP꣆ ט|2?Ǐ|=G]@h/FtIRFZ:E^ 9eh$p?ẓe8mz] z'{רYPѬ6O򡿯2%K$?1 `UVk`y4€"QN絛XS+/KUP$C*/qgUFLH !'gm j!mx@<8hp\m&KH竪k= r-l/7IWOW \8#@4+O9 m zEbV,@iv7;wsv˶oNzK@g?2s0'N$?q5X"w_e@|/8f W@>;/PR(Ph$7/X2+%Z!'{Mv3XOw7~r7.[mΞYY$>ЮyސTy|t\V+].p^Ι椌cݞOo{;~R{zw$"{fmZ}(bhE+CHo ߚHw/AWܡBf?f o*o?Keymz͟9OP>}8!sL |Cmx(gx,>~ʙOJZ $.СiHrl\ }S]5ga 5<Ev(n8ah/\B-TOۙاE{^mb5['n3m#~]tuG{ocԹ+rG!"Cq[z.q/t\[lk S^o͐Y 5"M J:Y%/Q:3 Uhˢ᏾P=M\?T +R"WC}Fх:=Ϯʝ ͐ꩍk )kfO$GGtXҟFTڍfF$Veozd]^Si[;J܍'5Yy K{ϕl'%;${:t0t9`ap~CoOm+nsm< i Bt)@wGwM&ʈmS%ǽve^y"ղ*3d=Ht>1&?H҃ ju&sorki>mên^|9 üYλ4a_uimN{GcG?EaY~2l= TS-89&zgfjwF rhjy}ã=9Ms7_5V0+].޺0tiD#mvdYrYW4F-e;LC+2'4ы5p;iUt3?k}~IJ#zr>iɆtc}T-"t[a~IΪCG>̴)cYdMC{53*qUu60hׄyj4b bG~a Zy^ЍyP˙ڭCܧ iưrp Hp_n5/1oitMOp0iXro]l0_ڳ<^;3_3Ѝp*eۉR(C1)q%"`ᓙ5*㮗nMp15ףщ҅ b^wnfajah- Isp͕9w1#7CѪU| o#{әZȋ"B؇*G)ñqyc뺪%VF〲>q9ќ#F 8]`'PHS'GXY34Jtp m1 7~Y ֬(ix%T>1>* <8i]("}Ke-T֣R"ĝdlC^ڧK'K6: "ΚA'.sXfm.ǒ!+%mƎe^] >MlnԱĨln-m^>{!#:s4ZAO=ƆiTcj[VK\rU6gP䱗sayaqA.^? L[U|H5D8)|m7p=K`r!YH 3x˩t1y٪@XDGVheGx@HH0@!Sa# >S!4FU&%íom"DW˔.֔i,0[j[eȾp0]})t0S??ǘA$݄n5z@#~`hIiՊo5` {|F"1}w:FiS"q@aYޭSۧBEml},h\$@MKSj cKR^:B1z,cxxDInn6 8L e9@i~o,0kHSkXyU`hq8,B}} F (SKy }"?b-/"j-v$BݪBJA*~޻>q?uU 窟&RyWH^jF os WGce4 endstream endobj 256 0 obj <>stream a@S@ @6Y [d{#˪16y3x"0L |*P[+KX8'0ÓafkmJNc W Qoz Г/-5@u(=h#Ƙm ~E-{64<Mw.@eE5urs_6p/5k?d ׳]@dl-jm`rKg7! `xxzX(17 IzZSG#cVfSs u~"/?k}`Q@x1jז븮(~ǘPrr99-zU/)ɶtciմ(<(1DMo"ڔc~O@.;\Dfj&DMT;9ǙCqr8&Ζ&CoSU.`̓sZ. 1C=:߳LE^X]gGt<1DW]0 +o]XCt9Dg 2XKFW O9gq:Ԋ\NhiksY2lKF&ybe'Zp#>ҮӦI4APYzx,b~"a?/p@.kyjvZ땼. 6K1SVVDQ%$ `1g0bG#n#"}1]]C%1YPLCȞ!9 `9vm1qκ\КFٲ"#eИXohWGL<GL<S(r.bj8qSӉ"* &L1ENGNu8*z5/L2;kB2;mQZR1=*%( ",Xz%Bq5yĂjCX~v؃s&I{a@ssD3 GBə&OvU;TLK7~\f~{ I6ξT[%D.RU-؍L^Bz_Y]få= LHf ;A׍iV5|]zE 0,] XV^x]kI<ص'aYa@.E4[ܜb_JǰY/u4m`6(hflFPEsݤ!"ظw,ri1)>X&6Q>Jv c krXU\GeXl?"vQ'Ohar==ٴ4- 8?FKÎ<]Xtxj`Ezبã^MHZ 4h(kP-馫2!)O{6 n1aQ*fu`JBUXiã-T.N@O1Fb+PаxQAVsmikOK5ORp)晡To`CA\Eo0zö\BUgX˲zEqf~o_4hr]v{M/2.DIh?EV6yL9kRNQbg5Nyu92/~Bcކhģ!,Cp2Ho՝)d\&K> ϒr8r:l L#qD#7x!KD7Bו qPHO΀gb7(I @mZy`&4gvӡ׍UtǞۿh~>9nm/lҡcAL3ﭑ |{M%e@Ug9jC="TBDY2j,[p4Z)Xloq. _VZQ|cJ xPg @}='+hMw ~8:ʠ[-]+xJ~fZ /hhI~-prV~iyuy*1 +#sxAHDh:Ä>2yqZ ]q^EAΨ6VPg^nAZf1(Gllu|j@\IZd&w- ~/oɹFtB.fBMsHz"Q;r\Q^E) Y 1Ξ ĵ9T zI{clt' vu旿qfobhl W*MeK[wڡ,/K4mzB GvXr4`0E~Z#3g7U̠%dV*\[l0jѸ;H`pFXe;0;]5RdzZ% &B!mN% XVP5@h4ۢ`*'k!ed_R^4 *QhFG*'Nx,dڀBh$۶#[$6Gq{[u1jIWp 5[M۠n5)\Xm)Y "ZmP*o'1uHKMNz!3QSoujR8`Ww1wHha$1Gxȍ93i*5Gy5j}좊Bd'_\ +g̻l:W $OA ߘkm<.^-5nM+՝kś! QHKiRG<ڈV Jzr1 lv|T` N.WJύ:>\^(VgH a_нcCvJT*9\ݟ(ʳ͍IUի&A"-+hL!PG˴37?Yg:C&Shiز{pI5re"?oyw;W2}: Oe ۋ gHdX 𼜅2*x *ʺQ c#Us`ʺ[1'Nzl|TR|Sl(t{71"'ʪѮ֜@A%[r/w(=y+ "49{`'<8>Rr&D/+' OStXn鑆՘7PMH奠Tԛ~vè:K|߂Ƶ^E>ot :#ys{J1ɠIq@K;p{\f6 %\rINvP1ztI3C\jǸPwG$,%E3wCº@ Spz=GY-|Ivh9='!G$`r_}HU[ 1hJMSY`ƚVO*k?oN0&<$n9V)GLWrAqnfۡaE ])'^ MgE- '4JR|hkFF>9.,J=`6GJ (w}C#NƮH JSu(RN.7^_,{Sfd}PpJ^Qj(=fO( 5$&!5MO9V-fpJC9hZ\5txH{eݡ{gqDρXP|O*3 h 1hԆ~xw@n~z-tz];x2peogjh &:Y"w$#"$ >0bp#7>")/l!s)fT_9QJDOY#2$ce'tE| /b[ Î%#r\v;JxdٱG ZޞxxE1ɡظ*fC<\E^ѼbQE .#P' cCmzh'־c %QXv(2'`m\,VT.Nm__uUAҿ38{#`xQCP6pqsu7Fq1̷È2.Rq/@aV_wFNJi9&l9|vLLpxW2Il-Td1Twr}D.܂ S,(+m[xg\g~3y?8&Űl׻f%}bH4Oz !Ч6ݱ#hwkQ/ȋN\&*p\ D.3ʑ7fqގSڑ7T :}j);g0ԛONM:;X_4o?$HGʪAjk8=%cnˤ7 آN= s.8c>hytJĹVvH7j 3BEaUki ȴiI0#z>7OцU!FQpg6Y3{`Z6ҫڸw{԰&#jgޢS* wJa'mfju/NQ /v3G3jP\/."%wi6q\)X Fhjݎʖk W]GgA"7ksI;7Si3<"?G9j)$.e,o3ޡ'sge)oym Y)7bZ/oR]^.3,ek@:s2hPZ/0.6NAw99WX%!=p$4 L̚lK*nA)Z#u\ӊ`w_4K4m׵ۿ]"l;|=:,W:Gyq%26 U *!3@WsJ+*ݢqM T:Ȏ B՘g-w]עFʶwKFBҤ<b.Vc;@GFSbd#3[ 3P?Kz*`]l@0cMz[A.\piaho]>̶,,s5CN(k"|?[b~}lCv*9{qxI`7)O8ru/XDִ݃՚RvB*nHl!p~./tBԱmfuA%񩄢 8{X;rBCbW>S`u p7<3Mۛ"A=U9fZ)TaJD@vR}uך]zg}Ϳ nY ,~ Wъ?_z9 {:S'w*l}(eZ!)4; ⪍J6ͯ3P5{#ԖRnQ}E#eg b&{mOL9s8 yl?}p;R_vwHpHFj\ٲwBO\:a2V^k%E5淁!'OM* _{.G`_49d8ZuUj`sgc,d>wI|u|:.){1]l&噝tb~ڪ)X t7mm^cx`[S27)u/S<۝!2h|Sv UVÆw ;$jbfku|>4A=>D2eo*fn雘Ѡ(:O^=CIzZ: }!߬j&n*rү妬"?PRћx;=ly c"؅kFl58];NU @6McjNQ5 GGE.-Q3 pZyFNι9C-*ˍg}|\nؑڞQw3^ִNNNP>.7ULcM#;G,-9ۄ:|S ']R46&҆49,sq#TvgOR=|Lp3F]LxF jצ`_4h]h49+[{̶庱^Дk qtUl`b2Z*rgb#eYHS0U܍kS4b2jOIW0.a=ycMt^5QS8q) )`5H/'Թ륵\B|<)`[wZ}6I ͿMml.oI\ՉA6Xl$}#(Vn{ q9I: 6_su鵥`qs?$FZp5pm҆QNbĚv_`D'a٥\w_4h~Ѡ6llVJ,v޸[2bi!&Nv avZGEzZTg fmWǾ=xaceVpUb9gwuӰq-^nەK.A{/=ٯ jMQY&UCI[azG|60ph0E-l>yx8XmQ/:S >XP"v|{ Qp.>ql:~-?Pm]SѶ2\񳩁OGt󇩂il=>eI7*3U ++ ,RUlu 6E2qɯŨKZ:oӌLLj 9>a*|z8}>߶W8~5=M`v/a HL"Moxn3$Niq;xKq&v{|1ڜ=n[|~z8Rmfio}^-w.tv 0~nnǡ0T1 -nL%F4_&n||іltf _l@M_/UCO>aVp?~r4p9όBĠѸ/ߏ'[\|_FZ)黮On0Z?uG 5,6ՔbLQfÝ*U#l8Y/f~`_pp:":S -8Sp [M_g2ǻBBs^HX@d1 6L[eCuluN+ƆD"J[rWy겗sM76& ߊ`3W{|ٿD-0/m-P)g~/bH*/rc@qNv>b "F&6h*JSD~4{n?#v_~ibՅ+s_@#諂Q|_^"8{? I8h~S >V7`~[ю!".Pu5}G6>Z@GpL]O.ȗtynIMcyyW,UԆw䪪6,m#z}&׮X\9OkybKPΥ]My]Lc0ɖ|@c@UڑchX/oQ&uʃa7f]u"p{%RЩi:*hAҗ1دJ q 1Odi;}07Y} Hhֶ5 8eqԊ P3օ1xŨf]?`*n'~s QlH/!\O#5l)>Q^a0R[NbԬ6W-:(t{Pd^6OhOZͫARw&9K2@Z􍨽Ie7C,̠G̡I'9@nD< (¾Ue7.5YI#}[g;'*&խQ[:+aR I9r0c!xo%fXzSZUwFh>gfKv2Kf6Iy!:'p5DS5y /bɍ~ll湷yn Bɣl}&0.+': ݒU+O^tE ,d:;E4xl2i$Hsup'2,{ރ"8z(X*&O7'!(1U]uʀ:Ruvq BUYꌑlhh`TXT0 GƒPI$ZdI (=D1alatpcbrRe2Gl*Dj%˅_A門xCT =~{̏ȦoQ!cė?՜ b( *7$\V;8l`\QتD0_&;%OD'h8Rr̰[@5DC`S v+; *G>HIM1IW~JOs+y2X3а& A*&Eĸ$@=P'hc7O cjO%CbDxDbFHVF hIJnZ'D:dH 6$hۢ-S0΁:_ۨG*E#k}"ll29dqh4VVkMK>hkAA1hۍ ҨYGEtH n)Ƣ%²6Ms4 +W/S2g݈@đ#fTMRϔMsFR3>\ k!W&ݵ\zbjZb1nʺޒн'x+RL`0Z;MN\4( ?AA8b 6Db(ۂ#`ձ3/5_LR1\ ~c-CFR&Qz"s$?堔B0}ZW=<CaЃ%ۮ9V5iϳmP#y]Z;Y8); ~FH\A_'OUٹ,G5eR+P[[)_RO\_b@&cgJ Fa6:˿vRڛ+gr,&rB <^JDe"2=cHZ鞠HsB{]VB&'Αi}sTjb(WU`}aK.I$4޼֍j*2n jQ#U=.IH؎4&0xz6x-T(Ìua })afj&6Qt7x`э۲z*г%P4f.$~ 3|cH69 :' ōvɶ-3@0f;}_,ژTɇA~{2[l[)=ٟl2"bpC[zX676\5$▹&TdYmjBP39H0TXzUrTѶWb5bٴ XGov"l' DZ[y@l# !7 MђܷJE_BE<^AJjBy=a[c %evqU_*7h`+U{U6&bDqe҇:¦vIlh V â̠cEݵYN<]bΗW2whD@XA'V'aC;yX\itN:udWO#%,}gڠ:}XEȚ𡲧4R~lCie=9|T'9PZEt'siT8u<=h6+Kn.03ޭ.`@([EbYN?wC9!exѮjQZU5i4$Th^Xh+\`C0) c&n%T#ߢ9u'4nNmt.|2Mɝv#,UF3p{Ǯ{ nBWΙ՜&,-Ӛ0u9؛*GixJ ֊r5{ $[f5@`F.:w%={nlڽ  qĮVL="3%$2ҷf#$jO""y/ڀ}UIg A(с*$+".Ȭ*ɂ%&@EMQ;%M!VQwGGV^Wڛ /yL5%֏.h|g?. n)/-3g <{}!jbCYhz NJ8ew׏mK1 ' 0_]>nQ2Q5!$`,kD)@?Hf( %Oߣ0DbBz|oju/j`"iv /"’NW0Mmҷ5{ T9Fg+82$@e'8*4]0f9b4IPT7^U,>͹ԓ6m7ُW[) gt{EN~k5bvUyZ#㤲cBvVCҕ=d6>_V=^/|P&%>Zshmb0fn rލFDV49o5m ߁ĴezɃa{8 ڢnq:D]e|kN qx>z,dY%NġcoϽ$ڵD ZtמBcS1B&d< b*w9}&{ȳowڰyO7}0sm;iw|k|ƘX{~Wǚt ȋu(> sM*bQ,==4O57`u,5cu;ΏծXe׏p͉h6,)WڀO2$WY2/dZ#'qąsAZjڶ֢XCQ kIfau8v+Is ` ‹uUM׏/%^Zi'.LJUzOpn uE~vۜ XeKQY|XsO )Wnֱ9X׸VM QX ȡ=@Q8$X;~se@:V"KgelĮ#sXS!9{8s7>*X>Mk|X5CoY6kC_b.ˣ 47G??֜MYO~nj+qc]-,Ə5~kIX;u1K`>!ӆ ʪڮ]`!4{b mRX'6-KxXG`s9H@+D} X/Uz YcEz㮖m[L/ <"6r5T@ `]9z1%sXCѣ@<67%h(Ċy ojyԪiq7V:T[4AutyFFG1H!89^ϡ1Bݝ ںRH/h]tEpKgx\IJNSԷF;8 Ћ\-43MP :MtFNG4TZ|(;zQAϲ'_ۀ*qA$xkcY#g 5jWNfUVoO0=k\e6.dTQ,ѩqqxkHpZ4># Lz j1 /˃>pg:C ~h@o8NH@q郫XֺN܂-(|t-o5g>f2C%rX~1r4$o߂Eha3~8wZhNa^ϝYu&U m0-RI&ӽ .Sxc,*^-0K26Z,ҧk(#z+TzO†}S4zK1{&K'(1(1w;,ŝwIO'novȊٌ\gw{ ^h93o@R|˻f[%JѬo~d?'I;Mrf3w\rOjTR}&PX_-K= ^Ľ@VO } 'lkdyh=f>-:NíOvSG0x=#&K^b.nFz) z=¦lLLIa ;s:W&1"tȰr]i ί`p4 tK <(vg27J@`Ù2ZeD&e>q[A"H AR@914M2BGl ǻ`iCJgf;4GJI DButo1˲(7޲V|p=\ ,{!'񀃘WTAt/f"@=dTb|%K.5=) &#1#{r[U叵*y+@h)C(J ![̉ϝ—{ݹﰓ+$z!|x16[Vj@5Lc4FdDHs}߈ɣB),`ҨN5i\re)1@ڭn v{^AAh_nOL6?ivF9H@nDШ dY6H}Ujl$bG+Nqn>֛$$Zl1 +TS.@ҸpCH~1) f:_1[̱k@ A(d~VEfo×ۊ D\\φX4V*;=!>6߲_Dn![gϲ}Ay,X@ƭ*Zr9x-O+QGtr>Z?1oa.~OH[||(ov9G|O||-RVR',q{b$V>׹:iE[ '[m$xo;; ˤL˪^})Eiov ŠgyI!|U'y@H'>VEnWG=Ij'/e Wu}+OM1y?,k&6vxm᭽*c&\J$Lw#dٝжˍzUv'ĊVUg\4ń῾R:?fiИ ^zzY)MoyƤx 9p[E*1@/ƍ5nGVD|[MʦWR,FPz7j '/opp͓y,B Ռ {i $3;ϯjr8Xo"X׌јaea>׈ƌ[Db)(frB\?!Xx HYyJ?t _dP' 0^iU{_Yzs7t۽FDcٰJG1S=~c{b~.3I\^>X(0ny/scFY#- y*8^ݏ"j mB&% %J-&Rb /Hw"YGdad!)tys A> %Lyl}[YEy::\(㟛/FI4qEyxYR~_(Ib#])-V}Lr=N_MR ( 3&|>߻ ww&JNo y]u. ^+!U)*=!52m|v'< Q aU D-j׭c9I9kX8Ţ,vzO-T|YWFQTTW]L,MyzКձ)P+hʏD& VvEWew2U&ӈY,vB~Nbo~ZM*/gBX[XnRŠ7 [ѥNEWb;_;*hj$kKd;j19yLÍ &n[+s4n{j4]3Ea;ND[֫?_-. $AJx;?VϋLPE>}c}|U}UIz/Jwqo<,՟)#p7 }ZE ~oRY/ Z?[ѾSa¾>J+>>m3OQ\ܟ({C}֛(#T¾e?SWǟ2iD[;sJN3,7Jr3o?߸ŏӧ+MӷHuի"ՓN̄KX$ڐ҈?BbIRxFt>Dzj>f+RS-?e2, e)<7‹˚߾.W.T6:DZo ʤIʹx{lHfG4%AHHo_wl*I~:f.[n7jqtɃ hr6e+.}K铒A Ѽw \ш$7 $,d1j]S6˘V^KN^IN$&gRg 1Z9I =qH@-`~]iO@T+xڷVV?x!#>+E3Aj?7tgS,k[Eܔmq](kl~0qT&RB ^wk[{ nmcPDG{7 .ˈXճ/K(,y!BZ{ ]yx~?aSF''gy\ }y6JJuW#v/.{$K_;=AWr/yQ}{/Vr 1ߍ6b@Q +0louwN ! 3@'TwWWUWҕ\>`\*?TgiR٧j5j++}toV{IJ~9gϻ@TILc+T!ph }kn~$ĸ_7A*顧~VqPmVMwlȼ[QЎty0&^y-F#CC'Dw/٣zYKFOsjX{ZlD"LٜO|KW[ft3YiӖ-{iC%3/8l H nz<zr|tf-N7]{Mv7t)׈Pf!\^9rD~Ӫ(:.SEB7J|3ιKtZ~,伶Gkb~Q\ OR=^zUX@fӊq¬[S5=iȾ<3:*;l>4}l|י\ܘ5:&L*ɯZ\5Tal^VƯݩ|fcU7@E+-%xGUYZn˘b7ŀA7^r/a`p򸗶{+ *Ƅ;iC{/>W~&V.63<ҾDϕU؍XsޅJ[C}$HۣTqJ⪷..⚷T3ߝŞfEYTx6;Iws&14qz-/gGu ]-0h."a+).vם1XB|*ygSEezib̿ռlKUuۘ MoAGni#/qY%kH9{B)Q:Nh %R~=9v44UP05GhsY̋Fi!S#.˿Y++$GJ|v )~`P)~;tJ^c'§†n7OFhBu<2#';FMz)^uT$)wxs7'Fhc\RNc)S?eHm׼1BLxdvD\WS6/-9g;fuf2bC[3U։:@R^mHGn1d'?_ދ ?zm=2UctNpdz}=b8oKTe&"n&,WwhvyzkvsT`'>8g^ĺfd%E|fGv#6:b>8mg*7wu |T,9ɭ1>Zq4S"YI$9N_5ƹ {nlkch)1/l"eٍY5Y?<䳉?Hؽ| O{nzЉvY)g=}1a.~i pk-c\g|վ8j|ʡLO4U & y#"G[nBb/.֫RB.8|6_g׫UͰ'W^gS!l^.W9jR@#M d EحM#e ہB7(<>4^S:=?qUL'փ`ySBjxZ'뻈o!vP~=OMA/gTdC&0Ai#Co۳KKΤ{FivL2K6VB{6 ,~0oߍaʒ._d68+þ'"n M W~OλwyY ?^.npkm BTW~P;o:eEIJ7ϕX.:Orl9ڸlj:z؋.cWFC;U܄I,@DFx| X0r4[Es ]WOa^F  B +ҎIHyVxwS{v5Ѣp8#Q`AM~HX8EcK OUy@@o꥗Az.¶B~56>75*,Y]޾Iizv:P)ہ)<5_)z uFkSV) W0#V:X_^N7( #^qȔS? I%Zj+nnk\o<Ӵ*dZݰ`p5u;?0'm P:Z\4v8_΢ql7 i{81=1zB-]"4KU%ȑνy$,-]Mw lW03raRMT൙P1aUzhp>1ґVL![Ĭm2(:frX'£<2:ʓ+4fg&dhUOa,vKU,<{`8} qSU#/AjԈ˂1 Xҫ?.<a$֢ؐ$ n"GS>\#geS+K)bz<u+w/G#-O~(›yM'Lnĭ4u<6 Yڤ$${Ƴ*C#cbwgKVdXMN;Gi8h;D^7iljp!gQ=WL5cwܓHMpPexۭZ3udFk YixhCg'ff ,mPmU5-yJ*Ȳ3X3I,0&& Ğ?_2C MVrݾ`GH+ͭDS:(|傎3 U@.{^}4 SGꅦ֪D|ۍ\պͽlYFԠH;4uϰ^nթ{a >=n}C֫1a; s8tޱ٢.z"ih6y204O.v9G?uD $Jo29vz|k|:gy~_]eWyd*H7)+)3Jm T&l؇-ߧ/4Y$?/-&h,<1옦6:fܱsQ <D LAb3O g`!\jpz6Z:^q ȅ{Tc,wGӷرbڝa;Snj[ TEh6!a+}Vppx-ҶIzEhpPo i'  "V%_ʢo^9塞z]zr,kl_* ;wn"'iL4z7a2OinQ*On0"JBF))_DqK)73eH8J<6aE\ՐY$@F%ʏ:al@C&i6amplMafhљCDskJ{ &l>&4%#nkbHSDkM iJ4~!MXC1 &4%F|YClXC&4%nXC-)ϚҔgHRd`1!gX MfXO aD ȏخ|h"4o@U:ƯboG?y- jTe"F7+ߎha3ot ݝ4V݆I׆Q /h/.z $)BH$d#ZTeFQ2 H2#+"B2j2Ȃ*Eר1&XW^T%D~I< )ë\`&)/( BQXM*3 4$## yXhM0wh@9A>stream %AI12_CompressedDatax]Ǒ._ߡqX18@ծ) ߎFC`S%m)P=/"sw}<qudԪ\y?~W_]<'?9{x_0_wDv󥣷~Ѿxo~?۷o],^߾x1o_eVTܿͨG#o/Z45[qmw;"FD՘_{7?iw]E\?_7/oi22V˛^? }oac~b/~~o~櫷>߾{ryǶa7߽z07?.߿yj@o7 cx|a/޼xw?02~b >iv,o/;GM?.Ëׯ_}7z?G~C:o"ӛw˛obU?x?{q ŗߊ2ơ.|ٿ>l'_\ሥsi㯎Jg'x[~c?߸>-".~śNoct9.슫ܵ;w}_}_k7|!r(au8p>c9XUxo]rɧbJiQzJnm{YvcN9kn|&/ahcJ/WM-wU_CCɵjWMw5B-Jޮu;v뾇{깗^{_~7>^+UW^~uuu}uv:\tunp}s}{}wpxH|(C;p8nw7߄xnM7f@gnjZmv=pWw7Oѷ>zFw+*5m Km|}0&iGkwcnX<21Mdz7Fva>^?eΘ1Oa̗܍ٻsxs9:8f٭csI>ǼeY\+a X!cRX/y4VOh,t7Vı:ҨYENj'Ǻtn1c^5{5Vn+uz?a}Xczƪjƻatp4vHď9c2{gѳ5}'6fchg=쩍}F-sijO:38ʙ}gpCr:\{s{y]V>T*-7nv;daȶ!ڡCci&L$1bc2U  & KH-Y"D^JdUd9%RJdJ(O"D6dR$S"<i$rHȠ%ϳ yTԁd K2}:De'y\wncBy?mHD@z cί }ίoפ>gW=ꉽ=}c}I~G9/}0rQQQQQQjc2"ɶrL5wk,8},*^n8(-qa+2lbSdh$ͦ<d$ נS\#FDȷ)HƩ#9#ydJ;|C YGҏߵrʒd!IH2Ȳ#=#%HKc;벍YyxY~15ޡ34_ynssmϕ=ݞ!zox͓7O:z gkykO?9#OǏ>a>8~N$vÇSA61ϓz'pY-'.Eg8~85o۾s(OPO}w~}tO<'zz=^8.s'y~ݻo^?\z^Ζ0㰎bދ/&|wǧ2Ac-k<>)|7X)W`ܲ=:3>-FYf+j-2l8>[4՛ mSYl\Rt`;,[V;m*Ub'UxիtŶYCV&6`F`3uvP\RHӁ-na.&! faa9);4*[[-V[^@jAaob{Mڣ=Wx:xƞ[{/U6XCq'rgpԿx>>N2v>c~>;0C>%P?z Ue85]yXwXi`=V?]s.]M+FE.Ї fs m 5D6)3{ o,:ኍ{nرv>JPEdoDbG\ _/A@r^w䡸aG-)A<;5HjvfvCC$[?!fs-?b諕BfV7,^oqJ7u;+q~͋LJ//ܜt(Ёmwl/>L6a4q{K11MG聣w lB,X[7OZi>ӧmyZqZrEu}g%[o<6coI'o!>R8g?$L(㱒 !<`΋r.(sq7-qr+'C%_RNI/s^!m( oDNO=3ڃc?\}s9|38>ͧ>'Ώ8=hѦI.F0Dxq Qy"jqI}yƟqO;ڇZSb<ϿwIp1Oxn;x>=9q|# q_;R;~p²_U樏)B.##ZѝR*Pyj6wXjZD^-|k/':O {2{]#{j%LgW8h2LӄyRguGK7aTʵ*o@uS/ U8޻sk1A>v/fu)r.R虙쳣,9c9J~PN:}q~Fw?;]1] aC_{֜O|TmMq :a5vJ N!5 y*6ȴՠ$%Zʵ%*Hr Uf Xrرw;l%(v||.[DFmc`qy<2n9Ⱥ-Odnnn9zUMy;8~\>v+2I܇l{|vfwްr{F` ߟ} q*v1+zn̺9ǻT}hcݚ|vcك'ݗO>xsޗ(3ߙ;EJZeJw]SS?Y[c>:<ĿFoZgA\B3+C2o΂:|G=**N :ժ6 wbonoMUmXWq^ d=Qu컣vWgΛsX:w-;X};,hd13Y% ؊FPewᱞz'VzRU^je߽yAZ5F˱EK>H[ ONǠ"]|~.|F?Z~~/awqO67<_oK .wSt8q}vHP@v&gr-\ީpAuwSd%.oc[8n@Ge{tۡPPzLPCV5p7}ӟ25jJ&0v+Y{`ӝ.0ѻQ3S%Yltk6_z9_ƒMpsKI~B)6ǭnO6IE6Y=Mtn_:ǭέvK̭vK'h6kt^x/2 إwzg7%]0KyntI/jh84cP7-Q&o8t헂1F5S~ȼw4}vӜ}~ú0UE:~ƹ)Q;DzZ7ƺjϢ~іߛOdDtǾ=׶z[HۯU~H+߲6 -6?9v5o>^w(ҐVJx5lvebjصJCn ^Qn(Nʒ,ݧ/H+{Ӓe}K]M?>t@JSuOsЮ.zu_J$ú ^1"62 -mvv^WڒVt_ٕQ%F/hPZw^wҐ(H[{]mXTHܻ:H366sی576n;֕7n}դҹ~mR;y,o:;j6~^;)ϣi!5=H!_>۳t 7/ٽAی>m+6ۇ),Y6`qf8>ZlK[c.[/y:XζhL\iKcst?twcyЎtv>gjzX=}CDkzw^ƶ .=G݀O MIOp .'}4OߑfnÚ)>q2%ҮoO]=$Ηէj:~K9ɔ¾a{v+&}skzXcMozҷML>軦kĀnI61&'oyj5зML>MMN]C]zؾIn}taWMO}7CXs݇~'};9,s*~ ~۩ޔdztp)~JT!un93d8oDŗ"MDD^[ds,JB2q(99u8 PjhE^)|K:"Ӽzmw]!U8RSz'o|ϾK l`N-,!/d}׉$}t&qI֯$Wt8񕡨b@%90Z:s57Qa.ak>|q_6a阊2?%}l{qhVS3_39j %q,*iȖm\V Չa6D|oIK]~&|bKݷF'=N">i*ʁT[]95tق 1%B,~X]}F?w%^Ms<"3vH=g- [$%RZ*K$nuwK90葮P_8*n@b75s :. }NөśJ6CRy9~MiK* B|:cAO:v7;No$\8I`Q ;D%f]B$SS7a'[(9yJE ͼLl.Ȼ.KZI} K:0(QdKb E9Fp z)Vms bRb$K^1r왐/&btGA"m!g_1|@ )A_ UGokq* t>ފ'4 q4Xilyvď(D&D:f=PtY$%N]}v@wVJ܉eaHr9:,j8Tg҂s)0"TqwHdt$T{$gT^j3Z9" P/}ý2QB/J-`I1G=yT:^xP5a,$Ql -aۜcTK}/t ŕs6KO4xCPBMnzs>S@?fLs $iO!%DIrsKtq!Bˍ9DA~&29@2Iw>ۢDL^U1l9*I^vcˁ24{H=ޱͩXac6@!B%r3WX Do!,;j96wDLCAz0Vn]5&qHMD-u2}{kxHr@D;BA\+&X{dl3X SwI#r"(uITC.nQj2c"tP1uX{d(0B@!F%/:Un2"APM;WyCjAnXlh̶y9I.0zAW< m9iIsc6GUp{gvĐHIl:@!ǛCA-Mojb2bۢҔA8"^M:3bU9fj 'o.+V=vu?*rm3iy.|` Ȳms>JX8XztUb$aУY0c sh+DBrUmDDdRghq(ބ~|lb+8UYt:,U\B:Y *YL bԑf64ņ Z5YB,t 1ۢe96'a"[og>Zǃ>F֏*n3Vf]M9 ┴rg&Mޑru8Xj~[J0̔ЋAܥ"Ly[hbl\5GN*W;!bޫa(I{a"}B̞]3mǬrev986CNJ4˒:Ie%T17~|/ #?:}ao53b]T,z(!S.*,jjՋPD}5űNxz6We2Cr`8:Վ֔[aoI2Adrr:ht^t[c׊;YP֫HlqN#:;ĥkz&YDv_>RC ͛EA:OsW?Onp^/6.*漦-OJp)wU=Jovz󥎼16KTe qp\N@>ӂ>Hqj j /N~!b`fڏ<ڢcgX8շ^E_9i9\t80A69F:V1B/'7MQeE;8 *dS'i7:"I)>q|ܶ!G GiE"mJ>Ƚ嚸}Z6ݹ3.U5N](jkC'VBGqq n;y~wt>" ;[RI7T.D]崓CTgx߸x9R$0_K!ɸUy;T҂GSknqR9IqU9w^9\@U78xxgnH^[Sѱh ɎZ~0璈ru1VJq6PFoQ{ZqpWR>,,(DϣIJrh $h(QFo*'h\:OIiM+{R9 XG.AIdCe!D_/CLQ*ĜB8 נY@Ye΋X""aٔb ; 4H-!'# )l~chhZ( Qt1^l_T%|8}j SzrS1 Wiwq愷19AYBkA3~x1G &~Cu8QO^WTЗ# ;D6gX@Ȋ(< QEFIևluؠ,kVOpHi{7`MWtMY1x!XkC)cNd~izbgdFݲ!/p&9L2/4zJUMZ/vO>Cww 2PhF:KT_n3g&orpIb'W1Wt5#>]/26  :bs[bX($^L?dvGEPBE/+;jdrjBhH4s] v#g)B 4SҜ3۴)T.FqTG&a0 @o >󢟤) HA4Y|P CDQObfјU=ƹmRR Do sLc C2g)YLr'ǕlZ8:Iu_UQvhqAD!'R&vf\uR-Lo:'|%z#½&I6 dx$8ckmo֭_HgA T5C8~tݬzx.f6eP [@BjEH@+ډ]Mvxr{|eƠȤb{Кm1rKITO{qczgI;:G׷d ҖR–m ,- Q-/-O%C%$"e( ʁ]O@ ^OR+')s^^΍ɇ]O~Lփ_H`mg 7ڏw=IaogSɔute;Nf٬`Ȳ*t"=WI9G II'wM"JAII0v(4D&z(`96[*{Cp:1g*mV#Na,Xu߭w4U6-!ZRP’"`?W1I'FhdV3#&  ft+qF|ʞ"QE8CM/AQj U2 Q >N̙)J jdT8\ɡp=|J)ډg+ǿ(J?R05%<# V9AE'>3ֽg5Jgh2*~ K˧+G%85# > K("gZG 8W~N)i`q(=A,CWYDG| D=e#ICXfya/Q<n݉ bJd.$P$RFHҦwy&`!ۯK% }Ld9PrJ]1x*pA9+~@E^Q2゘kv(@[gi>r"O7p$3jtB"\-@@ȑ rHE)'ѻR -zDCC")VY3Fr"ڣDOr(%&id89î`h6e,_:|!r N:A~Z*<Ją&jP2:"|M)ֺΐT n6ha@וȑ$$\o"6c\ eҩBwh,] 1'XDe]ge,?ᴦkLkSrEA&,ôZ 3.L`q, ڄAfib?T. G/J`rVd;x$Y:A"IsC4hXBP<}j1WY2<^5N)%FR#Q :Ef۾iE4(TAo!cxnH1g״\Aq$&-'^I] e8Fhۈ<1%@.ާ MbUkY6LU$,P=f WQF$&%i* .EKbzf|R(EñJf}]! HE#X)ȖJO5ָh7*jjRWş(͎}Ƭ8!Ń1Khqަǟ9g)jDB0,҅ 'jDOb)U`y.qtR f#WEL8xDבgQ/I(T mch Sk6N"7"kAӖM#O$թ`uhE7쪸\һeqnfn5#L֔1U$v$-LhZ5@_NXlȘ;+_vT> \jG@)! .=ԽrTd[UܙTEJl,KRQ\Rn.0/)-0L,YQ$1Jj36)Z%s>7ȋ1+z0 ;W+RJ%E7n)Y>kig)j8~R^f=+j1;Q R񨨌ED#d`g]SBвXC7@-%&){ E2U_9$EK#B^N=hJW|]|FB XЂ6) z]NJ ^G"Y*ZAPiHD\!wе ڐ )K(@prnwlН[2p$ HPQt54Q^rLYW/GAE{ߗO]Cg 䳆z$IZKbo*` *P-b1 DWM3o"ˉ8p(B3#M=DH;wCN6 i9EϨYdTWpIXY' Q,S REaAk(cx7eT)R2=-b#鎼C,%-EWz(!Ґ@RpsgIaժjn}="dD7{}Uk y.P߆hæg$3(6-XPƩCUH -ChZ1KJ[D:R,E)*֬GBIT=IXV6RRC]ߖo{H~WGQVh4_R.j2R]Vo8kZKv.ڢT4Y$'{}V>kUY:A 2JvDP+H۴yH:=+F3&[t O> ӎHvh,}C\ưo&R-} fE%:JPIUTUc,.hnRllX\{I͔~] P|sލR4}U >!Y06l+ZMv ,B0>ip%Z/#ITΛ֎*Q/)/ygD3=LYX4Yٴ鎛mjoM[!'}DC-m;KN B6aއM{iգ'}I֞oM X%qfST,f-0R !O:)n%P]=F LD(B'58Mb1TİתZfVqٓh'scVY 2z<:黦Љ]ȷУe&gF]A5q>'}״D]^DwM3]ӓkډr4Y|6q|vMOqӮ-9rv{ HEU$JHYL͎Pt6.)nn9䃦7$]ӓ^$89>Tb$*:Nh"g0zݏGt "h!R ( aTPtRabITVDŽZ[E&G]>,%PK1kW+!JۚPYRS^̵G/-ӝ&|/Kˇ^KzG ,oGtkRl9^&N$rԸkX*`Nv~[Gv=/d]W,ƩW@c@"IbpRX(}⮓Ymϙ5 WeGqr2\]@T-e/lq(,\Ѩ{^B#耛r' 0ΰlK-YZ|blKklW3قKFjQX i&n_Ӝ R U+l28 mRH>K^VJIcPv*6X\oZCtHsZ {JuwI<%D3D;Ѧ +V1zirQц0CQVե@"^M{ыiWyLbk!^j"5d6V-bT*Gsb/G=NZ׹őAnc%@`hFt.DDyun lnxw+ՈM>uc ݯPHV@qSE&= U~GHޱgW+8;c `>9"?t &=5}Qr #*ē~z7!k˒ðIyYtit5d!F&ZP- (cI9{TX^#ɳzQ79;/`JB9W&iߓ;ihoR(.e3"B%%̒cj# Ntd2W+&yq SKUojUocSsjM:'GNZQĖ q$޵ e\B1 吁ˊ߾%HĢq%n߷m-ut#*Ʈ8!9kzA$a1ܵh`.H+1;*k&!Lzlayܵv&"$Ŧ%53DqpC<ـr <)jZ+qXH[*DdY׌Ia'HM@%9SVWTUׂU$D3Zd[s/z[XvEDd dJLsI`*S HA ?:sUJ4Ziߣ'}|y1fz׮VFb\I`Z;RdGtPO*@?E͗Ozû7^?|͖߽yˋg@P.^/ŗѭ4|C7]ј^(|,C# Q+/??3JHa2 &Uґ8>-Dv=22sX8x!P1,LWHB^^Y1 9N2SjA4iB/'R&YL+GGpXСIb[,]L„%fF8g VDk7e:~7nAZ 0ٓ\ D@L;o6x*A8" ' Rf;, @C-fv] /N6GN b#ϻNqr!!k&{Y`:Tڳ ?fq9R[6UWuND;kXa-EXPpm/>KY,{n&*pл$. !2>N!D Eae4>V(L,UwXk/bl}rvMV'ea89r{N*TcxKȃ)׈*q}S}Ghd'@XbK&=vw-*.Ad4_N׆0A DsG :r@YBoL 3 H&ZgϢxea2]g/}s9+RF\ytdy#ߏ0fe0n+ M9 A},&_(pFݿɍ̀Q&B bDB}rl `),tzBΜ#]VbA/KyP :խɗx,] kV1ke-%wtG~„JY'9FIoMhNmQ F|O_ CLD.{t%~Wrdcȡ4SgMudD!(g"BPcU/fi)̇sœ 9d1aRd2/ɻW" !S zU, ʹR5/:N)FWK2zl}LjⰊ$gxPDE#5ף?hS'Mm\>Xv?PU(jb۵:!\O>t[˻9̸kKԅu.rX1zeWʆs>G ?f b/3;tѕe 5x4z*Ź1(/%B<#<耇52z~kk]|fY~L݋pb؛ΟC7=<2/`J Sz$9% nxb"FabGdA &vh|R/yسD $˫RꈉUqX\M\{&z.I&)bȷ:GBc9VAe rޗY]p<$Gi~]ǖ]ϑꮘHsT&bL Jۮϳab>ODG"*5'{FN#cPb,#' ZDHb3Kg=hԾrCLݎ{VL1*gkxwjT$8j2KUƬUe.*N_uÞɅ]LVv7_vQADEChpg"C55*U-.GnGZaA[:-OXRP|%F^Y[Z's{;/ *s;UHO6B!YhU(mٵP;6ħAH^)L"׸ W(HR膘EpB'$T 4goJG͸+P:2sCHf) 4=L-Bj8ۃT`y 6\ }B=RJ.ʛ8l^3 G-S},e1$ڽ,=$\Y5#10Z VQUN 8 6yUqŒ:r٪p mf~%KZåU  *\-hVMQ{[5M]G!u[éC2_UӠbWyc0Jf̶8j|3҅uU'u8NaR! PRu,j9y V/Z!Kdd+F܈RaI $ţ1/HW:^7BJL[GQ,A!`;  v h\>4gDF "bX]L^#My6bNflLy܂woOBĬ[/ f]&BdW|siΗis4T{R.( , XE-<\'vGytAQ`~kJU Aвtō~!p \S>i>xy=L.㤳\v+ 1=qy*5 Pqzc|M&U3e}wW.q-Lv7SsņgbW4*ĬT)Tn M S}#5tC_9_^6dݾABCYQ5ѻꔣIh~/3@k6` ZzX@^!N?N0=ٴ?8;i2dwzzg FW, OwC1xYMDGVKv㚓]جVI$]A""%KEQ܍iw31IQ zω"bQ1=VpV1spWb4go&kĵLJoBkH arv2v[LD`5BTwpf`]7= ڐ7GG:d(ݢ#|YEu ob&a׉7JFOꀠ *. N|ln4ȣ$,"z:E+% L+Iz,/òΎ\gpŦYHT^"ŎaJ<.˽*隞 ל1Qc\LR΅.sHt$A) ¨4?,&p!BJiBOsܒgqDKt3T gO]]îvƴfyV]Fﺍ)D@2QݝtQSc~E0ݺ f%2qr[^_fRWZs2 q$ki"\J?t8Ntᰌ7NA6qHcCΖ,0/7T`^LToE]LTp>.2 s"\Wk" a%I=It \FkIi  gi# a \h̬E譓BĖ!T.YAD᥷ %MTߛq͵ssl;VYei킢oe[rR 8EդLY_ϟB8 |s *.*S?zrĻbf8Ѫ 1Í'G:ݖd r@mÀc" "p'!&t8tSa-q/_ifP9A0'{p vKqRRs3@;#|I%'[B=T\ԣz %ګƘe0ָ7!-f(8^LćBE$x.b%6,+5 Yxb@' m@  Iղw4ÄU"xZ"LPIr1'UJuIWTDMV^'!5[TxW k@|&ik9˪^i(tzqhNi,yP#BU$O+ B.3HuTJF.xv {kr0fy \ՌQ Ae#\ \ 8=" ,g:Z P~Y '5#aUD+sS7<V~TMݼŰ*=Al*RQ&8hB}Df|T䄢JZx;  P(=)"V.u5YվKu:2n[r.ߗ!2f_#VRg8nHuj.tV*i(+vn$YJd]S@gS34M!fr9e>D.gb _ӻvޢT̬Pd;eu@d9IG+e"fy\% 0Z0xViQ m~ ;N9bSdΖ߃AfzJTriD|NBhSpwΖ+,tsM5f&HdR8u)]sR1W Bl/FuUsIQa%`R\ 5q"RVkBj3U^_mi)e,?8 fvk-l)/u8e, %D5Ugѣ:h5+9 fJZ-#a%+NXbC.d0u˯klL/?G-49Ma\xq"M 삗eJNeE?Lv8bEKUSW2CLp%ǎ//Yb:-2VI*'|8ͳMܧ2DP{,NpuyXmx97\9ZPJ(&td݈ŲÝE!?qUE+vX$FײPq2wL%R\L6)]25.(VӻZ*DuB,`$,Nђ/MBT)[2b ,k$ \MqӁ.g:_ tf5Ą+)~Ā)'>8JhD^\nJQE7[ ve-PFa Ɔ '7ƪvjAt7lTЧ 'rbwL.vV%[ 6dߠ.jDlhUkdN+ K(㰔[E~yVnQ|.Z$%nEeUIyBX83-ׁsڂ(aM,>c"cG\ }_sجֲb:^Ul,A<I3FG*$l*C&ɝfTL^a̟LEΦ4: Ef?S+)jbWa:s񶅒xL9LOJWğMs BH3G^炏֨ `C3D{g^XFrH$RCT,lʡ"!@Z#L+1tMqQ=R@P:[y.Lifk*Sj'p3ض(K/oV,KoB+lqz9*6\Ѳ4CNS ci)5 V ,S'2`#C\g / SzuXT`2A um{齹kY" $@v56R,+:~WF F ը-0P$mjj -~?/3",LۂbJo=$BQZk> }WzBvGR0$6a^0]$R_V7G 1G$`_bd!~L*r-=MV>Xmau$F4jXAW<[C^ 鋢2r[:,I27ZYEWHZ*,"6擹҃5hpV}rTJtSEh3^v@UOPmUK3 C:<aո6E <%.,VYqn`E !X'onχT;r da =71tܣd7!ZÁJ$)XNE(c8Do \mN-Cbהf4K;OوY8,رxjUZЬE?i5>,8C 8^fO `̐BtW]B6_6-8ATkq[w-;[| ׾UF$CBҥN@dY+{pϤGmԺ)Sg'bpypS+,J@|D7p#dB@ȧ6 ;Œ! DiR~hl(vR-YТ))|n*9 5٢ez{HL^Q羘Wbk @K-D$L,4|ΖoUm@5KݧYct5x d˅7(hdTl*Ũ7T-9Xd8QeZɍM$_55ǥdE說&P Z`X]T& X-lk-л{C-J,KM&kM5Lcm~L+ܖ%`\([[$4@'9-Q%*uS(2us2ut$ Ar}kۻn 3-CŪj.!DH3FCΙλ-wTsRO͌)_Yb}nc!*wpuj^PG-&i(&I; zJ"y.TMl#!f99 6 k5ź\{i8QQ40h b1F{lqp~Z,^fXK "U=,k >.A7~z QtSf`P]#iIΣ 6 ]@|7'L7 49o) [%P8izЍ+0&ܪ05:޸hԒ ~*,eLduĖtC3ΡbY30]7Y/L%ד0&ȼ"π4Yt.uZkVq5pVaQ%bsX*[5#TV)ʫ[5NǓ_ʜ#'OVé  ][M<LkL̫-wy@^rCU]C *<1`@fN/jP5y04M` {z|Mp v4V.]JYXm]2V5`;bi V8)C=FR(#0×Vq"H pL F bPLn#}&N;'jQX$fCz Qd*3:Pk0Ќ~d~e8XBYL`/S!z-b[윷u"v>Z|L:hKD'=kQ;jpAedGM7iY8TXXt '߶U\7DPU Z2Dg27@HY̒E%ˍ!eu\nn̩z'?ԩ K ܝY6f͇Rw2=4Y *S 7j1p`j67?%^gɓP願~43K fKz 9aՙ<,r2q%EzHAĵY p) ĸN7*iF3]a20qBDT*|}iͬ2yjQ&^HrH ~";#cw@9Wi4rB3gw۔q5=Xh90&ǃ­iS3!&_Q>;\L&9D]ުPQ].KIi̪*3Gj,e`]Nzd)rޞYPj1qݏaGf{+ ;TT!ݛd_ChRDMDOF$75vB^x+·OUUh,--~mdBN5m@4RzUOIWMD[,Q8 *-k(U@D,t~WX*GU -9sA<RsM[<^&|_$ @i96FEXbJt8Yr|J=g3Ae<EfFfW:RS3FٴgﱧE7**/6+3W@b$^KDS-3ҨX഑}J6S1/rEDV,YK" fƠYMTkqt.RL3Zo"zʱ[\[(Y3P><'(]z5DH1i;f"jpdҠZ1l*<)` kb@LW+|`/&&G)W[AټVBf,O'.-A5& `DTb"sAzLžZ0;"" cUnR&NTaF TbNM^fJ zeǿ(W Wzޕt`Xv.n2Ma}YZtv; 8גVu77Kt"[9npe0ɫ =P6y? xCtJĽz߀J-7ƶ j˒a)Ǘ`ы3*s S{Mi `)>fB*ვ(TM ڧExz~ O{}DFL  Ű\U'Ɣ́O0NL׬3x&\=a`˻t8 hY qhKW]_8$*HtNA'qYڸ׃$u/tR$[SNšb\Wj.8>Ԅ$(;jdR"z `< @&B(bn0 ZagՅA+J{Pi>E/xmV3I!i;'Jd+F[qĪzb Z.i!"3%ۂtV `1UYPDž4ܪ#D xiSx =M/|T"a:V/D65/LL*|D^"@w S,ь#IWbY!Ri&fʂ䭐tzfӂ;yӷXbvbcXwEN)(pR4 fknjƷk24kghQpxMe"MV,CcO,ؖ`D'u!&l,q|w":JLwZއ9iF92 |zE[\\_$e9g@LD*D5m/:nqMaDQ^ӑjޓɒ9 jP5'SdVEy\Eqd%`\DZTRZd3%fmE.J_#븲z/t@ Mj%-SKhoXh`W\ȭXM$ZYj6G2KI>jpz YW%n 깰d+^̚l QVq阱4ih$Dd"Lmܺ^SSF8 8衰B%ݩKf2>/1SKJ2ps)&)[cie srfX `M!ؒDZT* 'J{]x/4vJu;Պ|aTngwYhp 1:(]º'XF%C.ݢI9a9XeXـo}uHKxL֚, [$FFE>|j<(+$HH猪NW>0bĊWbHQMB{XYJSs:UNms68g0uckS ŊpeQP69ͩ.feO·!HU L i{I,,JMHRߖJ=RtcYi7Un^OsXAtSۈ^EteT_ N$4; Í􊠲'Xde9-f$4;5pYĪ$%! ROR"(ܣ)Y%Agᖞ1_8l iJ,RʔVK0#!:AI$F)s}daXym%q$ ĩ**C,34==#-â ^H o`juiLR!hd]C1~I<)(Bg@m^rk< K bȩVZ%6ؔי%Sɫ5'&XȦ eԪj3hOd".9`yJZӵ1~xq!ckB AsݤeͥDt Ux8+-],5Գ MKa`2Xa`6)2*XC:nB#9 IiͦVMi = 6ZY- N855` +0 8LM֭XaXVno. G-%B;@˶xhX3%YXԥ~g1]ޣlo ;dG%9lX8"vBi:dޔtDC[9&-XPb?ՊI~Tm#5 -^ӕk)BT(iQMCUߙ[nfP82{Z.w&/ `kB)MH0h/ڸ3xEVrOϼ˶_U5ϓm)^{pf:f Gk>bG -"&앝OC^ڌdtx)I9͎Dc= 5T[4)jV;-/ITK2ĤT_X[>cB]S ;D CKog'~ 7BPDDԺn%+}sRx!yx˫VmLج-TtY q0Naٔb75)Mu3)'ܽ4Z_cOS<Ƶ53DH|;_92FUR|E5Uop$ή#âDUf2{§e F33ᕬAL!k(jVp8VVɏS2Ŵ,Q]ܓ!s;a zTutPiZTmIDTW}ɄF :c)z˛yŐ @\ln(H *Gq÷nT/3TwTWUD Txju×ѿ:7Xֳ*at `͒0QfTbXʓ.-8rrmPG+]w5݈D@H{@-=~lbU-Jh15 h)Ƃ`}gȢTԾ(_6lx:..Yt^aF]h}˺( ؖ rM$ ->Ud`PB.J ai-]hiwVN3j!TKڸV}#4jEsnuU}A:,N=*7Jm10) L[s_LNe }վ 6!M O IJ(E$RސIC|M569dJQ:E-R:x @Ee (nJ@/:?b)FW9/|6vŦCrϏ 4uB[ՠ"TC0Cfp[j:u뺚ՙ^5y0j!ǚaQ+VYRPkXpіAS[T`#/ E*&=6VύĠ%;`ZOe˽h`LTIi&sbtI!6Aq*j1l("R-ұ,ĬD͒ĉB-YJ9Y˰d`g ݰb@tY%hUn,Љ랉sRnm/if0KYi>L}ASs^D=#֋=U+ޥz95w3eBdRsZ;ިL#0n|c&][09E<}7FA!*&jᠾEnª=ؙ4&R <˩iigF5h]FΖ U+oA Kae R|^ATꜞDfF֡/5CTneVX,5GݟvU'72\6nȔi{q(@A@JWEONe_Md-C Syuiq2 F1v Ț er(QB74拦WhM&V`^ݓSen plB VVUEǵ,C5tƀŶ-[pᠸIlu8EM@16/+NeS>@HBJL[B#פ[Fp\ Gc)=&=Pdb-iH@ (f*4ۢAmjcl=ح2\1kk=8NVg Iˡ7̥gg/ku>(o}(!H@3p/@J204msBhY ,ڃLg#꒙ҦW&N?rmcb6i4x*hfZ{*[,&e%s)J`e$U¥KWHhlY(='";Q kXtv^i0J` ?chïTZr+ϪNpp/Q Ktl^E@P?C A~#tniFe_ٺd?|$!,P̵ ]h{FxNCmg-W1]TY d14Gm:e5l~{t=kKLTP㺥 †. M=r""}¢A)]6l]K"uq1h9[k<] EpHjRCLLL(yAfDT0.AW=BM$(3 H85ZJXzkG-8(M:,w+Y%@v),Sdvb-FFa.NѿN3;ZtDj7fN ;hZ5#8;U1U^``&="]ag*AfDZRPoV"_C-ttWX߲=+ձ,7b`7t1lmlwaKvdNH]X$](&:є hY*[C⯉W<~4.h͎_7&^9 l!=r0EAx6[giqm^g~"[=x7A?u\'͙f~Y edM>ɗ3+XWWyT4&AE(_+]k&5]iVOg@gl`Y_ gl! Ǥqy{ˈ-c؉sV6[!heGv@Zf(-wNtR+ը^11%X2j8b0B:A"V7Mbok+|?7 -r+&~70E]ȹLvCV 5ɓa.&Jsa5hy.+Zo@fK洨Ϛp7(v`iKI(Ɍ2lˬK{Ejd.jcȪ_Z50ǜ普%lYĶl5Ͳk cX m8fʝ{9]lB"eF(HA^4)?E%j% (L-f!5S+r"^Z`.Qi{зT\f4+73q֔`\jϑf+j2BYqK5m,4]Y\8(O+Bש|]U+&%%4Ag+h~bMS7ul2L-MJRa7j(^)UKDɋW']Y؅e]ʕZހ7QtX4/n\f !A$ެ h Q h˰k!%W`}B e f>`G^++deEa^e"O[2vZ#_+neAof`(`URĬŻ)XbbU|ɛ" A(s !OĔ䍟CbU%Rz%!\s[!\`5_,zM1c_.R9vbÐg\Vv\8\&çY~LZ#oLU 1_=VKUw$  #VB2,V>JL5rVR~`cym4Yml1ZUؼ'W2և/? !ly ><V"Yx.|!2 h% L Gc.41XefOcd1h}d!Zi\"yI}NF`Au1ة NA&09E52EbF Z**= . ')e"!Dx.l ZQ O`h7W4奬 OvNyɯA/ /IwcOz!Ip JUem2ͼ }ѭYo} {z*)=Gy4B'ፎVaI4:4r gk3F=J=(>|"jEDjue3Ƒc6W!ҬMZN٢-ꡚ!gF)hHDx"V8ВYywM衵3KM&J]0o9-}Ś&Oh@%..dRKVA& E ov0dI; K'C̰v,5z oV\s[kCřIrM48d0Zq/Yf2FNɀ/INҍrĭqMa/8)Yv {85z5UM@B\sSٔt7Q2|G]_ᡏART&JGhDeWut)32h3 HX󲠶E~%)a4K nZRu=-4UJo+Y0zAk5y`A'ЏJWU0/f`~lg]'jWܨ́3B FU/{M]U6],B !]+]N 2hd JLE AٚN++`AKđ)!׿j1nW^ttI ٴ_\ZYh)S\d`5UVpji)%HM}X[8ȪnZP7v=@h`lABJWb݄-2 U Й6S, WnpUL("quR%&l24xh%1 V̾N7r צ Fk#E<kx^b/g˄H #Nʌ:)TNMS U$CX3\/E3<$J^ tњ5WXk{#Jp69l} -1.rU"Q#F\V- e.RT*kN +j]c[ [`}]$s,V:]Y>Qzh8o@)/J*Zy]BO16[cE4OdkW <πy"װ~= Oj_c!: ү6.@52JeJϜ 2oG/ݼ훯rvqw#O.Ynnή/^hw; eGx?#GA(_wzlcO\% ;e&tR~E_saʐMG1 Ʒ)s̪Sf@ ʹgHg,Op!l0|@oU^S '״,Xb!P>k콄QiB=$49+c .}(BKdT=m dI0~9F: @xZ1*_N P,· :,9'O x31?:џ+c B%M&SnV.=a(B6`TW^ƖSTq}e>{ f,@1l!l2$d(n"#yˆ =wdb rPBƤDE*9#2tLm o'9D,S(@8KΓț>kaV2C)y+ ?yw:DAbg 3'?%+ T_';\\*NtCʏ~cGyCSGU2_i9Ǔaĸ8:O-g+}ZƸBc%ƕu\ Mt/5xoĢӍ:`E,y8?2 }(C@JB,zi(`%0bH$n+Y[(̌m#iC]'L4%#SyRs] ;4>goe;@=4^Ɛ!˻C+Se&;uUnl@Uz+BmA\N;zPߵ<)lHDB7SYfXCb-u&1b$D2aE_@Uώ&5*:?{Uk]ikb*M% blb; /l!:L$%vAv<(,J><)1.LeIkU .'* 0ӶSUP&obPDO_Y"++ U2yf =6*7Crtcap"`+]=m9#%MpCC|X?>D4H;jR]+cj8DLW)T6hʞJ2D!Mn{H*+ycWe8q9*324 _JuQI3 ()  k=P0ѓGDT"|Mc .pD?"" h~! Lh㔒4bs$)5%SH[A=B2%5V;6z,]|Szypbҙ˛v)K% 0/}% AɴuyB "~'!R?{O."PFȩgtFX"DBOrtNgTˆs )zgP?cS_>ş/FWG2?7gw߿>>?n?]~ͯi46_]^~#o.寏n.͗m=z+|}F,5ÑS˯_?7_}淞}Bw{1﷟O7۷7?}6,q3]?m7`}boH'xj&jr7_ޟ[L 5|GOƪD4??{sv׃=Ty{owodYil~ʗoKqf<rym]rߧo/pwW;o=7Ŕn;~bO|^^s~H ^|qA=衏19CA==_ܝ OoA8gnooEt'у"zPDnAѵr]'^\}^[88t)zƏ1R^S{m/Ŀۋo/0]t寞X7o.n>?Q~z'}tH_?Zxo.?8S?L;\AEݿ|+V۲v0J%'曋?}ϕcYc7o8;l3'SSwf\Mwپys!S5i(mbjy9퉆wڨGKǞz\SZR0ǾXvHΣ9ށ]|bL~eF~/f;ʌ>H_f.rv>-p᳻/wػcjX?> ^Z;;:C ׏%C ?g ׫۫O..{{oٞxw9 3} yEd|חowXmdng[y K3~F :!e?}丹)P{ _ | ',$Lo|=a}o!ޜ>rް޾=~V!o~ly=>[cBfb|Uޛ@ToNW_\\}zquq5cO5ۿnٮ߳^gv}6Oښ;ѯqwZiAZٗWp]S+dQYqϷmK&M>=[n_\^]u6ۧwg߿GZgwX_^p o(5? ~bߗ~j`,gSMro9Yßh6[wgO5 2{ZhG|yHp}x)4!")H:yI<{CD!"tHzÐ~I//"iie_^CD3//"iְ/t;w6_ͧF%.//sfQG#xQ~K;hvRo+|lԁo80#<0—w7( 99>8xllpl _pv[pd~ۯn;_.qq?19Iew=.1<%v^!<)ы z:~?wow48z7/twowOj? Ԥ-dA&I[OgĴϯ 0u%i-`e'>}؍7 8͐=a6e8_V% '[A~WfbFq>(=d'neFq߄}y?Ƿ'A|sW;ȈGy?>vI:s1;>gK9poXs?8: |pƍ?8I|C !FCZO;^_~ x]7kX_YW7Cak?|~quu!tr/+?z2}Csq{nx#>?;⋬O_]\|</o^_~y񷗷W]b ?1j{!`e,)'wfHͻXdAn,]t0:NZ\`bbXt0:Nt0:Nd-/.`J{z|*j^^vNc0<<Ȟ?TyPmY=y?Lqܗy'kD1{CG#xil~uT8[:Gb!8OͶ/$=Lk%Owv;=ao=#>%3稈Ek$m~2?lAi'˭ww6ONԾxdrn&7~;P2NSA~B`9d~.&r0^IfL2$Em&-:d$l0gTƝ싻?^?vcO2 W{|b}M=vy 4ʏjd{\9A|𳧚(a7o{5.>[I_*.6=QUt{⠊> Tƒ2Rg[~]> yE&ꢻLmu}7+ũ;M頌Pet=Izu^4j* ?% qjƎ5۫ۻ돏tW;SSc)jO/qD!XW/ž#8?Bgbn\r{!_fvIV͛U\QW/\K3o(.wg ~z~~򣧚o}qv~$W_~~TG+ۃq]\fc_E^ңp*.'WT^J/R&$~pzono.vM/"sA" S{-xr;Avށm|\c{Q4]G 8YQڋXmeFfmo^L}uy{]u}Kբc=yw`=y?Lqܗy'}~(F"OLgwu{y>^ϯVc= {Gv}6ed?dNj=z;Y+Q,,&h6tݎ_"T6'ܟ0/?ަ݅}kO`1ϟ|A[}"]3CA+_qq˻gcyn;OVpVw4ˮ艹ޜu/='GyAsckLG g ŗ7I~܆#w~Gxџ_b?r߿ wGGף_|G$^5hI5ԣvR}j8=r_\᨟rA)Gh}=MZ+N\?)>*8?%vؿKy}(K='q|&$TQ3chTQ'vG'9vW|>އ1Rkc~Ii|?ՌQ< 1$0X5I*q 0h=c^~k/hmǗٸ엟j,N'_oE ~f`l˜lu9ְ>}v C,ezq?etL.{736387<8ayI/=$"GccP)xX8}"2::NY;?ݻq%ז{}C6Ý}Gm}qz UwqZt\X(vd*8?pfz/ȂkJEXޱ@㠻:UƎ$OzuT%:Ucj<@ :=hy u1RݍOxCh^gxóv6чxoXu\[M1Ɯɠߊ7:ip%q>կ,/8U|؞1>c}0/x7ޅrܘ>ljcgxs&W=:\-~$Nm!n<ΣYs=a S(:ILtNp]9gnjg }wުt4~xN Yfl"cזQ26f}tNd}d`)@{kjXJדH:7JڃI{*X1qW#HQ~ x!ǟbmlxsLgE-G7P؇qr4Dz= F ?<8^; I\(OW9_Cnj#K19^8-"qh鄬@TL\69رu:kcEkmҜ:ijy1qfppq@c>  B Me!zIo|fc, $QԡŠU7E܇LJY z`hơ tuS$i%XYad u  [OG{p!i tl;'I2Ud5}$\泈JԌԷ5%|j&'")'vIxG^Htpu mgKѤZ_ ؝>$]V^:HٗFbqí$;8.b+U<'kgAZ;lO#~Nx5Ŭa-yc5֘YV*PPU s=f>W?6r]Ȼ.on1$uQy]$upti!Kҥi2v{i@߃Ҙ%kbݳ,3O#M{֋TጵoaQ?š=oYਤԓȆ4Iձ3B]N{"@T·Y{b#44@b*v,oޠY?&~ǮɮǤ Bbv K[m'XF=.̓6 <žGb"k, "Tn5Vi%H{#DR4haﳍp|Ӑ:<$ YKwj=􌹎2Ƶqw8McFKOe#'9X]~;K5&@B9BKPɎ˰hߊ\KJWZg0 -/UxW& rJx:qǹ8f\޼G'6<ġ+x̿.WS2~91'_!JWvxG_ޝ2ڸzӕp;z<SG|9:$Xr1 m[:Gg;_|m3%KM䡾o4nّx 7%oBx)УS(AVI?]ͅܟO8Z7+Ya[ /`『A~H! hx( 9 "xN7C>ų2F`a2Krx#JSىzGJ 'd)DU|Gʒ)3d*4xj;|ԱTa4gqcme<̃ˣ49'zRe"."l,$L:^9ʼn! ʡ[ѧ3}v81%~I:G[;t9橁Ɍ6q$Z7ӑ[D!osqnɴ> 8T=1+ bH:9|jéA{#!zSLQTq'^9u*3=bFxK9>fـ?5YCHa<,k1 X)u#F?[.2gyjkd<$ Q{{S>$- 'E-|VNUR<è}Uv;\6)<Ёbޙ C{ 4 `)“ևAx eC<.,G8'mG?#P4ycOFJ DZO7_S6Ǝq${jj4'YS=@>a ) Q"1!\aaJgB4EC./Dص}8tzIC Ƒr"sӳnZ.fYиM>=]1uC] ٝV}M_y~͖h沢`<~#.=A7CX4E9m,\=D%"''?f˃ta#Çm PpӇI 6pį=l$:柜'8_glߏbk joRq6{iߵy9oJFZ- bբ`3Ǖ*¢nޖ/޲lޏfs5z=9 p,ưhon%ף(@( 9< A1JӫOϮ.olu^_/|sG >OnS~8`@[Hth2x/HJ;%Io%C 0W4%ENeI6MB@l5=;F#.:]>pzT!qN J99+  ̩o9B3sYI'Ng30dNJm<)(㜱 7EwCvbhYHX!xJ  *8S3,g:^7vp?,&E˂p$W@y@-=,C}"?9R(Јƈ@U8b 46' e~AN~E'9vmE$xqC c 4@m! $$ю.QMqH³04:$qObw. 8|C$0Jn_w*n5Jv~2za |.r8[){=?$ -߅ԮV9-;DIJ* B@h!>~]lcQ* k <q-9$"?`h(a)X\2ҡ KQ*H pR$I6^\\_HcEk)s.;l,RX@ "Qcd^D+Jn!B}I iB;p~H8eC>I\ʂ6Pít/o8UGyM㹅+Rl 9"ÁJ>"fy0*(Y U&STbF9^ Jb+1B(wB2G@7,.;ĖT&7+H=ӷ94\HHZ@2H,#xIFTX/ _0 S9I *H|ƙx$ Fe]. ǖn@ $oOF DC2"d.tRPb06RX2" (ΚDl([eyP.zׄD$| B{'\ \L4K.x6[:{.`B$EՓ(;*`0Oea9edPC-'Ĥ$@P~~ۛ!n-B3K褖|Pώ_I\s"J7bfDq(9'&!`HEy$#2$z+š(0^*pqz'Lljz\ Ʋ`I>BƱm,f6|{F?&.]$f9UQfP%Cѭhb bPsC# (ޤגdͮ+9&#9 4ѐEP& (V' bA//ރ8ʼOn :nh5P\T%54}͉ӂ7JnoX8*eT-.jM^౽zZ0)6MĎ&Yu`^iPѼ 79o2Ѿp׍^K\g8_86.Co^Q<}~5 JKF[dckzM-D0N^w=5L+aĕi9pҐ_uk^s|mKJ˜uUvЇ^]p0̈"sj"98r_άz+I65Ya֯j35t25,T}u*[OÓk53PB$IJ,kn2*/A a#=ƀkeI˜&(t*`Ef]>ݯ՘rM:ΘŭO_~~MS_]L y7&ɋj)VZ.NCpGy}KvU3Ձګ(w8>u2cݚ߉}kRĊo_7&!|1{&9h~E3 \ϼfkB awBq/rӁk^󑑢Z ,f;&ذG]l/2w6l%B덇΂ݎE7gzT<8_(G(m{V =T($!OhW Ϫ-~62 DΪ:8g3Q4%ės:)S V~[VE5>{*:\YHb B>*lvp@'}6H<%!6j hͪ+J\*_;݁%S^[-oy}FG3 9GoD@>~D4JK|NL@d=8|r+vdz\YbעY̢V$iyGp5de`ڌvw-#4f^; ¢9AxlDBRrӯب}(E=jbG_4L))5Ԣ.d r̟N`|זK&HX$R@h9j[$BurՁ֏(QbjrS[%Mt|n4qR8{`Z=BAQ$v(WpDq,6CS9 {j*GYK$6|ݣGQ&bǫ/wsV3Km5_;ӐeU\,%v)3@}GI*ˢ N Ţ2/A@.ԩNFnO]06 6"VQ`A]AGB^?!i*΍N.wb3̥g; G޺V6qyTlj9xhd̀(]\ 086jnT)ÀbnʏIAtϝ^ :mh[B{ͱỤSvޑItjJ`I$LP_"2l-8[a܉y\&]%f %>+ )[ظMoN%>Mk ^'fէ_#H|LǘQz6!Ky@*iBƐj1c6nRu0@~&Ȝ#?{Sz45?PlGzcg/R$U)^|YAaxҽZ`o/GZ"eN;0dJ+\A~$#%:' :ӟ*9˭O+ϻrGJ/2ҐaVWlgJKj9;DϮ4I$n&6 ehMx#XD;~H|E2\fs;!}*Ot&2q%ubm9[zJAqwt#ehw8(!\L>nN[3@wQOth2D_qAգ85/}b4D|<Ѓ5Z)',.q!!Kx!kJz՞wY -J4=7Y߀,K`j ,dI[ .Aht^)^!w3MAfGVauq(*D}1sBՉHIH׎pODܶF =5pg} +Ƃ./ms@٢4|i~ǡ! SCk U@X;g%oMb"@`yAV:K3^@e?yxISj3Tp H B~#+3y 4)A_ \& :eg^. kr*l5%'^^آ[W6*'H`{x#:-?#IAO[>tG6g_]j%:o{'GJ #SOV1XeNzKUȄjP&BҙiԱ y,"1߸"Ȟ[͞6'a«F-+ hҋr>wѩՑNnI"_&ELI={3(rCye}~N/x5vTLMhPF3R-p-*B582((c`mΘ~Fmw#Uu:hp $(ʫJ=L0}9`n[b@;iWKw#dRX`G/jkKU y=`-x @v |^FK_¯T-)U ʑw;F3T֟s+es$!!xL^W%=^ۑ{|bJ?04 q3iwV؛Cw|)ڈ`K4G|Di懲 ]}$L{ ,# >_AK? nu{qA}+]V]7IҮ< _"ɿIfpdYEFr"|ݩŐ`O(K($S݌!Oθi^>V3i,0e>$>C̓*a'm\C b+n)?E*>rj6%肢tpHEhg=k- ]< <#] Ԣ6$@wb&J@O˞­7xեk`&ClaL__ (I`CD4j v9鲣^ iD3خ#34jb$Jv]z G3<&ԙY3eD'@e#L=gT"uEs[Nǐak:{`/7 Ic2Y*KG}}S4.eZ gi+5 d]ALyS'Wr5zXEx >>7a\wg]ݦVBtWE"/&Lǟ:WֵG{ 3:%IMD*Z-6.>>6P"$d,rVOЍ}[,.33T!b0)5- (t`Z8\ÇAJ(EuxĢWQMJ:.z*QˠD*,bhr4*v R8wvɠ,Ɉj_VrǃsTZqrgi x( edp6LJ>C8MoԭvH+CJdǴEtLفXb>g1r4֏}ĢXa|w잣 䞶jRj `L['8JAiv=MX ,؜ ft FqnS$ؙڻ:G$]HTCM_F4X%($R ?N'TSdmE r6ۀdAhEHJ~v✤CL_2 ,e(ę_6ABGucя諠:#(`sLy.mee-D0n(4-R&c]x ߩ_, /Cnr'e00JŊlqAtTϔQ֪ElyPe;XE6ڱ o̘‰Uz%} WZPpsFl",t V֍[U\9# Xem`BGq0r ԃ!PCw%~L7J-ݦ5}}(cpIx]dԖp& ¡=k4X"d)\; )&; +*5n;=M=ZslR |H[PǾL/8u'^Wט??g K[#@i-Q aM ޏpW) $kx 9yd{k3RRJى,Q8C4Π YѲJd*U[f.`N)z2]Kv1|\3Mzv&7\ӯM<|Jvjjw6fcgc]YLzSX:`{>,-K|nߏD[bE(t|+ߜIp_HE#joS|:2ǤYpM'd$n.%*%uM_2c*ī -tee%Ɩ{Dvw*0d9v>. '讞(SVBfXxϙcdR\uWmd+Eِt, 4 uP̘)4WEhe&Fl )u@r:ǧs6fwiZK۴?-g+V6tp; yQe[1 ~N1$3\?F]!S fd[2P:tgZk3&`hx뫍 {M`V&L-. DcS0*[_n?O_/?_~/ޥoxoTJ8ߔ oFj_`lW"_O_/_MSwꗴkR;LQTxf2͸bʇ߻Dc2\T'0lcES6aALq=cZo隱!태9X~^4Y*g"QF QZ7J1R+$єT_a<]]'R2QK3F @d믉_ ok!^߫^t8U4,W)۱+3D? J&w*] RnLmEC)CSj+|zeGNr#joaYyRZrVT(+ja>u<l:SN:*Y\*e]蠥z篃Rf4wT1ܪ >Zi؜Z m!*44zQbHiu˶[{>-SYMOzI΢#\%ۄ0U{Oέ-ódxdۭn44E'0bՉr1FrS?QVŀR7v  qT-ymK1 ;}_,P]]DEZ!ur?pTE|tʑ+nj<!,>/' ߥ*g hP"Ax\$%>b@st;n6_t DdU$Pa.B.>xHU0Tp4tf0P^Z/Α} #[B`ad"a*W+pTVҐ#2⇘>ތfw.T;D.Ky8@3!q&]IW|zw4V+37CuV(;f3 BDnɧ]lb&4@۴/K%JZ)REa6@" w$ϏBt* n_Cۥ?16W/TIX]5jsFAp"rL摩{O^#.UV&EC[5C#>dG!jBw-R{yjUdnSrx\dMaȢh?RV,|liVlm߽?EҺX)Ճ`6R/~L+&.IaUlj 3WTW{ ٮXv6u/00+wi}^ƵpVTsNǨy)UDVn@5l"BS*D QWhyGJCxً* Y _DtKBGPcA⎪,mgrAgqwQ:WoU/$ oخRK8y Y-.Kk-5߼fPY ȶA)G ELH*"Ēm v'IH1COwK RNiwLjqԾ AD8-JV<-O)K=\ ('aqW d2nggJw)4q7DrPYNHlU4~.އG3681LL,{ *uo>Pl_KҶы4A5DK7z7}@|"0 F똛0˵C?򂔋Ƽ`O5#RU r,EuCJ4gE _` [DŽѫ> :@hnx^Aly?$={I9<_Y>@/`(@' {].t9zIA}mYP_>`d"Ɋ4J)lΑ{> }wA٠+$o!-bE~YCTK߃E`1꾤Y.Cn ^Gs!wHpF6t?›H}h endstream endobj 258 0 obj <>stream ]5+!TQf bZ)~TO bj5aEB1όn@ѭbjŵݤiBWY&1. $Pe@)U+' H+W"y'^ԁ!9BdGLVXTyQ;QâGԔ&Wڈ[]C2nK,JVع%F *byaM#"5*RCﲢ=D~hY+('^a.#4жc+PxUd1r M/Qm?E}pҎQ4~7^®'όp}JnUA782vŗg<۪0 ?~d&m`DJmݷS7?Xp<XJI鹳gLl82҅@]ǯYX9ϦĚU ZnԃJa҇[9*։ovU@vyijz,}g\stFAU]Px%0@#hU2=cp'aAI_fF g %o&z~׺rePRQGfB[!LfqA4 ;RM-ܖp(GDqJlqfՒAi۬bc0ZoIe2" p1x 4V#-K>S"p|dbІh 3jMCb.3.zXG9jV-yszSd6,Hvͨ4,|ܽ&qd\aa(I7  ӱ\q3RT!;$y;} X*;ퟣ'r,IR>ʲ(vWq=JWW] e,Avq C<&>y7.e:-$Lv]ۗ9#ĦG*W1wH !1{!0t6@^Nj32B! ]"G1rGq&_rms𪵌*u"&wv88Έp SL69O9ҩ+Rڞ32V #?vlX 2cYuP ' J ?~tyBU76 $$VL7ZvlM+7ZQ66icWxfG]-Z!˥aڝlGl|\tT}nq'H7*P|FʏT~i^m-Jpes9Ҧ"5;a":r0SS1AG_ua ]ecQs`TV,n]SVgqE(ڊ$=T]. ]5ͱ'/Y<61m$[=]6hl\Z^=ߕX&‡޲'LGBSof|gL?`=sz08MJSoJկգ֮N^q8R !Abj\i#Oc&]-jul$]v6dK"Kw YU^E>^IywZ2"JSScΐ3Өs~xz6\ŒF0R<E ;-p 0&U9HLwBI88) ȍ(ӾQe" j<+xW0!drTΩT"FHq~w ŽUK7mشr|d8O*Lq/iD|EF E!\= !1"} ]}`H(R[z|V€f32 ]$h;XW?qcvL⋘ 1IzPNDJFÄ?&DkXb=!. +\ |*W90F h#, +Ql 'h[GC&E.zS];( r~ȠS)uKϧQƦ؏qȯQ\!=Lg) %Iǝ!0 sc"re!c1cXNm VLy#@}= gG_,WE^wPny_s\DĔy?`IBoh3ywRo7Qc j[CLrۆ0;fIpOw 86x;?31?VAwmt J2Zz]瀖ЕPeD*{e@y`,`F=^c=«&=k,VA6 C-c3x3x c_G 2Ug|Pgor1bIQ۔(&)  (/_q^~e2J-μgTKVPW9װd7 Lw>,NJzML@-}BUX#n)DSʑE^IIR?|*䋗PӒ=Q /RD|NjGi9 ~}}b7'D%LV o0gI>wo6]wnSHa"_>8v viX^_ D3Dt55-wr@tj^ێmAu )< +% d Q];RNkEX] 3&KR͇/{?d V! bT|E +pDBP.oB! =|vb̏hw Ȣo*fPUU;ѽ>w_ !< UF%0$k~h2?2'XeqIEyj^cA,!PI Uv_UVrb5T5dϺD:k+kX a+~j0bOoy'(aD:2Nnq}R2'$愦m-HAf[T=ީv-gxTn"2<.ObCAgqʂ ^ {?98NQ|s|4FLQ&vPuںhs|tEmfohsuߏ_Gy旰n No[MN7mKı9cZ:~UV[k$m0{s`P},WܜgL=0Dc3JI6q]%UoDN}Yn ˷dxpVK94TY`w-0 ɋ l +#\,*Fԕsn%|\ qaبbGUlC0`KKrV Dǡ3Vet;8O7KɊwW hf軂k0uޱK5-r&HАIG36_s~нb9#'}3 ?ay?Nndz6 ?+׮FUSZzV i.;^#ֲ[CL5c&K. ?9{2QqN8pQ)p5iS=`ȱ:WľSb=J C|.#,sxn8>sw78v:W!0ٴ BUTE" n)솔VmfJB J ?ۆwԲu; $]d;a??f"(՝P/U:ӇX" 4q.1֤-~ܑ^aU67F>!K ׸ ˿< e )lf{fMprF |ΪkϩhSBgI|z3Gҭt![HS2n(tV n{ӥ5܆J_O=O7H’NuZqmzDN݅2e$dkN Mp0hnc?m5Wa;uh70}FfZPeo:{=P mf G1j`*ʪ.,͏|ŴJ{sJwh2//l~XQlt/J3w׮kBatպbX>S>#$**!cӋqB}^1_>P򖞱>/vVx2 ~nwnAC|)QҜꊫh+m%fCH7E+vsG$?ȺҦq[bc[\p+AzkJg#OERU7b?,)ȐJ]P-Ē"߶Fv,x#|K'."BLْqr)ݶC9{C@R$]zFA3L*0 xtHэ4z㪚+@{F _e4/(w2+YS5QKWB^q+8aˏf3t7'0#2!+ݣ1f?0Zy;HXB<آF$0ی:zy5=Fr.8#QUt.0b| abpr8#4%#c;GnUܻGӐDo:b~OĖ;OTƃe,p?NqfF&yg.Y$$ CrŒ^" _$Qbu.YĪ iH%+{BcDA׏vX_+&$_b}U =w&[ -egqy˘ gS6q9aHboCU+WӚ簕UA %$Ǥ[Y=+i&푿@]3Q=.U h3,7uYd:~SiB|ٕTKZ8Jo^Į$]eduqF2`% ۝}:20MO[@e$ZZ)N h8/{[ Y=M|b*|7O{bD,ń[lb$љ>S)TF84SUsd9)a/ QvuVEy!̣Vo=4ɰBA%cZ)( ~8剜h2᫢xu k >"ڙ¦Ƽ_gmXтlr+&$]fOTu x͉r%)xL`OtXO'I =YtaLS__0A&RnCǣ[,uJ)V75F^Z i mEuRI}7ۂ=$G%'}pwI~+P :bP>1%A^_o0b͉#^2l]belT+unFuO:ѕշ+#}#uҖyeu%n _@7PL3%:\WFI*ʞK@믗¯#pfkypüz[ xj0)3@ K=&vNWz$>(jOou?H|\[>gԖ$֪)㄄3vn,E"Y!9MJcc+dL^gq7fGCMqZdT4nVޤD Ⱥ\c$fl׋Tj{f݉RN -1ʷZУ[X4>b.{.6=7ۈq}>o'$5+GᆦAk",TT 6+;GCpcQ(Pie`xZh7nizG=Tw0lx~)X9MDW/eJLKUk] U Q(C7/;E,/b0NLfUw>ȈkR"D;JR=!+;JB.f.7RSvm0b~(sk9JÎ/(KFL5&)+4;ߣJOy᝟Ar,h~Ď %\P0T9$4htsc(]#&dz>w*%C$Iΐ96XKRD#;Ϻ KRc 9VNH 4ӣ B Ty/h?E?HoĔ]} q?d&|'!:DXYnΎ'_uߞ/ElƇ﫦[^C nw'ɺj@ҽJngEU:TϊD:|gڣ4V՛ X<@ܚH!\[pQUG7^Y3,J;z<%Kt9ݳck\E󓘼Tp׼Ԧ`V*xmiVD7:dKNͫy k9b&Cpg5j?fa-Y~(-1-zJȻ"h,#JLtD (ftVp=L૗8V3=;_^?ZʩxQiN%vT>xPMT7_T`J U=|9)Pm~ɵKni 4 8E1#,ԋ[ĪhZ 14PJ{zɿ3PQC5Qب#ڭ}@(J7$GPhTPE4^]y)5>b;Tf*7\] R| a/*6@x"}Y Tm u39KH?Hq+ !Hbd7*̉+䖿w&ssO| )CNun/Fl:lZ~1(g3H &5P}E\сK~Fܯ0殀qTT}?Q5G;0 #hR-u85ç#EKIxWg`ȼc*&1 ^66iR3Jr8> seaweSL^myI.Q7u Y +6֙r-{Xb= m0zdO;A-25<9PlK;QTԪBC}\a|*\<[$Ytf6N3kq?zo VͲbma]8 VEÉ2Ks}D|*vy2]䶴3e4.9/2 *a+*wI s:dHEANrCe~.)5+&]H՜Aq];G #L 3|Kz+w.{ X4-Ŀ!SY ^]`UҔQ i>J@.Njຬx۷W\9Fj?kAOM5>cT~1@eqz.}f ;X>f%c:LF# u\*H+ءӶy-rоoXphtuVοHC̾.i|VOֱ+cupW~5QgN3Y2֣CBᥨ8a0ofDJlA!E~)K$99("ll @ַyHMO4(FguA=ѱL;Ndӭ樆Яg,|$ib_mQI_t[?w)gjEjڐUDBRf`B/'<uҹU5JYtbrr y b ЈcfZ ܰ3Ԯ | 9õYP$C(βj"rgώYw* Wc@>% ·u"UNW?$2w /1YxJ7 tG6p;4CUR<:1ĂY\ENEٻlOb%J]ְ5֨61VX,_L-BԿPam|&ScCҁf-D2LG>ɧaf7ưERѹp5Yw$ &̎,Q Csw>{Jd4*/qFJN & *}EPQ ʥI{ QgBtUY^1U`;ot>BY+:sU\^%w3G  9P 𯿝αr-GJbD$(\DPy?j[@H/Ób r9{Ȳ99-`JWȱX&SOOY|g4,]j먛=j:d?*?+/i@o IB!U^jT:Ix(.).8*ؕ j02@мyo,9ٻLE*ȎYʔ}%VE`p 0@l(1Ɓԑ30Pv h~ =ȋXSFuzIS C)eR|se}G[YVAu޹ EVd1)0?7jg_2&T,X\/zD5BK٥ޜ~4Wp+tWҬHI*q By썌싓tkZoQ<Liǚ|w 倿EԿV"9 9c zYϭ|imXη`Ѽg@մ_>W@ݚ<:#cmvɯȧPu)׳ԭ]:R_1滊Fˢfnɿ y\H ux};IoAFy\%}3b. _[]ngm0z#BU3怸8/Cb^5uӢi2H0N% ̐ʓʳלag-e!‘di,kDnh֯ 5Hp#;B?=gX~[5E*O 1b9OJ] >EkPZ 'HAm̒4%L!RMxn0BrU=UpMpd :eN^#` 4C9BVZh0@&~~;[m<`E^;s2,8Q& 2;{(]3.7aʂ"/*u',g O/#ab ~;Jhwpbb2M 2HȈ`M?;ʾ}UH~{lj0Uw'2aUe!3#QTjkhڲ=#=9FN*EH'[i. HIZwUSd8ɬUe `SZgD C?O_MzR6)-SՍ;?=4b%@S:*aJ>CB~wt cVfT.psVGO!Z"n` ϥ:EdsA:Q YEia;豖K|)F @ 5JRLUC^N 5寎b3{吒SzsJ3h}tH<۵q WfԩCo~<ӯ3HҢ֥sDywy:)ЖJ\&緷WsLɜAKuEC}e~b_P>#ϗ0 0%jJQ|5^ݧv DdԠ/yHGN[hbB%l2ÐXsc dFq&*V̸joءhtX s.(=_ZܾY*kSPz7 ?נ\3lbD?蔯Gߎ'ȋKe.#`0Gwi \ɛyϮt(r&#ti]X+KC aNs^:b[qƓlT3B?V/y}G82w Ā@h ^7^M&(%d&}EČWrd΋lKII;|i\5u)6JAdW9ox?o}@a9kCNsńɷNݠnVWmJw~W(x:ϭߠݳD,?O|h}sLTmeLY(h0IIݕ'4?=A:/5+umOC]guc$Ƞ-, }+yG5ҵ|7b_ၡ]nXF`dd)SALV~^Ϯ戔CoµBѴa/t{g;(^*. "Gf\qQ7##C/RI>i>#2WAQPM87;9;O1GO6Y *h㋔:&clJ*hYXOANB?.j)f㐁 &_Qqc\;nZa2y48}Ez"OCGtV?CR"Ρu0֡FzӁ6|F=/D/ofU\,۾F菺U~#e]oQk!gqGeVK1fʧ$~uR]) 1uqsa9~K|ϰgyb'y8魣^ۯ{?x*c ~nQ_".@v%(,N8 Z՝;`(S+vL4i9D;sR^%+IZ+$@&hH$iAEno%{-UL)6o®/OKA5.;\6^sX"lSjX܈|T+ujx ߇bk͞BڿG.# Ϝ 03ga.CC|}ṣizٱmIx0/-:jƩvEyh%I۲8>/c2*0CkMan a*hW}n q:#=([œ_b/[]I-umԶj 1h1;!=G uFx2FeiClq͌*r:L3lV֬׸|G[!]ʰnvЫ~CG+A5 A@tVDpc)Ɩ/r =ڱt2P11X{ p}\YT[ 00U[znc<6,4x+^U 8s+N% }-fc&2(Lj!*#-DjvS ncj˰++, Leސ04)Io}P*_NqTlk)=:vA*C̃P%rೊԆ Я轠 >Ic:kz(e7eM@8"22c AAe& ^_뾭72d}h5SF*bIwvn!E+BF'*gh@i0'i` v:4 7B=U`Z2F"296$sM4i{"+W̛ +Y4NH<Z:؇t2!7Ky;GhK?$ٍ VMkfj\\Eo!?븵:W?RZH89Q8-tg!`8@Sse)-xhirk}WSS fGJ!7q]=KU3>K $Q$̌-]?w3;,|QUӊqoVO5C1N.ym8St@B1?2P=U/.mƵ%KH *8Fh֮ݮDu ^Xv0o'% )Đ\L)jUv[&o37y+Wbc:⸡sC*Y.)7+lS]~g|qzͽW$ޗ׭aAOCnZa Ϣ=,.JQ(YWe6Դbx+SSavvHm⮮Θ<]Cȝ>Oxܛ^w^/%z+@QJkCIFVOkt:cLi2.#cpt0ʎz|zԻ$K.ob/?P;Ç?rSVvܣXz^TQ7#oYF]vF nx́YpOqD\c 3lIfr]r]╒^Fj-?gjj2wiy}"O/Fp;dwk+4 { JcBBT9kxrg|4jň?l I_!m[RSF x;U}O!6bݑ%k%De4?z*sF $oߟ\CR$CX(L oo9֙ͻԂ $3nXv%"j嫊YFsm.'R-ߎ Ajٮe<' W@~5F n1ue/-{hYPjqEzUbw>S%8_\E2eԉ7"E;qWa/-X1-(κwEcK;DDZ0(z(? Ja{6Ezw3jv J 3_)(xKdNÏo- f[_ҫe]o6أGtgbp6OYGiA)3$MK{<UXwwIȲ- P*mv].Y/[k$EfJ2=p i!i< )(O5F1Kݯ~)ӌM{o\<7Ve;giT v%HpL9tlS^xH qzBP|HQ5aQxNC1!%)ENdaߦ3As57Xw*4BgXG|+<%3W{\lYE;3ySC%7 #HNH]`(j8<}G> Trn H۾X/w.74C7i|D -Cט#& y ٤?;m'2rV+y2I&3Y%a&QæY+HjxyҋJ[8f.CB'8ӕZ!֚4hqSv_j;~~HEE[E 24:=]h[x(2?Q.XF워XdwkelO}ɹ>_ a=i' e20Vk Yg>ؐ4ާ]]!$mʿW,{K㽴hI\J]lHsGn wEYgºU޳}(4qPWU%weċ?Lj޵C;K񙧅-&*}W-jη(w{,EG1֬ gМmyzxsdߴs $zw;(umC_,б`ͅsK&ez UcnI(6͹l]MTY7tDȾ^y-<;UC[D|Zό`@l^/UWdϾT?v&k !Qkd'{`M%1#Bg1&-}w|]o*ȥ$>?|ԓ<Q>>${?t>?j#?Aێud'T~: )ʣꔠV:oBz;p~OSӱz]q (R v-;&. W1Cqú_?Yg;F-.jˌV +8 ~Hy\uŅa$6%qXQ J-.' :sK_]7{܁/|%Q"Jq,IY݈gh/kLgi zLѼU10MYbvtO}(@aJ||g@ʨ k>$8H߫;w" vi.)Qs)(#z>20=G2Qq.X0t&t3M:-CGw1 .uIr~U+D<3]@}*F)e7-2q凘X\PfL{ 0~I'05|c~ۏOR6;V;}?i"lS=B=Yis Sj:19zK1)rPvg"9h*{{UOdwz 1{{QgL*Q+d?}a0.Θ!0aKj̟knXK M鳒o#' ~W(BL }d;xj5r jgz~~*L[Y}@~J Z!Wz^/rToMuUK& _L_e$pGo˸TZe"-e@sҦ 6tzIB@-x'M:v_$ __#glMͯ|z{} r_URJ}x><_MKDj=9$_?J=vRL_缚H 1o o(]֏ ߃WwiGe #_Y[.fGNvʈ .{v_ &^_v=?(!NV;*B0c%3kD{jb Gci {SJr۠(ґ1;եc'=!*. #@g_V5'zfW 1.r߃W+`PWySj[]ýPisKIB'h Ygm40ՊtQ˦fގ$#R\{;C ~g_-dG즶\[WpsNd'Ȉ6aP⍁/ 6Bcҍ)D:,6]lj.Öo6=bۄ:9k Z7x}Sa=_znx+ @8OT^cї8(S져x9 ,{W\̌ۘ1ײDi0t^a#̈Y;YBLu `, i>㌰9CR#LPÜS ((ѭ71뛳f ';H%Ȍ.Lƿ5_'VmBGU3)i*-ٱ"]4=d" eB$E#},1:Db@U}OŞ]1}iXH.F3Ń`-_/N-vk d4̸Keאzلׅl †a;3?,xȠ@gh94e0YaAUN⢿HdAm>,{zDî pKI^ݩT* `0Һ*{Hi=ۋ:;.ԫQ5>ϺHE9Œ͍rWdN7D<ļF@Y. ^ @#\"1!ްf6ϒ9rj%,M7Ri}MqpTtx*٩J>-n,A)Nt#Ӫ=Bik)JXa$lC+T4R ܴ }i/n ^!cAEyDV: t{P}2ae/ y=Jq~]ϕZv +%}ݪsP-,69}]T\'PxÁIh}*jGb} l2y53m'I0pI ;d*X3YEu= ~ 'F"t#/; }w9;|Zh>P1?MŇܪ[_0sJmP d;b8Dtރyh (8&9;6M;aUm)8.:/D"2҂AYׇ5b,Ƶ_q$BݡuGlt1c}V7Fc8k΃@X;udd_ Zx?@[8׽Gngr(il{\ܔ\r0YkW)׷F+#f57<+ˁc[|I9%k]LJ5pRָ?]$^gϖ{!z>_*X2&dmmf>i欫Lx2& XfiI\Xvp0w~U򏯒e8ӓYl {ֵGe8QT_đn l'"M˟t SÝncgݝI Jz}Ab!Bw&*@ά'`^$_u=f@5z&RF>t(=nXsv$5!* gH_{.^IOA"_WX!*{D%}gR/1OjyPunc]夼VϬWP6ifzIJ\ \ƶT;rt,hJ[,YYoR;*o~BwA;R<~VJ3Y^cgٺ):Sԑf!PKEmh#̃R 0Ǩ~>}{R9MOQ],ܮֵNgSf٫C 쪲gqwXOVg|)Z3EJ0rAK\ gs_ŨmDi> RHQDfn3y+9{E!yl3 mR+,[%7MdlweҲ9կ` b{v%F͡}߀3T&P`#W2(ե\"OwIIK2yeSGtbt˔F.{]ƶ] P]Fّn2>r[#ݕj0pO[WrK:*Oy.2ݑሬVcUF_Y#zP=rWtҞYX~WRt4bT|{&+^JqGDhåŋBÚ@84c=?h7ʒkÈ^1ԇk{Dr?VW<3=},hY#'jW /3ɡm&M);GH>q=}k#L6rklf9Pʵ0"y){o%kWJ~@K5jt?=J"gXQ# k-7qGw&RO ׵z*aJӺcǼ:Taޟ̍EJ`KZIF06-g ]C()P ۰7/1%"0o)Na7 .bDuGEbx:y=4\\h=Y# 0!ߕmӑC_ .*㯯5]lȶ'٧!Q'2Ǒ;倠⟵1ZyVÐӮKEypR6W*!&&ulC{TFB*EflYH- .5:#&XwMhQy(qF9T],]*9Q2 ܁lH>TC m˵*3cN9Q?>Ua#06zU.aoŽhz/c&gy7UJp|24/ 7> OUJ]lx]Ekݲegi NUzDBrH$$6A딊iIKBK-r5;< $ay̺4ꨪ&E]E,I~ AHFWQIMVƇB9|z8NZ> u5{鵐ZaRvQ@jJL) I\$ȎƧs;k XRRóF\䠪ʿʋYC6Z.@`W/we"HIoFWf=0-/ң+jT }t%.DZ4k?!|9r ȚMofj|ŽhT̈́,v젴Պqڃ"E-YKb:cZBR6};`Jmq˒ً:[*\H]Ô9hȦIڥ^KHC?"v4‡tr3(<0v m+ /0!Q|%|L%k>pXM.~{boG ܬB̒ &*~lm,Ica%154SɉK %C*c9EZ}uԨ_ Y[ @?W(j_Z#K!h|ݡAg-tn{NPx C a'd;zƝш3tq$r;"k{/~rR>89K +Ap+EwVB"BQP:g]{)L|z?yʂo<]8|{h >`$kTtH>L2PVT_*o Uvmg.V"0lIb;Yî%_z/&vl)#ܟ"ԜR="s@$fфeV.!WȄQ:Qq!0{w饮Y{Iz/>/U/Io\<ꤲ#A{S_MM鉙R}η[gףU!۳F#j.+}jP*6^jցT !ha|zmjĺ)@2;)ζ;MDtu,Sx)pqu]]7"qNZM֪Dꆘ tAA(t M@Gڜq/a6?RM@W5>hrnowp?=VPͺr{܇µ4L)CP)9Eə諕N"n-> $Vـhx/jP)bH)ce-C#}K}aQWbR(ٞTb o^ƢH'Wܷ5P33^WHAa8~ 5¬S A`C(Qb+rHsJH!z[=]u=֚%J܁o"-?s :b+5Qf0GP!=8zo@RA0 zcVԫ֌ .aat,@tݳy $~?!\Q7&~uE~GJ>~TdxŨq"*jìohqg)Š4AyW)YϦA顊Վ'(Uy : TN앁2W:~q{<{ij _ЇXi#(łS)/ԟ`X)q3$I*%g>ދ(tv}gr_g6F9{kUoz1_K1'iZ7<"4tTN-<֊Y B ww,tvDS!~-NEKI u#ALOk0oj6@7 8)ݦD"s$3)ߦ"Ş̹1BmXacK_P*2(ɶ;@JEB-b "=0I%i{o>8W/>P +%rp}+֜l j]qW[?A:ؑr۪HOd(NHUNlU1$2 YGd!h\Xb0ӨVTT->QZ2/R||k>pu 8-yM%Ɋ&f{o9*,4:?*>zlc <&S7#TQs=l SڷEq}xK# *꩹;m`Ul*:]g.g}Jٟ1?^Wǒ6> "\6.  ծJih4Qs`Wv;fgh9rq aU*WYĘ f7]n찀kX ^}M@U"WƬi|ؗ4bS2 +憜c8ZWhTx0Σ$S[[z̢ZS;ZGrGq~a,DT"0KtFEDj!޷G$[ ސӷNdt-Ia-UmKZoZ/~dsܹa/6B .sXC;]Rj{/DZpr`r ) q]P3Z@a{<γ[PdY-OT51>6%|uk.Xre*Sig3\S(*#?b|]㈔i(.=>|zA]jЊa>XQQgruZU fyd`b,/a SD c |aG /;G.t^E<|UГBB!>҉1␯\Jm (%I2iY-=k>Jٜ/8|U˰æz)z#UnCr0 gLYNyjHM QY@i']gʖ*uЀרbS3*iDϔbR#yQ2H! Q#xnZ: X =G4DN-h&:WUfZg ND$N!=.|hޫswMC3M_*R4i<񵭃.QޠEWVcUTM ʙt,cQU5:֫^>0!&"Q1Xh߿9_mѿ 8, ؿC7ewϦYq->kH"Xl7]-Dѵ!5g2[-HȻ .8 ](;pfPƣ>w: czQ*ݙLģCI8Wf"T7'񂽛ETfPpS|uV5μRnہW[Z;h3.Lbgele28-!ӭO-`%h>fbHLieVꑁXӣH1#F;fLʟul 9* [-MY>/;Z/y񈪔pc ?9>=;%eSI}ĻY1teL>)0%L" J<7Q! ᙱ}I4־OP;"tB4j9`Gj?p7ŗKgadO%̕@߻Qpe'LR8Q&B!mwʹÁUHZQM[U hVgE\}K-5|hl7`t{m) !#t-hXrչQ BbQK-vwқeZ]p([DŽee}_s:07|o ^O 7T$En_m{Y"LZELCd),4rw+ԉ- XaÍ}]Ryv=Ò ~Ghv,S@*t2IW#%fYYcjET{BFP` MԘSzr }}/͒>d\Xm&R{DL;Z\LpyS G,!q$pom! )sET\w8[mkY:Dy\ 0 T;S)̂حIui "j? މ\֖4zJG;84=ZB YæaP:6bٽ5P$9M'0,6@Rt[O?z3H>h2fQD)Z*}Tփr*.\@! X^QB^<+]uzmgHLoR\\[ A%==!f} 5>+k*C9A צpF&R9`J{M=08% R*'љohpB Ƅ ;>?b JQtq='qޕCqNS.j쐨#a`[Eo;JC$c+IB&G5c-}+qGۓ| Zoo+'2bBBQ*[ ' H,4׷0wA^ocҦtL{:qOEWX3J+ztoԁ>4+1F3&]kzo=<:̟_1$n1u*ls:"~qk`/>0(̲{5h)͏UإQ{Tq4hie>5dtw)kblMEK\i\@!$,z*/n\J?jRԻŜ]tO pl&@\Js;]}I>> ղpM˔]+j*&DPEVIY@Mîm9/9Z;Cm񾄒|`/ $ .RV:lG [D6iӅsz2Ǭq ?zejf,ث:UCm~YbvTJO b-v8Xo/;pz™pbΨ!1 *\JuON[Z 7 \PZ0s_ǘg~?vFy*f+*?3IS>2I|lѓ3˩CQr߆/*W}+X^׵")d.QC0-^1C a ֟]ZӼrΪ(e ~fDǔ1L紤buPs+1J7~E+AA~CԠJx@zx[PBO#NN$r/@OV9PU<ۊgʽ0dqMALv#XQT4G!~5cl=Q}uOvYuЙ u6;=3 0$gw*0US3}8}Bf4mjj}J mtNIY ^S8Hfe$gN4*Jke5#= Ytm]r%ǭq/aD];aIO+9~p rRqSbڨkOݣ/r?r<φ{XHw}~jA##7l*WՍ gfi׻pK#d%`2Yld9+t+l(Z]LQS?W]B_ tβ1@x.7_+\\^wP\>\u`C)6'MBA?0BD"rB4-HٔT;x@ZT/Q9b=nOi4qs)gQiQz罬g+Ɏ#Xy$V|3deMzN:@i DM~`~!gIHOoqDdh>:Ww+q ?U@f}䦴?l}H6[uAҾZg:aaywjJ[]+Uhg箖ơڹ&{/h>7}V>mSv:Dn}KL{#d ̲mN<C#Bw4mOp5+eL 8@YClʫp0 !@-X|ڋ]*&vc툛)U~fVa$(hRk IɳVKIe[{p(;`M.(! nſ> {Mүt0iwDn;AHQeu 4?8'pTW׈c#V[2JyG9_ɡ9lc[QuNU Ǥ5Ǚ.DHLDegoCe F1|g);0(V\pU~;%B+ " ʧb$k2^fuBd@]ǭl뎷Ǿz^|w0^A6)OaW."q]NoȮ35dRQTmBpѤJ xVm57 #H]'{뀑BZ9 M 8][*Z؈t 6X$U>Zƕ`\W X`;3kF\Y'Zx!뮲|r;r =Ia{?MmQGއ; Kri8\j6R&Y[~Jg}sӪ$ä#BfS{a 砤ߴ\LH>R?˲ǿ i/SU|# ̔8FƝxaA|=Y̜9^̭18Jmh Ae/v?%N"xR=RTnOڨO{i |\Wm{wa*g!Z[שҲb ݣPCe=D?:9˘FgW$L#n5@ ԯȻ/IEVQy:KʒנaE-k;;H\c;~>j(nɲ}{yꭱbx5QPO~+x.)-HH uӢ ]X(VK))YORG-Gb30qszQ,EmoZ[**Vۏ.]"NDBw1wsn2am *>d*,h[ɲHY$+ O^ɦL\1g@hӲȲf0Z<0Xd~SĬcXصbiBђw(H =gS$IK<"S'[N]䁔 { -!^q>A;t[O:ʟY7J|e >Z?B-(ɤr]B֯8dP9R4iӾEV} ##%VIF̍b* T" /d`þn([29h)/+)}߁'A;,`YEq rNG*7!JYLQʗaVT[wf(W0Y ŭf| x%Cڭ3O@t*@ n+Bo?orE2X*XI[q).:0:wڭks!Tc~"\hnaaa(dJ7ߣַ -bfbI_6b2HbJ*^6펠 4U;W>YxLy;H߁|נms:jüݜ5̬bp=2AS`MYH=WHP>dY*+nב^HvXcM + AW1mo5ɿNPBF.(Q%d,5Ls%5R%ȑq`Փ8*Od\נ+ϧ1\'ou t'~L3 A^_͚ȁVڽw;%ةU`|(Iy:lnmП&s1Cn18i !Nz9mtʣ ݊nWU:F0qW /(:Wx/FB =Mhܰ1Ze.?c}Giz{U _( JŷC |_I2vA̎P'G[;aP|EC6hҲ;NjTi aC"ZxH(nT笡}e9ՠg;\o}={gWǘ8c҇P{{0x&Zp^")y-ۉƷ'1rUhJ}rS`k`v/,GNK6PeCyBCYQ&+j;W M-'kA" S`D2\rW"PX%"KfUkz6oCoס!F[5P8k3tdx.iPaEY-k--8Q/9gXxNAT >Q,ʕMU˩G)ch8,Zka+pn_ѣѻh,cm}#R\[՜“Q{":7 ( l$9zTXQ"f5`٭4Gѥ}} Wy`Xx oVK+?>fHoJ8JeuDc^oW ;E^৾^S9_t_U mJ06w1w pmm375ȵ3TeS*f0h%ls{ [J"b<5ڷc]+C8Vχ~si mZZ-ba\џy}_QF|t23zgE^>dFirOgtI^uGFtVOz䒞dϽ - j^*jT7"OU{wwcoAJWU ):D 1r+k+Wľד=+< BZ#w;SMي81;j਒#H楛M9G4;B6484yƄ:c~e-O:U3EyDH׹(q@s֩A邕L_Oɰy-9asL( jo#?bzG%1ulᣩ;l fTO.5P&4[kʑGuO]ͺ0F T̉ GXog$U)Q@h)\&?Se-Bx*qIqeW )ʕyf˂j'uGxQ,kLV=b}ŬD*m9K(HRh&>m_^t"l[#MAt>p^a^Vme!WLEJ}ŨU =_;T{1j6F.Y'n#V{o Ug[ir$*Q{n1ځ4U"{=0{穊Įw_Ÿ#RW!6U‹|Q2$Ͼ9>l4hxGv(2[,_(AG=tGO)oYG@@3ەgJiZ,zh%`It[M}W @]*>J~T$TMYV "j7j+dF%*iI.L9g;~UX*Yڪl[.q]_)+'-PWZFw״L#>8+H;PG@<6Z|s7+CֹB/P"~` &2Zb}Z}=VF\ g?naǕ+7v܆w<3Y22)V~O?d nfL y5ē-v"^n\(* cog|%1Xߔ1Jj@}UrsƮrҾ˱~G}nmo7`9@_g'X3L z t7,O7*AX3&e"-GnۼHɦ'z~ұ/ Ia_=?۟m32es<9[JO&57+?#ad}S 3dj SمJL$K1.dT00ǪlR{m>רO@ܷGj*jZ6x}ڌU4G*N{_`uMkŸgO9Rʗv Y{2PtﭠO]t!EC59+!Tw@C-*-)ap7phgץ:veX2-uq`akV x1e{ nͺ unP/7u=OytYDŽ^O5 fv*˧isՀy/%~f#7<~0TN,kkx)8q# B-zh2̴LH0X= SqqwG'4FnUxW |\-WyD*<74|e8vTAȮ#vdkH!8֡>7c}5$yxIw9>8O:8F>lOFLn~NZTϕqcF{v,e4f;j ~ȭzv5>qUKs:*f:>KjFXhߏ.Y(e57CW5Z X" =_e-=Fz3-:]%qx7=r9 M/߹[|B8Dl j&b9? +]HxGa~);z?i92aOsx!)sw 'lF TLY$s(,TeD=U om21x՟2Du~>ׅ)*A Fguw|r}ׅ;>#F•l#7M ȃQݾӰj43b)u)k\h,  (̵G .6AZ-W1ƭ۹ٸ~?e'ߑ3馬] 󱎹哾ݼW˧EPz܃pi!bQ @x>kNT)>#ҳBt:2D]X*F bF~ InV\k完jds8}.DAJנ9NIѨ!Ԃ>COPK.gS7NG~)3ǃ3eOq5Hq9ZFu;}vXycDQ6kp&osl!#+g OyVOg`4xT*6"휵aw<ΧӸ501#:[=u+DvEpGD]kzឪy;5+8k.Rsi()QwVLwm y+wGo)Ei;E}v9b{[ aN wp]q0* s$"O6Dv2t5~.1IIU-J=BT9EQfSNҶ*-aGV8Ʌ*9K/,95gސ3oYm^q臈 _#{wY >v!V$v O΅)y>rCO Zxzg(U h\Ժ`?D!MȾm"X#e ##GP"GMss=ꃲ5}3vHsH1PhT ܡ^x?g9*ptaϳAD(Tz/^j48UrC͈bJT=UU PHX HfXmjX3Ff/}?gj}y?m(T ƧP$0Wmٙ٧'Ղư4R#aH[nQ%f`kp[ е% E>%lִT8?^B /ht-%+mu`ow62 Ug}"{]ZBˬ9눯7 Ixlg5Vt=ߙ>JM8$a%1*Й!n_(h ![kյS3SN^c}3bqtd09p,ǾEk4BTφ1t{}B{t̩SƒV6T6_a;e=>ɐLK5ϥYNH@O"1~i?e4/țZyEfBػQ+#Kw8+2(3(f?X#7W{ʶ +7|iɑ' )\r;,wG?d$X|k}9;놮W=ϯVIyCCvvȭXw/߿(MFso_?(21c_=|X۬$sѪqn_0J#߿Ho g)Qvr~>ܓ\y,Gp]LbuJiǕ&h˅`||4Zr}|}:ddP`ǟM\Pnh1˥(k ܴ w$TCђCw4`^ $89t^O:BZ(y:Gظɐ smW}*x7kEi繟@5\ I!8gDI`MY {Vredu7U&g [^Mwlf!04Ew!yϾmY3H!G38v5M󠦩}alfUf?{@X~~n=q,9W{HkƽOU|%{<L ќڗ%3:ww:BMG^}ksg#`_PY^?i{G5~hX$**kaľ j%5R-cχ緿uNDVeVKl`skƛKBHgjLթA2ANG.cbU&R*"VN(KƐV\ᐮ=˴ FןB>0TJR2FS )ܨ ]T"@2 eJ?" Rx3 `<*0jmL p<ۈBMR>+?/Ba+HRp5Ϳ$6L*tH^3A,WK-HT%9P1e bZ$?N~5tғB-Z|pqM%$IpN)d{&mmԗL 0i_7 $F,hIPI.Plb"IMdʻJ ࿒QVQi-KaRg(e`X^V_=x/,_޶1~կFB&< V ]傱ǀ<'hܬ;.M[`MjU /!Y;y姝Q`x9C'$aԹLw@n ‚15$Р@`FZV`P NdHfn9ce{%rf٭TIQAX9+lj]c уJW !ᰖR4OL{uB#N74o o*J6RxP:pS-5^f(/#p$0Z[sBG,@$T D%ZWєagpiƭؓB >gmR2GOZ\ePPAY*,X^?ci`(򠓈|>Mkb%H#W!!>hѦZ $R]u5,։#J.X`>19a@(( _<VT>[ a!N`zIS kFJLB*R)2-%GV(:{3&GԈlˠ. 9ShGpS" Cz"bu^ !xºPV=c NAcRǐ3T%LԤ\bLeR҉AqKBky+L!tu@mF_+&0b3`fr?$l4u|7 ȑ7Wp hi`$5SIM^:γ8P= &Le}( -BTgm؄X`8,v⚈,ժj+Ti{Kg#r:lGb-y3PՋ}G&%_-79 ذتW &8PUBhU:L$XT Bb!lEy.UvWF?/wMS*:̒L ]O(]+{՗"wD#uҭ OP5Qo`OwD d`pskd3c4Y j,Z/ .' F1>U)IYܠ>Ve4"#RڙTP#FR5[T #2kd KRB_E +Fdsg$fv@9@}h h)7 Qu0G͗X4)ڻ(J/Ad`d\'U"P x6B ]>dOv$i*Fj#HzBs8%MAV}̵R|Om_;Y^~( E)pb ś"'?D1gT>њETH,0l*ZjVGBG3 `I +Aȇ f7GTN@Lv ,Mr_:+fBZdj0u(N`UK %խ0]c"bƊOʫi#R !je_&X+ja, xh@ :$P}QdP ]+Nbq4;KRN.ṶpXeGB7%Txq꒎Wh22L fΫ6zdRYuJ NQ (CdBK&E^,Er J30xYXb% Äxxס" Pw8@QHzȐDZS$o>Bj)Pui5# U1C3A,}\RxLP&91SIQ f:d,[=3x*X4ұү8pmu`XCNpB^VgY$ 16.sNtbDhAaP$ڦSArTrQ޲,+u L |5,U9(Շ2kro0"ϙ:1 ːp*J\F T`QXS*#+&#}y,` N-]cv4JxÑJLMbevwJd泌ewU"!:,}!RV\.f<4!izăffΥƊϥ}T2,%0˽h"8+D;%k`j|@w^/R-JqQ [cDVZI(GDGb\4-!F"Z:+6$8X| 05`όzxdLq Oiaf$R~ېfX6zH Caj*eIGA4$n_kiQoHLe]LdEy j \J*Ce2^)n<}tN2#p5/cڷ5I-A\EdJ % -㙂&I (lڥ~Ij*wSڅ_3V,*{Vj .ҕRamtl:7f4!ae+sD;.”shT ǘA|:!n`ń±&#uF -쾔Z[[15P@7Q`abo2TAL#1k hykr(J9| %N -"qX$,,\:z%XNjr)J i.Wй  :ƕ*lkO 9AobE_=G̣.2;BXQinSgRd_8CV+`nHՠKlYl ı΀dg"bY,8J &V 8qƄ7 sDP/א^YqW^:K0.@9/ ѿfx-}5z8%KTre;9QnN6d$՛|R BuKIk:Ye͝]Z`cz;p=ݱΫ`{Pb0d "rY!b^Ix| 4t9k8E}$o'y&E@n "$8Cʄ(Y=fB`DaQѲ &XtҹHV=uz)aqYN7amZI -EDDW7\ 2R*|8IAg?9?Xĉ6*ӄEaB0b 0a4͐׋ M\l R 0ɚp 9 4%-評=a'P V྆sqb-JG'Zed s(P!`ZHd,;MJ(K9<T!$_\'N+ "OYJ).!G8&v &\ӈ@^O *QX=@A*to gNv&VԥJD4@BfOd}!61VAM&E4֓]NjA=}Z!:O0L«aH^*%@UϘ3fR+%9S, ر NY򞬹|uvg܂Q'm@jNE20dPD PCIi@,JXg WEl<I(69=9( Q';uH߹APX 0:R[mYS;y`U5,<|-W }@A78Ո$Z뒖mLDi -s>XO hJ01( sv.CLW!ִTLSdvmJku[j:w]>]a" ˑ%>JX%@d# jsI0!X3\\d:&I 5cJTb21LySs!V9)kh#FJt&2gN" 1DIDɜt` TʈL\u1PUL}+[-h DI,>)>NEtB!DB@xVrDE8v)#qj%h*7.e)\᝾> > )υx>&t6/ed'0WppԂDTWeλ]a^=:TȰL ~U=:M!"\ԜQ 1\m=Ow ޙ~.ȣi՚RI]nػ`D}E[E. P;tTfpP8t6WIH*7b}+lBW;SvAd3P(|P(T8 '8!51eKD\+6ś %y=x;]8,8e>3%TITJVkq`u! @U+ ) :78@W]ȃfnD:UTJ|b Iqw})I1~Pv%hfDN})lZ2uAq1r0,ˤ(O&gf 3L 4%@e|2 %l$@Y{Y&Bl&ș[ #* sѻ.o2y{c9Eb=1RdžGxjN|\0|@Vl⥡ΤV[tҪ4%iV2+))xk)@Wo5AMpyhiGAE86gV=#"@X1Ҫ;N5R)' $8s573I"VSSgo0PK U/$FoQw}B\)~y0iL萠0M>/D"W@TJR{GqoHD7?` `ј3e >Z}vk!ka1T@*V[JPh ID`)/X>#-4W WD]e{aŌBcI Lir^[ jB(ASk,;p &%D dM`{MR)ހSc% =QEڈ "Xұ*X)Hi0RoWDv?(<.q#лsR!C bhJmT 8`BHwqLS_Z@dp a4*0]*Qz%# (j5h 5p)6d`GS6=(ȡ(:y2EHRM, Y?鬴x{xNby\JF:*tg4p 2 p^|lX5Ӏ\9Avg*W"Xsj2t4Tej++tTM`Ųai$a|Vp`{SKWu 3<80߷ 4·5:@>( d CP1袢Ʒ,."1?l/$T y g㽃 N_!$"`GTr : 6D>k\%G #Tx8pIċ R*2YIMFU) I 2VHYr}eȹ&"SZ_=!GKDT_ DY#\T3UmARU: oa !b`sL 0͹! #4))j #1NKւ @ż{)b!v;C M2 ZD<'ߴUB5N_i=4z>%X2I&S4W/UZ~,T^%XO {P Y a l+7ÔY(| C4* N,tGTyȳR1 ġv&+qMS[2)(1P@$^^/F$&;Er?f'R$K`yɣ ̮G)j^ 5"TRp""zNl{Z;V" VLje"9 @B#?P~4S3!fiv5+^9ޯHoo.4JZb*YQ@ӳP,)''tBp̴Bv/Uҁ Q ï/Z7ܧTh}uǠ<[w҉UN* F 'wë'RcFilrSERNl`ꎾbo DRA !\rl~Kd]20 @W/] X߰i.*(,|wrKo щ@PA^^ ҵWq?"1m TP`l(X@4&{>x̨6 ?kbkᥙB!S)Y1 )5!r%L$#} $ID(j:OÊ -A#zcw1hT˘W"胉`Z!",$kvJ?b#P|b4 3EdLɁ.>'!ba*)T38*8 Lax K A44a 2`qB%F!\r܉"f\S+1e%]oGs$A -5{|;6 H 6x3|A7g̚aZ(qK~Ӽqb ;tDE_cR0ٌQ Ľ.fqLD@5Re'1&{pLtflJl)}VeH h %݉0bC o/"a`z~H$hyFxe2s|N\@yQ Y' `qF  V s fHuA|*كa>}jIErʬPב] קM Bbc5R7Ԏ),*tFV̐Ѫ@>6['Ya?|do0?@A?b`ثDtJ'WXroIdipr@)6 D,SP*e79qO] L ɮ)!|hDTݜh9$Mq5KUGݙiW84fM(bǁ.(kJ:pR5EL8&bLC\,D`36g1@\{0:N"N̗KE4+ ˶qLfa %%*/|EDE-v7^RlWzH.IgRDp,"i!B!MaMLR1Uq~7mU qW>K2՚[x(ʤ$&a`Ԏĥ@S`?tg.v**"1v*B gfF:[QVn{_Ԟ]lUE&km\\ˮ:\i6W ps[-ƃxPzم:hHVK.ꃴ/f,Y2-ZŌ_x&p/bV8tv/.*2.5Pp) / #1[:.|Kڻnxck~6l;=* UQ맾?oLɻjy~6-z}nzI:ߺA uTUJn~_eslS]V?39xqئ)'@fI(E Ϗ̭@fYNIU1))<$\},l0_FDD!N,~gY6ՎA/a02iK'3ЉqE2m$G%\@fܐh_ΝT)lI/ن*:8fQiGTn/鞕Gmnn_?@VACX8bɉ3ĺ¥/ 8J;+@%l=]$WK b٤~L%I1laV9$: WAu(]JR3P-Q6?V.-b.PfY0: R.K_rs}],1B25JFB^it&VfCnt;LYR%>4""UU﫼-(lsXlN7({$B/HiOPEay#SXxXM?}ɮ5ڡWBC+Gw=~&>S}BcKfC Y0<.67] $[ sbϐ$bj7b_mN>*{PYFe=Ð :W!Z H+Rq^4r5Y^xw'5juκ}Fˣ/\DƁ5fκ{@˼ lR3bde-4Z_,Bѷ\Bbw+m$e:h 622g)N$&cu hȔNS4/M`3i-L&nYq%E Y֞B H6J=L^gi?U|#-$"s²v7gUe-"J4 1yDʴ2}MzE+Ӽ.pG.2/d>W&|.r|d\7ԷwRHjﮐw\&0E Y3Z93,\YL/Ed8`3텎LtP44ư;s+DkdhE#\3Ÿ6'}އ>ǘ$ѐV' 1 /"K1H-%@gm\k)P%}'gSV$mBTGRg e^%a%I#)MR_Ai'5ㆉ\;f zYy-%Q#ԏU%:_Tu*edFukIU0O-UJS 5:@87u*eR#Ó婪N\2a\O) R.Sbx՜d$>WO@ O)Sl]DXz%09^{gwx<)j(x|`&;eUqZa62t+TVHQ!Yz䴀J#IENԨU.컬 \c %ɆV*}N%𜕍SɋxY:ԃq$megէğ 4ex]h]SzO ja&S֧PO)TZ¦z2dR6L$߲SA? ȱyD.%B x.a \08 1Q.6R"fY{ET nLk3O';W %EXZaf|uiZ!j=rxs8S E{<I"eI'+܊m"L눷0Wɴ,L3}0N=k>/QE(!܇PH\LBgא * 5%2E찓S7wRqŏ4Bآ+mǙ('%G6gKW@!# iJ/P=V-8]*PRi]jt3ČQ  T6{&)EwKw@Hbi ~CgdCӝ$K9ܱnZvT 8E N,AB|D`c|Z7T"F H,\'_\rr8hYfм(ZK좃k'@T*$4({zL~{; z9u|>vO|=4&%ŗysiP~8% ~bGpX N!ZAũ; sk&fl$&|5RE 6n(PnIv߆ЄP%x&FeDJMQ;ToTNvjBj$m.i~/I&`5'XᴕHjXw{,En֢"D}Tz;n=6SӒ9 BW!hbY;yڢ(ڭ g%ކx'Yܡh)|\ޭ.&-.?qxsBR;3!am~t/6km` ݑO5t5ƩtT 9 M|~n}T#Nź8T+NźnuSq*7L|8Sn*Nź8Sb]7NU}Hm}uSq*bTu8a5qSq*MũXWJ0)Ʃ0BuTSn*NźTSn*NźTSn*NźTbTu{ũX7bTSY7Nf8b8MũxRq*6SQƩl*NfSq*6+N1uɖ.˦TlGͦTl6IqJPTSdkCq*6SY7Nf{ũl*NfSq* ,q*6SY4 D^q*6STSTMTlWJP6STl6b8b]7Nź8Sn*Nź8Sn*Nź8Sn*Nź8;)Sg[G;chlNN])Rur㣷uurɹ=cܓbScEZS:9Nα:9GԄԎN̴%:yv^'VPlZ'65R:yu^ܔN uhТjGSӒurNvsIbܫ5N-r3N^;+]jrW'cv7]j+$MozC'ѾTI>NK%yhQCޛNE]ߑFsPq+#>Z} "<ר@U}$#ؒ`ZuNQLޕoǵIu\ʴuI6ӸAG:MFwJ׶Ju{LڨN `!Zui'yg:޸0†xBOZmܗ&k0&OdCmԛvJk v {Rv5kނ1Ћ 4r/7Ε.{{4ӟ-)kMqI|/:Q_k LDmc[m=آﳟ s9aNB616V8ԐFaAXa^N 54[B}ID?,!0*4TeVRL5FY!{uP!K /aH/ f`*3L];0 % %%u|نՏr(zJOܡDsA ܖKyؽ٢3|̣ :e Q@GS! z_ yX1h*й<9\ܐ*yG;ehqFӀtʁ!&ACmCAd{{<1{ 2 MJj!"puGeE弉 *jjbUB*M_0%eTsK1L(M 0o̜ I6T oBD\6 5d%=K}n8nhCj[@Rh.8 313p]ovd&p|1K|lNͩ.xĬ)Y_Q%p/ Jyڵ7wbpn?+ᜩ@w ff{ؕ 8rsʉZxFo/+'vﵴ9qt›28] ٺʧT&ʚnĐѽ;+B8 Ѥ!%JJRtgބ+) K]':+ Yp\zr{ܡyN-ϭ]v@i%i^P:HwynP$6 endstream endobj 259 0 obj <>stream nD}]"% ;kMlrҨcXs[|tYf\U1IηOwx껝&prPZkBzhy*ViM}/9o/h{UDz4탟R4to0/YrfOJ`.כxiҌa,M~I3j6.'N^~/m'ߊ?yso7fVԷ)r|xOm{>$ȺryA`3N/Aw^lx{'VkGov7GΦ ')^x x5vRnxw[ߧ+ߴŸvC>ּ%e[ۣ1VMk/]ѶBGˮF]_[#hazm*hHl~<3dɢ*xtCIt6ڨ>7^glڢ^l(΄tivKg;Mh;7YIg#Mh#}T`?MOw<5-{.ѕ3K5|?w4;0z3ML6/oWGimv}s'nxϭoSbi=$(=sLmjy4\,GW'?nD_?7sqKHrys}ύW7&͝cJ__Jhշ33>^&oz׃Id}09?䯋hzz3\Ӧu8ӳMOfh*2|nm{ Ǯa]RoO+ov|Z|\f;Cz2r|ye;E'>"{8teǞ=o {陞3O7=oe 0?u5`^J^F묿Bz&+P:"]5|g_`ϜlNspr[ .KM  hP gnn Mo7k^3w`vf~ д98©z|޽͞4 ٢43zk{dy^Eo >ݝxk(`0޹]^ooUl$RNBɟʍ=Av={[t'U\fWpO ˏ;oǻ5}n~2S;H_讃?_TiU?7/6i |m l^eUg{~/ԟ G23t<LN__wwvGt{?ol q>{;Syg-8\|y>i;w=\}yw}]ic ݄FK_#^ 6׿ܐ}1`L+; v#_/ K1Ț=22)CL/m0M0iwtKO*ۛG!Z/S!d7>` _ -\#ѷ.Isg_䏕-;@?!tGb7cB i2$5\WNd~ 4(u =8P{G.Væ*cx ?=KG <$m<`_JN̾xY {e)"LMI4V9`cb9! &;By.E&yzi129`_ @L\q4~,)Y- <_JeFDWrf}9v;mtojupq/fQL2W|Cv}j2Ή~C4ey(A0A$|9 P龝Z\F6*; 5_. p78,bEicPUG%A>(N *ypEi'[05<Șr 4~Ð$R);Q25Cb#AAJ YLL-{*QvO :`rwr^XUvdb#@]P4`:O;>@>`r^o E^cgc1{Oհc;?[AN˗㏙=eEf_N` D~ ߞR~v89AVsYMQK/,T)/.F lo{㿚=|9JMQr6wCgT2)N8<_rZ s0q,miæ8czXp3d|cTGxay.?kEG0fp{<`7{>n 7;h4o(>"g~}:-GIp0~{Po\}_6csV:K_==|p9ۛqޕQDCҮ'*]_YyZ_ȯu&I??Qo]lջ~S&r<NJnI̥a=e\NL[837kzOr{kS~=E9dK*$iqPe${77?i5'ǏfM/o㮝sJ<3o%%bͥϳZk^OӾ&*WC>f&'EOoiF/OR#};7Z,*ј̰SҔf<}V-_jvɿ-ӿb8}Ohi&WѲ}LՎ.lnGݍp+'Vvk{?nHs\Ø~ӣM~vk4nodm_EmVr}1 d\ Կݯ/fgSOp@b{`/o?`vowWviM}!<ߙG:>wxOoΠ}{o\OEBd͋DC4:Hl[$~xū/˲6Ry7o>{gۻR ;` ^2{d7K-2蕥͝?J/b_r3 CKwi0vP[^HgѻK{5{~_O;Mkui2D$.{?@/(vڷwFh.nղՎ=٨)ܾ#YڝBFAx}^^;szw"zVyEomNt/z9Wԛ!ʪOGӪ۲-n,hYt[:ܫT=ntdݖvݖfnd>_w.LkiZN=-x]p{Z)'{2􎜾rel$y,󩥅VnVtsgK%pjQ9RZ>1Osk9{}j}XwD> 7o>fFw1䛞G pu^w| =<ع|@_rSI1 "|ә?-?cck;[> Cs;kC|_Gy_ɷ!~7k%\j _`aP`֮~pxou?j|?bY1We {Ȩf E?d\/ywjIɰVl?jxc rK?\}!Uԗ?T3GfQ{.h-:/M8Dgݍx.,ucz v`3LzKE Y_ 8xxk~2K^G!4 K';dgt9׷j UfzÈ`fK4F<4 ]q~n+.Iz.wکBﵙ-}P~ƣg{~NIlmixxwz2Ԧ;୷oz; j6_?$x;S]pݞN\;4:}wwgfGvD9}{0cwy}_;v^gVn4j(覆v=m~08o_1XO휍趷;vp;\I3D{nww͊s"&[2Y bWFQsomw;SOBr]7 ,#9ρ~c qXF~ܑrkz]tK!گqu twq3C^ginޖNtem/}ؤX>l7撹$ O]VDW&vn6`4N TFCo9ۏ޶zTg Jҽ>6h_-^<ɝ}jyfm4ܪyLlu}ߣsrnZy]4'7VKh.t|s^o}ff. ~_(h]wR2_[l@4Yu`t f}غ7{wN}Q~p89+>d0vWWm ?Yf{ȭnaS[{)\rOjdKh8o=1ga~/ƛ y^]Ym>GK3r*WVypf:h Ƶ6޼;qqXXn>8;3d46f?{\ k4uTl>?_`kw\6#Ms[eV۪0lG=cG_<>ZwY}sы'/{q]?>?ҡC'>th͟?H;95_x=8'[W.7Aqn2L2E@~7uw_lZ?3Zf!sO뗽ٽQ?~j}c7ϝ?;p..xeq{rv.o1oguTk1o 9| ɍ+ru0uLrы_'/$եg+;\-O S??=~r}rR]=z؛GG=8k/$3~>;Nuw_=}oX1{v!ۜ;rܜ3gl&hIӋcw^lkevc5}썻+YZ=~z|曧gg.?z~lmf_w^s^ zy ^<9mƿWoOs#W}fGQ׋./oWW.\{u:Ջ[uƑozsڵlxaxѷs?]xwE»/F?~>]rҍO6&텕kgݛo[s?:l`.V{yV7w'Oʕlp@=|f#'ɑΏKogMɾ::vci)w_˗f~wevNmvrW޾ՕON9={d؍8 gO,%̻wr{k2{b:8~ή,Y^kz;s7/-( w|zzst狥Go>zz';O^|kegN8?煅ΟY|xq-7S~xz7QqYQ,o>x3?0VW_|25]yp7ݟ\>r{اk{^Z ^ݹƛw_o?v44g֎ 3?\w{׎4bcڭ7Ty{;N~2ݺܸۙO]ͣ[)#gCko$?՟92õV̟9=c:?=t3_$O|ɅNzݩ'ertvѱٛ?<5w죹͝'g&'N"43WڥY~g]|2Id^o"+Ny&͵ &?5~8 ^.(?}|rbxN^;2<~ް _:sƓY o:aë?],|~D/^W[pŕΥw/G=4z6|tΡ3' NtڱO\{>x[ǟԟ[{O4rթˏ o=R,~zNjxF[/o=^:gK9ť޸>wȚn.??l_`lKOnmxfw/wOxV6<z/̼8w7NɉWg'.^OV~\xp_} <=t#<>_ZǏǗ~[;_.6/{O󗯜:>7_lbNIN.;1מP>`@j\aK0a#zg=)8%@IIBʬK 濃l'"?rgQJ;\ևv/U;φYkΝG㡮Odv՞K9)׿3yzWA'G_[}+z- YlVTmS*",UE/L\Y GLYk,vn[vW  ~n<F4M?#Z_ݲԷtߺ;|nhu7[7\ap/ήiՕr>om ^ʔh+k>vfY O0g%'MltwBV ]5zQ}rcWK[>KVP;HS^Im%Bf msGߌmǢl 'Kԟ &+rBqT֯Mdl5D2q6cT9?q?񀧝@XgFy@s5G)Ui[˒7[_6I5\{ձ_ I!юPԤ#0`uJ&5o %=}T/h2sԍG$֮c*4t`]_+9 M1A=ctߏ\eG pqp`벶j"/f'`D;qd!Ik+E[0r5S.،V[['zIu\)k7؇qKɟZ_f]Xedwܺ)+ꂷ9^G^O>jUmW"J0<KA^P̨(;)ևY nQa [Ť$hZUbE1瞧(fχR?bú'(^s\O>Er}]0zOu?$᝸+LL ?8{-G{[$Fb32Xi]P8nfm'`l)sN~K~}'ܨunM}s .ijO\9G)|YZJ5y6yK k<15C3[?[+kdS?)M7*yp:~H_-E[)xGo7dl}$ӝA{vGyXf:2;Tjd;=a7 Xյ߆&eb\ޡZ(՗^]2nb@kosg]!EH#EUuz֔-iK %_rC )r:!oեt0u9 {X!Z*RB5JmVцk@;2H< lpf JQY &sjT~h\/NAgeZUߺͩomqA,ѦAK:|`㠂MD:aϭn=D$ uU{v:Sb奃V%?\3Ru[qΑ~.}򥘿 Ϡ}rspc)11 F*3qG[I*ՎVm$Qȣs!_lP= Н0hƒVgWvEm^0jObn.;ک $! qsx͊Na6r)^ 'Wøֿz3]F.R{Ӆ/r!nz"Oз.8]?2o>ŷ*!r:XδTn~6ʡ VS/jG8(%3 YKi/o浺L$(Oj-Iv:عޱ䥚}->ZReeGսj&Lt5e~JJ|ŷz]߿O[90#}֮NT;oY9J쌿˻n%V(hwt]0$hUNh^7سr^-6/A+ n Znfuwz瞰2 $mj/ML+p\n {EuZ=rnŒ48u,qBZLqs}+v\ A{Bun[6i-Qk6ll͖BQOUjƦʹ~k3^is nސbchWC3*&sވns9C":1ϿuOh"hH%읮뒣2 $fbuM df,o1}ƞv b ] z QX.p/h{5+řgGZH[D{%ߠ5;Pm%/' i:Tސ=owD̾7|wyK7|(kO^=;8,HB<0VO@-c^%jgSEHŎstPs!.$)Y`[v/#B#Ey`4z>߾l[ KѯfPW 0-3gwu$G?vp4tDi0]DQ~,mxSkrcS5hX[K>1Er HGlW&d^}!ҷkSZǮ;-?T:T}Rw"q8wj٘mMQ@[U9/~nNP|wʱa3zKB5KIH݈M7K;P0w6'pF%uvsܮsC5nDqnj@hF5<^ iT49+S$PFXF(SsVXA!w^+gfس|h %MvZ3OҮ%EB5[6@b8eߢ {.F3?$\f&?*y/Ād+6Û>~kV0 ny>El+s~Y#lTeb(@u!fl-8=blQj4>> !vU Rwa?\x%1\ 1ڻwȩs#{u[޶e!F2 f4k >~{KCߙ'>_oKwț z_!s BptœF9 p[ U:/51j\ٽª 0 a䢚o$tŴAnY m)sه:֘ ~~/.e7]TFT, |L-uNK!䠪Eյ?wf<-bv3ﲧO#v;m8=܁t͞Uv" x<@F&#Zeӌfo/ răϔcχ:c|:weZR K,/Qkm1aXk2 z{()FJ:OڑZ<ڠEsPK^hDpz,A"y ='쒶xZ{*yi6ŏY Fbn-qCVzh򲫨Mݿm(UmQۗETR _:D.Cw=h+tME\iͥ(W[Uj"ZhY'+S${Dt4:%f'n#s$/fQKɨzpM'ƧݙR`^"1ܔṔF~b-\)seS-xW`s:.u+B`^ _x*g̾3Z$=ÑTP+%'-uSעm0?MȐ#}iU<a7Ge1¬/S\ c%3JRr6/o˫3<"#ef'}&}W&U5NJHVS|p%x.y/w߭=EVy9^605Iݬ99^gTi'Z/>̕PZx.Xn݇j%L`7#M=H_eUHa{G䏸ݍmrSjb]uHc+Vq^a&a/'Hj&>9ߩ&%^U EN|QB:ѮC(=% 1=hD=S\jhxt@ e@+ԋMڨ%%S^ih-~|-^Nŷ@;EήX^a+JIA^$P-~.q\FcD*5Ylg姽w,`6:%fsӴ:*T$GRةy{Wz:-U.\*2-fL gVͿF_s|+Ŕn2H[ow2hʘKsw|*LZ ee'\݁gH}uYG~I.U0h Nm5suc^Hj }]jY њhi]׿]X Rn ա-E:D\k\yI4՘˔ŎFRs*w`ڬ7zUb0П1[gzΖfM +n&y:>Ʃ?gl e'Ln5Rm+g$y0z|yR}oU=/؃ bGˇ]~uʰC]zoEV.STkRuo6Մ'; u0og5B%plfpsċ׽l7RhQˑ>?"c+0sY[Sn-Nc;>c)i*"i1Α ;^2/V ͫn{>~UHRG@ۋ> ;N2d켩{a@׾lƅ"|>6ջ>rq5X@r'=}Pn& d)n*Z'*4h?ɡ6I}󍺩hEOسuԾ h|@h>}}C5QG.6/#=O-9#mOvDeZ?!7S9Su&{umy=#gj %"hW=֞xB36-iC|%{uENsNMa_u)78hWk٠[@;2Մ7hXZwں:5{zc]¸@޵G^F<8 ޓZ8sk]Z*RR DhaMy?uCɡR zn㠱Z1=W[\KOWiZ9㤏\մ_BDNHxGR*a (f[aܚ@د[-lRx֘`g7F['tr}^tUqh((oc|z̯=^Gp,*ڒ0{8P|F^˭s -#Hwե" H8i6so(0+T74ٓR`/4uLkOoclyET2My=IdUf+z DBYc $fܰjۯUvQpZri_!pϏ8f6^T&qj~0M/T69DȎBuP4p}'~,m3ʗ;=XYd{h7TLvn`s kxZ g:]Xh[x769`7Ri{j\ٰZ^Q^T>A,prU[?APNc(6|QP. GƙkwIR #^J^0VXG3lW)%}[=+! 2=9eCЏ1 Rk^anwK@MġfI/,ΫU̸KN L8%4rԚ~CKJ+/.Gȥvqq ccyH|6uuC^P[|?mMi**}@Sg:pIYw~ UVXkq2VJp=U]1F#eNyl.;H? ֯q~Fsjm?$لOzal] 7:'5o`&5u>֙nAڍ~iZWXJ +CLkhvfKQ)?|Xp]7GC&oS|4Vm69h?ǥop:Ƃ&NN{>: ;Efl_ 2|gxT?6%>X[i_J)^x|nw!o yl:Pa|o)IUħ{NO\0 ih{;bɮ݋Mk]»{<"f,/ʧJ\{j'N\5Gʼ{)WMr(o =͗n;zE|ahS Έ]k +bmurJSH-P[6UHk)s:SɈn kjzp_QR yM&0 $l/+:(!]4==)aҒץ+5+8qS'R$sj ;s[.+y>k\f-wmNw5C'_ޱZQ^=]z)Tt2ujz$C%nOnk^qf;gսNk+U48x[ ݟ6**ݱ`wz;)7 ֳ js}媷dvMtqDDrH_cqG/?J '1IKe# ;d$˲;g G_:'Fu?72:rdANŴ"`\.7yXٔⴽ~jKv[eu41RxBu&/NlǼ:&ۦI_O$sW( iѧa;uoHg4suώddܞ\e64ktq|JnD_rd ~}&XKcr ˭qKQW*GQQE1xXf164-lo23Q/ &5ts薂c:z'XsN.9z+Nq;|cIpѣĝԽ<.C*xR8@b3@m|$G8zkjj3w%ܓD9mŪ7g[S"楚[;C(u^calg"8`?)K,ܗPܦQ~gH J\B޿".V͝:` C 9{uADS/%ۖ6cvk/B8ğ=M&@ԍxpVKbMyZ͋H|j0"k#vݩxmUץڼoG߈)zB8-p?iՑ6 3[Rb Ò_O[+GsHʕ"9i9Wo wɵqf9Q7e哼`-G`c9o*]}G%fFfP\фE> RƪyVJ]+ 1`KbJ2>l;(+-2EvdJV!9K)"oc͎ePڴ^z6XP">?J4uJO;tӎ:TZH/MܺMW¿VJAB(^) sk1ʭ(P4B]*UE|d*ȄCf*Zm)OT2G*nn_|Gm=@^ צ8z=R>i*xyi6iW2e(I8v(Λ&i*=Bl:lu };*2US{ZNlDye8=GHk.u70ڥ{+Ϛ35Oخʢ M:G?oG'("_T!Y,{gHv#3BjR[7v\6ZI{+dgIۃ)(USSM)xlrwJX}k+C]gafTj8)d-{QlɲӖ6CeFk,ru~E7lёɍD3xD:Xa,ATxb[pS|ùp¸U]G)ܷWV%+ՑwD,Nt;'-2.ryI7(3onWEVyAJJHWa=YMfEP+m.ךt]uw_s9~([_wcd6۟5<rz.wE6D451w3RH'U6@馷GLk3aeFʸjH@fGkѧ%֪Ҕm]ުFٕ_ʸפߣ`<J4@F M蔸mٮYz|b6Ɯj,mdY e{ U#en[E`}o2 ZSKec ,zr%iK-qlayz/+ċurU>-N?r~Ư,B#t^jFsdMbuZ{RG L}G(V-'Tpvn" {j䎭[ҝ h%Z(3?g~OO8qf۾iNZcziX1}(\\QRfVGY\-4Ͱx#SϘ_a27γ xTl60_g@ńJ=ݽXs6x=̃&^un͚z:VN>D\X]Z]J6X!u_I.hqC8>'F{ʹs0JtX:w_>zW^&ز9Fp؀0% vp6*ڨIe2<͉`q/U;# 83i V[[ k?HV,$ggZdїjMp/?c:Ǧ:[O b_XPÃ͍֑'hmV6dob,g"hR7. kj.dbkB"xpm7蛤V66ԧZg(>HJx]Rsi^cKbF%'&5T~ b=%9 d< 86jШsWNϭZ6t[DX#WEPH'Lbw~c\d,\׉jCH y㳽aZ-̆ ?$̩o篔e4W9nXз)oQ-?- N:&Ck6is4{UTTG1ZpwY)$>0vMW~ CK7mw둏_Cի~TTi'KIuc1O>0Ä PT;gwEOBX);\eDN/yԾur~V58kyTJ4z- ss?Z)ꚞOCrCz|K~҂X %~%&?Af\A!.[X)ǵpNVFr{UGEA-alXպ$'WafiћQ o9F}maUѹ-놾Nlv1jHGhs2m/HudŝDVBL?s2Mֵ*X6$oΝ}D:dR$رbrlo #ϴ[ZN?:BQ\}oF887=j#7w <ڜ݌j| ^v Pzktsc "v?t@BPn4UIkP">c\K&q..A_l%(l ijNչcQEaQj 8G2ED/}R_Gr R%L_-7VXg^ݫ^08h}jUYXD =i)Nn&{h? zmrR-[G.$L==q踑*lp'.i͏%*'=Y{RD-"PXׇ QWY&k/P:: _jeR.ʖ?dXfc5W.QJ˨2ۮÕC#iݏ>殝F gZxd-jw)G@[l:M!,&[6G>XI 5:HB i{( b8V3PP.8jDixs?)S$fl_,]7}LIp L"}3:g37>52eoYͣa8#˖3U5e<4r-*IoTɻ` C~[C< NCە` q9LTІ KJ,[GNFqhOU)d#e׵TOEgP@`L޿XZYnn~9@<fT%i<ЯJzjwޠQUԇ/ _ĭa35GwE4~ݸCrCF̸>Vr$}Ju=sp:`7l7lhᢸ@(HLJ4|nX-[f~ήrh#3w*6AFglo7sL):x^%QW~Lf0Aʙ63Nɋ5KB1|{y*{Z qvВ|gi, ,aoJc=Lw] YXmR:v"PcKe9sjJ"v?`嶔uS?>PhTioWOdB/ >GW,&$1%N;x^=ee{eBo`P0\[8Cӳf}Z=XOWP/f/;߲ƳN(?Wx\Lqf45ۋ&Ѣ[c2UOEiSщǿ9/uM :(ۍeK[95 8+&nك=5lͣc@. ^e_?$v۝j]*A: GAswM_ҩu;4\VvxY ~NN\BO¦]ulekykpU 1pIW('7:uqr)qY,X7]XfX!G0|8BкQ!hWz |#Ɓ^m.*-$xN"-ݥh;Qzӯ5s~fؗ"̗0ƌ?H!l4K||2SF|u̼PRĝv/\aPݰ2>OGv/RUL_bE(UMTgj_$#:|&ooXc-wL^U)+ ~L:n>0 #>7_~e\j1 GtCgڳGpiNrbTT T_{0rb|y폄Q̓ȫ@ pVj}a|T !JG/Ut&.dw~O#\NjXFn݂+Ha*>p~5bPa!qTt7?gsQ. Sw@襏UOԚ TN:B--i>F?H'}1ђ r(7?enQRUerT7 e"Мxi6F/7oj~9 C*LCejC#&pd{uvZ+R YISkSi龅tgthœQAꟋT[aӝ\L18s&=_zt߲`C4u;Q†!ws`q5A߭yb"ERU$i{(OKO.!IewZw'cwZ3Sg9d}rߴQer 9G̾y(JoT*f^H/x\fy7|e0X)aGvF ^7<*:Ϊ aEg3Ev7_tĵ] lvvH$jEb:L? 2hi7N2X"r-c{K|`Bjv)E6`p/%:"!Qjyr-j1lvjlhiK"_/-kVp\ݷeMs,c=E"|y ڪ?{R >S*@no>jtd՜W ` Y "V馬G7~B/N v,rXZMqXpN.jȒ'šo^o;X6!A˛WO>Fk9N.f)nls1 6&fKc(/W-GCBAh7.[W*̾~'C;'P\u>J0y~Ä-3P/ntom |9sb6eϠJNhT&_.LR}9IR@ 3gvؖ§vJ@uc@|]zslk[HR~y$tث9\`}KB'?{PQ>`̖J G!Z5f 7Vw<ҤdUؽujګ(lXS,x-Q%h:PKR0TjR.R/0Z^8WD~y&S:ŻMLaOF Ni!04LJ ֲ/`TQWhG.An8a'[WDtK+T]q&:Zj ۙ'b"Q+oZ>] 57WiQ3}0?$Af3P[PpNID&(y]m&8=:E@VvEciQWnp\h 0.h1hμDW+RۼQR%]k8?9S-$ zj 'T(;Zժzŧ!_Lr"TW6v~ۯ)8< 罵f@gbwH{h$K/buMʶ.t͈\k?ܺ$Ku߸}ef_jiځ;%u䦢K/׺M윺Dž*!CR)!OԬ\'Zm*@;EiZI^!N8.^[jlvbR e;~1#J&oAeaV^t;,f Eu[X[Z52kir^}/8m vO)I)oUJ)@>quݱrP͊3yqzV:~ǂ6g45F᦭?*.>/ݻ. S nU% zUU֙,[1硟Z壡t0[zt TilvW;C==K.֜WouRm 8dm ;'E(S'㮓=ڟM^(@˞ cB耫\,  K~GJ%Q Db*6 Vy4brK(g|cKZ*鲧I3/9 d,/ʮd/;(ǫi;A/QfWG1TUEh \wOUr5b.㔭 clM%ē^/~Aqy {eZ撪ʭNݑ5,w64^>X!#h%|m_1[2 ?a" [ۂ?Gލl5`h[M͑u0 !VerzTH < l͹!uStwwM8ʄ{KN^x~O/mK3ŷJPMFY*DOl"4҆ҝeeE hU Q8WN7P>Tm~7C-0#uPyUt/R'!"?wX[e.XGkh_B[4 0EbU`EO:)*Bo9^4lj46VE*>[j|Ο%|~٠u:tN;5]߻T4X @svua%8:G7f\]݅"PZ2t; Y3՞Z#p2wV?K~աP~ukiG0w JևPM%|8Pά v&sk~ t^{{9"R9yWؽ 巵ˆ{W/Bs"F{Cӄ{ިZ QN?k.+7 'Ow#:,/~<;sup",3)xxovՓEan 󌐞ٱѳ !35oXVijĚk8{D*-v. հ?}fFt!){RsV4W' ;H/l$gaX(]mm2zmR>DQ~T\AB6Rm~ #ZIgbV/dPn,ǸKscxg1t&չD LGzR nj8޹ z},;g?>1Мo#r(LdhGq!@#F*`pXR_C'7&PW!c5~9Mb`օ+fV׫1uI`(9_ "RIA4h!5 e;e#P@`nj+7Vt1{Jvɿyؕ8uTn~?p&*;Ae-~Ґxِ mo lܐ‹Z5C\ D5BR}.+^XVzC .ͻ:vn\RW&sfV|t'ieaHNj@!hWZ֦D/@W\ft^f4+g֭,C|{9]qzrt܄>|JECcQƊP[Na(h鸻ͲBdlָq>ygT{tt;Tz0$aù!|l,dSL#G?Q^,Vsj0vG9-hiWq֤ᡏ.*L<>v V9LZ'U{5Z3ɴ\1}dƸʻ4Rqb?&t'{FI!Ng'OҚ3U$nRUq]ir k+]o0G,&Hԁ WߋmR))\+q-yqښ(܉ة; xxD"էHٵ-hI5F#> 5,*lf>d>ubwAS Kl' u; iUEBxa*{;Kٛ/@8wk;6S1#-{uj=] 3v.Ѓ2*h^M#mڹ=1$<\:! fgU}~UjWq8(c\=WcvEsl3Kϋu臘, >cri3X#Xt3k)-Kڙr (Fc5< :SV& #t*~Kce/3p.<. gȣwx `Cij?Vګ8>2ǕoP⋄Y"u$-EU", 6CvӬz>h|StKv*#dUhiܨ- Jz=iI뼚Z-z^,~uDW$>1^ "̱ ݱ'=i#HB+<^ bEyeSi[$Na[gz o[\An(O<jAU_}6_I\*HC?+PHZُ"pֽg3a~nv.esREzTgAA{$ZU~Pר9VQCdgO-rcx~TO[ƍ{dgۿc99SRʼn0fXT)i22SnmVXR!,ro{w*_.PtVUST @V$̱0r>yKyݧLN~KOVC0\15Søp{<Q >}?SF. B2$8²2IϦέ[1W]1IO_E)yer5"7#Ї޻5Y}K`-MuԘf]bfեq?H6iE?=^Sۻ+cL' )Rûgc8owV`6 'W}4qlfh5Vr}4boߞ̯_]}{ :d} mA{.~zvSiޕ_|]DXҀg} 8ӝV".ÎxN|]D̥g{^lR!atf͡텮RDRtJիB+I04xlg5oӬu{cõ>ٽaIj,[5iN:miƜ$ٺl 1{LllD8nMZ>-[wu }s3,)7FuվFpvM߂YQ3k$RKtaWʫX-Tj7zgU yfygYl\^v?e}]l?M~6Ͱ ۾ ijy UE|Tqťp2ݗ|oݳvy`MXa]ڳE[XQ!\Ʒ z`{ TD'Vغz$^X)H΢/{,4\b˿H@}bg nUt;Y}&m0iZGRit@ ܋[^|npD\,qrDbJ@y%-;KC??2|nޣ(;if ={C$^WVڱPAa$|*A>?d㼔5׸yWG}\Հú c3Lj[;N]0^|6ծ87 rOG$2ذɽ@5*Nbn e?Hʏ,US-so~xt 2Y+ y1nXíO?ߓƳGc91BmDElT;r«=)Dmz̐e\cg8<2$3ǭ ggj3ܭ:897j=ڃ_V9x7>01Jzya*% dM28'aYz8DtZ'ȝbZ?EYT=5K Jے`CD]5ebEJ^32FhLssn[5j?| zQ3=}eAqW T5Rsgqw{hr\ w[ow N:;N>zbEse@v(&t{V\seK Շ/+'{L 1Uǰb_y.M`-aZ=9r.!^^y2d+b,%LN,~Y| IԀqP- yJZʭW yA4:V5,*켑ަZO.W%Z&+Jdj:L-5zWuav%CΛ;pF&@wfQ&[W>KSO+W01WFct0 k0I=< q&zgvcs( "VOU,CWE"2,9ut8x%;bX'iTFG_Ի& įU>zݢ+9dy_@M'g>ф2b5P1k7ܔݒ@Fz{`xѯ\>g`4W G"{NuCBFR#Mauѧ*:xG&vq2s7ڍ ,W+;M^2NY{ B AP Γ uEj  ?,ވ^cүn1_0H @b P0ܚϠWpFHnO#W0:.m2u0mq˂3=51 M5 w#T2*]fTn徑/' u7a3ċ)hd}֣` '38sW{wQc]ZZ SSREy'21~kTZw)pljdԆf0:vr=*ʋPm*x>+7=h -}d^?$=n>- lP݄^+/3Ts•|D'G3ϊ_q1ܝV=ԴS b. 9}8m>5 =aL`n,së7umR9kLsU*ρyMnGj@YqIFz6 9=:f C!lO1TrJ}YZ-֏t|r/Wu%rn9esDdz*_.w֨.ZD<9y:[MyVRdͫҨ2au'VWヲ$yx$$9@ڪ߰g{qøT qAioႶ?qzA ޠYQݛ5]j2l^fBZŀWro-d5[ZgȞ)zT>S͡TFBU;M sw;3̐1`|3fwV,[3jSM3n- ɿ؊؞LhED7'P]7}O&"+QafYsٖ:ǝGS{.fpV[Wzja˛\2_FJL%3N5N^ ұ~㊤ 7?&vi& 7KǦ_p]J^ITċV|TtphvVk*,,l垮uT֊7?:z-QM!*x>JLMid~u-5 Ih3op0|!t⸬*"衢VwN{-}յUsvqphv{έ^= 4C+M>?7My6*7CC ՝9eTb T^NQ:!Rsr4ע\<:{o1+tB [%zhU{"V"aQ@07u#aڹH[KcۤP8X̳sneз39iJ6 kuH$ܭw8MHhjc͕^޹0iJ>Ɩggh+f@p?k_Zߌj-KTڗәqYy{o&i\"̽47rF!OZ$.%7vY;vHK.Zu0:=;p\E`9'? Ht bJjUzT4(7ۅW/ iE|cO`REh_qx$)W )ggŴj2ޥX]ۓ쥞:6 8qA^u|!2 2 ȰJ6m{)!UPTf|MiUy\᚛@yus;Z z-K 7nP]~-)_z+О薆_GV495 ?U$}tvNH<$Ҝ0ZF꣉ ܳ"[-̢X{XqZN:?=:`J~#+/{S^/XuEoH#i'R=OJ`b~)x=Bm[x J%TNM*33gm~Mk}C>akC ,.^O1:;AZƭgcym+ii%qWo|+[Aڶ&< U:)j%Lo-8k;?=uh1`Vq*lR%\^;quI&VЭeqzSiQ!/%;XWj9tmE% XWZU%8pv]f5[3G<}}*lKUJyJ yduՋH!AC1<~3=xX#HHr^m@W{S 6K$Cb)N(c)Zʲ5ѺY#Ƈ[qe,{ z(s59\v";XN}pmµV5.sg{Jb842fU\H &pc{~I&,}Wozn;ὀέ̭ Wg%)^#?zJMj;ˍ /2? ǽy:J/Wbzht`OT\ˡ6ljnq3QyyN%_p$0XyNI=~KteIDYMNNC̲JpSϠ,,nB>T\%x$@D6tduY_`y/q2hMɇB?x)A(-g{%.*!Ug1w2f  fܡP^kD>T˚]#DMϛ,[u~XLbIwo(jud| :]1}7<ɞrBo. f);}{ZD6% sc9$)7J=y<wf%LeMG~.ڭgvqIn+HbpFs:pXu;{Q >]-n˝n\irFv%\9YY *K mg[YnX>@k"ԂQOJxޯ7ǣ@cu]rNn1VBZ< RqVM_z1"p' ro*X7OTUqp3K5y1ǭMgn"R Q^nʒO{yr8y!,lkƌ f4J⒡ w9 Erݙ`UBgFɽr݀@JkߑR2+aAp\LSX#7̀z;+ ,4J%h\ק˝Gy!*RyƢ/ \ P_vȈs vR8 K])۹ hU'bzapfbk%1"mqVDw"40^#]<\GaM{犵Y:MnN=P"1DZwցQqx/jU=z<]au{c+Q?`a4S֒{OYV Շj5Dr.#{o~"HRJL _C+VU-E6HSkz|qlA!Bra ! ծŶ6}Qݸ`G}ܢei[<EX~ZT~fثHRCsbb $wM!kDԛ<6m 3: nՑꩵ40Jxq=zGO){ } 6i4ɚ©zn,^lD1ۥFՆXG>ã#کT UDU!*3yLi~pS}kL'fŐ8EzAqսģƮ8Wy(P۽D`0C0f@Ə$d8cV&N:}R2qYI;!c[mNw6穩h^$X ,aūTu@m%")b}/f)tulӕ[ NjFNRGKUaGgu2h tZ-X oьEV)T|Y7[CnW]wOĮnyfP["w:g HӶBQb߼=J>*[y",8@ C a2ref0uSveB28ml{:ܞ$B9^ű-Q?t5n7rJgZPOV8g 8ٗY CT^;S̝J$;%F!}'|s2K[uo^^ǰMpQ=9 IԘLџ.Yt\(R|zH?p܎x% m2|9 lMh1~oZ?_$u*Ob֔?ӿIs丗Z:\9whA"0]/0Ϧb0d+Sqw G#6_cB;eV[r/M|V-LGYWI9ؽ W?QGj_\ 3{`S^\Ojfi)k0drcu/W ≸N^PH]7iUlB`ﶼ]]wbGǗg죔7Yf6}GQuqN ۘYb5Hըٔ<9 =Zp8>y$UϵkZQqim̮B)S=j8Hݴnw硐35 5#kڈ <.Ls Jkm*ϗF< ez&vlxK73U~87:4b& 47оP 幆nc_l癪 [cԩEyuz. 넮 eiT#hXsB؈N*֯a9 8'1 yLL\0M^3w#箮k4r_V zvEP5k[:G[Z&75bq@ټMN~߉;HF>ZUwyrԤWۘ4;s"*H_e{#e7:,9;yD&ra7|*͞gM`Gr|78nбȭK2\/}C$1}!K܀S&?(Y[s5u@n|u~ր/תo(s*܉) W@jc><]6^,8$>xzK)iG*8>Y\*M^d.6~\^?Iu& v!UGPW -P2{;kPp,'y[ىD&Qp1P)]CHPܳ?T 鬴0Yb$}Ccfc[Ljg?$Jb7 AhuۖuN} 31)m)꽴uA4u̖K׭&c*rphwqMOOzΗV.)?@tqWjT_5fg2Q$iNn^CXs˵H)4!:>:٥ߏM؞=b5؆>qtmH%o*% nn3LNLNr CE["jҴ=.bZ:nP4u'&׷ƅ {Jz( 1^T4hH$_ ;fZIHImNJQP~TX0چ,I %1$dF2mW!4Wꖾy F8/ߨ7 0~Kjwۍ\&ȘEg 㐇K9ʔqt"K>i0F6(mwQYmiٴ}6">_,5gt:z'^\.@\lo4"C'BEE<7=圵+N A(mאى{OEߜ>₤G,LT6' |˗hF4jV܀ .z4*0]J|n%߯e/laپ>HYˣ |vl,.j;_A''އV1(ﳹ"s]D0N2=5Nwk9(1/[委+9C]g5d8Z2+Ú9>kZi+4P*q] .A4|ʬ#:ºUGS_#I?A5< ۖk ,4s]!7o b=l7nf߷.⸜$M/op[[dHMۙݓfk69=iEs^ U<"?yz&m0v.<)o:Ym鎯8 -?ջ&BخTI6bR0#Cz=%4=VoX[I(7+n(t m#̢tQ9*!b {£õ(_z7qk-OBK) n\qƔ>X:E$l C?>HNS4,%BX%KZDmąJid\.|Cu5/>0erSނÓ ЍPejoٸ }Oū % =D%Ƨ9|jDę& ABm@C_ŘfĞ^7}FaQWj۶cxˠ?夕`DZdAwZ|L}E,NȒ^J%:畘5> T8-B(|Y؊n=_ŕǴ9u-tʖoLgXpᨶ}0}sڤؼ[cpqcWZgc52D$rVfx)?;[Z_T9WJ}CkC_*T{Fzx k4j ;MIiٚ2J?p}Pr#EӆJo%iݘJBQ2"#Z9HvueY@ @F'D:<)*~ 5X0+_EWf,2+}lkM wru+3EiEvBgzD=24:JIcj0NT0ODx #yzy/wº,j:bqCʊOeW|AJI9kYn l0 : sy0RjV|m\D?NT΢d[i'opk a>m䫨7hԊI&',~.էNWR'9|޴\pӛOW}g``ܷgn?t0p2黢t|!`hS1, ^-Kܚhi@TPܽ]iku2+ TI.mʠ?^ ^)KvFlN J.7!Z YKPiɹvRM\?ϔe {,**F9l"}xˀJE/5٩jհy н|XٚpmjuBɗYR*b)--:A;v[Hr^Dm >J#Qf$ʃ?+oz_W!T! Їx]k%ջJXٻͥOfއxNZ~m1/!O1ۧ3HUB ǭ5A' Sf7hK}Lo^mcR ye$anp6-]| /j[5Ǖx74yys|`Gc)I! VGkǍ |C%v 8Z2ѯ"_~5M?MY.ǵ:ۮYZSzN-dsF@w`XKb| )DJ)v R>p`Қfn2VʐS5_qqC$XŇ qh+-ҀNn=BԬnwYӏnb察#r;%569!Q+u \ArPw[a$:(t/A\o^JMO~FmZr됱Td>V{j[7b,Qߧ&f2k"Ίu%f[k_[ي3_$I}띛,<,@ YYo`se[sIs[ yu γn)OZ{̹'~.~͖U؏z [# j{l$ݬӇZ+_)T>]zc`%v;ï'j۪E\ 9dw'C@_Ǡmkޡ / qF߀`CpN6 *$wW:{Y^3Źm&oP`=d { 9 ƿbi4[!OWTM0%#pZ (C CN_IW@Jmٍ"yfF+KŰvyt^F(D|VOb"]p/=&.ҩ\*IR^塅gYy܏d+Lf `1p~E|Ipd xۇʛ,>F?=3I8^]r}mƋxo}tqy|#Ǿ YZ TX\ sPܶU<ќJ9C8i n,u3qB< k?~c4@q6oWY#x?+IcW["9n'W^67{&iY;pY7kG!7nu{XKj 0.cTnVܒ}LhR(#vOS,4|RrxYv[cQ=Av?C(kJr X*"b6_WȣKo&ʟFr==΋U~n?Bݚ"&B)SnqgOX(@2!hFnG7U4Sd}>m[F9A>8ښC S2?:T ^!.!3{leqnu5t XH[m,Y;W7tyYwW`wR߻Y²i[ͷ:4!UFg2L:+(9N@ٰ_֓6Ra߃ιTQ ^Tjs qX#I'$KqEe&3X)PeF!Xw+>\īWxF g(„B0D;!иMq>+qC5x׭Z m|4՝:inb+^-?@s㨢e1V.%եإpyF1SnvӏnMuP(xdĽ=ܕSGt ;63[3K/:.u}ꎶBޙ}Yŋ ǍSÓF6wDž{BUlrp( S foMg}T#2wRfaM{ ~e*V]wjw-Gz~f 7ňxgbGpwLް#YU\б"DMЏ Ϧ5hԿ<8xu+6['JM= 1ϳߚ"hY\u& 4X>Bğ9^pþ6^qޚ+]1Υ(]-R.g"XYϞcn+c*'WD_jf')hz"*!(bC$ 02S->;]t.:Afpr w$U>Z?pSћoz\HN^-QQ8e6(UZ5pZr{կ/Ӭh{\mKұ1;Gi9Tk5F߼/ln&U|C΅π( #jcKp Jtߝiz M$uutR%=i5V{K큄ȰK#Jhva5?g$ԽP=wעn3iYWKѯ >Xmkmm̛f wfޯݣC*si[*K]y}%^ڼbQQ:S4kef51XbV=@SBͦQ9 q6H`{R!hV˪%FQDNhUs1jsa]E+ܭLwJU)kqkѫ,\_5$)Ia#y:ԼM5 :yrƴȡJoDCc991c+c zPKxQ&Isk"揖N YyF,#S,jM(nkѡ-5olJ/z*$E{}0d'^ n -nyz:H@Ak{ /ˊFd4߳X8+8v%v״ɪ{mߧz*ՙuFֵFaoSakAŻLϬ7 KɨlGgR]4<-՛Y a%KW.e~aVhR-luBV92^+r8BXw5_-x 4i;m>]lתRk)L`V8m/qg.WC -΢ 4ڝ2=s/*ۄ갭VՎxi7Ngs=-22 fҦ3:E{XGO(OJPɏVe臧OKN!|פk.+Tweqr;.Nw&@u?\'_s4[: p5Ҷc\dMb<3 贈gԲg*QCͷN$by|]i;mbٹEu7/}A( ==X>c}nMޒ]c]a,|4YoS?R^ge檸(F%pZin;v7W)96cƻC3թۅ+_eNe0+f]^׏ٕ} sy*C[>H *ȟe _aWEN"E0߱zGhwsdVYͲ]TQQV5}?q&c[;]q"avIֱ`ޫG1$߬ilE?ҰhlZNn?m䑆tBT^ĸ35`1fTssT;oX oŤfSFR[~l[8]mFuO!ΰ֧zCEĮά{1gk=imXjsc6=[sGœџrk3, te:_P2n0`kcScsn}̊~xW+Ɂ떫C]` ح M|aB,٭1Tv- {n7]@FyZi3?[U ^ibDE;bxܒd ѱn"fo*s>3yHSk|c]>Y*`_jȣe`T?|dxDTK7jۂRCh+E S|?"0mE`"V}Yĝ\ۊP MD߼)kOo|X`6v9ﳎ]gUqaj𠎌+$6/#d5wdܶ7}3I2RE?_RUSR?ļ7skUbF=>HTħDMA> ګ'ݮ}}UUӤ'hZ?°A*Akj9E٘4a=WoB=$^tDmq1xV[ }pn-` `_aK bv3hAlcntu{FQ#2X{/{I'6.DH &g$boky%2O`7ش3f=YQvni:38d&j^rmxbQЗw|> 6\:w>i3؋j?Z{ίzrvD?"|>-B|XQLض|JzwN^g];;K`̊\>)n-g%C,j1لjUUC/|5gr Ǯ[=m Y YܾQZ¥N Y4';cA2-JB!Za$ɏkߩ*[|\kz:-ROWkC4)΃wx4+4ugӳ`f/#?F>]FZ򎬻gU[E96⼡ע"`}%8]*V3{:\GeR06^QIzH9g^&^+uW!Fr9!?GgNψZ+!ק$[$O3<{ h9^rͫr_/H63c҃%tr4z@NG{+Xbh3W9Ƅ'8T߿׬auϝ 6X0|v2bXrvy,iSzc>_= 1 r-3fVt1e2J) .ClA2i'mn{ S6EH3F"d4RH1X`9MBoR`FשӶ*zn%7ڔ2j;ډήZY4bfrbDtl$em3Ml6e>RreEyamw 7&4$zn<>k5R폶?|#=}>vW^[^jiְ93o Zn:Rۓ KmfuNnkS U ͅ2RHfuxo&mwxr '^/:6Z&l;%ͿH ȊN![=a6Ť1⨳pոM$:l':1^8 KI zpJ7iW4g9]˞CyVW)VU\0<}e%p 37:e=KQrwq\U]EsjIAE%ٿHRjS!g';0C*ɊjA>"^'tf}sj~R|[3hUkv.\/o78ȭ?|q}vs?ıfnVl@Gƪ["S/o~>cɘYoXҠ! e\4t XG8jAهsrM;$'dC7oy̥Gvm)#k;7*p[oD|i #ɡphX OL/l2XĘOcNr1 o`kڜsљZU#E׭Weo-? m?7ʃ/my-46v(a4Xg%껁u|JWՎ+UVk:@C:N?b3aZio &#Ҹ4حܜ?=}ko(ZQe1t" J2lKlNѓo)mOSj'G>~=]_8K} qմ-]0;}\ lUv urJ^y}Ƽ^gc2W^V{ÎGqx8OucV5tOTOWOt37,FŠ[_ЗG/s;,w3zݧ;թ8ʱ9ͻ;=G|`mNc46ז䂋/s=5]{"Dn&{j*5uJx&~,O|ε5IjEW;Wh<Ownt{~5"" ;h;9(JbSlmRS/STeٿ*YϮwζSvFlA$h ǩX0Xאx(:2ٍpۆު{P#ҧY2}"jh7-Ș&䂃T2MբE8ٝvPbV0ܪ#F^vMij5ڮ~іW3*fHouW.\[fmມ,!2 Ia~eó-I]qLǹ7=\dp s5&$XXLwQN)RZ ud50돫r_yaA꧲k{Yv՘J J |JG~KN|=R,A^,>N_ gN>{KVRf}{}K#7{y`ƬQ,{mCJuX(SC}ߪޫ4myq t$pr~OgM= ^4d{ \!JƜ=$婑# 9LrdPe?P C8u Ttobm3[9˧ ȄH'n*rR뤏j-141iT }iG id?-0:``b Y&k%i'dnP-7/.VBBO/:aa\KC ʷѩdRr_{͛-og#z.v#iofKQgn"r pۈj(u} (Gjma}{QQl޵ބx!Ӻp Ǔ>Pnk3*8tKtHMY%o2IybL%]qW\v@mᨒӽa>9A7:o GO)ݾ3R /*`礤 G|(`\/~7Pb/-\+vjCr (\] -;=VsQn3s$iugNl.~*'r]w, ^4HJQƥ 澝W+`-DT[ ~yix])CDz]__*նvά 4/DR5G"rt 1JcX,MHz^7nu;_KVhnZY˨mU ҍ]+PL^| T9Eh&q+*~w9]8yv52W^6.vTG- mg9rY*y\/ay=k-m Xvt>݉[ G4P{DQ qsz}ʇduIwy3> ]@1oUФʼn?-_Ӭn쇺"@BU~0 >d^:t 蠓#0s.V5ho5O &g ;_4f)IZv V3xFSC ݓ~J_QHLR{?㗍 ɏ+a5(<<$10e ^$eصƤ:SLʚU 9AjZbۚl\}&w]{6Xh^l g`ϳW~rP`[8b ʄ^>Ѝ|L滀~QgB97`5UgG:oPhLZ[S!ŬfIm+%ypQ7޶ Y-؇ A[e"Wcֽ&;!9cncq>B ChEL1v|I'9Rv ͬ8h*Qib Cu7z ۾Iw +,Z.]g⽁o7^VߦIT&6zJVѪ~ơIkP sTx(x,{9_μ`p&ơXOld7.rs>.դ_ É~k-lr71hlLT ?kiꋷQpf7ZU4N}שUx~[ 5%q2o0J{^'M2dTep?Kc(0=];=UuAjpЀuTf^-k@7cWT L/xdzXf&zqENFX ǭ+v/mN㕣:Vy%O-QϬ~%(&Qdfo,&)SR|bPp8K({eʿ,"Fd/&jɏ%"j!s zbN?uIמ1^b~h1ns' ׽+=7s3J}f7nT8Hԋ9ԮEߪ*=2iY-T2Nhy."Fv;$Cگts_ ̯Ǥzx'ZyILRrNq)V)d3L?X7W{-iR@N Kn?>Pܖ+Lڕ=ID>\< m[2h/Au|g=~+8SEl`Ч5+zJItָٺ„ᄬ`=}rƖ]``,Ol,JQuh6|wkjisFˇjF3/R|F4#EDXEidn̓9fI*_yNj|ڮ WzԬ'+۩CP]%Hu(>ޙǾ7A{IhQVOYe\t/4_ۛR]Wi`74FC57OkA\ 'sQpX v~Igod_&$bkJ{5ugؓoUr'sE51UPuk;eym`ei9?ߑQ`ڥuRq%),!_jOFhuts2n䦍аt|QEo4Xw&%%/VЋ-˸3a;iwfnv5p~!`cb1әF/?4hh@C}q5wыDX51wiS.<t~: Z=Ee9v![EmMl%kEUCGsʈi71-2[{Noݎ/S~|c{ך:j,݈A\W4UgEqPfkF- l2;8 %Z> Qx͠i矆Vvՙˆ1eđ$/ǵgOP~*|"Um1qݮg N ҷ#]U T9Ἣl֦%fU~BnmuZ6x4u+CW \$_]zU pM늏 ?bϕJc:DxuZ}Pu1ɠnWkhVs;ǿp]jU~PV;+ˢUg "B[71q\4Ƈ?[+1KptcdRNpMphW$"n#2 ̶#ROuz31 {<;vK6*27~f~%N.28ZiAZbKkdHdMtK7' ^>xhgJ=pк v(`#E}jyHK0lyĖ9E>-eB.-e*Oe"[Et6rP65-'Btd~; h*=8ӎZk~ڛ g.v!8]mlvjfPxz|Sѳ*pהpOYe<(Eܝh;ѝ_+y\ZiTM]|BZqU-93)U5K $tG< &ӎnCQOݼ㔼oɠ<iԭY~^uxWkVfͬs$y)E`"M}xГ?ݩ{gsgmZ5U)= lvaw&'l~at.fu㴙o^|cqXmz`J|*x=j  '!}~m|]EF ֻV?`ɏcd'䦳bUYK{o IAp%HʅӸ=O{1rm Jwzw){^kK1Բw7P3E\L:bV"ݦ}܋lyCe5Uqз tߓzFёD.7 (8zoEB'fSNVIXcwnV[$U )z֏r?>/qQ(>Fv]EXag6e-jg=CSA+0H(w]cO tP5jiDw[Sv?7_)YܻZ[k.gY~~֧ɬBcl }N\;&32Hz55m(aUgYMljI9a2Ohվdfb'{y€jH}+QzAV y:2q>|$]6 WnدWA\֟/ba/:q7ڙWą 0A.B=-ѐtRnvi<aa|*3m+w,^(U9|~Iֳ϶2U SSa@ĄF:N;[ԕWi}4dԐ|ʞ"uiUqqzr:fejgWe}a-Ræu㛡$wCe2苿ɮ!-ɝUSwd0mh/E9Ԓԅu9EŸׇɩCL4_"պ@#e4y+vi+jsZ,J3Q~l;u/k/*ȷsvl01?Sb߯*jG#|aA_eŏk+w%*PJ:Wʯr#k^CU?+?S67c=M%nJe bK=b9`O][,ӧ>"HH}L9"UAN]ݽؗ'c%%4r:ҳK0Q3OXM[`\%p~i;M2J;s;[ *;Pj\ha*X~jq'VsLxm&?4!^a\7jVa]0 6Cu25JM'}m_V8k<VM,.ܮ{jUoksZk>M);p;MB#-9Z +4y̭-P4o`nˑۛ:3 m=jF 605#n QF Dߨlӳifunw7zk*M07yA}*f1H:rq8 cm!,pD/>g}Ѫ KOIˣB2dp*YB|_烜T-s0fwuKyn~|h6#CE;x{q6I u[#߱7ܟKOi&Yv d$ ; WuObyW0͕Xr/Y\%F;F;Iaql9a9qK+I<); C!enVvkrt?ZBy7A[;-] /Oߍ?K-FU7V% z:cy"LBOFM^9`?EdUflח٪Jkos"E%DLh'f(*ʨ'6r-j3d*s"Z^5oGV_)" 2tw|`ZKm<\S23ICUu[\㻊m]W;./eMXE ;ּE;~lv<ޑң, W5̪}0ܑ08pzܪ֦*].@4Jw| E8"u'} QQ}0oMM姢鲇=#|MڸTȹ*5? _h׊WX!.!Ї`ޙ~xyK=NISuZǶfsck3w_]Aڔz36=3zlv§4'zgu p͐rqKԬdJPdx,}7, 1&uFw`xis{N*$ٗHj|2|Gŭ^#zҟuOdu:uEBEgf!f.&j?d#PK *\ĿP}սw%Q:j~$!5UyQc&UiUЬm{@b1 imG1,#Tk'dsz936L7[X00wpaIn^VD;z1.x9a:>R!8892QIb%9|~ovHHUCB~Ƣ*cCQ/ Sye&jZM\?hJPr`UQ -m=ȳn Bc`IUZ:!,WP3'ӱy BR)[)wϳjK8d6.8:gs}zxj,fKXJ!+[ <$Dr{rVyqbQ5wiBt~x&sꁕyϓjR6?iZ8T0Sajnj3bǑe^&CW4dՏ^ARtLt?O3Aw=6DW/TkG`WJMR_slmzg9]# ((R+%}isI>4.rI0㵲piUITȄRD)z>c6fySJ Kyk!(.Jꬬ/9nO@܆Q>wI/?ƢҳijIጚ;-;ޤyĄ!(อ'{~SRpo;A)Ý\֕\N.pYbKk{z*f" ?rMU(> "JHe;vwsaZ; sOo}Qzsihx޿n[t ^s(m~}Vǐ9;BF`fRZWҧ4 #vq"}~Q̌>VlQ:;%f0lRXe<$2Zm=B|!m[Bo|rcI4wD0p9:̊Md|a@vgaLG@EG 7[x*.u>6f$eb#^ۤ; J֤L8:9= endstream endobj 260 0 obj <>stream ћk6mZ@YkI *3@}fyiCTiYv4ͤ+M.OZκZBGvkRF zso ȩ ]EƎc .AVʖlS^jr^+k8yԺAgN%1)_rUˆ ң='ip{ZVOi"ip0Xsϓz1ѹ4j4\aU":=S#`}Mt3oa䰯˅5{O=Fx=+gczq%GЩܲvRkٚ r+hhu-J^Uo H${j92̟W-x 8Q22vv=bPB{ս3?nϲϗngXc1 TƑfitYzr xovϘmvk7|Ca SO9:W;C 2PZn[?=R0(k~*wڄV lz}ғїB*0Yn*)/Lvob V4ʐt^l Ìᔶ=ͷۗք*32HajlWeܮF>sչJүMjյbic:q.N! ۛL꥞4 N7]u꾰mMh\򨮻K#$Y7c)(ٖ%_Żп_TAo5䧕w,ӖhX0 lWgb#\7?+gS 05&9w~Ы(pz8طs+IMA$=Xb/ 1\/cSjh}-іwWKݝ7v>`9%l%7mNI_QZՁfCQ8Z܋gl݁WJ_Ћ`S{갣82$mθw<65hߨr{{sy~_":7kW$[܀r7{{e˻Z|9 4%5Iؚ}3WXJc;9p<27^-ܟA-½=,NZ攜s, /n.sM?Z~!8X-{{9J+,aMg>xMi]ݩHy_̆q[/φX^zŵr]r |~"eC+U[-"ƜMOfܦyڬ¦YY0ȵ}܆kDg~) ً빢Ȓ7' J;c^J DtޅcTuo[/O;K=|gsG7z4?[X'2?g_{m'YPaAv:4d}fu K?nwLjw9#yGІA;&5?[iĪ%dm4Lh:-\{ri\gTi;&fVr7Ņ,naM5&yjqoͣ/:~mY:K(ʵWzm =O|x97q=:$ol^&Xh4:3ZU/[RK8QmIkҖ +:3ţy,s>h\ݖd?_fϧņkˢE2k83(ώ3>dP}ptɜ5dؗUIXPXZO怉My\7NRWmr - 4%0g_1Q%sPbL^SoO_ 7@F +=J_5 ڷؗTH!,^j 3氽]ϪjFe{>!UOLӻpMYC^scu^nх0L^8 'NaWפf>qPz?N 8KWeRD; _g򔑺̗'u֝/I>:0[ րtܯ-GԿ=tKYj5sT}Y_()tsRingRf`[R<#m{CA-{+Du niV|F Ϝy2{U&@Y-S][-xPA>g=^" O/ww!_[ߺQHb0:s)a n 2q]X=MγIO-@`Iu#ˠ^6*ؓ=r侇z`.mlz=%7|hX[;3qFx~髹,4Dr4`hՠPlRxP^l~wT+f`;Q)m}6P[|sur&} /dH͙ԉKw:zZNcf-QDIcc{snυEyw#n붿DKmSd׃F燉ꍼ:ꇫ9Jk[q)_b};^)ÎWfv:N.QLKqoX=-$F'I~U ͑Rpo{B\$_;= vX\'``˫w͘iwdVk,BY-7M];{>R?B99@CQ&,}nV\RA|L]LvQƭ+ݐw9o4'{x;;m!Y㿿`+ӿd1(-󩏮:wܰ}=G# rPpWDAíARk_$ƥ'luPWMZ+`d.Zf&GI/m=oŀQ*xbpٻE5Ə.#)"mK-@hYTFERt/ Ď)Æj*YE{LM̩WݕzZhKR=* = m ])=ˆNrVS^fjX$ܨl9\G~[4-s&r7chj)ɔ CUdУuT+y[,rB'-2P?%w^skG~u; ^HtF^*%w?HU~oDkT䪱w_ee~\;ykբz~Y|u3\uw˨KkKƽM?B<ɫLaHyL3 6}t?#k`E =KdewBdfzЄ}աTxdPp];ryX22Hxrz|8*nFy3gv4oy@k^,ݺWG}i#P䒽Jo*)s͛:LQ.ܬ&7W|~y3]5SEuŇN FVpT QLxzն[,~,f6mօVi |a/'*ǒveqPpGLr3bˋ_~'A$QYW86dUM |fm31Cm: m;x]=lY/Q;w(:}ﴵ 4`bf]bޘ^ϴjgQutB4VY/u{>׭6_87?҉5#} m[,KcdKW`ƮȦz$?=)WL O޲ U53?a#<{c q&͍ح,7G p˃my_.@I]s]rήloӛ/ Ylގa~5.f9XmlVZL-/{{uE<9ٶ7 =rmس};w, }N3-sQ>CyA99W>]~V mS * 3?V7 8! ޿X5!CŸ#na}e@ϵicP( =h W{kp&rpzB{eh]adp mj :3CJ_; $].[[^Kk*|~nͤgn.A!ahZ.+-4:*u鳬o צ80fʨE $MX_D\B]BKډ;y@߬!W>[NM_gƾç޷ܰЬǃa0ƹYn/]̙ڌamȢw)]MBx_ ic.m[6}A};cn}fQvپݒ?%8Zf.x\qx@Y74:⃣!?!Υ(c>穉݆FKl::<XVf8"1"۳GhNc/b^S(iU9M5Mc71O_#g~-ʳg؃˞)ܙ;69 "' QvP t\d]P),~=IJֶsz-jNRˎ6yA{}:f:n,xwkR]=O^^M[o{7Jkhg uzκݫ48H#)d7h򉥳m&wr#asX܃_m ;~ΝgǠ]-BCFuRG;fo;q77Eߵ.}-4sLunrdp_+Ywswu=x8JSWa3K LKWonQ1ȃ]#$*6~\_NvZ~O~=dI9/{ =B:pdOc0i$9qnK6*n|YzPHЛMJ"=;{_uFftpjepQhN' Ԝ1B968QnҪ_:GĚ 󉋱Z3/ A\\Gm(9k΄RشE_z0wA=7UR[@j,Jiۺ)ZζvjeƖ6IT v:)f ݷO^x=^X:9Jջ?-߯ }8NԶjA(hn^lGx%J.<7Lb^g8?8xoJ؇< tx΂U !CL-߮O󱳅dV^[v2cr6vd{<:JQ4|8E+/)7~^wbѵetF$UwefzE0l} wjcKClbJaOk}Gl?QG^O/Q=@KZ:ϴ;طzqegb[j 6i Y\ϕw 4pm0}^uFbfMvkmV͕3j  .%4gg  NkY*t7jI*jz@TI'Rxx, γ6p% vB.k<,*M_ڻ`&rk[ /l޽S7V5OҒOfoϮb{P@iŶ|~Ywx1QȜ9U3'ӑQx:ܾ>}qZ3ဎz)s 1g )-&DL^Y9FE%n'P_ُ|9ȵxTmQ1ܵҠ?連W=7]'kQ&df%s0yy\Ũk?'ZV+pf3`y^&M}j`1N`|yʭy>T Q]PQBX_Դ,ahc ulcYrUYBKV0^#mLԀHX"E7etcVAb/p 3"`bdUw ժwvՎE8AyCkNgf5<=9M˯NX9Z#P+L3 :ߌ.%rM#vz):nzv4[):ޘWij6?18aL ֻ7a&q V'i⛒}(žo^*^+M}S]9o.(%T`z{6n_bHBDG[$jah"V$F.+@Pgl&yՉ0H((](Oq7 PNOݧp_j}Vy~^\}As<].1~ eٖz  A>ԢUVJ,^*,1O\s337{4B_IMV=^5B>fųQ:KjrbQq] ϛswoVڍ`@XnK"B9bךYin4ңwy _ #Tz#zQ40(:zyG#`ob>pS",.6kÕIOP瓜vijؤ` >Eo~91oWd0t1 YGYLXjU4< NTYdKM }[^n9.OIEy|*,jn[J#|蚱{zqc JjG Lf`/Kwz8 {We4SSOJ#\z{:bsP^ƆoO&?6QX1Fle;QQۉ7Mӛr_1@-)eGt[ +~TKhD.o;b?Q75'#㖲,X!7 OՆv*D_T~$cNv$ &kv~7 ]9*Mwݫ(ǰ5$-U5Kz߽J_*p&`,xժ㥆mTso͘l;ָ {lXZ5[=IA=|_BwtӔLԞ cL/"xUj2ܬgW})oŌͿ1}{xܚ iJ^@[ qG%m_QO0= Oh+K# wUy'Gz{@ӇP῝_7/~^uswk1<16FKX `?K,Pzz"Gh2'˂ 1>G<'o}s <\֔6kOr}U,ɻxs2'uJ ^r]ɳB7.DH$eWE0gC"lkG lOW^q'UQ@:N\N" JhNDn de˟*f6t5V6'CC>KJPq~h 7ʛrw ZpE48myyUsṢ&Vjk-`_8l8?eXm3e^!(6#FYݫQnό󵇫y?o!F?vBED*W{{~jYٛyZ`'C^zZ>3Cb{1.=XΗ$M?gEZ?|'^NԗXF?k'(^NUGo~AWԉvαrg{)yӲR[k:# '=V,~cWUu{2EcH`%Z.O7a_T8diɥ21+%,Uex &3O4B?Oo2F EGKQۺ7R79}~+J'|蝟@7nT 8b|XӃp򵴑]Tpv]7u^GT=$c| < ~/3]~9 {X xja?@_શl%{ojfI y#WeզK m~XڵQҷs=`tn݊gRq)e<-62m&ADžؔ}6o`(Bp{tӛA q~E};$Z.0G2- i:z]mAc|Ǭdov'อnb^~{w<5qJ(Tj]n ~4J;)L:UlϪj{96/a3LsRmӖnE/ӷ%z񺵶.2,XvjW,p w'(|r瀶~X&(67ȔYܵ}Otg04M{0gi֒N6.O?f'cT,_$ex$z:ܚSS`qFn ~U'yC4m_781@ٳdTrMrk'͖bVk4Nss~)uwh=~hd!GvrLalv!61x_tОMV?;/¬$Z2c+ &{bZKu^^uŽqv1?q:J #KQrZtt߀&dtpG|;ڨ yQL_pCM5'(=uٽc}(  eP&c+Fm }Yͻ:xUqhzPf N{\25ĺ'0l̊Og]7^>˫1S\Ժɷ2VB$⤖,lY#1|$ZU*áYTg!2́-sDÏoS兕pg`coa`MA)بs鯺nṄJ@t: w0Hwwm*PO\ܹTF;AH 2pun "%UF{k&V֯;҉i=o(\=` 1%_@L)3yT2A#!1½$*Y*@]_w/i]E}mdѴ$ 625uO05$)/JFz2PS2cZSQr[M]Mo6;Ee/vwyh 0P)<Mt<+YDo/,W߫/KާȌ{%_xox2K`z:eHuuHjc] F߈q#GjI.TӤKj"hzQ2~3{ȸ]*Ň}ZfbMݛ]-t}0uun!L"ٹEG;Q2q̞bws! î4Rܰ@+Rc}z_A_F#rlXzXCH|@lkMapU\U"%Dc(5;wpdzn'ӔմQHejVZ/uVh+5ƈ+֭k΅1hHWNwyD2 b|^;31BdI*u^X%h#=H9LUF n,*WW5_0?k?R 漡^/jtșLm9FLӫ*we/H%/=ә+E~ [su Mxsl…%&ќT_۶$s Yg‰0U46Eϔ2-_cLO0p{ gS xy׿̞f,rnƶB}릮~6\hDiKk FWzݱ^Ž{_m^V,ݕ@/FW53}Yi;}f]6y3#`|:$gKsAY*5̛_O&.?h殗w϶Mm' L]dhz*2[4Ӯ(xE,%J; 2){b~~VsX/^=^/G6Stimm@S747&TYw C:{SI;8ԏS0W $O\9%IoŽUHqmGrb5ovc/β9k^&[WN648bx"r: vr<|DH4jɌrX*wGaL;ASRn!]oT#ʔrWu?_in'lpqOѸj^07NBM()"{N7:Lk|m4Ǭg3fRGgZ0;n2LG7uoQ烼SڲhDFa]Nl8 % v V.d9x[R,NoÑ.% *'2G{-؂"/WzOyw_ݷcgjP I!\ A۽~Xî  UP5ZJ~?g޸4QY-[ cb5Pxy}f x:5PSbKYkiQyJu0»͔/j~^SMfzZ?dnm2~Il6߿+8 ^agm8iwog67/`Zs2(۳};EU܃81!렫g؃U'=-*ߘę͗wϿ5&8aZ,Awd;\o;݊s;_n<%6YtWq;Hv f=N`Y\67Jzɕܞ,ǩ Zȿdܼne`ǹzrb&bi[x~"c_O=K6 ƨ/+P8_<_txв`ґU歛NyN=vUp4KR݀krsVrdEƞۂWң(V˟Cc?׈*MZ_K[#zU}]Mum W>hoRC\1Rs^Q{r}=㪃ۗo.Z9`D^m޸dg:g = W&KL@֓@ vҹ9>,}8U>'CK|;[kr~ 0fwi:!k[)nI~ێ7Yh>[;PW\?m?ߔmؓؕwLw;ߛqO iO?L 柁3`#oifkZ_k;jم }c-ahfˋ>^46^UȐ yfK7[̄cԜz3q!-v لb[ܹX̉:Ҷ4M-ޜ:;\2er3&pt%:`t#1n ~:I]C/"˱?2;T,i΋R{_}sPjg[県ݺFEw]70BY_~_*ވ=V<"G4Du=[@FW0oh++4||Wz'>r\YJ-1vuoN؋8wxn+W\;48%AKn|VRBJ=Tl맜إC}6?MږsUgHHʺNZL KY5bE QPuW ]煆&Xj(Ѕ1: t#pMm^q`PWCA͓P[c;W;S;@~ GxXD'A~ų=u2Bq^6 E0nU-nG˹ZqsXzX}O-ߊpAsId!~A Ϟ@?)Q-zD Ú4WrZFܨs.z÷kӑ/[-w`YWVN:i>GC7^ !`b:>zu $.4ljk {dC# d ڟqÓ'Z cs5w3V*$؂7{~z77ұvLǗH;e(y(")Rϑ/VvgcI'OR"*+Y_\bf?u®qlU‰|,~ִʗ&o2Iq-AOӘ tʠ%(ׄT 9:*m-_>JyP1ƅ Q^TٿإǵKIR̷NUгTP 6>6q>!4CHKgy*4XC)TOKhIϔ[| 0Qw}Qү:izǀ <|=R?d8r =\6cGBtvK@b&~#_DǶk&[$QߏXFwyD1t%&Hxl t}v^0AO״%Q% &x}O澜gw3eh Y9^7Q5;\P 2:0&L,8^9qlH D=G+y5dsuu!HGZ/;ﲵ:r<-_^2݌sFG{,HIx;ȝ3]2SA؇mi"oͽ)lߜ|Yk/KrLŭ'W-SKobh@mKU*=#-ĩs{"H% ǝ1]Ļn`nQޒ.-enI #g\dPrXiWVgϮZ"f[Ҵ-lxIgc[ؑ ] %#fA݀;v.Wu♱'T.r_ٸ}ʽ[o2<⹗Xk/)&C-7};]ޔ주r/"]f aQy. ">ͩ Ǔ `mdâ;n-z3WI1L/guf  Y`QHoQ [wTjٓ I6 V>L;[ f̿HC*jIrQ }6iwzg'*VZXA9]*S>!XRWY0Zjx>/¾Z#Ɗd @v٦":plj$a"_ãѰhm)݂$݅ya6F=Ӂ.C/|7|(Ct-M XEZ?jzѡ5q8$]^-y=І l6|$[+OFN8_?8D~vWŦvz;1ߋvAOɷ&i JqH(Ge6iɹ{ Oׇ` iwR'^}J҈b}ST:Zy>Y#SaM;"X 5qLI0!h1JihDm:hȏӊ47s:BGlѽXz?bRDQwkp0UA/:;ʶbӡnBQ"iJ폵h l˿N4V%d2^R;G+"͛%7Y!hUn"0ӟҤ,i7_63L4k:\|a3D sO;I})1k/Q]w/u[+F U/n|nnݍgHohFk<6mqcg˽I$ZX!Z Vh${Q(QRٹXæZfwGnGs%wmBך1*x-BQ?qk0uFٰ*6- {*{EdNˊTGqtS(;m6]neJO?OoQZNOD*sN[p@O`eɹ[lvTɪQؤfJPm# Tb)=m~~h%!KOޔIMϋNaxrہ}+(WVZUV4B/!9&~m7JZ114́H>H>EnC"AlM2ՙ79˻Rtͫ_n36MBOnLZМw46<**w.-}"GC6:{]>.&/ /w+ab}crjWUd "V=mƈ[˾. I6=U :DS?}rŵMB+Ebn~>t{qg5&Wa׃jP,.VdL79Lq6Dh\ֳhӅݜmUv{Ҭ۰_*zNi_':sR H;?pQ9Oꎻ M~F'צXI-Ҿ4]aA;T-!r1FKldU{fwǽ͟wu(vNtw˪OHdAS˩sňEK(q [3ê\!-u}6/т{}c2i6Zj܀^[*:_ٜ8K'sM&/_LWd}z!;+}\(PZU-kr@-=GL[ 3XT$sI)x_j_3è+X:5{egCz[Sխc@z1[1 `ܛO{;! 2p{+UyD6z6oH52BϚm9^K|:3W?ݗ7WCs8v/ @i2g,g{ǵreW[#v[ORjبy"yYѸʗ)soY[HVtF}p헼N>eEjznn*[Wڂ47.6f=*[eOaA^%8ߍƾ1[uJąa]6b  RU daI9M 5)NJq][nyPsIݧ(N M3à["o6P1l F=y,k)l@bfP[Ms!M8mAc_m9:X!.'胿ٳ"I hMzlw{hB,lr%/2ov^/g՚|%4>CT>una?ui ^ =e$^f`Y9ښ!*G+&u6xͺŕmj!`O[vG6XiǪ-oյwA~%jV7۝-T *XZJ Zk >X ;,%v.[cM9|OJpwSڨٖ0At݂mt? J[_*`JzX_!MKq ZNҎub{ڻ2e tbiAuDX;_^a.m(.{wA R/@j|ԆupU7Wѐ<ҵT$NYiѷL4f23j"OU66$Î5x Vk.~ܒuP/~JЀ|D3ys^h mII;jbGiAdey$SR >i/FE]Aogf]-h'P~np)*Qt%b{:(}AN $mqsrܽMK[5xq0G+ɉrMm7xՈt[׫\`c8>WnK2z\(J ,1TnS-^JT'3ǚ-}蕋 Eȩ:t4v\twgm>pjg.vgٔ/̛,z2tЗ˞B%ecslY5wʿoN\))ݱ'\{-4D͵7-9)dW}RHw5>vRb)?R:*t^_B3j)o' ȪbamlXκgI y׆זg_9- 5 PwqMr^6sSjAъ~˹[+v2<2^,T{5YBo7$h=JP=.rruVE"T8KI{Gê~Ǥ*3oϑn+ޱaXlϫn{_m0AGc8L6(ۓckN1~zs)yp5E^:8^ġ6p6 mJV>s# x(:M_zf-u{BN1xH>lN;-~:Mj[oxMȾur"rWtzQ#M #^e!jwo,ALup}{yiAՠlvDtKm): &gzKYjV{Y;bEg+n e|%iAdەdpЋ*YynAR +Yr]HUe`ǒ'1*k~᪬jqOC/ bnܗ)?t"X#k: Heb_^m$ #!}= j:ӫlR>*2C]~o_oإGdD:KǟMg [1 BXt8Q8+o~P?Pۧ+(e5Cy,đg}p,ة"ڻKS6W*= ^E>D thP\ m^ʨUCO\'70L5U|6ѡ&َgsٮ_]uCl ?+FxxPpgymY)jaoo6/ 9ˢ]Ԧ_>p0(2@{k%ݪjwoݳ:6\6- v{Vt`B'q+nAˢ_]=F;s3lƼ9i4Ns7yO) 4I n奸HM׾vSU#yzykrgkCfӋwh`HEt&|5. [ ܱΚd32 z?.lsFL{-qW"I\P5eU |YZ깊GuAP4yc[Ʋd eYd,.U>y<msە/3f42F4D1,;([OmbB~?0R#'a4(ѢԱKXQru3޶mޢBєOfeYOvH ȟ,g)=5W[` 7l|Q̓ٹ@V|a;ʵvG8)32|Ϙ<6<l:܊?8nUqM{ժ2Tl? >qdIZ^E;xAR;5dOO~i y=S;7|O_¥hs{.mj}† ګ|_-.B:C Xz{pHհ?n2hK"YYُ_oq.EF3FȢo.D#E[.fcȹ"!-h;S\Hzk(gkW5V'kCᅮ8a !`NПH:Ѓ8R$ 5JKa:.Euq&wh; Q1<0pT]淏=cwNqob=R6w _[Uk3Hwbx)ʭ#"^{:TimB%d7ΫU=V([I3_u M`Y)BQS0O۰K6gvm=vB s֙tZax|Pֶ-&/*f0/b.xޤ}t]d-{Ԋli#ֻ4!(>6|T"MCƘ_Eۮ+&W?b(1)rMK(H>3 2 d䠸U j3:zEOCQ=\0_]2lEЧq|uvJ҇'E1eݞgYW\47b_DWW&KAЯ_О씬h uIA'ΛÂR87%v~d}::kYMNh%Bз26H[h|qI EEJyu&?k+'M̟؉^Ѓx%MZ'5Nx3y؝K[]UM(_ݻڙâQǑF鞚k4~'<6m >C}o;{f߾Z%/k,ES>[[{r1T.dthn+l j¯LX_.ڕuk71`f<[㭧X]Ru[U $yO׼늰tFg].gy˻;Lvcx޴z0)aޡ0ռ}[^at;zr|,#dt#Qb;|zǫ;J Վ>ow,3t,$ ?2MH9`T\69nJTTE:f}vݱXiHX >'eDU)[ZXW)Kx;cMj"JVLNYQ;4;hfCpX==K;ҒM0bjɦmEeRa:3q[ jn;&q#r|Z~U)X2 i=:o%N#2Ojt0hwJ%,qzI ~=4 6kA@bw){q_*z{ _tWaTW ;lsԽkjѴD[mתk!U6mpW1Dz"rJGpE#Ko*~x,NiV֋鷧;D;-v 3k;9HuٸV_(W(_}_/L=ל^/$ G |{/yaO%fKSR@gŲrgq]<˧$=!| Q[cZOMmbAU+<ܛFЖQ.T^.NutdVdm)c~ cN~mhn2md|:+zTg/cEgfikXHe7 ?ZG6tOlǧ_f+ >_W_Lߗ Ƅ˞,n=#o6K:?% r|e]vEAnEAnE~t*V}:\oCT8Cg#/O_tv%9e& ߓ'~<@ihw)~=bciT@o7neQ9QQ9{7Z{S*-.ϫ_l1R鷛/U/w4r~ώ_Q鷛m4.v[Ϳo7k4m._r>ۯr>WN\/j եHE\Ms==C2Ȍ9ָ#旲wuEKr?n Z9If',|fv}76ot[Ŀdz-ǻ۬_`2nʳn-جd4?纪H׮`9QTdA$ v7W^&(*dk%#dD&pf ?b;$ӏDt x^rJ.kL=}aI)wbqvK%_pb7%*h́GF[DYyT] aNaLuw"Rؔ#;oMhc )ap 6Z6\m:zJu?tu—-YMz%]V- lIvgZl 0җV\޼vTj ̆v̦9gaY}UT/N[mdf5ѯ :l_aR}CKTv775j3y g@8cCIBmgQ}CQ׮濩~?/\U~ϫTOp~\Z;]4y!cOW_Nxڏ3>wߣvƃ 9o {X/=-ay!Ic[aVx|p3t\lGcdbXxHR@K~F];pToۼĨWf"JבJ@[a4yޥL܎y}N5F~9M+$soeVו}MFS^PsdHH*ʂ{ګj 0de[I?IJbT3&&Z嘓o՝^XFFHr*sRkyC<#ϻ~N;Yƙ=P}Ы|ħm';|Դo1꽜uМ:2Z~ml}yͣ])}ʹtJy |׽D RtHK ='9IjHd zbKRe:o,YJ`{Ί|ΣY!v'Z BϨ5vCQ{;U\eRw;zҐ_vF+G0u[kO"tڈE8aUE91vKNi,ۂBߤ@?Tگ;RFNLVODzl#yrNѠ+SfXn &ds6bQOo#Ќ|Y>WO?u5FUoqʳC逾ph)=wy/`@i I.מqfiqx7hlo]$j O/$_Ÿ vN7O8yʻԙRy;IێV:Ex$Nڠpmh#vSeWwTKI[YEH!V>gjcc0)c$'цIxkד<%o]{,h7[p*[4{m*$IE )ĵF}f6nG qG i/^2MvW >>6A'|2MќM$ F)E3D˭"$YO{RS2iZ6MsO ;,zJDl`:\GuȚ7P[5{im' 5+(EhX+W= Saf'[`ҳA*{G ێUzm{N5 IbQ||vRV[Vģ:R@sDKRnle=)-P68 L#Gw%hT l^\KD>_eQX ze.%r G;l0fwhI(|pJ˒zcB˱p iҰ$>[;|#qT3Zy!oR eb;mi^@sy,Yﺪ5K%rotciP\p =޿I(K I~3y!_Jv/p&.cWCLB?dTiL88X87wWiLj~BIQSQW\ͨEj~ f*ք[)UJ݆։-Q[y}ҹ)x9giT͘sZtȾy C \scċaTs=s& O~z!yQݮ[r9踄Bͅ&ƹ:Cwg(`2!֗ym%-E_sŽ4t>LtoOH%G1* uݣq1'%Qm z2:$\jIdI[O+0HxXHM{$gg2xZ$cK6л^Mx w%M'K(813@8P~Z$:F.*57FK#IUJt-OT$fQ /#U3L\`;˃I~'RʀDPc!핾K@GV=o6$vIם 1*ƨ.jn1/VlogF'ZQXZGf;SJ) uĸ+Its qE0MR}vs\B̩꣨am) \u1>cJ;\6Rj[s3Dd/;xv){Y #نmJJc#89ۮC~YZDEDwwh|5aWɓĤ$z;u$.M(`'כ# /K!0i/%ILQĨr:IA> &f,eLQ!vꅏVr׭\+GJEZfísPIXH7[{mcV#TE:@ޅbU|Aey~6lY 53m۵kmzXs1}/I^?#QCDNwۼF)=4Nd햜rBttq V mv} uHzt^`0)P!ipn`jWs"#nKL ?w!Ȯ(fDHͽHikk;6IJcgfLq'rydVD<~^7⿮>{hurARGv~A\icT]4t$3[_TLnj3*yU|pcǿ8~矮櫨_LiߋΏ PX[?3qkOwi^i~jzbŀB=Er%?2lmk2v~u]2hor|u]CwEubfJ89?=,B\\( f/4$yO0eںj Iyf1pʌ0dGYNt]]kSg6نe+杂!8)R&JYbbl}jF#$ɒrT'-r@zkX;q@/{' Iε5ND-ʽS>:'fAM?ڇ 0I>!_vbz\~⨆$(0Bv|L^z&@߹S"07 Yk*&q[>I|6\~SVlަx}ڿs2$KƜ.›oݨvLmn {_f3܍ rѵ1Q=9ݞO~Ч$Qq!oemUn0˜$I3c%b Mߙ :%Kmа6b);8%Ţ+\&ܸ[c81IvIB6FĎFcG}P(#0FE۱GTM<Tt]t YȗjEt _ƞG_Qe8,{u 'Ԉ@Fc=0Yֵys¥,.ͷˊq ͭ_pԣBO9㪐N^N6 ^t$ %io'-gvӭQc^ ;npKje(/49}e2<8ʊ>ҫFU$V! U$|@I~Ѯ _rYiEC:=}x\{}pjp ac%q- trĉE{ΉI%cN G|=ȹzeSDB[ܞ=`4YSMI0+`jJ"a$b(HױbTS^5ksԉbdGM׭.+ێ;nWp#-H4ᄣvۧ_Q8tT8%4⿔k6*Aŝu8fZ1>H|RJ3:Nf9w9F-`O"3u' )Yj /2^ҿϜtF͙m0*)  jBowpxmIiF|d$ߖix>ҙ{S.rOvp*Z 8]#tˮAHGZ[n'7%8dž/N&)I2םQ?X˥l}=ʜV%k>P,~'I%,OO0&V0c{8O)u9/Q}m!0Fem?PMX**r3GnN@ ).vy/;sڷIX;4qEOənk;~U4a>n6Mـr^W(\n%|tM4lĜ,6ɠpƴ~ 8 +ZIR~<vYpܶwa:.<\d~}f^_X̆8}(t&/ F'bo+O毊JZao`e{jܺ+ 睬7kЌjrh <`%Ag:RC%TYig8@bd$}rCWvfЗL9q:oG]fK]͹Նjˇ)-Dž+iXTX>y) *էsTߌ!s=oNPźʦzL&u]?>JpKFO^K|n\ڶNrrµ= \?KſfbTt$KƨZWyaru_3KJQY3fJPd _eZ?4>Q,:b wJo~b[^vm }%yr<&,փe_jr{ P3^'3^{{1韕kh)LNO0T D0߹6QfQ0J,(x={B.H $][a6=&K"QEĨgeU+qd2{UviZkmE6uɬRg !`1߼* UI]u0!jv#ҩkc<9ܨ6YT (Jh(~8- @V5J0ffCD#b؍qvoO|?Ys[?ITZXL۩8+pxA9ߟf<|(ws ZEͭ>nv[Jwm^ri%:mk D >``?$[#hޛ>%/#$Sj_ɚB|kڮ[8]~U$琺skIwx0x^[]-nF@[ڤtǭAsʨ Cm1V#ڂcN" I(DK<5f܌V`Iw0(޹iYU&eޖ3P}JMn:Pqnvz _3N.?ɱdaj|:{lnekF5S`3E~ rB^T,YO;C!Iv {K;'>}Ɣ ջ CTAbaW+D-7zJz@M IF:]lΉbSzƌ7Ӑ}/LG(xLUGvѭVRnƀ[-)pcZ[8M2&5Xp2tvlX;.ktOwD$Mһ)1S%>_!'-ŔpKϰ:\P{^׋T?\>Zd;5aqM לA35 yk3e7i|)0]}D%U Ovw5Z}~@} Rm?o.zMG.KX4qMnP/vWFW2eR##K$Bg2^/|;8~LJOW3ꪭƼbL cJ>.1JB9Fŷ-?߼N<'p a=er]p߭Ǘ \9l]7^؉ |~Ӫ&VJi}SA&F]5 ~~yƨ IjFH\g.Lxœ!i͘cE˘ F,Uz'IBDUV[&zrcT_%xbN$CfUMjš\>+u2G1oјY=.Ff\{bZS?-˒UI.Ϩ<4~:F,( Dp:QJw-WaK$%5iEv42 $rSC Ϋa׏E&Ok#e31-~bv|tny͆gxmhȦn>VY"0$gHUWUsՌ*\*E<]U$)#,+=jQk_$sɨNtQ<#_9r/ftOS-F01%ꅑ(iHW-m+ KO.۽4}0Z%n!ˋX.>ҲxF3+3 מ]b@EioͨMݟN9y"qq01eu'Uwحei5%uCPCKO~Be$YNI$5L =wҼg]yh9vpRzB=$}}W}P%(ZU%ۈtH- b M~<=R_zBbtL|Dj>FI^f78fϮZ&,'\^UƹKL}7ȋiJB5DrnH->% EQP!j1l&U8܅@P|7()3 sdGbM+S/SΩ]Y MO=X )tkhKq,1$MZE㶄zE~$&#{jϑȀ9&/>?-} Y*|kBp|:HkP}Dyjm&y=68n--dN45Žps4An3/d !vJJ5n9]X_ϒTXSvӱ[ͨ}>ݯMK9{qλ/RshjD'6;/7T'Co^'x)b RZA~$Q'3nԨKɋBɞO*CKay8]^]GejJFEhG5n~] 8_Ufez],{{>ۯJTO9 gt[үh1F#7(hKςT+X+[r֡憠P_ű]t t(W+ÅaX@2='QPGCO[tKqv ;}aeW֢1sjvΨU!uzvzJ|6In%Vʠdvo7 ] clk]!zZPJ-wM$1f܇BÛ3zgLߚ޹7zI$Q3̥=bDp{\ [ºJ>-sV몺ZVz۹>nl=t&?N4z$_~$ǒ+F)v4){FX# m^HH{i@7= btҢZm&OXM1Mqr#IvkD9 kIv[5MJɚdwgHCC8pGJ XBqxK>1Z4UDP*>U'3jTŪ(pXiP^Jr 4Ŷ@{ϼM'tjBq+>& Qsѩoa+f[gL:PߋΤs_Oݨ_Lrn XGԲTJoUm_ ]reff}UD$hT9jQ-+5~UC9f|~n|U<}_ƞXUq 9詼si;BJJ[rAÎ#kʹUS$uEqTY_QeEDUH½`T[ȟz<ޢG4߇̬[ϥwn?cN[;ԇ.tm)yؒE""eY*"~JTZT!`,vжIgKý]G'݇ҹtf۶ V赳w`Fݞ^B>Ϊy[B=wo*@ɤS2yWg::l' d\:9IЏ;M%ŵzVcTpkJ\^FDZE`͛C&;ts7}9_U3u5ir V?D>u]:Ĩ DJ8G |x=wZVɶ=ƞ>n={8MhZBC~4޵HGדmJ֩Gi1בUNM*QU4'O)"O^dTtlզ,xy%3|m] Jߜ5koq[,F8Jҹw+U=Ĩ{MŨ%grI,> |ҳZz>ֹ3ٶ %ĎkH73Wnu_eSitvěm֭Lqv84rA0]br }M2ƾ*?s>OdT9z#h5UsE^u( =/ EsfE}[WTb. JK\pQU=Yh9&c*'|!Il 1e3$Sy+ɧdɺJ֍P=z ,8ػw(Uo"ɀ=ZTܜҷ᥸61*pO__(%ytHo9i9F _/{PN:p'٤I`@ԒP50籠Up&KI^0jLxw[i銐a.ۥBӤEuN[s)I.t8Ͱ{cĢ)X?IRuogԡI^gzp_r7键|S6p';K?^thZuÉnts7al'lwCYǂm*W~;g&ڊQU1ۏ]{{C4gFsk.;QDׯ_Gn4xdGxǛwGS}> "߅WOFe,5r\J\NTL[~T1 VXޤG7U*áɀYekSRN-4ac֕Dw-`*va{ڍmO躆6$ O3`4Fm1ȓ@ǠaI2{$yM4Zm"d'Ul.mzH$ݡʫm.[_|}ݽMFMk6DyzV\ܜfn@^7Ͷå߬ǂOv?S k'D t2u0}oM߮yy KDUtiT]m j^'v-%eH%e=XѮ?$9jvd"-}\׭ RB)@YKؙb un$EGLUGfͨ|NԪ 7kUx0gYvyPGlMܺÝfP_\nPjҋjgR ޛ7mTv|UyD@=jzk=OWLyuZI %ct[S[jym{ ,rv+U)wZI,p|3ojVg0 X ]D1 _fٖ/^ uz|}J"PF`lOؖe|73*?8JD/Eմj>FFVm܊EM\n4Ht[;)6+R1ޠo?H J9  Pştd6ع\DR8B/:*7u4/ʋ芷o ֋`pg{&Н4tG\t%3'8y$~A/F%|\=boլ:OYvoJy|HTj~ӽAScCPʹ]Hj}߳d',~c\PJAkt߼84fsjcVf=[׃X7jiS3>[Txͻrb囡56&LN 0;ΨnT,خ]J [vG*ͼT7qG8YgVlX"}H\b.F&.Ch+] :Z=CJǁ;NAv7Q>],3iЙuv0ummjtJȻշ{u~N!@@$$}4,]XXmם%:BԎس+O1-$T w;mBl;$XJ_^"xAkH`R/DW 68WW\%z@Xzu==Go᷁g2tWc<ٶr::*=щ[IMnW4v&# !xWw,w3hN.v7b~8Jƒ=i7=&o66 [T7?Ho RM)v~ ]~=mE׋sTUu˅+.ܖPo&GHAxCH.oR4.=AC;-F^XܶBFuDYڋieeۃɾO<1=+5}) [!ў=w[vT:b'H:h#TPӒ'Rt؊( ڢ.Pʧ ch̟wRvaytzEc35u&_¯Oo|DǔqT0蛥AH$~ VկmrZP.agzP>|mno8O7Vi`,O̞̔t<61V(W8j}L/J, ؏ +/<柛bU1?d]]8 $:N[xNWa"q7͚I]Zz\Jt{m^9 cwhkEX>{6W4@tԲDzn,iuul/Շqq{3$e0/YṣJ;o-Vn|Y^G-evŷ[CUɑ7@S$j٤V;]e=6U2맹NF܆.+|ȓ:(aw7=43&M>,Yus= БlLT/%-8C.]L kp[i4jWX%//h37O|7q!٬{U.G58ϓ<&޳r֐>iGdS3j+Ji!'gxz%JLNO7J)BY'ExN+E0sbP&G.kv( ^UAQLOV5 U)DS؃{:hu.@O`Ib/Tӵz-nsFX48?07+M1&6t7pI1f aQbF{RWi.G7 u3;uսSc19?-֙׭_o4rHVq,RVgظwû:+Ojβ9Dz| ǻg':no o\v0YK/DsYlPYT FlC-'ªvG7VgpƎGb?>_z jaWl^"};՗aիx&Z}a[0Q~ pZ2VlKyXH{qdVܪON!XwR^Vҩ5.'o|v6M{}mi4x܌OB_! sV@ HuX2yN7^08u~ziA-zq ]1vfxhC;(m<[Z5ǰ}\' sK Tg]4^f P?<5$'m %~gF3y @>u1Wv{ϩ3nkOu5IlVY W^ЙNW3uqHM\HD"%:/q j HnQ@?d r@yɪ5hEj":Qz=u N\cv?.M MΜ4K[t|$Vv^V +f@Qh= |0ByoPk޺y2]?}ok\I鉣MMoͣn, Dwzn"uda x"b7/sFi] Sr'Ϋ08J=!+(?}^*ͥEvN_ToI:8Yf_ai̤mhi$V8B\YX$3S LSQYzW.>N}_.:Y6%ı=L) RR"C6h EĸDe%oL@pX/OedCauZS971ɵo}T;8ym_5.v_J_:<)Gu1M0ac P^ɝ^ɊKemux|+:~YaNS器Ѹ\;Jq,m4{B"frĝvu+ `bx+Rlp lΞ~?2hX+=*޸׹q-[ QWtPZh[#abH.+{ 3'P$$mA9PşsT% 5Mh[˷×!&>f-|\ސ9nLɱ_t(84&][8TSRkG4Q^0~ oi WZ޳EkwU[CMG޸ھ˧s+vNހ{:RFx$8T1[KR{j$Z-\:O?s=Ĥ+qF;P6* >i$00HK9PdqOp%۽+? Hlqln9V%jM̞|<@]elC${<aAv.%x8IP`v4͗}6Jc hѯKYCQ0zeFVJ(3*0Pki~Њ=y wu3Δ{;Gm 5HS=MkxF3lVRM#zX3 oN꣚P^:Tr U{Y nn G)8~?6.lH62>j&W~tV;LrvIz-K;,EȞ"Je$}56A؉ATP)@}$[B#eyk1YQm4Y˓T_Ǫ#|/|Ey^ʥ $iLDAލ#4݂/t#4e:Fd}+4rmy0Nշڟ+9\¹*]\a2Ǭc۟kԪ&?HC|^PJi azY"^9/nN33 jR&,FzІskyj=Is|+.Go5_>֭]& CǢ? \ޚdJs”[?:G;诼Nxn@sw^yY6rIZ->ͅj}ԇ@OJ|k`֎QF$uZ#Tf^夹/Oi>CTH?Ԥ7;J>:_uZ@8_'V}p7~nvgއlo!=Ϸ8sה'Щndni"LĖߓ\Kͻr:BO<g<\jo2 D > EL OǍ6ś/o㊐czy̢n5e yVs1S"lqU, 3.ָb]gB}0 *ë;eenv{%(Nby6i!uD j#ˊCWߘR[D;|\+W n q?߀Y>1rdl̜W6{N i[xwPFJ [R&#-]ƴ%-;]3"@,G6*0fkn _K(o0ܡ"3aCpI-2>Auݗ#k.(o N*4rLz0wT%qkqX:gJ .$236 %_>[c>E/ΈvW֜ NFDj,J6]ǜ#k0Aԩagnhh4k{ENVvlud-. ? L8r-;#J? igeDvپ( 4K_7 1m&ԻyZ}hlWj_g噬q۔<0+ȓ׆ks>_aKmTZTd&G{D|/{yހ*92 zZq+N{N[]œ$1#[O'`шLT|V2B4,%8 c"SQ;o|Q;I@NRiΤR[Ak.}_$S >xF_vIF*yQo\ƧW F^NgVD8"#p 'c&`D뙟E3?5!aVDif*3ɫ0ܰR)b?}?M=:7EZ.Dn9֊I Dy57bSmReؽgv`\uA8X] wokOӵdEqtnSeV01;ͦUg:.bvVߝ[܌gB9@ V9ӮI:\{⻫->]l\nFTHsPzlj]튍y'8x 4|9=|MkG~VP=?@3Hs%Frc[S13ƑT҇nuʹI~Ձ(^jX^{Eϖ֯> 7`Qby׋ AI(hWڗL/WRӲ*PR0 =(^mRg7 wDj,fz~.!dhO1u{$ll)U6jP!jV~~Lˉ]D*;OLTtH=1( Dl yN=;Ho Ӱ]|#A{ڹp4gW=Cv7&:vs"TzWfD^ L]nktAiPV XA|xVu8|fć0G젶˷ͽKW?HC$RkPCGGĚD23^ŎPI?8IJdg(q{zu&ZK E<3h]NѰZ3\zbi`+I'wĠs'2&vIO@'2NZD+O~#̫g>߶N=%~@N_d̞$uؽ`k8M"y5-: $1LE&-ir홺ݵ㩟9;X;Z ~5(I:T;ln`7<56n!Ա$doh\Mf _vuDбXSg /g tG姡woLv/0GrH<l+1߆$&nDg Y=׎|u%ӂ\垩nf}i~\_NF?>e6w }?ֳ7>0g89Ao[*.19(jLc3 K#qͮMxDu' [<~ax.և`)7;(ub/ڭ3tpef}5:z:Z%ĝ>ST e{Kߠa^j%"٧d7O;w4>Ԇ>d{k;r!o\§uێ\qfkh~F_hZWjWaUm։AWMLƠ,\FQ>iiPODQU]ALPJAY `3,s]򵇺f]F1ݵQl)o\8ҐnҪ(XsTrD# כ]-Xٞ6+r޲!ֱHGNa'3;؈@tIjTmx4 stVfXHD)RC|MFJlH%p4;7lc+{up܆6\r1砍;|hZM@{GmI.=>miԿ=O=pymGί P.JE|3ĭ3+TgzWN`8c~:[OjK! X>vX<^mިs}hEڐq5MmԩjvTW䱽{LTQ~ٙ0:)vVNe?%sS:%@2Sդ.n!͇f|p۩Vl?vknn[BHW/Tju5g Y̆EMBzv&mȢտbTqAłS@Dn!HuĤ*_M+L ǁӟ,:U$H2cf1e1^VS0Y˕QNȢ= c{6v ƙU;X:ر ~՗ y`O*C_|-Y)ݩ뗿ӣm"JMqg#a.Q _+h!:ӹNuY>Zb&[L5~&ő(* ]53j"KxJW7{$#i(Y@uLtb܍,,CoOߗd0jX;_G[j%Fvue9jjLzI\r&1I 9lsѕ(X^$󓺸;p]!$ˣ653Հ6gXv3A{$U({VUPnsh uwS_k׳]`NԍhDo WrFs{A@K2q`A4+g~te`*n=1dߗSȟFLyo~cMS[v=gpi•;G#4,҅]a 3Ky;Z GUƲBAn%b6U|Q8 zqjDl)C(HryB{?e< 7fWO_Ew+ipz#ĻO'zXho1W*Mw}Ek`6Qjja_MϹԉ#uV3XayHG^x3hKAi3]sCq͡q'$3q_H7ƖL >_̎xʽDw4cs6m|c*-G=)ͭf5 .;V(k'}g'mmLx&ōۃU*rS%u_V;(^ -;P[]xl'䫹DT辕F&W4v)Dnxvl2 VΰhQM}&PcmS21@MⶋS}'m>+!m!Ҥ00g;q#~꿵X w6~Rzܒ]*k{vo${`y֕02ݧ5P݃!ѹv rtZk+rs3 sJ[;ԋ$R֫"Uz\r]Mi:뛇Ot3*\t*$鉢 3{^Ԣ,m<.zE0'd{tAxT:WO^i`ݫVY8r仿ǘ!>:뗓ӣޙE;b9JԕF ]NpW+{+꡶4mRlS^jp߫bWnbTe/G0i6Pm""8oH (g PHZ J(P;;PeҞsAi@X5P(2s((h(( %P Z0(MPh5#\/Zh% +į?u(]YчOKz'YHL]Tܕ g;ݦO>1:/⽔xx zOki c 'ƕ[oЯ SzїN՟rF%nH9lkVy?hy^^W$/9v;9]hd ή/JS |%Y\_2"i r3䧅oIFZS8`b@3s٭3%'lt{ UvOtNn_ʷDjO~@!Vtt^TT&DB"~yw,EyrU'y:Lv}OvFmǕgh`3mnygp8B>5@ ~0rV=J{0zG4: Eg}cnm`f848Q.od6XOVS_?umtZ+4̔(ߡT638Dg_]}^] < csܾhpqlgMXKU]ҭj_>]YA[ZY8KҮH~SuPn㗧-'=i9U) =]ea~is1z{&HHe+Trcj_eQ[t$]k$$}UIB=/&R>/\`.)n=puoXnd:freu/hWD0D?0J;s^I]^vb6Z%Y :,B*44YDE,< `6xqeK9]əiɢtn%LS_o5ez).mUF1B4]VYnޜ?Lȟf@/LcV'PrG2/1*@r|K|}=D= ؼ9GG?kt\TNe[w=؏ݱ"%rnsҵYsYFk{KzxBW_$N$i&tM@R[JH[\h=LƫAs爭О5+{~팱>ud !XK"3?S%2%<>N=G\ дuKp '#r`G/ Odn# (ﴪUU`ew.6HMo<ć!zG8n_rÕnO-kىE c,tT]M#O)_g/:'qb=Zo_@NǕ3v <ZT^#hi^gMȇ42Ey)  rcܣ|.5F gE ۷d#kpUG5 $&d%gTX\fUכkAt nA\c՚l}cmZ)9K<.pK=t6< UE3Buψ軿 נw޿g Y WPFw~ggd*wXFP\,UX~FXDן UEeD|Q΁uו2NHׁY&;}Xӹ~s](m􌁟f?>+/xť8G{&;"?W-F+NToKwrZg9u(^TZK}{͊F3 >܊+^k];&~c;&_":WmŸa?0]g ϡq[u,Zߦ 6{r{a}QF'n5Z[pg1%[-״ -J.Z<Sߥ=k݃Żnqvx>~]]"ƃ=uXƕup* (X̊q1Qϯ\K)]+r[ coǏ]D:y> 0k{f:At۫D_ 9]繥ք+8ds€@DL 0!?{G?Mnyj,Z=;3d7qn-\W!DG<`p|V%W >Re-6_]8vٺpy`YM,!бZXGz$pbo 4S|V9[)vZy'^?7ꃈqrf0Q_CS6Ri~[-6sN[jDk&YMB.R+[|j+v%1F2+1,Z¦ |^~HPݢPkV+mΡO+MCOc!§]p[4@lBHAf}=~i,VUƭUvWԹ,t\^/a>7?߰Z Ă(1SGP> ZuZyh:ۑ(xlhItӬ&1\~ AIηBD&\:|麹֊)HQh|tŁq9 堓ײ+ch UE3IlRnzL4}ڒ9P!BiVڂRhzQβXG:fA:w~|8rC|} #D@L+Fcd_38Tt{X\h̰] m|(@*c `o +- Mp{d`>!X8*Ӕ>Dy% YMJN"i7Nh| $DXOĮzAqvhXB $ $kjt³g 9͒ҴB{{q$ 4p\D~ ' @*t``]DO%ڝ[ӭ3HQӌ?jq*mF`6L$zwdR@mѳC 8ģxҹ:?$/)Ty@J1}?H]o0PT=M|KI.c0h3h$$Anc4LcnNPy=*BTիTXgy\ȑ=/WM:+IiĦv~^?ҰjN- &xܛoWwz˿ij> wtO7᱋핁{alSW eq q;nH%jҀo {d z(6aQ_'۫>Cg L,k?XitwMK^VD~+u^/fF.hUAGcF.e׵fS!Y?tor݊4kٿЈ xykAnj>yIgκwEsCA<&0;[ O懚]w|^Zbe @glһτ.O |L,f 5 `\(v?KgGs,~k"דER k;ߵ'Kc^,Ud-0 ^f:tZR-jWKY uԩ:Y,-(ڶyZA*9fiQ8^?c7i <0d$ it||w)OO\C'LË)lfQgTv椹0`ՠ6T<Ξ'?W3r?5{BerXM~Rl"Dkr4I9qripKc79?[9]kg1K/<܍k.5Eʬ T7=&cd⎯ɏ5R QSgۊ=bf K#{@'2933iZԴojS\Ek2-#@e4͵tR.3WO0kأxUK$gX j ;z}L 4؉5|*=~<$^|d+_;璱<6N씻 fՓ1F?մq#۝{p_ܦWhiKЗŸ*3'{tLlb`Wt?>uKp%5.[z0s\8?*͌bz/,wS#c)%e\ZWKq K>Al_s03 2k_@^H5Ҳ^2黔{%)ɼ/bqZ]n?7oqtO[cqP|[X˱%\DdgyvC3cw@QQ1]rS uthI:sc[#{S0Ӝ 75GI_ltU`F|`q\5شovxh9_+onXFoԻ^; ټ=;NOl^yw2x˅z=]loo':kUweUV+a|.J,?Aq)5atޜ%Zn;dvT7:kh?hFO[HqٿMod@-T1kFkk&֟jn2*+#N}JzΌN̢zo(r1;H%{k\+|K妷tmNU-j V?H9P.YUlɝ^:~1AK@[)v,oC%v0ⴾf ZgĺB_,u]J,( ڸ76A^y[ϙ_8uA;J1P9O:^ݱ+qx!$B}=ʵAY"S7g\x:w-~!+lyc2?'N  3=`H`R+~| O0}@ A(Č& Y>Ws7_s\ 3Pti-,~ΰCv.J0CZ؞թ`ԫ^kvt;ng m C{}_V4P: *ߐTH1hilXyN 0YR=FOu\ʑ>oM~*EVY?3;nOJ9ssy{0+ eb'ϔU4a {[Rz%; oq%O~pez`fTx"wEKq ?AxdYH棃{ +yc[ztoaٰZSqL8S)1_1)Cms 4߼MݶDjhfBP%vQ5T23Ό?4-\ nu|+^z_Y&R=ĕO^TXQ@ʉԽcݾ)LznL3>0orvt~f_].,Zg=LpL4AX62Y`Iw=~^ĕSml5ekr[A㛉o$}_L?jA~]G[?jΓw}-vzЬ.X"]e:eUH~MG•3qx}_憼l'T \/8N F9?Z߻'KWrWԎ%M@mɛ*>ۢN[z%n)=P+y#ZEVVAui(rhISMrW$#|^HFmbk,ք,Ff,ނ׌X7u:tZ҃6[݌/͖s[-i_4hS3VZ6ij}&I_O.Q&k=AAjx{t'))U+%ݨn5~`[w=xND2ڽۭNtdE{oj68э++/Р9 R=wó|-3gf5~U+>H㵖ȥpvO` @ԋ62A~UH?6m!GwIo޶P 3j5Vw}Ea;ЩiTܠW̼]x4ڹO5JL״NM,tvxMqNPJI4z\tślᎾpί%OPKrm]jJe-OT"=R|Ѫ=]`\(52aek\1qN$&Hf -䇇^:{.6BAu k64nD՟5DZ\VH'i]NzO3sN';xZLδE,y;ލƀUv(ҥwn,Q]߶N{ 6Ua1Uu4bugA:=n}A`Bɔ끻WeH9ѥw >v3z|W펜ߐt{ORv]7Tfs;7Z؞7\.>t14Y۫[ς/GHOMFgtQyfcX\1ߍ¶sƋY\cVjͼoym77l{DꤤV{\{܁䧻bl'\?hRF$g,[Cgru| Fw5#o0tv73>is8{23oާrwr~ϸ{=}A}WO{cu`kuΕP߬L6*%6G(7Ƿ7Jq1$̆̇'ʱ?~?޹5;i`N,O!Kx5sݩ)&jTEeM[>a!k2id2l1Qٕd~^tQ;ޛ~s{xtui]K>Mj]*}wjq~bO*3;;-KLJPT]bGpYTslYF H{N1@n@1955LpnNi++@h]r?SF%FԢ>]'n.՞=kՀyݭՠfAySg(tNf:'K CJ?7>G\؁#j+x%ϗc72&1( fd;˴-mVm޾x4Dː']V*W,k6nCjX)‡# L̛A-'./OF7]{:[V:P1h <eyE]YMgKV73N]Sm pYk.. q bJxtubG,=GԌƒYTBfiRt8\YoJ`xj;{z`/2)ޮdq; [oA(tIJV~úf>=jlyeVXf f!ҋ Z'b % endstream endobj 261 0 obj <>stream u 5jIdIhόlfBA.:Mt}՞*o1y(L麉iY8gLmBd͂6] [h>8_sD{x|u,3VݯB찶P䚞^+%k_TmIV`P!B-:ALŢgՕBb&Q7@r !LA· oPfHxI$ԌiƦr.xGsޚD 4vke|,./h%7m)_V]Ġ.p{!p0Q54ZD=r='H, M>] uKHTltln189ϑ_H#, @=P$b*K sK@Ht5@'g,F $!wߛIq:8c|9 r!ARjxƿ   @%t&@ "$.—lz6B/L{8$_ZIiVH5d`TU@2 M 8)slL7Ljl)Yw@@Y6 _G>\y^pIt  =GRCR~"U= mNX &_v ZD¬RO-;>t p_L}vcvzacyi)  LQڞe 4ѱywD΂-@Wk;P.*4WB$7qN>xyg^5ţJtDd4t8k1aݹXKz|ͿBxԠۙ#b)[ЉDMe+,VޏKAadGsd2; v$cs lq 1~x,J@s tg3{e~:T.|Iz/BiOyS/),? gxAvo\"wGfO#.Ios!wYo*wyAަhpWE͂?gx:Yz4<?o|W`0عHw3lw)YϺz׷`썫.@&>U g]fBn7Qg;֜ ퟘ rߗ;ߖg(`[I,2CuF[^xZjn] rX|ځNkf x,K#1Ca*|A!l85l\zi5d^;2oc}GlNG~ͯ`zisDzJzsFczkwZkvTS/UFYEK0?_}w<[ߐ4PxlE0yRlp?~M U)*D-5no$EzѧTF./;x\+j vfz{iS;+Exu"IFg~qy|$w`ZzYx* ~mΞ-Bۜ7~``N #OJݮW/.ԫI|uԥ.6uw++t*- bYkX"l4Ru36p*Fڒ^TMjew RKtE6X6֭I7]uc4 /&MB,ڔXsrN=w|! Gij{Ny75ήI$b9-o.ٙq9id$?tVwV{V|Ktߐ\Gn&zwܯ ,6. ~/FKm=X+qUt'eF+-f;ܳ7_\}yNmU)#:58sLٷNnTjAo٦D NN|,>YfLn; λ=83ࢬ5eJc8j{K|$n!,thʨAt9kQ&f^U;ed/ U@gY=W|4+֗Q3t.|5&#a7 Nxi4]g>l LJ3"+!$+]$+ƸY#1oߐ%T+(ٵll޺z?%*sMpmܼК~ҿTO-z\do *^:Bvjh_4EHyy`uz1ը=[oY7n=߰۬7Mڿ >iyV18qs4s@^KEhkIoQ<13nryKʫ$Ovg>>͑fŲ.m8 ޙ*<ݧGn*\߰;؈E7{Tr/w`vSO5ԧOp J2t5r%rAh,RgT3k=TwQEa^Lf9M82][7,78goz}\8?*NU(w]QCQui](֔qTz}:UjVYWuV!BoQ.$ў]8o zGu%oL|̈ػlrkqэ;XS} ϽN !HlChl/G㡴BԺсwo ܐ*ꝈPjT*z&U-a,h#7&^.6]b詸϶8q{c, 5P.*MüSN'Ul'+vɆk6gՠX9֩,4nI M,;9?]:D)b 4Su WXF*xF niU O菃Z}h\PP^ɂlx+났1dj -ȎCd)`?ҍtLKed{k}({t8_R\ȺD*n(  MI>dLA6tV =6 +OT(]ud7Wz]^&wɸ,LӖs@/4N2-.'   ` W·E eRaoȝlo_b!O&onfv$jS7[(/{F5nOG@]`Ot֏H> .+@kԃ $G@X[^%dn1҃]fߜoB#*/ K coqſuy{zMqV@6@ NGoFg M)GSw|3Yb/rkGzR+u87SgN63G.{G:1n~!&Ri xБ-r+@k2:*I^Ta Ϟ1f/ exK[5~&Y=~ n^i@OoS|_l:?ytnGw,ls O @:@v/( 46~0޹l=.#"fjީnRΗV*Wl\}_i-{~޹U ɧQC'.OGs5vy>!x#.:V55LweqmoV-}Uz3);>R>cz4}_/|6Kr9GnNő¶kSshZ5tn <  d1Y'DWi>^?=a8 JYF9 Pۻ^HveUǪbv-uݾu{.ʂQ+ӕͪh̗⻪-+\5<Q~@N @O`<1Qv0_U=?P8 tpߋ ȷ^f*6aG`}5٪Rj{Q,kow]des-b*&?&n&ŷsSxi9t櫩ڱcrwB30Ww3XlSjZԗ31# T֟7|km}8-K@5RH#LaW)`=Ԓ.iĤ,E-JW6קC`u:bMh|w7/(ZNTs.4 mWuge5{O4<) Wg*tv7>={yB4`DgO7yU\®ZP=B!,kø^xVRmQ9`b}d<ߞRfv#=lR~KNj<-/b~\ou}. 2 YT %BEP4ʇ;;řC6\IA2`Zj_6x+ΝS /;e.;sl*&]v_LRm5?;'y7Z_c&=^*.wh,i^z@é1*$TB/0rjqr!u^P.](r"͘_e/꛸>ώ W3ܝ)Wzw} ;$tR' 5 ;0 ڈ{ &7F'nVTS.D\C/}k'lUT෷v39Pkq01 (<1tH߆C{X ,TPx}p ]hl6u6W)\qK9d>O}BC( QdWMRo{>njӖ\UC,)Z_25^p{9U\ˮd2K0 1FPIy>/7ިk<=^ Vҷ.5*G@XTâk `n`3O̠hhyDRN}Bjy9렔3|fak`z|Ig'XŞu.*p4K }v9w WQ *!0Xs?2k&}z)Qʇ޹55K;k|kD^?etGez3[£9hf5ܓ ۫z3{>$nKS<6A=2f,X|\{.v-輭rNin{!""/5޴+E}EɃv_/DRmdu6b}񨍴 =hpĮӄIuxM<&N}X &h"jaA8&ΩooKlk吃Y*oyIϗ?v7yR*wHς^UT[H&h>YZ:xBŏ&T{dWE!u"tzH!rW+;<sL'& i:rqGi^5t-k+ b[/ ZO>rXu)lcsx<wɋNԊӹ.>/DJXsqo94;tBo9j#3H-=ݹa^.&8Ȭ̇7g[jGc(Lz"ut:%דGtF6d`hdޯ$Dh  ݴ]͏֧j O%>b ֲMye"2&Ҩ3޳$p|ӦO9EeԮኆja?LfW7\DwLA;k֤֞~uDv[VE!r9|tZ.:w8r(S`ސ^=-„Y"쬺Hh^R\TNrJr^t+<6ɱIk xXK 0R^&4OR =e?O@7-M1H o{Z2%jvx0KcR %I[)K2a;tIw$'%$GI'v$S쭔M;&`7c8~SDflYOINKi`yU{I~I~46yJ9O[W i+Lcrғ\''nsSaWz{S@Rcm>zL]{i޶x {Ii|/ b]@fiiґ|o3%y h$Iycs#{z\\LVo{w^u۾N&Sɳ>;V;֬s[!]!0w^?$E$bSO 3.%$>O 4H =) vj S)gs()h,zݱ9={Fzla%C>mP4 N xS󥝚ӵz__.!8K-)%2)jZ&Ev"oAzVxak(Bg[")4TQ6I1\w 5|Бa%ޛ9Áa+YMJ]wG%RT>ֳӔ;2nvqo \yuJZ2yfۛŭ&o7'}Nq-( v,v% 5lUɡiq\Ċo"4w$ q8+f%,iv||R|[ZC謢u.oYY3,vt=?LVwi돦ބ=%m6h]צ#DL+J':?ioR]DIc߽v?jkd_lq[ء86?o,>ٛ tF Vjt@:yn̉%X+-JwEi0Ɖ(ׯ][1._imx>ӱ^$hb#5f3YqІ R479FU gv[A--8Fk^9'7,ͦ-,_{|? UOh%gDe!*ԮG3r~`*YuF޵=WD&@7[Ɣ t4tX7\'/rS3fһƟe<@O./.. v/ل^$͖h^ER~ȋVnywv|yQ?۰}#Y3Awy'UM=Qg_O[vl)4圼X4w0KP^Q@USxd#ӰjtD{Ϧ.yfp/3|T\Uq5ۆc{ziLǖ*p(͐ml1r娽HQ+@nyT/o6l6\7I|leE/Ij}N7{ V |ޜqEL7{'`\8*f}8ndkSdTʍKr'n(Qָ]K;Q|3i܆Cf`_ S0*h\fmRJ!vNZ{]6.қE.'KFe-Y9) q݇ a~-F9"WλSc)p$jrc.<fFHJ~sﭘ˶QܥynrwTq4$vMUUi#}7z ^,T9@lMp&{"6qd,0d NWt=1713Midx"xVGP u̎|v.4W̬68Nb|C=%IVAU<bc?6esZvL|s_rBkӯV߾0Ck)~-7,&Gۃy?{eo%0j+lᤆ뭼 V'DJgsm6ܢ%,}jn)d{CCJSEJ3j?guR'ߍ{gLɛ\c-Sk' U ̝2 Hϸ\Fnn @qwpDu;w?>?/>7V=cб۱-#c ԱZW eEօ>s􍬭>ִS%&@s"$8mJP`cPĂ%Z^a2kgwd3on̢61u 5[^:HN{v8~kWB]kKa:mAXe`6 b?a܌Qk+n= z[Qlpਊ^cu/硩V_ip0DGw3s8ߊWDae#klۃJՆ-ri b GxMh{4Z(Uc? cٻ"DlzޣEe5.Σ +E'PwR/a_8, :xKs)43YBObdr>.CxjVtӢ;2PL~u^!jQް!>`f}B[ ճFUKQۃTq\n5B@z ֚Ԇ%N% 2+Hʡ-ht[Ww/׎kL! pƯFmՏ.sHQ^٣(Mߨ}IAq"g#7>UzÐq˳{Z+ x vp6F6mV|P6G[WE#U+EU"?+!U\"x7~nS˅'"U4U{1釅G7ĘDɾ7i76i4'1E#Pkaa*Y|*s[>ZY^nd "l ]e$y&A1+!yŗOB뢛sC5Nό]ZYV}r)u*ڴBfSu!u[Wm[Ciy^K|cO+,*qpyZ}ۉrϨl}^Fua6[kE>3WH=FXNnoy$7 Wf%6J}S||;"8frjQa,/Pnfv,x][:.=O)>r&)HS M$ȥ-tf?F8;u Cj)eL)g* 98}7,f)Uҷ)bi-"mh'$+j \mUH_> X\LJm߷ {B)f+܉)#%eaܜDv)zk]}J ğvzPodpD~̿/ҰOW@ӷ4Tk$9Cc i 'x$2H* z)AW9*~d|[=>7]<*x)vvG/WfB LhV~Iri~n@G$[)޻+-!%#eSAU*EԬv5BSg~WKzLTԻyKS/eEaRW?0WVk}/4'ɷItjR_$/uzy\K7(>ZϠz7zo'sqnѼӝg8 ݾs' lnXIFJ%W'%($ T{$)YZ 4::auuÍ?h%׈ a+Uj]A.bwx$+;{L8 zjQ 6+nVwѻ]U;-n?bI UHb=) ZV-ۙx{,5b & p><_ǩnWxuuoK>ė5,԰]8Am*Ja]lty=rܑ% Z?$w5 ~" fRL;8:>gGMʟ-E>F~ |Evِ#ם-kvMOے-]g^T4?t+a:]::n#ܢEl7O ?k~+*tt'Fn ؜KE V-V,_Rf3#s@\G?Q. ۥ}ݙ,̫-1t>k`OR@b>|[`=x/ۭNfWj\;w]غqrؐJ(O'pfdžΤK-?[:%#f)UnT0!)ZڛG>ayl_&`Kfl+._^N/u=.N}Y}ka3k2VgNdE[XmY0-X70P?ϻM[aQ{YXX!6ͿGp:uOgD7{VJ^slnT=A#,*;}7L K nF,w}ddf 9]E9m`rR827&/z?tMlW{lWf7vxr~m@Y7՞ebG֜KprH hRo38oaw^&Ϩ\}GA $.lf,K/ޏ\M{=Gl]_vҏ]F"yy#mQ7 '{DQm~n|UpkuwN`{Uw-aIht1eӫ1.>Y. %)ƻqꇋ4{z+ϗOt<8m\My7b0%DNdOǺMΟώ*ú%Uؗ`e?vFlMZnN,l̦X!9塧$yi*cf;k%m Jåh4Ds gю]6~ Dw)`OkH褗7 ϟǶSR@>}-.GDuV~JNMv>[m`$~Gj`V<7,) +=0]jppW66)ʍ.w뇨Įn7}_bvSmw?s+M^1FsSEJ!y(36.>N ¨q!*|+ƒG'hs~**)9.}9/:̎IVmW6뽳w&GIIU~/2۬s$8՗gO =L6hMMӑ:߈[zj[_Yri@*ZKs*p<"BjnOmn$Z^ytﶷWc}qVz+\fs5RlULͼx]Z(.Gg>/p# 3&=?&07ky)rA<͟Bd/t_}$ákT}&]Գnwe/5aInna\5Cd21lzC0 z>%..VN=x !nE kw˫-X,L JWܑ.9"L.ߺg%Ӣ}2ߔ:C]K%G3F4p$@"zGeC>CLmi3dUY@OB@ .>rxY/8F9.;R$]p~/ƚt>Hdžd3D~1)hۗcж Jy\[o8whB)iur6aOG ̶Wؙo#j~iNg1SrD8 ~{0 : |_8Ԏ14&?9\4?7a@[ !Cn-]#Fs%4M8y6JN*Rc;<| 3ɆDo,Ƕ=gʢ3>`KQXUyQRGfh9n /lP թև@\حYnwFwCG VUjfѯRTUNU[j E?]L2fȖo3¬e$WWBl)+fX]^S!SC\ Q5j[ŞWQ|?N@qa« ! eR>(nvOi]mKyૌbk*7O[pHaE&v;*iF5d*/UٷMB6D,ۙ~ ,w4\0+(\.^Pا\='(\ֹkA;M^~Po塥b쩰"Pf"vyR~`#9ڀ2'$%h1Ol_**Vl^fva[{>#)r(QHq"amȞR?tVvuԽ, !G1kԛKVV|iY0ؓu[Eo4ׁ B)r kRK'Mњi#F,_i%6!Ʒ{[jB!ӫ6SIގ?0Na̔&/ǛR@Ԟ'q'AKM$Rۏ񷆉)tϹqH)O?V-mDʡSXit9ՋnOIBV+VU= I%F4˪o c P7^;./ 9eR1{jڭ7bop{S(/vg/k#pF4hn aU~ ]Yֻ<2nРx;hz7Wy0xB0[, AHP?%wQ?YZīއz-x.%$lT)0@m_|Y 0!L .\FZw5ЈB:u*0hN'j0OXp]!z0+}1m3.C=Mey\ DYjfȿY$^zi5N yշxBXp|ݿѽq;eDq;9u֦R6ӆ;`WO}/т/c2;9Z8o b0lŋRc}^Fy$)RA-\AЭǨ=cvP"ݽ6ײ,ǴZ~&ݬR+ IK7v?D, sa]޹FjiTIRmv)%وrt1j#k[U@ucϵg7"jvY[CUWr[ksɵU8i9ҶLLeqxi{!ɮ#HwxTyuYZ:~vA8VM3X;\C`\_OՏ!Xt9gΏ_5zqD+ٴ4hUv\Is1'X6Z0O ㇓ "mqm]Ǚhd~-gpҪFj@G!&fַoOAbCUB٣lW{ye#qE%%TGR9\3Щڀ{o~k|w̸3.9Q)l9y)+lOXE{8?UzFy`F6\'Tٲ= (޷ʘDƵWJPn8F<ܳ$floKl&_w47Yh!4v%&. ^:%Hfq$5_L> pޝl]+}Ey6 )S8:+2 !\|wmk,{mi=z%<=\P{QSzBE1vM9`C_.e2*_c fڻMV/D!uGZ!?^{Fǂ 5s纍M:Q1aNT][h=x{Ba+!$z0yI(Zf![:w3K[FłDxgFGX0yL8Ǫ2d=IopK'r*2M1^KpT`W3lb#ҰQMQ^[Qj˅oBqfd{b[=&?^WMdxlO [$ ;p'Uju4mNX&^]JGD/C',zΨ5CիfE`;:¹Y? gh<9_Sά55Mdzl"ҝ~^o?F;׬9YvRM.D]D%(?紟UauWЫOO9˹pڷ[h2{|ZX{/ͫ V /aJq/6quk'/}XbmrX^UזSq$I%VOR$He3b%IǽEtTpdLKmvh$Na7I~oQG}G]c?$[B,TP$]wM]>y7K]aIz^eq^ &n:$} +Gqm\E0,9x}<Û"RHb&o^en5myɂfګsvϯS~J/2je'~vȼm)<|֕֊>2ӾKg7Ԑ-PGwyIg %!?R)hW;Lns˿_D#AWOނ̇ywEӫwMT_?3W(LW;w/(O?uF>owukAE`?Y}ӕ/3\vj=_er}U> / 3QGFTv0.}+#tl?4qwYĢ^zo\'*? P sޓ<4|ؤ'Hs!uʰin_eV*(7+ޒ psLӧҍ+'D>cX+}dծƷIQp*j-H8k5 ħ-Wզڪ=I}mb,OsNIebqav'+QM Q~5/HK9( |-ܫږLwO.dͭ5v+7[!Y`}O]X,ʢ7!?Oׄm-^㛣̚EFKŧu^BFwhWKyMĸCEXǭփzTڏˤz:Pr(kL.}gdʟ Ig;?vs7/ReD)vPgvt4.ޱ7zϭ}zۍe Kb:Cٞіj.8}{Y/ل9* fMc>ۘ}|744,D=̍^1F-!WeмczNNrai,̧CQ"s"GΔYۺLЗƓFƼtėur_jk4ar˚ZۀR7`szʲ3N?L; DVńme/FՕF`i~k(XfIx~Ğ''gFSnUe8 B#7WEiU;BA竲8Uoj(Z\wP^}iI;^a9=~=0͓"ŻAneHMCsAo1^nOerB+äm|%W'>ƒY זZ|pw lC$d6JL&hv_cga$+?UuY2Vq? .WIn`cA}Y)D KPW$z,,W$P \4|6{1Mx(w$Bb]J*%eʷgyEOd&vz-RM#? xTm҉A٨/ҬCb"-,TXBNȏ_1sh8#%F, 6A+C͈f_/;0Yj X7˱~Ēn2S+<5FkӜ\ŕo:$a\&F*< S:fȷlFErָ F܄Y\$\]d/7QTxb˃ ݟEO.ʹTqL2,6wCFUxKϦ:$n&#P{)p#5qiqI~FiUSP#Gz"LYqk@3vc<*wxq]-Ur׼ ^uoea]Ӷ=VsL47׈,.:LX_x׷gQ"||$^B e%@Yh4^ a>"d\/ٿS #Ndx׋ކqx]h><&q :x!VTnBl`05eY>u3td%Az_]`M}EzLцޥhe5j}34 px)!+ Sh[8YOZz+i bK%ż,]YWgxϠsLC:\oa:SR퀕ewUlL\孤}QNnJĽ/%HbfVEHoyFPEsSxQ=fRrcakv Wa~zQEkd銄lsI~DsԈ&҄Bl??}mnx^D3;c87yVsg[!gqN(ha8J8|1YZa~a!lznj1q宣,~^Н]A8#A5e #mbbw!v7"!~,J);OG.޶~ }u> 'a+숊. 1AN/>2OUғ\\b.nâ+Qv3{u`u8g%6#[+F [䳨*n={~k }C³vD[QkRkp<xhKWm+Mj!OQFSA~ԝփl5~>\64u@s/E])İTQ! :Z uν} YÚ/onUzq/\M{ ]o˗Hvs.QrcnٝsuP$m+X#PhD|o ,:MJ,9qfu+U uP p@?k۟j̻y.PB9Z9V^m쪃iۭN| ;Vy566f47w[$+yopQj\ݿHo#2RORڄӕ5ZS]$La(Yܾ9Cg7:/1|s~Ѥ|g 7E6ʄ׃O;p^7^M:)ZczTܨ Uы];q 1t{,?fhzt۝[nDDnQwIw7r/_X⾢'Kg)EhV6 >Aʉ 6d 5KfKr. {SYKE'bA,U~\dsScӘkKJ+ؑZ|P Zl`-{{H:h lE&-{ =^w!#8 Vqv -&BV1Q];RIakXۿAdИ268.F՞l"@;a{_LOK) !,nJ`=X㷶.4{s?aCNd؞{ PZЃGJXa$X=q O6 Ƽ0c )Yaæ4-,K˿-"ا~hSl3-iTk.}e'E)Cyų`Jޗ6C}HpZ}<$x.@ *q  r/eN*ە^wնSQʝK=`Dl7sx مX.>'_@ * rW72Y$oVoSC=%1G^WXɻYI$$ys$Y:$nI PNDeBJx,izT@wU}_x2Cw2=B]Hʫہl nC<8`YɻQrt /I ?Ik$.YnZgDYn> 4ٌ^udZ_wPV5COB9R޵އ; /)ɇH9Γw;\/# 7Hޓ4'>"ƷSZ#*L}Ƒ=Q'Nηc/oo w5d1Ӛ%Aoj|삝|G3Toӭo 'osr'$ ;Aqxo`;89nZ 37z/ *[+:<as'Z:eȎB2+6`H>Ii|^ifd_/CSMk9o(UN+ y)>:}ǭR||knfidGͧkNea͸F7qI˷S/*pA[_1Xuerny؉5ænX+͍GF ųIUI `(x|۹۲넛xOK`C p GaueK9t‹qjl96D PJ桲>3%nxbuUkէV\P74Z6.=HOBFmw9z#^{&(\׫Y.{tV0v0̣>Gg.>;̬9BE\VGںfl\͞vQtL7سgnh1:La[{/Bz/veFgl# 4u֪'KU> @+Uql}X$}m5ToR]#%O+ET+['(drM;\g Ġ{jN:Zvh滵1)dfiC1M;Nj=RP7AUeJwtrZ1w#EL}r$1o|"ւX{r7Ek[ٗ5V؊hlmӅajz)Pb/S=L=lYuמ94xii.vRQwureJm:|U90m:7??{8$q۳6@<>BQj(%$1z>7ԭLY5WVqn=ށ|2oU⋅xxk * 9y19nʈ_s#N_?'cYoWɎh8l5|Xq]eHSwG+o|urgs{;eH0б. p(R~,7_'=>\R*^ٕ|f0\Awc~C ֻ\Yq=ThLд%!Jĩ}>Gg1:z\4ш?/|ԩ|J|us!3q_pdrQ,24 ~%!s\cpP1o9s6_Bu;Mh>mQ "=sY,o+h|Qm^66܄2[nRen)A?.NؙHcjt{x:iB>K{NmϮu'Ȋ:ܯR^|='݇__ƃP1Ng>)rG;ZzbGZk+=k0mCR}C&pf=oeիw {۠AF׊+b Δp)[r:SLv3-xGy?֋.ػkc՚ {eݐaMa|"R(1>;H^2ptvݫ݉y_tܶ"׶aYvݚuW-qZ!B>aKovSNiKeaԬwft6&f^Ww;& 9_Q,>(:ںGv`V[/hapq6SLǟ:79 ^dFf?)֖GzuZd_Cv9<]B&\YU+ɫakq8O+IklRKS^GߛM>|u>uHp,?k5ŪVO6 sZ9#rn+Sf c'lxhz6Ϥ4(-  򊽶ھMm얭FjF_=v `JRʅ^W*Z٩t-#2 ![~֥`) 9%GhHAKDb.i'j0x]PJ'ea+Gz?rkJ}^jດ; Qѝ)׌]Zd\p6 8ȋ B8(/>)G#1ül<+.۱dԱ[hJXfvbЩz Vtz#eʋgY+zw V?"?Y/} sbS+UߵT[7l}21 ,7o >пnF1ӱfu4wME: J[NnD9oS X\h9mMi2Eg@@I K*Iʆb{l )Hjf.fON\VPzE{zCdó|.=ܣt@Ըэ[suu3 FjƝ){Hɻ^$w$;7̢5ːG$!6 /9:~#f݅{jk{tZ< qe_W8/^{AwU' ?dR\gy&αAYe~MvvT&I ]hgqܣ; {\s%^}rmsg4.ω|sgHN8SlvNڛmGy!N7||}R~EXOџkYMryMf9'c٬e]`g"3X/7j~nhm]o#7[[c70=A}5֬]Y\ZgY>H[ $e c.:aqRAXTïQ^rvc[{_|;y"QU#u'C4>oZ*سY='gGrH ,Jԅ2Ymg9SH.Μ68kS'I4䦲*ܣM6[fժ6wRwq|mG*hvb#EEtcΫ$zEj ̸s? /Rcyp~z6U ZgrPhjӯ(F\(lωǙVcD/k=VL8xv}_^-OQ)XepMajW!saF^aAkdeZ Nf"wnjz4k֊58ٳ6k:z]x>Qv.I2kFMsDr>09vk2)>I??)C~<:'^3na5Gt-۞r{.*8h殮@o^oI 霥z?hFJ4ϊïa&.H2tm.CmnUq%㻽pêpy:tFr<5S@^mC-J͔t-5#fH ҆禕%*(RU&G0&-]Zp+DI.R[ʂSw,|uw<.xhn5q_^D{{Rh~!Zukn׃󧻶~f;N^-ЊF|I|FHLӛϗ8-Lj\x1Mͪ l"脯v{nw8j8tt~|Seu |_7Yr=;ѼKW_BTs4 &Qְ0£du@hawcޚAMw+aDf._>'q=7ZW" >Ҧ퓦(cIQa7Ə`QAC3j;%ʆSyCM#lhk&h`- O2.騒/z V`UaeEφonlocHB +[jXw.;j7Hpo-ҷGF;27n^aɜow/CU};h"v1ih8^oym[dײB?;&~ z%{frI=8|^#͘$| bLYGɉ 0\#L{ EcPZߦ;Qd}݃M%qc)h;`593ϓ]h!tjN.!Ï.KU zbj?Pxw8Tg:3t*Ŋ|1֘ /w᜹C'~_TBH\6BM5!j@H᧪254czG=_DG^-EF^m"|7P+0?|Pk,y#v:pܸw@4x{/.L 7rZ!P`ݓTHACPlçc= FvYH;rTT NS_UL6`܇:UF:R#l"*y<g6f )#q[_~gg\;縎G} IFnQ̎ާ^:SsmG<̧[x.A"ު8[:gioik37Ipo#;`&H-'jBL왎_*޵f$+W46^?kGަ <:.^#t&˦s͒ព;X3n` 7&YM*=0N}5 D]{a>Byv8EwOo@rF 8O`x|\}c <(Gn_LԆmҺkytyڬ[?6n[39 J?Ss$toVnWJBy'QɥSrqkD@ޛ gۤOn:+K8+!\kP;|ۥJgzqDC[̓Z5|/hj,Ԡ:ѠVҼAI[lؑSuc2vj7R.n#_\DF.)mYs[n~L2i:E)$u nc Ftm KgS Vc]VBU4`) /븜|fPmu> '$>F*p qT^:zـ׵p(E5\jU|Ujk8+WITGEzeNH9mu*q凵(Yz ]}]!k9`#7&\ҹƐ1 &K;eN+%dk{\% _E; 2>o6XjtF`bjCxKvYyݿV n.{mO;No3mNn,0,?9 C+]/MJ]1oHL#,4VsUUlG'}>0uGa^/.:2:aq~~ 'x}(}G[5|^$EG}y}Fvqf 7uf})CԀ@oSf0'wܧ@V2>K6I'%I' ?Ƞ{+I(XI23?ӄdmkZA]EEdųVu= ?߅Gt&P_zZ(Jט#fWlm2?pde:IF~le!ZYU7COwBl6OP>V{@L#xk>PnrIϤd*טW\/h| /;qZu‰ c~ʛC֏?}0I|vE e_EHsqnM~(~ŝԺ62+w4D]CGgȧUQOyE: ߋҮ<2Zy: c)w-dC+I:0ԿA,ԭ,J{o=7:\P?"yӪN<2:p-nw~o(;u ՝<ҷ'v:[*a|``OS-2Hg,Y[.+{God;ώ87,n:ȣ!ynvɇM?'@l-Kj>-ۡ"Q{g`zsw;>.κ])K"!y{2W%拏*Tv}bΑzENE&MM9Oc0<|-f^pX>˹Jڧ>>2Cյ4W8Ԗ:/:Ҵcvgɧ|%~y|1uVu4e"4)l96|=J]e=2@W׵z,XOqv7bUG݅1>|9) hVn0<|2aS:;,[-os-߷xBgIi BC9tlNO8aoq`jQL|Dy%_ [JhN²]p֢?!4I;gwj^yAĸa@@$ԟ[cjT Wi2nvj8͚}oelJvJ.uǽ!ٝޞX{+d{N~ ։,+ 57M/Z{+^\{)MLzg).B%ll!K' M),7'OZnȻkoJAY_7U`5[74]b yVϹtA}ifOzڧ7<6iY~3*?+&S3[r?$=JB'bzMrBBu:ؽG\TZ-\WQ;C2J]ٮ(Ьm6(s`0>;NZ-UWWWOiuĭnf4oi١iGÑ_A`+is|*wZ:K5z& uW&8Ƅgn)Vrmfvvk\ Y壬GQ ܎cP.V׽e ;kv2j28p?Kv8PcJr-ҕg:[tZe)휶CkKoi˻7wsf"8qhUpجg*c$;~kbƪj+qoZ~9q3c3]/E&gL0)L"Ow57hxuԻG==^BOR-΂vg\=4XOk6,ڙ&l> wG/tV]5[Wz5 0Ym¸.kUεwMǚ8ھUeSFRlu5SMLϾX529~gHGPo`27mA5mhzmộ,jӑn,vs;XA~=c0;"Lj4 1Lu45s^ iWw!/,ۢȶde[.),?Xsv{Xt^5mxjv%ԯma11IXHA aVCX6O2}A)'S'z)UBG)6' o x=&?pO>:Bad^RYi(}L\|FZ"[Mzg5Ref8dXZzt o䡱#!'#;%Wy o$9i TMB /Rd (n<2;b?uYQjܣpDsɳ>@d[y_VqMMg +%t ԯқstvҶqJ'⡛ˊ - T,p|ut+JJ^"YmRvw+~ACmPȝbݮt!"-+E)[KvA] r= Xߘk:'uo]_~eu9'sF81|Q[V;3CFC&/6z0ڊ.=n&ܘ||ôQA}Oۨ=N1Q+|QUCMK*IK`׈ '=7k1'AŽg23mI^΂':a+Oʢ%#(!.fd.1&' uCVL&lvz^3tfL}˖hԦ)$Y d@]M mɇox<jxܱfx/x=^Z*N7dEз:>٥)ǭ(}ᆽ6jͱJЋK;bkFUyb+@(*w=&֗) t$` :"'t^eran4`;-DƝYZ?ya]ܩGQ'C j8 {v6`7L|L>ѱDh&䊑# ;:!v~KHqz!@Imٺ!EvaE>6](` Mu }n]ҡ#tNH hc2ޡo[ XP1^jc$}0K)?|'Mʞ0w^([P#)?]qS=C/\C_Ya\Mv{ggFCJHrcke"qNYBW I+[+9Y.ϋe.LqzUWn}*r \(6Yt RRL畟?--C'wuxɺXԦ19 歬c#ϕ*VΕ@{֙>-Yzɂf7>Qv4)-s!;mIȺEtb38~k<2R2re0Fڦ?8e)6$V<@OGV>lQp +kv!'P4RBef[7 Z`jrhjGSt6)f>EL}kX:Z̏ZZV PO`yގ.>7"k=Ht^Yr< aШ,W:#U'%Gʭ9>sT?oɄɍ{DZ-$^M}U/#b!v-E47E~`;#`\WC *= )[V1@˪{rPңϒF9.jz~ށ/Wf6`ۂw1s'n$/y[[ ]t7%# 3E9\1ɗA3Cw]k]s K D"Ԣ&q@LHI@ E<R8X~H$%a|)$QY)I4NDgTܪϬ{3=ܗhK!m+z\f& T.BǸ0l|w9DߓhwU M hSGiSdߟj nZۧ1rPStT٣t;5n|vIRtrڅ?MqtVSGzfl=@n?񡙞{~H^s61@SDIzZ1ck_ܸ}b}c;tALTiXȣzÌrqmx:hv9Jou4]w*ԫ9J+>f +ǝ,$i6+i;>?I{JB)3GZuqm7'le;^~li#)Lkz)e";(sSΖz:suh/x39ثoCړtRS_'"oHK)ڴrփ;meycM\@d+~!-[d7a>aɖ[VЭ9swi='#c57yDy͸sNc=GổIi(_UxC# }Ks"@A~%o! ㋠U5-9[mviQ|޸fU][eB SwmfQVB>0Sɭ6&mӟ'K<^auW²oZrлG%v}i &0 o+7 WEɻ`+'6y2ɋԾ;9ø?#U yD F zizXh|vIqu.˺xKwm?.7ߒ6VS} ̎ &mNѶ= <Im/5Jl8v҆nnO-3[6a;s;0*8{m! > o]_ubvPpʧuM}kP%uq:mک?ڵ:Zߵܤ6Z z_~~>]xl)Ѱou8+@goOQFPFE7q};'wTSiUͬcWy'7y{{m]v6 q* gHZpRGMygb}/RѢvcj\TO3PSj#U&s;Kqm{"vM>h)u&6])RBM6p.ļ{ĝMqė[>9u}DnTnvcVO:|2C| k"b 8uކz|PzJtQNN3--Ty-͍G~3˴7[Ms[^{ U]qݮF^͑pX8>6ofoyc#6Ya1΅N!_oi HWFPev UދwDz W$_X7ʬnǹQ=ZnGLnַς$x.vOWtjiJ \kk6Ve5lb{A%A6&1ųQEtU)ulcG1NL߱l)\_F.q.E SڋSWMkUjz3jp.ذc.ghx2  zw'~֟xkb5C^$<ydY(9lRuL>;̋[k@]m g;_{ aT'{!̩qܱv9aOΠ}EsG]U9Fʔ3%+yV3rqHnqTǷ߈~ z<q=_&L@f@7e]$ nkߚap\}oPj(kL*&q+7s69AwtC,&/g3}^!C{r͏M!E'wKƈĘ8o/˛qݳrl7=#4#:ؼ`򩢃^c>w? IJ7Zfմ[!+*@!Eb嗹"APidP(S lj&mxǘUq:ձO0vhr^aFe/UQhjAf3cBLif#&ŧJobRUR>՞X3rYa)g\{;aTᷜ#=jidt$IT)UʤJ~S`3@,)!E@˫{/WM\TkZwî z]wCC[Zg^?vG/e63Y rjhiyWLwž`LI= \vER.[tWb C&|<nLxyV89M{nz [J^l4^dLxh4's,gZoڋ،[Sˆ[/ Qy$b^,\'従pSy dW,Υ*(A]̈́܇0׃Nѫkϩg|ΜK~vNYFNԲ n-=V ,(v~|Zp"DfoB`EPAĦ ? :Erˮo9[9LtP}b[8@L x D>(1J5^N87hxW"_W Fv;_1Sޞd p~zspR`vMѦH|6/R@JC PEB2=ǀG1XTy7)CMioΚOe>CgdJ\RyKD,Yɼw+W~`@7Х"M*tM=I\WO=M=2zF&Ѓ9݄ۅ+ϟ4_2aiO^BSO56̎4ixuFN)U/ e&]:`QC?`[XG`m :Iqb;hf{X?.]ב2+dvv0=@?Vth աՊWaX.`ߧH,ט.w".95 òx:W):@e@NS3!vk$oBͽ9+A/O]+:L~ :9a R*Q@\ .c w$8ݦ)گ$gO{$QQ>x0WEQѢY?}U/~-綡ͽu77j_d^jTOuҁJѳ簜Oy!vCHHɧ)>L\0Km|96.jVsDd!ѽC(;K*PH[@ ^z#O&t~'i/v2]dLj""k׹gۇ^G&AƣSe"r 7fN cR g_p Ct)\3t^? ﭺdy_NxN^Ʋ|-pmѵ_ņ:+5w~͢f܍;6 {8h^i=t:T\w~TB4R=uw.v <:簱P}]c6QmDlIŅ(Q܁n_y8/6]꓃qׁD b͇ή_;^m:#4ܹPgӺ{ mnSƝ8w-4fјɱazL֋i=XYv+2W/=U8:ߗ;/yf[ ҵŪ.Fڽz>ז$x}4;HGr"2X}qA:rɶ VM??>67fjBx ek7v8Gxs>jp]6۞z #mc}P֚Qp6wԀnE1`*_ԟqN'QyW0f=V_++{@V\~r&g8q6窺+8NJq\{eXS;to-T%a3>B}ECfP"Y0]Qzs/;[4Nn_k\]1Ցe Τ`b=ۆmYZVS[e ],MYG*Ʊ#ѧo|7w2Zjn5WFMnrDw[g7j}_9jr]7O2Vyd¦i&eîAݞVvKɊдv;ZD)?w;; X64z:tVy&JRǜ1W*lZ>ˎl{1*6t,So+m^ %z{Shi|-4Մ~*oqnWSM#֔R)AT&+ue&k;JXk̢Rv{+[U[zɁ aGa-5$Lc=ֶ&z,5>դC]!kϼ+u즢ʳN'M?K%S+MxDrtz6e30apm`ZZg=,$Mq!4״hAj򶺪V*ȶfv+rAs^E%gvz J9Jf  Jo2+~|܃1u͹sJӫ^/%t1 ʲ!mM:|D(c]Ѧ3.u<=;rUJ+KLm'nn,֠#+g/ෳßCs˷oYa|ˡ{|KG_ g;}q^֮gUik SGTT&USr;+&ҪiRjbC"pBשZif48yu85ܰOFȰOVgfhyq5zw;yed]6:jB ìկs:Dz}S30'=" !֢'zVGxp7=zd6ɊWEgm’QͨŬ(^!3&n/R2}mmZS$DT]xNW[2WٺT^чШx(Ljz72w e=`MyPߛ1Oʌ#5ds=.2 mص: Q(" !v#ŢOCqH D'ӻU.-=١}t[` CFLJ`х̰F-6Ј*w4鵌Y C%"~z]\9ևx3*xs3xM ӎ0py8n:ukOO/W;Twav`v N5K9~*uW5@֠FVr88AS>~xw8w:0.csоY~bqK}YEdAl}I/%vgIeh, PFh.''̖1g#YK=N̽ ¦ MMJnĺR B7U]ƕ1jf+i +j"SC[mCFyNY.JadmRjNŃOX7LqrxfD+b[Q5eF62/d~S ɻ8h=$Z imx_ʬa{}fFnDkx 97<[3EQ#J^s8h<]cT3ɱGT[y9%ews+6^oTQP >d*L-7b\V(\WйR z9@Bp ,k\5Xʅv9pnzKKh0Fxy@>xG/\Q?#Qq_J ؊ַ"-)MBOCxCkهI5̿"?*q^Y|Zr32-|Nodqh&CNյW=dX]n=?ɜ\<_NmARqɫ#uǟBnVќqTw쒘:Y}if6,u92{l u|Gf5YBiijk2_S'/A;Shl bkM+ap.{dKLcAb)l#Ů=DnD^Iy&taw[7Gtt|R3jY->&Wx\(0VG%0%s-W.Y#g@ ^\+?)TTڀ3@:95jK^h' 9$)fc@bZKAӡ#pRd-b +CKn vP,@)\PwdUˈ&#P\[sUn a@H1ť| D.X@"Powb}αҦ7mEӕ`MXoR*rˁțf zo`A~{1sx>?dauRs&7Vhաzi X(e *`_a3C}O s2g-99,/Kjna ksZAŜ"?׹-Jn޶f([R_vlj~#ߪ6FPP}%@XJ@$_cQJ@K V41JqM)2L o cV}> vӛm'soyO FLNWm!ߝ8IG|4s1:KMOOCɧdIqA1+w>ސ b3n sNrD?]4P7sϦ rd u\35sl6pJ.N?C>-7uv37^lL_㧖[}?wkoL'U :v*Ic,&/",T?ts"fNE4Ykr6& 1B `Ua$}ސf֧oJ*Dr/K|."z-l>FOSd&{'!&) 7u{+?:*EտrSbؘ=;mJtB JsR3S(MyQż܌5?bclFäW "ydR߄ZpYVSN.)>?5&,۽mBZlfKAM zN{5=7숏pȚA$z@}7LmJ}siFNNަzttN-!o:؎Þhͼk>fOLmabkvGyWgv"( wUDZGC8?#4C'n#[\4xnΩQ,u$Ǵ"zs|h$KWSAI0E?\{;q9f DmI="1|ful@հj QfiUyZ'6mBZ}[bVy4Gn*7טT}yo-!rz ½vx\b}@^;G[gۦ90B vpO~}vwQ[3\m|ЗҸ(u7uA9;i(/Ԉ. 5;|UvX(dJA'EY^=YN:f{AA}i=6-ʹ^h?Kt)9@*}'1ө57Nja1f8wfͱ[U}rp{F$۸C4rւ/Zrx׬[jzͶ+oF \g+6n@>x)/heWFf59jU:7h9ZjWٲ0M_[-uw77701 k׎SKoABVvmEZa.5v?N̻Y<^ Own~_K_~8Zf9W5*yVi7ZE50҈bbZoۣNfFֹ}B}|r*zf+Hys2JzaMQFE7WcEǽAZvj6>MTG[LÞGǪI^TDU?ufmIZ-v+;r{v4.,,uvv{Ʋ֩Ifg.f~n6/jLQ,aE-;PA I vNQVD- 'l]+źi0f wpr}#ULdl{}q4-i3<)x|S>hPRFqFedHJ "ұ#a-BBq<=.;.y(ϱojozla:~hطGXw8~9]!MnLd( z%'W*q¾ma)`O7-xSu"$˱Zg7wXoBaB뤱B%(  Uڵt%Tdmqb>%wL.WJyJH+x#$+]] uΦñN}(>sVػ'ݓ €nm'HOBO̬π1^nq4J=jK1_]**7<KY+RM{1POYallЬ2Lfd5F]=!{ZWgejw% neջsH4er6"@q_a8g'SwGGU*-V_qp-."r[[qcE7 FA$=9v-:K-ޤm0͚\c؝&LGO씁GTqB9;$;.pRt\\ AVmpךЪFZ(O| 蜈&dȜr\ ts4a\wż֎QILOUB[Za%םB&ˏG-%qw ި*vI#:'2gq*ް7<܋UiԺgWqy7.R)[[$4ԊK+1܌rķ e#7w?$. Ÿg(D"޸N5,V]L1fK#V%[;˜+?dg\RZ;Q{c2VZs޳ ["f VT䫳C"' qbU8Fm.txQsVC ۯʁYn jL)td ,EbV:^"iJZՂCIڛ2V%G)}Cb򠿕 5@k7l- >XA0$]Z*fTw,Ir6Rsի#B  k=X+¢TdVO\~U^1ufNi҈p1J79ny%&z˅Yhsl-n[#"=Vq g1#x#|<2ib>4Sh/!cVdX]]Xo_xˣ:,&Nʥd>%p}|y s󂘁aYP¯Zs.1SA%  ٹJY2y)ggo~r`j dU"'cUH.KyJcqP$s6NԳR ko)rpa~Oږ6|`WPCO@m0.WS$f'y[5;j-j /Sdvdw r9EY1ω[(+EMr;FƓfdlH{ 6y1x`(X̥,8lal+)F.`x3Eg vJ1G;uUΈo`{5`Yl3}9Ѵ8.gAp8K1׺XȃT뼐PqeJB\ \7wh")xy.$w>"a8OGZ؄ŅvnЪlld46M' Z1r- WNco*HSᏞ~  qq mkR#D]j@IO8'V>;݊IY/Lj}g1+va+?y_c|20wtx+ѕBmYe/'ey#>|C\FR=wȧ?m>_kԢRωQ-nv;Fh~6V| upmn:DykW=rwZ>sޠEegKSf,b>}6? ݿwqω^jp;G絫<hK1lIvu@YӼ[ r/g2Gڬ93t|>tO}4c1ϣ5?I]=qF>oG9|Ƶ\gVéU1ug5?^bY靹N7&Mbrs,9Jj782T.t!d"Cc] O%Ϭ #ѢwƇ]8N:لu+,8C_F$̖Jݙ6Y7Q6 I3(zSweu[:LCHwU>";quDrɩN:XsIg OῸKrJz~-4Il](LlNX>z%ֻnN~j'Ǯ-^֭|󟦑!`px`!1FٮcZ]GYqp5&ոWsn$3Dx}%E  wI&÷n^k)2Uq~3>F~~_ܷ'VY:ܿ<g3^\k`K$,mi{7sWZ^+V-l׫ ުLZ/ר\tuEVCʚz24&+ƳS\dڻb1n|7ݵybxvZg(:ǠFe h,zcxZV.GڸQXӕ~*mU-keB@Q wR~wݭHU֩/eCbv& ;|65+r;*j$~3t6O󡢰KY7tHizFvV⦫Rd\VV]߉Av}T#;xWVRV? ̞jk;U ϵ7)D-6*{oȦ~G@`%wC7?~&ޕA𬬲.9q_pnK<9n}L{JY+9Ć7ǒxb&2U[CmR0ΘnXJހWIӶWKϭg_Q{ '-p*5״*4uכbyN-6:{8Z'nps:cK1(I^>%@5Vw UyeԋgJ+kYH&V驔ޕCNeSl,rq# bԜ+b-s?K =s,⮲r>-7uuݍ^ #|5biI=`hJ.Qq4nwG_>7hFnK>B( x^!t;y+Bvi]K|~a;)pՋI[ S;GΩ5&Wdk {YsvrۑZH#یR_3$'Ahzcz0$Q5RXt8Jm6U)śsd{[9ɯ?9|m :P\j+V~p@nלt?xI)4u\qlk>!§1 泎  L{,;< Va m}<ۮ%[6TQf'e'6e}vʒtO'ű|z E!FJF#Tw0`Y ҂G> jfxy M1O:_>asf}Z5}ڛ eȭ]as})Br-zmx1 f>QU쇟Lwm1 [獄)afn։aA]bF0g3ͲĒC8x7Pyg޽^ !4{J3~TlK"P` Kc~KݍbnEOϜ>9ldmA*^Xr7R}2 Uѧ5v[Nn O ң.7.D %-БfGSbsd?-vMw&gJw{q /94V]"pDUO߰VGetg0gֲ$ӂ7Ska@Ֆ|қN,]6&ԇ3G>J2F:Z(I:K H7yOezwłYk 0C45TA?v|*StEuғ?D>1N,OY އwNw0\.|:mPtFڸ}/|:h3DpV~h9PvʴZZ ;#HՏAJg7Nŷv v\iskzԣWIؤnPإbs@єۛ5obtGo97-,?cpgTq$KR#?An C}01Pbj(1|Kr'HtC֤ɮJMbujCNն՛LKj)dk[@'\F>LC6"{c2y lj~Ku+A3TC1VV{b%YA.=@.x6%kiz#Je;iι͑ Uroe8#∖aǭEW'oSv~Yu 2ʳ̝Rkynl9%M{f X2kpZ` 9fjݹJnaϓ"խ8:sCTIx> j(pHWouj ){BFLQ̥Y:1OsYDW$h^L>ގ2%2G3%Rxl욖{)mNj3;KQ 9`Ø/>!7<߶Z(7rݨR F7;k˖GeT3f 6}ěBRX@|$@g)vV74<~b)&ς}[v¬ :-=N#W|7%M qQgurh : 3bM(S ʾ@A' )|'C ߀gs/mŅR<@h4Ɔ5?,j n(%Qe('<2 \=/B[@s zSc ebtES\!@CжZ$0Efޞө si:TП% u2_-ʢy9l4x[!`03Oam#0Cw `M 0iq!=IsA'^h0uvmStS웶6 Z/j⢳!Q`> _ #AΞ]sE[ X~Ām>'̯cE,8MgWi.*W5>~O7(ubn$͓&J P>"Cߠyp7vs;}B,}'p@~sU `UƽOvX0ɤd´LM2W4HGr-BE&&,x=[(gp_+4C+(H!9}bS s8m̟&L_O7l_ߨbR:́ҵב#iQfտP ]n k@3#?ogsS ϧ=~dᶤu=W"||.kL3p~N,sgt+IYYᜣ+Ø1L0뇵g1N;?ӦVO"w-8i(3 ?ES#~ݾпpp3C1uzQ`vlNB&Rn;bl79V0qJ\hJ_>2Uw З糂nFȔ%kWk$c n?F7,sj[St6S]*ˈEO+/k2_dy~e)Lbϣ&ul5>NOk(x"Vޝ5sMysW6FC9:AU=C AMJ3;ݘgqJIQЫ=>ߥ1{ܲ){ ">6뻹\,G޲^o:7 !^rb̊RISĝI>g5a$P9?3@s`AAvi}d*=/M]bW;qm8<8䮵g;TL[|]3_+6ƪ][Fħ0Caa~KQ$d gFwyu7%y%6ڪS'ma!w.InZuu-5Yjٌ]XV1+ $ne,;.=.ۮ[(g6VrSy|ᮟNٰIr;if[DVOW?07_#[qdzƬx[7\4/칎Gm0qj]KUu>+x,U$?J?Xq<7x`cN߸GvV/Hz/!X%tE**bwղ.K ѫ=%k5 KP<,gf\NU,y|jDhZGbAwkhQrOZMɯ2ϐ9O}sYܶܯ+goR^&eKi`G_,6o/.G-w+hũFD>`ũ ֙ZVW]졚Z+<Һ0\b,0vύ2l~K[鑃EYo}pG)Vrלwqǡ{>mCL׾d]-QT#;ƨ_Il>Fu'CV@YGZRK2ͱ>uSk+xdJJRLo=;?4&Jx)-`lm8qڷyJbfBh*WW,l(FvIYgґ.-yL-ū~USURJ}0) V)]..bJܢ+uCpe2E:hQ֤_TNI)/_itzc벾w;j!v @ +xVyaRr^%樓|JO_ RG+ b?Ek^73]N0`lp.3>N2ճ6zSJ- ܑٿmBJCREfCwGf|ֲeKNlF8T +& OP-!cngTos<<nadnQȟyxx@yx~jekB]E@ǘ NG_Tjuuɭ''Z[ otFHp W2O䝌#np:JjkĮ-9%e%ݑڹ]OSصFE]%?J|۝۲r_@2vUiMDI>6t.#@ٳY"'5 dw8>"<[J&K*-SM[Yn:x{>|j1}4Xʅ:ɲ|Z>Z`(dKTVRi纂Y*3PA3<{m4Gle[D#}&LýS4 YXEu)*4$T?WYcys[J"?Y*t7F 㞳ݾf{0976RcݒY(yg2dr汛ʫ}o>#kF7+ىJ;> ST<Kdoj_/y"'KF/0Y{G?4ZoCiҾ`I\Grvm-VMZn Wøsn5o]ZTx(wW]'UJZ 11(d}'E7`;wťwl躭Yx-5=~?}[bg}>gKe/& R[)JdVyHM0DXED\fdmqqsNJC3:Z?.(b'w/=^9hDwXdhd.LO+SFٯ6-U_q!Oy;@b [kb%]Z^0߃uPf<`Zl8}D18H"8Arb ,t:_ &//?O֙"ݺmm*hbs(ZZv(\p/LsܼE6AB(㈸#ypO^SܾC\ d.aeS\ 0Z|B`|]G}oUVO1hsK\c؄(;Zp1Pf;pV<ޭytj[[蟠xzAѝCFgR+ Y*3W]|،-*k# C $;h;6*B4 i4S7{'mNZ7}5 zj%P. ^e'A;kփxf-x s #jZS ZspHh#p pL/h0JmēqDY $Xd翧N'Zd7w?lDnLX3'Z)*]@ xxIB $5H\BA?*U@s;z?,YL'JvG2'_d.'~Mpp by JW2 O!vbNC2Ekt&#,x@ꌝbQ@gS\@ IoH@ l)X*VHZVsFG2gV1Տ7t7[eՀr j6W=@e6(U92@[ 7XJcPP3bԼojvDNje@߸1DV Ke(nF8Unƕ .<.I3xCk6/5)&C@7^s@5F Cq|rQ`TLmF)W00: b-J!5| i>x|IۈYsȁuz IvFOuhIw1怙/~by~GrVn[g)k*ph 8 lRD,x۠E[ۿpF>Lr1=491' V\7 vYLա`_psk@ww/ݳY&OY`?ko @`ǀL~Xq6a_NWvoܲ$kY{A:7v_ '" b@u HTy@RIgN(R,؅ЄeYTڹl|"HfyC)WOKə|&Ĝ/_9u.&W^QP(5[@e!PYl|X4NoTQf}ltwW"  ޕ9꯳8o1 =FOX]/妻au[빥::FS~._%k1̐wb\be]Cpoy{l3=mTa «Mwn!_V*\)18]| 0m>Ӯ92.g̿fbEP?KQkAbpK};)0s#q2#6\W .uX'^Tǫ6[UN76O4Ql9>LQÞCp"'A#9Un%5*?w"»sg/3t?&sLs87 #7T=Ï9 R} Feqg%oA[i@Zy0Z\"ۊ S cqkʾ:U1sZY/]R&OZո9T>җo*`sOT YM:rϾe1[/MmhjNv`8G6HǁXk>8Wdc>wGCJҧ&mĩ垩s8^:eq,|=*jUe] WEolZru|GS,] z`>7Jy_ԪzeXߒz *ӟ{FRƏnAqoK"Q28HلKVю~ͮ7=.fXXf3\f|Sj) 9"FNX0zkZyvw(Ϻޛfo従 2w ;Rҙ}A)$Qśxi 8aa?y~| x9>fZC cCx1λCRRJ`-{ؗD>160x;;;bnrghĮ̂-³+/iK$PZSG76-ubTE~+!f=д _سvv'Vt5cJ]5 vEVy,\,,.Uڵ4r:=]mAqa J ^%}"$hM*"Kn*ŮcO<{~tYae9zXZ78OK<|>v󤶝{ogUdH Յuo+9_1%̋=I/ݘ>F 5Tu pOHŰ"9@ه֡*a~!d9p\Bkid>.y y9,hՌ`cwkX0Ew = 4hHJlfMuLG*;H=!3L̴+\W`չ4 }u9~mJV]f_NWa*Ⱟsh%ws]D:E@g?Adܦ5[^ƅYYtCwn|73;4O1K9*MFe"cOm+_՚tUB*ҭ$7^t!2){͈Nd!R )k`F MѫH: In f l֚40KqN+/{e7AsYفX;vCǩoY-k晼)#aau!L+$5e 2oF>^)F 2O^xk8n {zBep3f~,z~+g>0G/ޱt\ q+1 \ZNB Үw+@+ . CvuI1r'E[8@t| el9h~ gH˳2"zmCmenNt@9˟I?vC-m-RХy?@c@M*vjQ|6 )\(%,K$͔XE!*<rR] dWw>;!cR2[޿4'4/'B!lPTX YZ7,2 $΢ʜ]%TqAD@zZFK=\JG*_RPʥˈ1P.6M^עg[Ö),Rpjeʾ+|S n~.֯SY xLxQ+[qV)xDH yNȋ]^Me):4<)YهvP> eՈJa-`y*]0)x"T>P\)M/JqǦ_,dF<={<}2J29WtMDaI_Os)k3Z2 BARaxm5>ve((>yms?C8mʼnI cLnR_>9ڍU+͆TNI_'^U}^^rMbw^oߚ3l=1=Kժ 'P½R\Y2k39M{]; ccȚ W^& Qc ΃^.-fkJjJ=(iJ#Hd'|tMM0/)=z˭;mg\TQfOϲeh1W/30KCnN1v74*hZ]l7"_MzU?lE#3+')y?8q=w?"2J&TjwO}ny]v,k E,5,#M6#GӜ.%Rr6[]O2-N'24Liv)Ws`Y)#",.o34&CqWsqY57w- c?pz=)]hNk I}PU52`S[[ZS4G}VS)I\ ŷXxYUvA`om CE~4'?}i)&m*G^n6)-Z5TmU /QΜ?*E̒|$lz+Z6 k Ns.`u?EyJw|w8ٰW c^ oF_rNaꇶBW?Wq]6Q5 WcB D%ʊŊ$zXću ˝97ʆiնYyto |{zuNߛ|I^5wc jϢ鮳wGMf6vBGϚjvvT37`XwL/?X1 XprrkMʱ1SfO8$?ZUgݮnڥ!Q3SKEI.4 ȹJ J +.nxb]:Ib\tlzӦg endstream endobj 262 0 obj <>stream g/[h͗u1V>^_kBX*[ K R2}Y=P:;$iz y u&>& J4$ݖXG(U`DHwfq"y^ū=e]Wm~yZՔ!k+мJ bFϋv BCG8Dô@Pb904t|؀G2T^ |KCp~x"X^+Xɗ0iaa(mIѸ` &gx^*(:1wVEW(|%WIiS9S= D]/YD(H߷'xE\@q ;-d'5&K>&˹%th{JlHol3{t1D7n](׭7fb.k )EvUkRŠgӁ)_AI7!쬍pLDEF;,M3mgjmxǖs%nT]Cէ03m%HG-Γvy M0# 솏 T^:fO+@8!a$/z邝: <0I]OMPZZ/rҲ}Sx7I2E$YM0`BEֈ3Lr:! DV"0&ɼVdD$ nLoD > j?*ؐ]S^?-G[''n.S?ѺȀSW5 ql*[ά4XY#"<ygy4J.M9t~j't9ڀ;;@yrqr=~cH r@+9t-e* LXCBYϜy@[P>ejuTvuYuR,8@]PM;SoYi n8k#"-%+?\ %,j1iiDu KX݈hogz3GՇhUxۡr$ͽ ,Yq"k0pak0Hog'L~mzVeU/9LJ:jbn XKX2c` =.攩jkr3}"`%* X;Fu` X EMҬ3Zׯͩ[58eU|}wxp+lhY߈ۆRz+^ q6Y݋؎K8#- ; `}+4h* 6({GC4JIŢ,@)dv^uS|79ߥ.׋\p[A~NaDZoafձz~hhߑ zq/q7 .y;!: f[q[QZ#4{V4>nc9~)'Yɦ|_\K{;iBb̴nGRg_~y[qqR}%}>{( f_I.VԖBaoq NC&8㸴&{tejCyOνFDD>l,ARH V9neߠsS. ]*/-tTRϛQtH+|7}0łų'`D"c3nC+N.qTR0~%NL2ye=E6L 9bP6tbVߔ#&yX"J)V(-0ͫΡXٵ>I mr4__ܦyfX1wh$˿ ;kY9Jf}y^I&^0\ΰn9 ;|[ W\7,[\8+i>ysCfS#vIɴ<OZ㌆">^~sMb}r^?oaĞIJlEkZGed.YkYt,q"gڭƫ> kEVR5ZiYɽGȏ@frp^+q%w+ۓS=شSصF{hdfa\vI>_2o#p|\O{f_.`vZfZFYFl&Zl[kt s^fŖR wg~9;%8`icl o}-,zeRH36fj8u}vu!sjgAjr-gU dj*}(:C,1)iIe<*W8BNz:(֢qu6KtMPva sCxu|Ӵs 5ɨ׈+_ Tw)tXy%"$*/Cœ-DP5, y(&kUB$z!s_$JW!#X<182 3"t #Z <~FeM)HЪ^- JTU2"JM#V J^!0ŷo :LaꠓZͭ|hʻOȭ vUgT+ aoY&W\E{L#Ӑ?I9%/ΜY@ %٘Ɂm0Yvۀ9}9DVO*MPTfFqχGz*u抿E!w3;4 e NOV1p!Ĕ2Ad7u!E9Zeu.p:%iDʀ }>|8NāOz RCH|N"o-G@le㋍C:eOo~4퀠GtBqYԁPs@(  ԥ*ȓ&0'"߅TUnZ/G\rߚr>95r_Dz~Sz=$ $HXJd;?iĒ@jr@*@J7 +ek'G?&QoAjB1@˙s|v l(s(o} O PECt[tGzda:`M!honoKcGqP:u^OF92@w9,yOc/X/Mu'\GnfdXVrpwxgӐw?Dbe_xW~Ewq7m:YZVa/h0>,Fc=s#al8n4_ _w^ w+u]OC| E؀cf%Kymƭ[XO#uyW96(Q6^6? J0k)nv&Ҿ3zto7G]/&vg!x 2ξ(j8#=\ >8#u0cj@4 MKۜt{dwj~`b ~'XR;2} }jVom v[mkL;&l]c;Y]S6 [dmsLj|ڊk/p(sq߮Xd^zMf:݀ :sY!\m|[4Rosg|nM}CWY{E[^>aQddS/o֐ 7 2d>Ut ߭AZj5Z$s萫E595 YTj ʾIEP1\5m[5zQ2ݹZIV9 Mmr{2{`0a$$7U"c9}4:h/"0Sn_~0sjɄ1Ly]As >\&}J;6s3Xgn38I[ߤtʡ[qmJ ̤lPF׊Co(:Ȫ[ίw. n F;}V|˚b K jw7;|vߛY)x252q4f4utq{c;q{Uw,%| )_+xc;wYɓdYrUNH Y7?Ca=.2ɴ>kym e=]\žS^ìK Xw˷~AU!,9#JE*~ܩafx;Lɭ4@jb]@}`^tSS*S?&\nJY;v+OXJkn1hN6WG5ib 3 d0o úvI&cECըܷլҫt-BuaɎlK~$b4<(~ۋ\ Zq6-C㺋;I/8B?fưvķoz=keQAso 6vZiTܑg^~\r>q4fdzH-_VvN욚֖}eOVĚG"@, IaT }2oŽ棎3sכ#eW`O*&p=-m4OQimm-JL]k2*G䒟a+7hub9Ɇgu^F\& k^s ! 5%j?2A-wX-;[b6ط rLy ? \!U*Mh sȝ c .(f'7d;YzٚO4] NrM{K;ՆKbxKJ."­Wȭ!z8m[c}> Ad< *ȱ G"L/ә9)m?;nQ.~}7흼 /OG7V,y,еw/#ڒ_;^`upL^6C_`pqL Љ*ifJ:X5 |0:JδsSHЎ.'_3{=؈Pz#訴#\?KTV졉6ރ(_?R>3&ryc9GOd8-Y,O6sk6Qz jdPQĵ1`Rn~|ãn2}L<I1\R5$|Ia@ȒrTZ\SDF" 'C"wӮ Ȯ2d7( 8HZ\c1AO%o|nuF HtsM­ɥ>CP?$&R]xuZf"HG@C PBPFu c+jVc1wZ/GE?߿'L024#eѽ,}ԽCȁz؀,`o 0r+0.MLFn=a0h 3T:)צ$-ǟQgރHØ%]HcÒ}␟)kJQ_6 wlUlcFk pH =23`GUdW~YI&#QDLÆ\`5>xs#R?aå4ϟg_h[ po @,Ew G~Y_ߤvYUu"c ʶgN30b:Rq97ekXt ~NRmրTxXrz/ Ѧ$q\B1Dnn1n-,=AiM)R9LYVH@\A%(dF7PdITq@QWs37?!"yZӬ Xms]ΑMs.+9 NM'ա192-\@wZ:}^m@Oy|~{Dā/-wO.ڭ|A~gl%n9Еּ1mN9estFz4^E&ZLTr{iWDΊ,64Z4 KjwSݜ v.4u!^}!>wV?gnMd99t<8RQ3cf̘OAW4!UjlNqnq]ԇC6|jO׾޲*fV>umڊiN&W.ͮVFpsF;^0 @ԜAcF#/C=0ȼs?rА-sG.-).)4rZqZnOٽMZaI6il3ꦵo R/oԚrѽZF ;[f"*+"osˌ>X-m5x )=[עyq A!b5+v.M&iFشWh:;S j?1~+Zꥳhzm~[=J䒬r72\wLHnwBuS(Ux+ǣJ/8~СR =ܶV>QWY_$$ }Qdy_BξX<$qEa (̾] NMH7SnN?4ͺCė$ ^ yE M }QVY>s@kWR8u/ n^#KҞ}O''WTҐ0%}e'[W-3Y"?.iH uQF@}gCCYgNk8'+OPFCǸ{_9$?Y:u{BfѾڞ"36|5mS6 >qA&e1j{w '}X>ΰrF-vQKdXt{ӝ*;Ҧ8owqjT_35+n1|yc3m#pǡLGۻT=,G~:4,um{ɓߘr%iȞ̊jO>%ZXT[]ɘE~`V30Of]34g7Z#jn,j`Tm~ה^Pzky~-wd7leyOpe 2cilfM//=tn_]ߊ?FN5e5^t],۪F=^eQ_uC֟}ّŹVҤsHaq:xb4b4fb4b44 A%oZd.yҝY38&]~UG~^+&˯NDaeG(irXSCMzCG͍/boUXPTgfnC|? nd\XN^ѭX(&>l&( &=_Y>I8Z0[[ghYFu2MWCvc,e_ka KTEVB5~+v<vp>9!}g17bc0W$3dd3]uE(-{(j-)$('F~35˞m;|p[?^I>> 뒿w9|f"ڒ=ϳKϮX2{dtr΋tk7z&Gy Ґ&9}gd_D~ZDϡae-Em钺Ôf*W߬W{i&rGLW$ƌ>LFiz[RFoD#FAnCΠFfw;XOȟAFu|A&8n;lWC0* *!m. QJ\e^c55~Kmd[ָvAJ<.#8vºY:Fc,Dm[poh&*U]x"^jԭj1L1/ȅ:7#XϩEKx=yƟ{^J bo9rG9bz,>[xTT|}:8c9.uBQN'QNWG<"ՔbG)XyMEjwil%\ǝZTJ\Y"t&"0MTѥ zu;5 Ko,~nIx=(d=⽺7DT7RL# ۊڑڻvpڙZyf -;7t#<ӑ̄yB<.SS+)=j2,"V"qsB=5qђWw?%n#=rz5imTR]P)@7e Эė49Uɔ9¥\@&5=?*oN!cH$ ۸-.7ϦiD h WPNGer( XX6X},A9f VK%>HNOrVra6|60V$0AJ- )EO1u] AO.~qG g~=/^{9ϽH Uh ɮ 'k:l;]q"bG`a9X8JF()}W8WK} }r{"à̒P.@k([E Ħ ]u'~K7<^cʟUYʉhhǺ׽]87lHsƸ#OЌ^(=_$>]yf }25 R5; Pa ~ c nrP#d 7Zu\tMU21D=+CF>ÂLM2iE*HH Hvm$͔ &)=\R1:HH6f;, Ļ Z.FZعyt4Gj" B: Ї=_{SIJ'O?<&ҵCVw 0=>pvH+!H*H(Ai,6 ''S 9HݮHC }ՠß3ku07|'} 7H WCPy  =nW8&AMds|Zq<'5]ĖiGυA 3ڽ_ӎr!DXb"|?砺DX07Yz_jCuQS{%Vخ m<  ;:".S< 0: IBBvHSaqajoP#w=2 Vl;(7IP.}P22( bplx~}aOы7"MI(uM …I&/ckoNa%hg֞Mŵ6Z yș~3Vy=mL0ϣS[и\K-^uX6txGqy`w} E\CC0,"+s@#Ћ#ej7s? y;E#Uwz)}V~r\{ I#} r8$0IGO_QCX PC/l]zJ%#)y=m)UmA.;{*8O1sXI'\ &,q1!Ff0Z M'#>L]nYlѓF2J6 ?ANSE~s{Gf>Z?L^d9{Zszc|zWJrY/r|˱* mZk}2Č(=w1nǂǺ6>K0?ONXj5q I##aء<>,|`XHNΝAq9FF̤kCvP,q<=9.:y'b^|\Sl*;|PE[*hl|.>~_z^<(,Q;n-g퓕O@A(7MK5C}[@뗧P4 Zi Kv7,w'EًX3Nگv>la*%)MȽxMy*#a}@k$LޑXwh\ H!P;0>-r{QkP砽g-WK/Z\ȷJh]jҬu\w<pks ԑ lP=Suq:j݉T6j$/S%,6i$ki)^ufW8*g!C[ nd@ғaHOFb"=f}cMMņ?bUe*|uɃ^igTϵg=tnW+kU-!۫dYYe w&Fhz3IPLH͇a*[0Փ.gZ½ZuK{>Wz1VR5>Y853޼kJzҎ+$נ*ةyNef&O)|y&ܽ$1(elܻPa\P.^-MǞ3V?&+#&hN{l.jz>tMj]re֑ں<#bݖʆR'%(e cwzdOn4p)pգskDۣVƑ43FW%M˒C˦:Sh ]^vhQ2ܤrM%YXF2#qGCtKrN)(XbS\ꩄms6.rƃIFf%;{|((Jsh|[)SPIijg*E#Y8xm7fcM'E8hF1tϑ~H ,Ubtbz:hR9÷-o>U<5cĨyQ;ò=řb`zi}WgI_}4$k[^ƌE(4i$)"MC4C OMN(d+F8˪$ՊL!ʦH-["PEDFșHyx5kE?3T.{SKqk[lGhr4Ě$.w6ϐO9F=9F˞@/>lE#wJvDn͉HՏTNh)|HF +A*ZxFW/hlFOTb!Qé0KqN_ȶ!cG19DzO^V(#Aq"VRdZH"*D" Q ě\!r k yGH\j,W/~6ΩS:Ix%&Opˑ -Dqx! E"VŽfr-W򂨖4DΌ'a<6Xܳ%qg9MrU5=/hd}_Zpqy k%ƇIh4C9,I P:кBtpew#U4@\~_9px Й @B*b^Gc,;laȝFcƐ%Xxΰ+Q= kHabRsTϑ*"`z}`xf BYK$/3 \ }蜲X6^,f=~ngYW|xLTۇuLS FJN407 l.(,?hd:vvVAY[0;ǂmO\`zʮc ;iW\`jK|lکLr=GK2u͖hovQX~X5 ~ C4xG8\Y*ܠZ1E-\""A|u޷׭wHc<ۜ"a_8l71t\iL}h.azb<'!f8t mb8ΘrJۀˇtq(v2!d(94Ǔ > )TP= gRI'w˜G|CXcS"A8/BhX]@- iJ|BV: hJ_w#@yq$UYJ.Y9[^gb%4@k1A/DD<{D/SQQ}Q6yf 5@hbgU#| k'bʥXI vW O[>f6t_+I"(5 =GAw`J9#Jv}Sh:['((V2}fBu|R!4CWr[ AfA) YmA2s}dJě耄{AB< ѫQSlL<L9#k-VCǡ9-6Yg"Y|[; m -i^$c ݐk / @jo 6@RyDGR:$HR -?L|p||C#B5,ej\-9rV76Ri>:e? ig2J4\48*q}cFyg!Sh5C74!)4ς_ްjn谆0x.[O@a5W@P($(ۤfEDP $hy}ZX"$j<qJḧ́9e)͂ vkb'~兊r\yJbJ !OR̕Lι>5K;ްS,t"Un:U6ߛE ҃+6'ӵtX7ջ6igyz"\dOLv8iޮM}$|Y!ݠO ]f&c>G[=H EBNl۬{Uh2|e&ɬWەvx;kX/ӓ׋[y^J)j;x_!v:pݯlb+ot?8ķ l[6DTw2S1z2\.XEHYPf%#{%33n:YhkQG a`UYEc#C״~h[6;;SH {5*, YMnDB+]\,2] kUCy<FQn圶{)V$"y6Y-U ZY?La{#4ӜثC #-jckO=uauLLJ%Z{)A.RpLY"?8X&jI>J3r8oN~I6Y=(a>y  )plRK]7S ]=pM͂;2w0U-⺂G)/ dp>X@$lb<42en0'L4뜍-c;lkxQv&Qu=?OeJ!,nG,ť5Q/+'lQ7h? }ksf~stH/{{ o5! 6{W o )l7ܛ_ r3?9+7pk'P~oؿz2Whj04pѿ;; `(BIL \:TB*AT/O lo[yaq),ںOwբ[\|LR!?@T\ _5ow= s3 Puڪ-koa4:5x^#Ra8K^/5|o`o񫡄=\m2)>]xe9P`3_ȨqTfwh׭۫^ݳ07${͛ N2V%NcYޖ=f]\!9<#f;hz-By8Q{=V:Z~S5-]s)4A߈"4/‘ifjW׿P`37r_ YF)lqSY[4GjhC_*(MgA|i8R>]yh/U`3#҅]W~RL . .RVJ-Oc[r`xe ;8fZL.e|> omg#n+{;J9U5ڦ4P;ӆFV:rO*+Q@Jpc+;n fYP肷f!Vގ*y5t0/L9D7|:lN.nshMn8YX~̌-7Ľ7FZgfz 9H^zA ĩROox\AJ?1IYɒf݌@ 3?fa}(u][䌮YTOIW_.#gAӾ-6-߫-v#:3/b24P$͟3;Wˊ!jg떥 蘦Te㢿tDӂy/?w!iy۴Y0s3j' n1ϾA+Y/|o%+sL֋8DŽ[ h*WV]B9{Q)'Cv.[żя-fN{oYb^6rQ .dbƦ9*sثS6h&nugAU5n1W=oX9 _-w^ i2Q:s\o#7I*)PۚxL+:qU5OY/ Oe+Io\YoٛB`EoCͯ3h~,~owo~K?~s;~&{{/{zҚpOJ>̆>@@uU\!jl+k|{uqQ3x{!F`H Ŭ^/CѾfFbPS;l*O٧Wx~fuAT}L/Pi)Ӫ)׬]p`4K _'uXs{W6%{6o9W%{Q_fAଧ]JghV 8wgd95~1d1"3cK#Ke/eۼs5-c"V \:f9(}^{S߯WQ, i2Kr bDc(PWwqQc0JM"И߿F<Ƃ1}ATu⎄~D!jʀz%"1QndPuPea]^-B̈(_|xmRla쎌*װ䴳 a4D566qup%0Zq൞$|:swIZnݖAy݇GivN: Qex*nܼ6IeP9kmK G*GU؇;iB2F6Ɯ26rHva#.'G:2tvb&Ӭ|ӜdrA,Umٹ$y.fr[1J;zU '@يVJnD 5.vYOƳ62s_X.B"Ti{Nc*7_Ґ7dcL?/UEpF-ۍt W=A;eȂHEtR;+<7әޯNg2?lj@˴HZ؄Wqqw6]"z޾e9.ƧIN6Iz`lF'*?5|ax!S"oy8?^~)dvw!`TRcY[3ՐmYoYɓדf0YܨuW PO̗ʈ' g-gD]5aH9PDV 8aHͥ F\6$k#E*GjWli]qY. j0Ņ~.uX=u5륽7ͤbW gV߯F}y7^Rv^$!pDG.pU>x>4+947uWnjtֿ)cڼei˝&T6:EY8'AxS>>:y+xP&C7o9eqL?']yO:gGb ]'ۿ@ |8^^[#_Q_Uk#A.|KF}} )7"pnW J]bUIv!QW ([AXr$Ev 5%q0O %F0`QrJ0 o$,zS2]9[NokY:exA kx2+pWr ub.Q'؝HmШj`2E-ryy]y\92^ |h6fo6'^~F!Or`{S pkqӪhگ# quMHSy}F]/N'f{=T[vUw?jax=&۽W7c*u)iY5އ*.=Xx,צ'z^t;lYz\Q n?#A:-PQʽ{՝mbZ66DɅuF%W?x0]76+&%nQ֡nT2Hng_;,8Fν!b^ǎsbdaifCk?<}.Jz$D^T9}URsiΥaiGYoNrЯֽZ]sx59DV򢯝pF{9:&Sԙv.NPt~.xa:¬5kyʕ< uluLܗr:&>fs9ɣ)rR؉38ȱ `8u%}j^ Ñi)7kopo峣K`m*r+XW|"lY5(ʚsԇNzd"r{hM=v}f^Lk*̬Juu7=!HT:%$y6T$-nTRewFL5'uLvftł 1S;d(k3=5i* :J?|Lv|>{^_vjV$O c[_; ߤŸ28}M=9Dodwbޠ2; y/V0!S7v;O4J0h #b,P{PsuďNa7rG'6 6~{dzA'= ꚠF6RLޯs#٠Ą,コj ^I=Vzf[99뢋 [Ԅdk.hwoMs{Wl_6- i\b)bB?`nxnfY[Oc7rLgZy|QbZa ,cO<*~k)(w lo[L$ KcXq <.$^:<ם =썩~N1Un>mi74 9_PS뮧MƘ*SD׌Dz/C~:8m~P 4uWu_P{2 {vwrC{w9we3ޕĢӝ(,guVY i'|C 9W?9$0PyƃGtLGNaW%g@]r="<{;qu$X濠+5m}Zǁl=f=z|D"L^/(nK'֩Om~P "sC }+AUm :۪ _H%4{X]^눠7%l6=3)V&oU>Q{|r^l;tcmef00V7x1-+JG WYve$]8ʬU,տn3*S3Uq]ND4pU]|a^i W9c)~q?(7iUe}?pK> r+bϠr|{>L/ɝ O _;]wl%u34b%ڿ}U7Bױ ):Dk:-}NMDcۃc[Yv]v/ɝ&wŭ(cُNޠΙ |EƧn^Wus|$2fCtm sZNL5K.."[[4;gtPKf2o9O3jgli5rf2~52[ʍg{03?ey2!;U¼f^%czHŧgsL9oRu2}au֓0~S >,=`w^V}ÀQH!>w斉^{%P18ˊy xc2VHu/r( yPW~K~={8&w\'NPtVڞ֞^bwZl<āמ c-/Gwz֘烑6xu㞵Btޙb'}\;33:[eOmkm^fcKvWWRmxf~A~:N)?~%qp]1erd}2_G;-flrխ9׷eM=?|rr1yt]Z| ? )? L7C`E{{? /38T;Qgw۱q*E9Ns2 !9*䌷 , Gwory8$DC5Knkç0?͓0rא BmI!͹3W{q vX,.:X ) |s<!ŪƝތ%Y}s%Tx )1R~d? jk+:Qq24d YoR) ̃# e95p+P]FU,/oNBT}jB3'z P/;>! lUΦ] 8e^PFכPRv\eŽr~sz 9GʗQD^ɉ38vM],8=2ɐ6QGG]'\:m9Mj@}6sOuTUKjpR(m'5ҫH.Jg7ǾЗ>nd/׏$I$,tc5tTg n1(VQբYWhH?B٘h#=zJیߊaӜs'Y\+SWrњϏ}T);9VLy@NWLSlm;ό+tlfO\b; 5]sGU2`NVMԇвOu{uU_奨k(W~vrSlW g3w =6G'͜N3M#㐖ŘC(!Oy[PY;{qw'eVdH6MgzN|Yx;ԉO .96|rt).?}8Jw@:khHn7kk2 :MKֲ)݄wZ֒N9Y/#Hd J!2EddIL^?JtysOd7$uLG(kmk.}o=-4uߒNr|JL\ͣ"g#.&_>쿗 c~bN ɽڃ|>gfcaZRVDruQT39J{뤄^ms5y62uؔngK89\ŮVТƆmZ>Zېpء.cĘz, 7tRIgn 6岒l1=# Q ;F=TN{uݜ}%8-ͫGR=Ęl jhOb۝r͟_-)&qؙẒEޝdqnRC>ܴ2 jzj#٠9]t'e{jBe"K0`+p{Eǀ8F\Ό]]jvMr Z: ?/j̙@^@t@C LZzge^[H 7f@Ճ$j [~)cؒuG㦵7#FtD8RRHg-8퀺_LPO~pn-w5h cGVVFO]7٢IH[]wP2X?t4\9m#\[ObJ+ỒZ<2r$a/篩"j딠1꽖 R_꟡#٥i(s OhԊ4LOoOZٻzHz;rP$gxI&km?r}IYC_!ݷv9.Ɯ{߁Y&fʒqNFcVtZY23~j?; ԞN|+)űAĦ#ծW/%̟5GtCUGCBP+z|T[#Er^-\~{%%)$6q, ;z9xQi/N(WM̌Qyn/80s7FrRnm1}ոrk B0-4ɦ"wOMwȒB$b g+9qW 5i9f*dͤ,σ~*V*@2xw-o>2}JXbg86$DėC >ݯ7ǯP5 AbT*;1+&aPXZ/Vn^%T}H<ԆfyG7@dձ-璱ow-wz'sMzD'yiA\kJky֢Z'TEVWLFH㑄{]%gO}}hI'tNc?dR5=T7j|㖻rM.ꎼ Ms IiZo꾘{RAyD6&oaGB!/ŝBXq`y[Hbt@@&g+},N3j5]{jGluToN'K:ݚ|y;{~"T}@虄4.MMJ{}=c%)bGaAlj؋L@J2Z z@.N1FկqHZ(ʜ@BseO,,6V&>Z2f]&Oh7w.Tr))~HLSO8|ȓJbt%Q;> |]}poW*vLYnŪre֤K°p8}K: y7M%)[: rUR#H4GGqHӿƜj;K?O +љicEd;(1(ֶznԽn ?8xXD9d)Ò?[O-&\~Ҿ,@.EJ{Ն5kt=zknfIZMIӈb4'Rޔnlnm;;Ao4*tx39 2:޵ u[0!xd̯=1ӧ0.$ XvcH˄Ԏ nMwp k̩LbN^9w)hy>]vAM.cpyt[S[ʭ\R9ޱo ]f#q9ˉŵ啗Ik$cU^q42A.l4lֲ5]?\]aYp%RvEo+Ð`P:Kr"ݔ'YyE>?@ss 7R/0}Zf ֞á2f=DG`7ilAׂLτ " tF8sTۭ<@(̮} #rnmOP'fKBNI8h" PY,GwSX|&y\k5{W~pZea &stW&_/46c;b{"iyjĚg,B8=6|8~.d .QVrpPi?/"ON ՚JUZ #h{?8i^7(]D5+s12#xHPs!! kɄ%,9%ћpQJ,?f5Էs]N8&HgY]Yyϣ1sLfWEQiMik0Ko2ҥ9Wq'&o絒pGFADO̡̚3 moU>lD)ߺ̭etP*[!բpL?ۑG.S YF{u`X>LlMQ5;:e?rwq'}$ga\h: "@O)lOGb\A){BMLIuȒB6L-_ErbSݧgLMA2,ͬqt3[lP(,C !Ɇ51(kBCň;},56-\_) 1P0ɧ >S;fs!kh\rwyK=o^zL{T{/PP뼦֕-P˖ P4dL|pE|J^ڠdom0Gy*U֬{MVFptSm8Gs!gy>T3y?L-8ר?O9)>OPfbΩnZ j`~?0T=71wE oOrsRZuNj"n<s6&8/lcj ֥m.-PXNhjW-*QξJ7Q}`jH6υ~^y.W֎a0߰vͰrMGPb-45lrDp ҽ97;;7f1ΫFX>aW9MF F-~8ԂHU88_ s3mǩ<0*']#OUˆ>e swoKJu\6ѰS 'Ͱ٨EK-O'U']'ib퇏p K?\Y+kW')6A#fSl\}2&8[.3 mO u69\N3|6꓅SSR7|7`j/#6o|ojԮ5,-b.F1D—K ;/&tLD_wϛ|LZHc̯UYx/}(gfT~ӊ';%Fԡt]͖z;\vfIGOŔ̟$/OcKUc$cxĖRSU]/o/?H XbbԣħoytK Jf+K5ΜYzA٨prr4v~"B' ,8_\ǎcs=wY;"S&5!z"N: fWtx= /vGYneg 5}h3Ӷw>W{~Tsx(9gOud)ZxviHލEvsVZ8ej*iZRI#h+ZjgY,+ Yg(кwzf8'^T̈骧d]$~$>=V?HR'_3 ՌCj%3)-'F*nןqw1\l4A٧6W7ꬳN"Sêa܉vpOUӘtN-Ir)nrSs_qTd&e4̟ɆF] 3L makp&:KPlTSOaWȻiܢQXmm[zkR >.Bu␆ݯ }@x  AIwZ/C6C1l"ZH ͚ co qصW8U4+3n1+1;+RZRLOCEOC$5#@=A6FXJ1'7uZi_ Aa"DYiO['-h&}tt~*27_)Q%v /|3{(M%kh3Mp3c:V\' &AvCx8);콼zAD?NKDzkJ=htPڵqt9iO~4؛ 2 {}se)Qސ x<ʍI[<6+r?[@aC1~_Oz1wf3CSiOZDgڄƨ拼F'8M˱ ȬrX6r=@xƩߠ~w]}-ը3 paﲄKW2l^:o&3emJOr6jK=eMnEGi"' #΍ޔoqy))SzMq?O"6~*܋E1>\dSPXXk^71bgerZiS0rV`X:n4FF%r!Y-5r)e%u4$ͿN@@QgAoFOoytS¬+?Yv39MDGᄊMT8j7ZP=gs,L9iɠNLy`eLb6M8ޯaȿɣ(0oY|. Gt#w Y_xX2d{{ k:0p/}0Rƈ󮼍{bnT8Rۋ@Y{N:PrxkW.{m TK:ruZHFlwyӣ%A@|:>JNX|w+9uQ& K\a[\@圳6/T;u ԓ|  hQ.7/VͽӛՄt ˟ 89~~=-fޮyKdbD4qcJ̵xviDib_3*}ȣuD3KCu\J3_ϐ`pV6SZ~OH.3aA0:u8_ILsm .1bɊodiךPUp DrJ}r3$ ƌ9Lq,~k <X_q4Trֺ?k^`- FWZnC첧݉OX0aj\c5X +cMg:n[2դ%D$5~wvVTNFmфxVd:#u鏐*92b`Xx9XG͠J !|0%Z {EK vM(u-. WwưQj1n"[{|#& cKq 6mwO7>2=fzUGfJs Qdm_7Z* زA9ʖABXr|zO椟 {g&V8u ?/fMPhAjl):[w4l"\Mؾk_U#35 w ҇| ]^S.{M='^X#AOcBqu׻S PqP<l\*wERA /}2Sý&@-8j+;ʟew[5_,c4nvi<0Lqt_?PbXߋ9fMʙijD;s !F8Ѓy}Su{BZ͸[[nN?T<>vi?[g5,Hv#zՂUj@^nS.\ס/|(畁y{b{+saoi7P@dDϒjj{*E?|giB?=~ 'UPU7?@=P/%;rtv . (Dǫޥv0ڒ){ݾs@Ɔ ӣ(2bTߚwOiѠ$$jr9w խnAz}%dԱGPWsqB/V!rvyHf;hl% " TTw⮴߹O0wǧ[)@~;`aߋ 't|XC $Tl):* ".ėɟ *F2!b"FK'p۽|Nzu7X-}0 ]*<{3Dr!공9.x.n YrYRXRh$yg}{*BԦd$P/A]fM|Ϲt[U^?!vCDk7-V[l%u\٦^Ym2E= ~|N("G"z n96AmMo<,+yy[$x*YP+S٥"|OeK*֭ځƛ9\} GEnUTueVc &Z!_K?Z-/ʲx4٣}-nrv&xgIGj1͗\dn _ΎUK ii.N[qܦ ۞nUJqRG?ۓ Z&$mуn^VY7|y!od򡄎NɠB-S"7%_O$XuzRX !=:VBr~FBpuu9:s[j%82 9Y7ZgŦ>ӯ˛Zz%[_٦^\ bcvrF!fUp9.U^̌78J] j+jw56٣gK;#]8Hn+@bJF\uބ0&B}s_{^6Ӆ4ް?R\rӳթB@hNh<2J񜣸hcg[8onCo5h#7gB$w<`cu"'5Xk$ {.V':}M*"7$+Lڐ@@ k1hHAyKWNfQZ7?\% Y|e6,S~y΢Ju g74s7u9mm{K"xwndROx3d"(oÒoO.#}zǻb wdoΓc[ZzvHP M9%@'vHl2/iDY,dxCڃO2W~:8זbcjYheVa3fe6J)Y"oz~{/=ҽ"0@󠄢X, |<=cJp!wa׳.GGކI!hFUЭ$A+g1:ʠ$R+P5LP(ny31/, _O=Z]t-q&a.g&A Z)C^q)F]xUJ{'8/ٜ#kj'tQ (iO4ż$fVrc1_ɏB9Z^\͐M*k6~]{b?mT\J3S6V,R@;Q+ l tie-<^޻^y |=Ռu.v :4~^ ^ W [hIגF} (O7hUި5aYK@eq^@?g?N&uP~ѝ^s^+$]F VQ)ee%jiO רU9g:(Y TObKK+Pmv_W:_uw+r\$f~WiquZX|(ߍfGR2+c9w_~ud ߝ`c76|"Znj-RIVcGoUx;"xgu pn=`[(-RS 1sGV\ŊiB23Hd AtPܼw$m"blɮC4 8qP+T/NW]g~?I>|CȽy ߳\Jr39LJVsPdSdK53kƩ ~2T*AmJAI) .t-`_Dq!#ٷ99 .d@K]&{M] {E]3 c#\3ITu*KڪQ qT}bZGӰZmn]lvXHkRpٚ|AL0)\>#mM3asO?9~l~O?PS&` Ƨ^agBk7}_]= 7I!i4+g,>ސ(aڹG+zMVG#g<[zgYh^ P R+Nb$jr}yՑbTv@@r JP^=[{{$.??Kc)w.on{bjHJo%] @'Neu3YmxeޓP1ߞ`;H]'j$ݭ$jW_?N SO#S3a'z5(xmb!or|&=[ݰHs,X^ OCk?؝쥊i*~5?AQ<{eh~r$Nz_i~eKҊq#*8Pk3&mHDV9#l1USdܖad/߷gnj]M5 N>}LJPQ:l Gj/Ɂ6e!O[aQ3dxԐunTuA.x 4 572) e00~{~@kT{qrmr|%qujyfIIDIb?aҗJuT=Ey- 'X㏆$qƄˣk KFoP>9z3!.].\]v%չßߋ.IJ׼!jBkͳH ?KC})1j29>[΄8sVAb=g^3JuټԹkx3,o]imip'>`b/fr-۴z =aᚃ6SMe/3 9KSC2Cer9ӹ]gX~wNq5k6GfMXVK|?|EǮpg;ux6ǡ>""%CfL A83<\ֳ^ݞ3]nB:bMd'VP[p"%鶲ֶ{,*5 `bF1V{.a$='\O[y9^c8`zw8_Fln8W4sUM7Nܨ'vbջR̍rs kFw7O>]ǟ}kΠER KO8e@^xCrk2y =Y!.xP| >!Wr>STdRI%5s"_,*=_Ϫ53.uTW99 ̣ѫ-T_x=&[A/y2]N0h6gPsD *w|nbiw>_O*{4;}6lFpci9$:9J_@SԹ\~FIx">tz7w=f^aisxR/RkPO:P\ZXom7V/Z*[[ىrB@%@ϭ`0ἹcvqOqWCe@?yZ)=D% f_gd{-*5wtjۙw?&^m/J!2`0 },ÀlH60m<\^ZcHWI>.T=M_ /R;C=5N'mN!`H}Pgkl&80?ie#LyP/GGc|9'v{x w:߻cwE۝4kٹvfr*ѴR1yd$sCZyj:`@߅NMcyRӌ$q 15{o+TFSڝyjn; ufųXm TEԹd%gkn~W9X$ݓ {IΉZd3msOg{uޛzwWm}p?tkSX`9M[k>D=/Kw:Ag{{SLvbfRq~?{''sVp<3h#j#ULrfvOUD9s?[nB^FMdk ~Ȏfc3qsi^`wQ4l9}qv?YDeQ<$LM6OՍ pB)U+ un?ߥ0bzrhU/tv# zSgonhh/v^p>i3aSWrSz~`?(x\]I/ҢKӛ`]a:뒷 rk2KPw%y:*ZmT܅mDQM9>؛.,)]GtVȈ"o# MišݨOj ey$@ ZkTeԒ,g|nH濿x:t)%If[`z:k98nav)E̫5ே85fe(G[x bfv'4z-^[U~Ypbk:1 cw@%za⪿K}/BL wsNɻiǕBr/uO&Pl)J=wH8>C" ;=r\n*nAI az?%{@먙KvkґV{ѸLk+INc5E Toq6n'cWoth-IֿU}'H.ީLR1Sbb_BFY= j}TXeZ.>z/v?OT7H9o%7@vS߆Ӎ(.}~uK^=V_ iBPb R(?%^㩇kK51%7 ezѕęq@ǔw't~KɎs;]ea]3kvV^>+nJ)|#bxDcjn" /~kP5&ιZQJx˩˭6ࡋUm|W$Y}~ScwI?٬\oӣNC>Lbm{ējܸO^ZPJ ހ"yIQtNke*~B(SW i[b3-1 {x,.7:RrdIvj NC5ǵ>[}SW*¦QKΗQ-U _y`Otгh+\jΙ/VnjygfV,hjܭ/[/r-']>I jZjAY˓R.~/lΣ=7/IyIe6HFr]TaGy;@uzœ6SWn+vfQYK96uI}-ޢL@Lg͟5cN\m6gUvb- `Қk}c@7PgqT,-Em>%[&f"bǥpNJQLŏTm`3b;>~q?i?[O6zg,Hw&Pz׽A7@(6@,x=w`۫*:8wgzӹq;`%?U JL3=7ӬP!Ƅr6,3ɶC_6)iEUb;Q} }fzy9ә<m~VHbf]:G>Ȁl@d')7, CW7Bۨl4JJRa) 7a ш mNaFL~a;gp|_ Gk{sJlPXB[#*>l=G"l`=`˅FeY`HfҼ4 _vAer__z<~/?ԡ?>b1cYU@naQ2[b:xbo/Y4S72XuX}ﶮ&>`3HuqEWWsNdi{<+juFߟdX^ ۑqyT% w 22ܷb׻ͭ+@-1BoP"  n)@ܟw ZGh̓zA̟ߚR?$JqSnD>H&L_L}oٮH+L覛?HP$ XC@U~du@6pZ@=p ]@Rv|jѯۂ^uyV 8r_y%K'кr:g͝ڎJSb!N_p4<R:٦iF 3E[r|nT"բdK~#QK:GՏKSY,_}} )TWS PꑁRPG=tOq `Zb\̵j/n&.CŽp+y/};W_,ɾ}Tz.'\SI3E"V e.N`YbP꣘Dß ^ VHlKHGіn}ܺ^^)M.."&#؄e+Z?s{ҍ˭]ZӒU_)2y|qR3_:DwX1`r_[Ld d-fkʛspMWE?W?~QOTe5kPܭnJnI/Mhb d-c5ώ[/+KM4)@}ٮm._t;q=)U֒ꁜ:Wn/[=l+[)7U[Vs.[̼83?҄k IH59B Ԡԗ{I u򾒒6(&󬨒y*omP[l(uud3k yAyD [Tƅβ3F iԯ:HPL;gWlLz*~fr2Z~ڻf'fZW.+)L}_ZNR?ƽe5T8paB'}nIĜEؖF`PJ3P`ҹ{fe 9nVy(cgY(?+U=aiD[McA:EBmBv_l3()XX*+ð+ט/J:zɼ_%L/> c8oyy<#qxfvsM}9C5C "Q(` ɟfIVp>[XmY?t=1A 3b !?|hj޽zJ9CZ 敔/$wU&,+( xM]'-;Өzm%^yjIzxvyL60qG0/<ۇ}zGcNZY"NcPm {siNUg6R "u;mQulg[ӊա{r799ӋaLUrY izjv,*\ $.m|ZY r.ޮr}}Z<ڿX+4hAbsQ>NM.z,x]s֡:ѼHzZsQ #wtٟBx=˂'LkD01yԐ&= JnwRk fq=jVlb[]?OmlkJ*)UR,Vߌ-q^@X[y1\GffW%~)POn/&+WѦ۩vgvazFOBSE3wV^(nאs]IUM#uPYvዔK"llZS55s6y.J^cimy$-HJ&J,{,WWGIѱdiRЌ'/ɹr]{&Ͳ";M޳B)q7EYȈ(̯tc,s&4W79(A,BfӾJ8#,.:\ytkNvx6k ES:rV^Wb˭SVW)kJu5gt.rp£+>t\cSWy~v6ܴj (#%Fћājm;J3WZ4A`f-zT$].[v z齪z{,~\*2積J) zAr7=+VS5?ɏu:cf7W[\;N'*J1r%sk2>_js(ġYiwy瑬 +`Oׄ2O~Z ,櫅 W~Ů7NˈO U ?UL;l56z܆?ɰѢcI\@@l@'{K | u`uF޻qI^gưq5kE~Z'O> )< Rf0U hV(jodɕY)GB8^ĽYCɯzxe D 'G].*@ /heyw =fTNj@dHH@RgkeF ڮHAB@2e̢2/P z:X0m?&z'$J]Pe*Ϸ>#"R$zTP >@ *U(v,Cxi-S^DP'ʁT$@} llu1Q=TG ?U7s:!؂r>0 Bbƻ0e8]j+F.BX# H\CL/Pt1d!@j)2{o<.DZ3؟𤳹2Ӄ.>Hw~ O> n\|f~:yvMxؤ]I [Džszy n/EUbWFlb=Gm.M5~J bY',]i<H \N`i;@88(}Ϸ\O_קkAȧA̳ݫ_ڝuq[vݘWdRmf9hy6ۥ&Z>>[ @r =J *k=U~_x5y |yiMro/ǥ*<D]dٖ*)f?Iv-" yךڛ<Ԋ9j4Xx˯:,M }8RćR׫rlz/Ya|IayJQw Ψ]_RJ–厙Jas:M^l[_x6#79P2&cƙgwWb7ގArĥni#pӁZwvY4shٖtƛ Ʀ&,@a>RQjX$vNfvN^KUz.2iZѤQឭ"mK\9/|gF}'-8rZdGC,7<<w;.2Zt?NR(k~ O߹W3 K$wҳ_"NTz=!%Gqd"^qD~ƥU8×7u3v:_vZ=ξ5 |eK7xۃZ}tjRM@k+~i勿/oԦ~ +zfwscqf\6" zOFAw__څ2jI%x}e~-GmڕY3IlQ=R0R nP4={<}/j߶/~ ,Z`:`9'oOG`=ijv宏dɒMw۵ՕۭlYl}1j~zǟ/sD JڂSֻF)k}Au;k5KggܦXQ.6g #6wj&Mw(ːO,8t ,Mꏖ'4.|~v!nSFWANNpӽfso{sʔ6s^m2na?:C=OQk2oOyzX+{Fy5*RaBVk@ԍM#6I"o :jWG=t>UBW^no$vI[MgUG?exun!n뽺Z-uDnaw^>ii16(>ٔ?p32*.}j|!-+~)uj^ҦnUgݚ:ݡ}]mo&'<2fLM_i׷ٮҲܛs\wtjrqCʘt@4NB\m@Ca31J 6%Kλ;[:xˉORRQ U}Gi̹N-^ 7= 3Kj>,peG=,^՗ Q38P<>~EHYߎa6fbpQ?"y|ڏX}F>ISs|hT5MHv#P*Ƥ^ei4ϕ=LW.vW{GbjvXM(tP΢n Ԧtn.PG.L-\6%@EDg?wjGUr\UBt.| (QTjqW.e;( ba%g+d~^+%wWyj6jQ1!f~[$r0tR6Ar JXn@Ď7- |v|4v1,B^ynRyekx,㟰^ndfj7y?t)6:cIF[6 ă^[)Hz U5pF%]QT qQfsd{V:I-zd :["sWՄtH;{]$6 +i!&ߧV$xdḑt6WK,Z̜h3]:t}|eJ-бHsLX#6{stS)ne1BTD:wIH?'`tfrkOQTa4s+k9?{XGhZU>ܙoЀvӍ㐔jNhUe>e3% R9SyPzMSgO35wįMWuʍzc b$BTy9 : 3>OA@&x A::S 3* - A'=@zm Gz2sˀL7x@d(Sӎ,8%  SXO\ s@*AFFP~} Vݠ}> A-@:8ޠr Cb&DbrL@+f^染T`AuX<=PPJd;RĖ sAfe8 @F}b٨ 7~}@&BE_wsR 2<kҍ{K:\wae<9/@Ig+GWdP7f 5@T#$u+ s7 N=Y6:}:bBAD?j?HR{J"wm1l.4$E](u3Vx%`E?ԭDȜD#X"ݔ@ Lq?ESOQR-\tj^ [2HxN`16T[:zP<ԟZr+TWm }Q(k$.@nO.Y 7Yc^zv9NhSTh|VOȟr9:?58G'3<ܾv+֑hVUЌ]7>5|{\ůsA.|}+1=}0f]S &dL1wke MG(b">tM뜝k_lKx)84F?xƣN-MIݪj/\0Ht:77Lۏcs{2џܶPj$4ApxpŌԻuȼsto{X2҄C^w:lASkMô+6*uȩ/YgޡLo`Z=ٍ*,ɉc+݊tUMI<hn&yuZrOoޝ wX}Uc,KL&5;r: 4gOf}zLjN`wJB3}' @,Ҁ,,MuſWs1ǯHn pRo,7I[ƅܟYnV:l&>=ՠxd{cGdZ}~-2N4ru1~z yOܟMSl!GpQ l"3yǮ/M@I<,ҌfsǢvSi*FΨo s1M8l"nξ=+G_ˉ.T>_ˍ]/DZib}1۪pc6fW;4'Wm-flgWk=B~ b奔?[>}-9YȋBƊKMN+?dPhtԙ]\rҋ53ޓdӓn=d@KɁSɧlYx[x qNk2q?:wl,9oz]j sᢱQDڳ]A7>VCNbn)}Mx\\;cqt=Dj{ h@ ~Z.o+?UG2\gXҧ9]e (+ ]]>dm1_.kca^GO;t]/]~;T͓zE'grՏ?^`[@H|Hwİ}`WMVZ^ufc)?A'?:1R(m \: tG]MWgyEn-8lRTD5}2KέJ<˾GOmބO~Cno;"ۂkw0t[;t,Rva1Sq'7KV =KRSޢG\ƵV/ NnuKI6}RjmWfS'wnqT>N؍]Ckڒ͌3c7n3KLn0],]Zv|k(Kɟ7Qskt]~~?AZ|V9MsAr79 f&FN+>npRsiϯʋ.lk:fmu69{NnvuZo#iJve-MrpY6]\lW|A}8OR6% ?hFҤr\uwyn Zɭ4)߼TnZkU[YU5i9vO]ҝk{O[x5$# 5ϬJ|"]*%H'H3Y!)SۅP'<>OtTZ"II[.t+wB:KBA*nN݈(}0XdJώͽT!3IOjuq.T0JU& q` 1ɝyeX=W1Iw9s\;x$(_U>$;n˺(E΃{sjufVk]z^R =V2L*~QK>7]SUpV;PAQEEQ~׷=܁TIK22"~ 3q\iSdwٶ};W/_wZeEla//\{µ%Jۊ)B\t]'_.=//ZMR|f‛ž= 96C<ҝV^awu906a8PxK뾐iEzTEOfV{9.feYho8]| { i#˩Ú\z- r^;-ds>ǡFTz[$B붪e۞+_nw~{]29'gKhF`(X&-s1_ 魱:u~s>Q;^Gy,*M̘M ݧn%@DWܨy4%) RzFYzl2kmR=^ݑjSp+xD'1ʗ8"pĽ՞5l a&K߈H>e~a`BLeSlhd9= ϑdU{d: <@|x 2<};@+3&/l,_m( ]n@|`٘Yb?vNU~tXzŒ7y&/EN X'P [ ^$. b+LCj Z<oVMC<[5T5G={{ϑҜ+,rt *fckC*Fڀ /% m8(  Z XeiṶ}23zV}cm? q'-!="!jz˺?Bw@8W2sHkƯN4K`3uFݷMNG˳>gNեzm9pw5-[Ν{w oPa)?#>˫H+bJDE;?yA,ms⧔пy:ɑ^tռ5b59q|ěWyu8wq0n~sŝjkCu= bjOhTݡti.s8>s.,uZwl;sK~?!VFi6 ׋|.t4Oˣu6w6#†)D5ƶ (z|y _`/{Z{2k=s;2a_yF _0]ѫi82gl߭;18F=Ǚ;wѬBֽ Rk v;: 6u|;MdP\T=%Ѳ h3wٺYsWV>.h|]ad_17/۩Uvv@aڪtw,C@,;e@H}-11LjɾyߵvOo9_H>Ь>*;9OhmOsfoLB0Ӧ^ͼȯ]RY<*{X-@@m3m)ӳ1ljK2$ۍ3u[XAe،25&Jb}WW; y_}1{0g2e?:'W$hܢ=rn_{Si?@9<;0尙s'}?Y9iP}}۵`Gh/R <8w^^FJQz5bWFJo7(4j۩GnQjUaؖ*q?Vȃ6o '87fbdu5uGӤZDɨs3no=V;R oIs&% 8-R{L5{)MnmD4ݼQT *3r`I[}jJjϠȜ_ݨ68\p()Gv+ENr%@Cگ=Q_5fu  /“52^6nz)vo}/^k$;u^S.ƯUbO3ɵ`d4ק?.8~&W 3ҭmXf$j˸ٗzQh?#jU1ӄ @~:Re|ϝڡ]@ғʈj& WC:uOooμZLP Fbz+d^i<@[7|%ԤFwRsQ[}ߠ5X/R&;d׹=R͌PZX}&h_ ^^u#\y*6/l G6LGGZ#T; ֦osSp)v^MIYU7&wخNU}s d?&,wVM|r2YI- yl~+SD r950 UK^{\5ڏ-ֿ/fS&۠eWJNz^ʵ8N7 Ԍ}뎓6>&|ԝ3v֣D+!*85"=Wsrz~-mO_zAp+ŀlW\wZa޲t9RT[37ۜA+$ 6ЋU CLhݣXitL{n;k^wXU}g'b.r0'3*<-}\Hes0N_؂Y\-e]e֌8NgYls`l4ĝN(OWz&=ذzV_(׶ŭ( g黗fb;s@3>VTRGQf/ڈ7?XZc_.ڊ>s;%z[44׻]/;17!vLK_w]+a%f,`rQ/J9}]:g5J*[iPS=2#-Cj{Y")A%|'|BL+TInXJa ~A3@GEn*mڷ>.?i:^|W֥:7vKQj.T9|UBI'da1"C>"4d?TX;4f3Ҫy9L[bNfr/s˙ș*+zĩi`V.CRo x[ $~7]e\Z!nsD /P/BbDUk%x9y ;Iۣ׹_" N5S@W n;j Vr98M Tu!oHEok׆R*;U q}2$)&sjA07 X 1!,@QZ7 :mۣHRTR `q ` nThjJ"/ܚ @H BBX&@t)A#] 1p3ץk0G7i uL`!0{[6!~>:}z@ L@yK-R05Ea!bve12 ۸͜srtc,'Eϳg%wAlo~wbmY]m Z .S2{7-(8L+5[8*c'@/W6aqnjpS8UOa)>={4&{k1YRėR[WlD"ǙM< z GY+KNCwUo)_\Ni U\dzvsĭQ? s/J٥JJ3 b )Th8Yׂy3ͣ3N5WE\.^G™>WHgͩ,7:y?AMu.OQy6D[@jZԯב0WYY_|?xpo SL6"+]8޺ͳ:n 8J& :g(Rwvn}4mŻ-v]V=]8h/|BIg(POvvA:NST0e%:h2=\/`Axݵ(M=SmvT=Վ&|SW2YarmC|x&1J+w ?{=ݾR{_=^tT\XÍ[$r^]=%"M3{.LȷplvaP8 XE-UyiVV銔<}|kRr& ŲإDL0Q"q~2B#ۼd~qF<iQqF=iæhj)]Tå f%DJT~*%Ew7&~ҘRc2uM459?HR-o)x؍pX3?b{M\9:XkzdXP{Y0KlM[,\JwQygϔ&=Eƃa`AB^~>{zh wݬ ʰ"z-^Uxrp&owA_L#QMٱ`% DE]70) O|wXjm9a endstream endobj 263 0 obj <>stream VwU+)j[ խIn׬- 7`_vVbnW1NRUO#3긟ZRKI2"M[pEc6!BZfatS˄˼<{-V}Pm6߼I׍_ќ)iz.Bx1W*Ns[m?ޗ J{oהWv-c}mrHɁڒfu"Oyq*jmK]]Jq}S'XB2>x_ʖS*XM,,W̯Po_8v$\ݲk7 :YM]CT6Yn Ց^ʝ4a^tiBZ)LJoyJ]k4"&E ] a޸JaǼzvvѪ޴;ҟ)kaK˶׸{5'4OyvJP(˯&k۞UjCq]~S7te*P@BʹA/}R6vess?t[t<5;7-dPoN5G\{$/*OT3~/ϙ2(-flLBql3s@)K?G;_l~rU}.\v&,?_XufbyƛL~HԶӦ^Ň6UdZc*ER,*n١?\ЌxMM?}iA(4:vSOGJl6Ta~RA.ObBz&_=v. xfi !sˏ!T;Lq&|Oj>['W$p*(g074@F bpc - %c#HbFoŜ!8k >xݥDs#J{irOkRW7(Mlk!*MCܕ6btwYX_QRH3ӂsMK)K "F"lwέ(5w:kXK0 /Y_:/ `%XMXnFpz`H]i}(|6KyZ(ԤTb7qmc"W}DygY|dڳ2'@j7?"6/Fq`F(A8,I.tuS[)`u{*r=~·d2)/GӮ欜{4jZkW,.h~><ŮW`4,'*.  & Ч p??GsT"{n{r8Z 4*<"*?8j-ۮ9:q=hU GsYo{/wl;~wށ|~[{ρ/` cfQX|Rgr 27oqV$eWˣYwL_3rQ_A<=,ӿ z1GETO1Mw%U`^R][Y- _}tq$Oy)>4ۏ̯A.y{+CEi9n;콁xH#Q:/4vVXmBw]o۫v)PRi.4oT ~0CHYg &x;ΥG',wM>TQ:gRn\&V7DLltUT QXtZx$K(·m*ab͜9>hAPLu*F1顃e}ܔr\ȓ5^zi3غl2qMÈAhltMLʿ4S;1zL8$1=~G^JôTVIsPCu66Hi| X͟_χ& ~ !_:s-}/@مP g [W Rf8ov̴DW{|sa߃pC-X*>azJ'>ǔ;(YNPJn%V$8ٯ!gg?{io6H<!6R<ꏦ yX43eo(L~{dX]x$UشWK'tky}G\w_䮊?w>qWt7@V~f6B>ҽn^̰:lr-u;=(7;>םhyeo E휄g^4@H|NZA,Ij+[vGNzج~E ]r'i7AbFAjU.'O:= 哳:A9fnʾ%DXLߺfdr!TaXn0gsGbs7k/Bo5]q rW붹uxR}4իx\ K}(l]ޛƠZ#SZUfV T#81?ԍҧW7*yndAn`|f2~!hQtZuuoR-wsX}6)f>l>d5 ϭǯU\PR.8ŷƽ"KU<(Յ0jW B?\TCy;.&۫-۰c^2oLd5Jeyɹ?M 3MoMn=qȭ9[?}Q ] sLak`gvij^<EaWYko&j;?W(s΢|'Nqs bd?oViqAIz=u5fd˰jZ, %i,ۛwnW%e ּg9a ¤8IڮTͪm7ZģrAm?sXEI sd**Lw⮻Z^92ιwg8ha1)P͑]2⼅Ĥ8Ѽ:-L6:SclЙJWu~]x1rڮGjrftg,[dQ)?bP,cs. \[D#[X;Ya=qR̎Lۑl_6"l=þ/ENSgJ7m5sڮmjk?U5+{7j)V%_,=M{J6_,ĜJ6zlu>2vz>D8Lo;lbLJuEMQxKM*Yf PIbȯqbX_vA9VY&R _6+B^oƢ,wf3[bAMy-uRz 8+ߖBk.`raQk H&{3^l#BcrA!ɤauM[T, s΍P*Pɕۏ %D%"MVEeRd,o&QC/V/LD5lq1#Md,"T!"zZb9"~!ŢRmkk|5sglFy184A7 ~ۗrXp (PLE YdZe:Po>P<`j yx!_0^;Uf?#A4K*t 7 W`)1hb3½@L!D¤ a M%Vʄ؝cEC/?0oo|~2(`Pj'zoP?L?&cpgazG?ֻg\~_] 5pk$ex݅ㅻ#doTܤ%P; ;m#1"JNhm hw:=:͎ȲS;Je Ai?>JsvdɎkg[HY9_a\œv Jx3{U?hC}84 PnD$kL’i12=߿ņmׇԍ}wu/9,nNnxij]^M7EX/qL;#$ R} ]#~ڒf߯'ϊ@zwI׃kXUVeRx8Kzj5q7 ǵ4%|/v0>^{i;&X깍TmRkIVCuP=9'V0ο\?oܽ5Kl=8彯T+S\bu d^ sR+Q„-la}[m{#&=yfkH.7O~nv!ؽۙU![h%C@L\t*+2-?Vga2a?n!}s6]l֛6U{)f'8PNo./2cۯ Trf}3d?B}cM[?6]K1K۠{hlnL%m%ٲ̬I7-'5E̺v9)9p,of' BHO?N95]u3TLN/$s=5_xnơiFz{#$ AW|VDFHx@ 7#{n_~>vG?/`lmnS>lV'.kR FOKܭIJ-hbu=]PO1qљUE{(԰d]H'Mp`ɸvsOÊUKi~2nŗ__8KXB\ y'Frʶ%fR>KOVM XB Y.{ȭlma=1d̾f$^?R2=.KXm^ĎK¹Zdj+ZƇ,sӂ.. 9w#}O ݹajfլ\S$MwӔxg?ww B~KOʺ^ 8RL,QMS:_;j;V۪[R /E}̅ dzՙCgpK@6y֤Bz ~@}N]=dpCzv}Z{)3e+[NHxRq4#sf yEYu0A>1&KEF#jᢽ~aʼT::S,#k`$?B㍇qkN'a;j+͜=b,>'FeXF .Χ$/,sf!{3K{vYs^,L\T.99.c&DEEE'!:ZwWIֈŲ/GF[rs6 `9ب~GIGKg^q;v5fVAc.n/o`;Im {fꄰ|'Xt@w?ݥsrO IM1L{|܆fiև(g8gbv)H(1ٌclGhRF)1d'۶Ƌ$׈}9A1; b}BFev}Ӗ<F#y4fa*nL9מo|ZgyТ"]6 ܠFԬѽ:r# &TU.z']ޗwUcGpzj.U<+k~'IA1D{g0\qc|Z Vߴ-b3Y;\^byrދ%/-@j0L@`)V`IsO*J; +9im}eէ}ItG]?F†J{}UarZFο*]Ez5W.t⡞C!CU -N{8d5z,1ʋۦէ>Ž՘Dwl۽f'`s۷rTA0/ըY(F¹3>卼C27k%[0{kXbw"v}s/Th%΄ɨ4a`g^(.pHn)!M;Ϸ\3wG%= jҧ^~})[*YR'̷Xo'eE[%]X$<N{l7ZG҆f=^+r?A?^8fpLziWpTS!ܑ}QJo&.8Qv8sIZ1¦z׏&ezJ>R>8S.FcFxZ3U{I/eAɲ4v' 2K'*x87;]DZ)t׍c Sǔ}XRjʢJOQ4jb`_y%{/c{6PC! Uzg O|K 8Ѳsq•w1A:rk8E*,)>OR5NtۋzsCύr)<9C/]OlblҧBKpn(?B9Ln]"|13_|<(m)TrYEWktX!"Rx!r)nM'w}peprӧQ?YuߍQa$'rz8Rn}1U)}O}Z,h|﮺vIDy^ ^<dckMJ=O~ImjMaP+壬 JB,?~U{18|e_{o] ]] N|+9'wA d7"<coKҸ- x]6Kb6]M*+!ɯN$o߯d&W' Wbokdq#uf4.Å{چ%q^3k])A2~Ɩ-XDgq㊘]*;c}_Q»$BahcRownF.eg]!Xtje> &R]uqE/m(yQ)qP{-$˙rBqsՎ&n&nj/i$~/>yqLW?uf{ܵWZ~[ךRN]0 6"}80#ߛn4.i\T>` 71;Һ0gvi6Rc;/&țq/CP_rNf5PkByKvcI>X D\zEvv해7?;'u!N3K_=!yB !S L=xvzURrelI/)+ent~Pp}KucK0ϋ jnڬC5[iI>Q  |tј PL֢HC.To e'f=E[N71Zg沘JWǒ] k;_"д#JaUc^.s`h,dӸ͙W8^OzM"ԼZXh*᎑{QŻ#p qYZ'fdMlXmjcA6Z,qyo>^Y`vTj"*(JZ;Yâ]DB 't +2쪭mFVYxX:b`,GqW oGL;⌛Q\Xrtp[i5?<*'xc Hu[cLjagO]작rXe,3kpy n3!xwڟG#h娺tqӘyd,PԙP3 /Pu+|քiJLwXǦഝD(mumyۄ6sq$Ox4s:̜ llzAyKUBd2d}lփYM%!aJu7hg3R4Aw:D5SbyᢹR;% Ym9DsFݰi_aǥZTEA¾p.r7`;wooi#WSuy>>cDc c.%afOթeMH 25~B*zTop~yKkngP*͏ $ 8 ~Kz!Kn]Q6P>y[vZ$oGֹ. v;$L䋇=ף A'A.Oޅڄ9l`kAXhwVz)졌|qabhKY'e>WX:8>.6QT޺Vzﮀ{F9W3AdZl"M2X%xtpڦ-'_> ވ!,klȴ؞'\vi4nkuGp bTn e@zo!q Z5CPU@k@?*3zmv' 6E~ߠc5cB>}Tڅ`|V qTlCxnk7+ ?iU/:%O[;Sio b16} v~-s8w#41߯5]kj[&سkRgE6;%j#;SFʬolͿU4^l "%OCwdEq)x8q<")Wh'sKn87)rN_:/R&QuN9J@ ͔0!J_J_+i]`;@nZik#J niBNKYNFSj&GJbj)^s*{^,ޣc+Ͽ/ .oy%93N}#M7rYı&4TrL7иGh۵n3vKƅh_&z~&6{V̇`tc~LJ_~~Kڋ,DO7'}q(8[?yj@S"%Nr;v"JMk]^`s|*yD7h4f{7){a=bws p!K?ܛ7=MAts7'X~şJN n/݊Ͳ^f"woWSwQ˴Hp|*"-ﰭA~ y;1zV_grO9 * P̟ir\ljtkjuxwTFQ#cWi4",lܝߏGX5bQ/_$͓\\&9}ܖx\=shqV48no>o7l *fXКjQ /,\t1$ϢA-]-Z-J_$9d3%c~'쳹b s!y氯$7 T.Dz_J^8皥VuVA8ΚoE׽^q߸qs7t*c2fv̌YKp]K=v*Intms{p7=-됪~h.z8h#QɥFP Xa) #O؍%°54s4o{ 0m5J;3JYC%d˟(<]ߐ)  a'\jWz ȏ69}p_;:;Kώr҇FüFl!n̩i)~:z0nt忴6ʫSԚa(b77Iм@wTVjC粣+i<^qg,M[Wy2@w/4\z@m u8o@>xdE֡,!em3Hvwq7 u_亮4q%Rv+9kK@Z,M (g"}q /z3xi\PNY~[>"]t",Zv6}bV^2 嗟zSOC-Qs0E'3[qTsTp5Tz+rqs*EAEME6<_&7C$ ?_Xv/h;]7G"xO-'J١sv^S|>աiZQڦUY3r1 M~]_McJ DTc}@$A[FvjD %Ce/rO[?ݒ]^:6>ݾ'0uhA(ǐe}%gY&k+C> ^lU >i9IRT=.OFLy)8 =@[Çg^}~與C2ӣ\^.Y E9o|(e]->z8!WnYGVL8 #)I&w{_~$W&:tH縔@ΐ$=eSCTYz8?;Y4'hPf3,Ye=FuU{ak!`;(bB{b$! (D֨A;b]cFš}oF&'Ƣ˕U g'[ϜИNWmglb\/ ueƸ1,dF [YVGMWpb<%ǥ:`j X[-^|kxZq2q-2F!dXبiQ!?atp{2 Wn>y8 ;mɼg\F,! }aZ@Ervy SU1C@Or,ZQyIҸWvi(%+'s!%2ֺԝqKpq5,P;6BH .`Goܾ+ 9l#833n+5kOo!P/i:U a?h5Ӆ*Ud.&V:2[ǫܾ>ҟuoIO2;QW0NqoY^g* tEOl1_6Cw:!N=-6Qvݭzee\XMfƦ/ R/-Kiʥ#a޳aJ3Y*TiRO5tSˊiPަTf!Y^B8{Wĺn.=:uL *9Tzl® kC^ϔ}1ߞu: 9\PrwF˱ |(jں{ _ ct RN3 :CۛBĭ/Nvu)⁤sS"9o~ ϝ+|:SJO`<pːk#JU-el)"u큅јο[Cfr,nQM\;'M9 M^aT;,RW@r #OڷrkZ Eř9r{ʩ @˜ U+䇣 CwOln{~l}W@@@.b'狶@i k&Xo+goYlcR1KY~IKy7Xs`sGnu{ d3؟fIWu7Wn[3/;aOU︚ۣ8+:G9ᗟk<' )8T! IJ[q}γiޛ;)Ooo`l3( I?~{ϩrV xƭ{vЫ5Fk#_⯳_UxkTvxoF.NӠM=ų7wwۺ_s(xڑlxq~Yȝr[ʑn<] C oޕ3E^o>b Ҍ_Զ]d5:I|_^V]|q@mzZ6DǶoz?qfjIv8Aĺ0u?*D5.^֥}&Ec!#zq1㗚CKnRkt>=4%Sz1Cn.`X.}->#\~+)kh~G?|0/3$4Yu^-F=mW =ɻ_$@`^膜ֵ}:WA6jel6򢃎fAG}Qއ_jh7͉B/oV+oEoSWW>Aܑ;IMG=CUPޞ\klҟsE eҫg`(Kgig0r[Zkg⊻GKki>.Ӵ6񋓬U*X>]y^B&A%Z: =ȏ̃[F+TS;9[9D2⤟^aEc7Y&?WnU3b #,E պgەn| rb=/ VҎAֲJ7=WJPUB ɭQ7'S>kgi(f iHn364Q~Cr9p^_vѭHi硻ki@,["2`g1O,G1†D+emE#Hi9yz I2E;$^Z)otE$&gjJFEdӮ[]jk0cFx._rP2η,?wÓwQoc/N1Gtm),&ƥ_}>F$%Oww|DJB,JD,z:By T6Ȣh/|yjf/'421'|s(ͩfv7;K<1.dK6$Q*\tKDsՂWwQW*^=S8cO 7w(&CV'7Up:ֽOzF댻BpܰrcO|Р;D7+lbv)y6=Vz"ZMw[=r{^#}˪3ܪr(|H לԭ+X˛ ZU %. M'{`qI%*8Fzb특[gzb\m(mk|n[m[i[AM7jwf΍mu_r[}ِ㸟%!ud1;W c7:ȼww/ ՎnsGX6->4¢,0CY`kchgӴ5^R[w:%:mHVO8PN:ctohf2Kf,?55K+ݣ0 4Hl>L>ZkЪgTN?vܩrAJAm++T0k@r]3D"AVAsvk`Ol,VX$9Ja\8>x}Qp+sIfsJXs5Ӂ.+\\*F iq+RY,|uF}΋soZ/wL|%y3Cg~w{FsDE]lciRtG т1(ڼ (-+9 U/-&yXDtEV5hv ڥ8O:Ϧ\\J;}&}a<TUn}- YQ9G?`s;HQ``0Tnki[=1o' c1i=Aѭܼ"y9޼u`f.Z VqS%?GwƫO@ 'DzA1m@Џ gތQEq?W?,-_LoZB]Ű_FVoR9J٤ꬾwItNA_JH;Ӷok^ΘC?׊\ow%(^t:lT.T&5JB3zO+ze=W1؉"K}5od-2[@Q {+oZ_쏾*D|J7B|)wIDLb̉uYWkisR8vTx*cMVͳ m39uNܳwaRtyG)f}o:[==27FN^S7⮕8 c|Q%9ne-auGKޞt|gϣ_1كt3^)O47[dč AYj\ĝ-Vd"~"ƚYDnZIe:zݻb^E+7grj5~^5ңëxlczmau9o+ okZqa!-ܷB4C˛-~V}"Z-(;9]!_>XQ琍ޫ1 YWj'}^f5~%[GtT Ҧai,Ln'OS;lO&$(k6= ?y 1lG+%R%A /:G>%>%xpjPgߏ~=V[4m0N.'"͘ϣHG#~C%n;_:?J ׾vmC+wO}媬#t#[]8sXFIb]Qt7[3x4=*4}Hk4XVl:ĞAƳr-=X#V䐪iԞ:o{+Rtgn) |%-2Ti/K=D~&Bہo/;AϺUޙ{=|ֲ.m3sy'pt$;gR-R˙[[^+|xΕ賂Wݮ—Mݢx$a6G,NԽ aF˻]?j[̍bP 3xa7]2vIfurohۦ?Η&U-D =\GlKa5e5mn6 f5i*{LD V΀[vgc1Dqo2T(+3fo\OlȃV~2S֚6YCז+M꿮*!%* Љ48V8٢.w7u~/[:әk&}eX:%l[ nQK6F;caI23qD5$;xyfĚgt'_$-S7kAY{& F<`%|32UHI~=Ԣؑ>b_At(ΚVZPʯݖ_3X}/}F-(RV\>VvZ8X۸mT IRg1Y_ܪ!պ+]lx@VޏM̱ph]shqfυ$]6!n`sCQ' <.PNJvu5U[7N^W7 e::J9L&$DZr+w5FlFMPQo͗gU[/ K̵OLVggyb R_TǺ?ByrF`TՖnkJ}Du׽kʾ8|1}ܛ:lJgnj6lZkHEQԘǠSuɒM'TU3 5 q -ZWしG4IMX5xn6ˣ7mUhUNƫ<&ղxYeq8)J]NRKZV>BRɼ\Oml܏0p򨳽K i Sbt&z=s a%R]ZLb.CS, i/ ŵ9}]=_AֿS=Tjaȏe/!;t9G+ɰVIEm$u>c;y@ez3MdbfL6JIi=钼oY2O5ٷ}<cQ@n|c]w7 pKpqشTZgQ1og>K:3Ғxu=n:B&p#2 BƕN-eI-f.2KDg =Y";,ހj  G7=d?2qA1;v;eURmgS>~=uxC< CTUc @!3PTz {jcu11F:tN*Z @ZM@Wgt:܆a#` މ%_"`K}\IkA~OX[jxfQ&ʐF㩷sk% qx-4tz٬;c N}\Z\J.|s)H.zG iO y֫qC.;ϨxEPv 4va+_7;X}:4z诫0@67a"W_HL1fERj |ox%~eCqλgW=~ޕWVGQH*d#h|x/16jj'vMbxKzׅ|F|xQ\>Zz;U/ж|;DLqCs1/Ǘp /=pv&|K12\Wc/2׬#E/ֲ?^13ZpˑAl|̟|7Y_G~ˇ Wi1픈IN[ȫ %aklWJ;%R)BD/U1ʷ_r7[/Gd/"GO}M!]ۯd,nRs_uvrK+ ƴsFۻٔbO7oaưf4=W׊Uj+7a4>v^T[R]F2$3'J/Oߵ=y5EEPyqpfEӛUm:f_Ǐao?A!z;{GV{GH٥^p|fݽO.[R?N"9P?wXc|}G/}Q˷}΋f#q?LO?/(-?=x %s&] v{VhD=9,ꎘ:T[v=;s :M[Ko 4lE9,#EèM6!/z~uS70Q{7}}Pg[^+="Tc+vr󅓊!:aZ|4M#jAu6glIxӟEE֙>VCulo"5u<`y'_ X,VsldRER׆o ҬmٳwEeAN$%?Nkzkj#Lr&⦮_\&l.v%lm+ɀ]nnֻBbq7 #Ms 󙖵ʶiaKֺ}-|xh~F4QɲLP*ZAs9-J06@RRr x#88Wp._Ӵz>d)Xp8ƶc V[ izަC_gG6**7?ͶaIQ3cQYJNV5C';:_N=3c8jp5.%W`ܤ\Ne,WܻԩmmDDg+F oxj*JY^gH0ny2߰&LE>C)M%IQVDBƙϱ4Z Qu?38r|})b4eYf\.dƝu̪InWhjpAuhwCQ/o@ҼYޏN5#cb9{4$%b b&%KGBny+m<ʅJ6Ft5rNpȯi;Yz%ƥ g`M}eVcCY=wt+\r-iH;W>>다Ӽ3f' O!$~Y 3R$(^ZW-y*Rd޵uم=\%79~fdLu oۋ71e`ɭе a,ڨESg3WR-J| XV(7:31Yp;epue23gCxc\+0-0Z 9k7mub[D](%ai7U=wihiC7w94ٚl>> k~{#I(ۖjg}*6ړxNBk R|^sTafZKf̦hJ5H LU~|tu%bRub\Ұ@w2}o,6Z-ɗYBkU٧ΉY}7Jy6Y _{΢W4Yg[b Jk2(1&r Y׭DrE|2HuOqjm\+R?:q$*R[vIqsUf=G==̭ ?OӃ{;EQhZ%)Ff%h.J׸:ha^T ^G-x1?*?N=ќ^-嵖Vo8B]Kl;1c~.nM/fL}$dIYFDfP8`p*a.yێDϻ=ϭdFuW1| |b\XU[h&IM =; "ɓ2i-pqYbaS=N*)%j(?ٕ~^m+j//Hu[M9샃8Jj= z{w86n$Ah(5iDr U'{*׮=Q꾚Wk#mU.}&9{U(AiR }${TzHv!YE[ As^m1|7{JB;m J,( `f+Oã\ (ɀKBBr4@rGl^\ʮ gI峅m! Ѳy7/Ν;uL:_j|14.xZ? Kg<5MZBIdfPX؁*( ไ:c6S>wٽuεP+GUϲIek~A쇌 ʹ LD&S3[FW\ÈW_@7AW&c&Z9OTil߫LrTDͬؕs9.MǥpbDHx!Ikb.\yH% 4 `܊a9;;1`' 3l=}l3㿛yA I/4Kڎ[0ۈYJf93Pb`r J5dBXNmITDaD1a!1l|BѠ@49 x#cUαwF]hRI;gL I#x<vv9va#p?:tVYdT2ƈH F% PPpT8bz^qx"PZJ Bϥz}[oJE+w2L8<6ҕ&.GlA^T_؀ưNsL3{ #` %MK\t0W MBacA`F* d'C'v o!lz8rovt !pÅD\.V//[E#-r c\M&,v0?+Sd}9vۘr|W߀oVoCsZ{ò>1V$cӋ"Vb( |czG;|nٟ`?!J1 SڳE~ _Ow})U7d`t5 ooD|ϏҲҺ{uJCópW.mMhW]HIU_[W4˿ڵ[󣠷w^>6*i}j<ȿ[ln/s&rtWY[#QVٟE^rTvZU1h!weg;BE/_gN ];yQ;jg s-t;Yca0[pn¯ |%7F,\}9i'YK?K]JQ2?~s>j[LrkS_եq*._N4n[:Nw N+kh1K [mѽ;*} ?d1Լ֠aDs|_zlwjr%NCZA4ˏstH'G5On L GwaF̵"QiS҇a7jA0h@moŀ=c zPqqOu _|G-$W^ac{X4\:7#q>S"_~wg:nW5@Ͽ=98Ȯվ~^4YZU&X:^_#L=vJN\@}=8Bf7=ҭsE ,HNWPo엂~QE7^VV;4V{+]$9|!۰ְ%:ptd!} fj1vʨxA>2*uW= (ɇ|92C9mf:eU2R)ny]9c/GtN\CxY Xo4cU0K@1vm¨m6.ힺ;gK#MBU{ntX`1sE疩i79]z9ۊow2{}np  fNV=3KKjXD>yJ0ݼ~]kiyK+rD(l4(\ZucW槾Hl\US3?0ַ}jb~vλ6(*wƝT`fT[vDR M.:ጚ +VHX+6x%CENSÈIc:Q/C ˝z$c~ܓG<=qqliPpܧo>L{.2Iu$_2S>wtFj kK>UA+xGe$r׺Yd+7ӝlTXMܕv:mjwmKmN: l)X?=+Q]4$=yˠdɕ AbLM ":4UJFZДJ45uq6KϮaKEH_nBX+52Iå8YbM}"]v_<.k1shT=+Y:'fu3Va>9Pj61 fdpj<FfG4Re+/o!=2oj70.bhR 5O.})~VIKe,Vȵ`x#79R)l'O"0R.(l [(ëR~xyK- *r9+]g/"D&<3YM捽0'f;@m ( Sw Ŏ!ϺTMrԇڮ!3sV(5҅xߥ ښ|"=\w1빇<^0g`#b3k1{M73(J'H(VγLZcM% ~:K>9?p&g*avܸ7[w8,oP;R%\{)$WH8l_H]`mPcJlx Ĥh:t3aco)avS@zmh4C+ۂ9ƫiiǶLX@6!U=GH҃*ʲ+O(׼=@'kDbzWb4W2~&W-1`Eo, P}^[z-@O_XD6qn\gǼMmI!m#jEHyg>*~.U&(dpN /u*V$mUxox b21>4_>j%>^a TeK]!z2wp 7v#M?M5EK<ˀSW9 Gq+kd c%w\@G@2 ' HY16]Tr! uӏ):ミ:<<*8{@No_W=|#~/rd?1ox~COi?C}Ȼ-ȹ/=EǷ5)7]{VF/bM o~ o u֦nk<ʧ_W׀t-ҲFz۹MV.G)]:;_j$u&di{S}D3=Hh?#Ϋ=ܧIn|ܿXcskǤWMm] NKfR Ht/nA[u}nvچ߅EMUVH-B\D-2p0=y#R?<Oo>WzgrAG~Eiv c7;>6tLI+r5Q7>;P|Lp53W,촲*.܏ɛ32_|5l4J~{֕}{AD͎++lfy?+Ll`-+2 Q [YcxNBNG}鎘[q ?~}?1շ^TB-]ykc}?s([޳Vd}$f ?9*`|яݺF9zN4L^Lʖ, 痔U![ꙕY=ݐCɝ^]% }qԜ9l YuE5r9n&AX uVv-Ekr#ÏGô/vp\a=Q9<={;}Uյ^Uj\2)mu_ 6 NmP G߭&FL'f1j2;=Dv0۲#ne>BdXv,24{NX xo,wSܭ3]N@ILV2: fQ̩F{[w y\zѯ_ƯܾUs^^NHӟl';\DUUn=&.MHZHu2wR \,>ɥa(ިKz6F- ;8{>OjZZ*?LZwlM dkSҔ(+f5x>fm5RyۏzKM#v֖_a+|\KvhÄzۮ~KUP3EK5zEU?W+Vͬe[W.4lͼA#Ͷ =5jVqr&)KqӹMnC'hҋx Yw>KJ?췱;m,LF:,*f^GJB͔lo`E0y46WUo+D-Wo nOr4/{`7u٠kRphύ@vb4)oLА-8uOy.j@Tk.bT"fo1X, 9Qm Y xkfVFF[eӽ-:5yYZO\99'_˹,mbAzkj-y췒jB|ER6ʰVJcf=#S:Xr2'zj6gr-KwI/ Kt(*|v;~,v+ ~PJ~Ce&HJÁMeE):M$8в3p fuj7FP0ԭv.s|?[KrJq_ߓ۝=G;vn>ls,3id%wLZ_҅QqsNi)x4OLX(P=Dv9(W%sw&U'Ɵ?Oj⚸^{7KmZcm3\V4X{=l\L /NgRSKin[Apؖ qe4 sQh4xWIѠmK3Wtl,}lT8=Dǃl$HrmAL1`&&۬{k̲ t^C{+qo~ h4_XI_h^XV1Yu8GnkS ':r7-$G8(.Tr\YqkNvkZCY?jS ~k6NiN#sߑE߰`iӴ?&{^⎈U?hKN0asd-,,\&4ǍAКdVB˔O|H,Kj4'y@̭(a^IѠ}e*~~AYN&mƙGWbjEBkCnaԞ}yd}tk'K0.߀9U@o9ctO #@;;Zە1vwۓQ`!di $ l؄_Y!:+l;\_Wܰ'`3v8X8&`C9HpPH `]Xu,"φ +pTG⮴]G+! Ђe/,u=tC8h0E TGeW7PG  8uIpWq>荔T>.&^)9K5w%@>ܮKz.J1ݎ:˽T Y17XH,|^>Dis Cbe0u%ˈB>#T U>9߇j w ǩ@| @{ dgRtTF2%jJ{5<-&/|BpsTFtk@] AnH:8qXY@#rҚ}@.w< X.M3P--s,0!Pd?W:aoWfk]TAն@NO[Pǒ ̖qF-y?=;Kn%S)ڄ4W45;5qA?3b~ :@E w`H.`ȃ&0j&T4M7d`'uHx?t&8Ο1{ qgaHsB}f4qxSyw=jӘԀlɾE3"i}hƽt*:PnD[DӾ҃^r4jO(Qgq$N,28liR?nj8ey?uKK6Z12{>h~u'NV]Q}c7sA,85u }Gv-1Ez,M숬MM| J2B^Fo U:ʩ^nOp/PvY=Np޶ 7M\szb?/Key.C[ZM!U9h.?SSb/S&ɏBa"cEq]SNk\w+>̺ %m5qyeFKmu&yNܸx16Ԧ~vҜ슳^-csfw%7xBC#i w۵Rrz4{Y έs?Cvc53^^cnQHsÈ C2ikkvN/~CaګOl]F:lM cRmgq՞o,#L/b!Ч&,ݬ2x&;~sf- }O箏IC@᱑A&GQK(P*{絔ϦQ;wq h}tq$Dg6kuR?Of3Yy[ljV2h]pc@!wPPֽZKC_6 PJ_6}8SwK>XT>𨴷V[7qy\>TS7jo._ܪV l洰B8>7{`8-T*nax-5{U.}\i|#Xn7WArGE=O&wtԱZQ\U*~d^` 7쫥eI~ח&1W[;ԨdgZ37?tim)xBLQQBQ֧-?#Z$=[իt3@~f>og=xNr XS,U6nEmvRb}IQ_v4~A#?@!wcCj%SGDqe]:hh,xV -^rBS? bi֙ktu+pwvu^.)PFݾ->_y뾘;VJVdL"7K0d^*_+kfSҶ'oL4r樢'*KmٝF'hmS-4ct\]yizQ|UUT1œa?uKE#۰?IzVIٚЫ>\n}5Oޤڔf8+T}W̗KDHNޚ.HSڲ;^ jퟪnio;4|oLӀ}TpYGzY[#<"! bl.kf0w945#88?o*7c;+ڼ~ZNiU)kai7GXhV1 On}XG󍶪w ?XZJ'ɟEQ? kyyqp֢@}1A.J~yUTG"65]̋,Uk4̢c>^qQ[Umu*]. "8Y5;2n25+)AênRy!mrzq*~9iCA,\N,;2!V]eҮұ` kЈQWQUzg1JS]hؓC IX?H;3Av<ƽY 6oðcT/[H|G!4K?j,'2 tlXh۫!sS㶅*2k/2gR/JܭIj-ŷrى{#YN4xw>W%9%L*gMVtm,xPMPYZSms}143UZʛA MQY7qIvxݙPDV'B>jsG-૱iR9yÒ"ÒVٷVѫn5z+o_'|ú>X!g0ōg Fvg#rGfc^XHWߋðwM BS̙"? Nh{֩ _fg@ %!ƹ7K@Ǥ81P hT'kDBX62)),F,ڷT2Wjv!P@U#JK<6%/(a%Bxx._]#>em>F_LJJcֹ :Rp9uU^VBpn9N-Ҙ=>c">.bVY/\>1&DT*YEtk7кN)ײ?hN{)5ڶ*$ѡ4{)4 3hUPs/~;f,SG!>l(34k3䒓Cn-CD;QQ5xke%k>6BW`-RAb?PXDF]zKv_Liv"ym΁FN.[2?w5~;!A̎M6k$ l dmg<ęw~bjQ)=~D!V^(pTSs#[L7)R{ U 瘴1v$af~Yj!Bqq`>LPF8@ ;ciNpV9V;՞JU)zjV6g%RO $_̉irZqM"уM%AZUg`N..` qM} <)h4Y'gMT@j P/SE'߆A0[v3ŕ)iUw4TBu3jB(oz=]{pfۜ@=[0 58FH0`j0I?2AɉGj xT3g&աXbXc=[vXF8N7+=fw% p8lpS8Sbط>~2e] h9|-I7z!ƕzd=_:> x,u} k +@(A6qY恠.qXK:e ֯'Zq*۩Q.9/:&v22]l~XLo?%}M{So3 iĿojmP)P**5" 6P{ &Ԧ'2|gAwQ^gFrά^ U[?.87MH%|{{SՁ z,lǓmO D\#e< `HYӗCRI?4vy{lJPL*{KG[OkfVm套6 VZFīxB?ϡ*=cljg>$';oNR77R8N+xtimTM؞([:fZ/6;-7[]zyūxͿOvKg,.}tmzd +&7qνό =A;@Q8Is -}5c9ycgC}]- W.͐nM}T'N[& G!͆m1c/jĩTIrJ%4w=(| x`Inriu( f [֖`R};,д|#MT#J_F =~cV[Pᰬf=`;RR}K-x?ыn up˙_|OZ8|>sFw;#T:Z6WAp7FP#?sj?iwA=Ex{CSw$Vmag:MZ`t>w-<+[,%o~u*P1FҰ*laiq7nTӞzZ+K`Ω[?xB [ uxيeA/1_zGHG:y䪨aD]&97s[ya>`RX 8[*wfC6$BP}/׽1 7!Yt2 |9۫dT*B+̨TO6;[T[I Wk[^rk/yu-bcsI赬ZlYj7i}jL̾".ڲ{jejPVTS|Caiw ,[L#R| S-=Q܀-zLTgQ| h*; kSVax4S3/=v-Ճ'w*-ʁ'vb r.j\Oҭt $}`Dy|#C X)~iq)Euk^Ȕ0 =;@Vn5 [iU+ϿZWO4ty'En,Ӭԭ1I>RV+h Gy' <"+} ͮ3ړ]C(a")Xq & C:k`~?/K Q_Fec&h+@Å\#]>ET: C1?n݇Hfх ' *A.>C[+V7,jD=M4&,48%vcP-#U3nIfDkt Q _IA5$^3exdJJ˫%Mv!^L<\R77?mM|Ώpg.pvEfp˗o |_-70tѠ1 W5;/*r7$. "y2>mX 8$AVZvZ5}U&sLc!B-v~lWAn!e'B ek{n4r2O;El SSB:d@^'`ݟK4kJ>镼ml|_dP&0LY ϖ=,늅nx˙2j4>ȷwv)B5*JwMr0Sk ?UM\ʝG`qд5 6jfm)) X L$hiȒA /]m'/׀Lg>Ҿ>"=J*oq,*7Z%zZTȷ?n_ǪkS(\~s B* Fi$27-B Z j2,#@f*  YX|(琒넵oA)`(< |byBO$|3׾.@7YwZU[]@APDg(z s* ($8厒7 ĺ=@e oR&k*h|I+8sPaF8eDI&ف5fV lџh|N/ꣵS1L%#Mu!*O!ϭ3/䍫T}lQrmӻ:6Ƿ'૨I4O0 LFì ~Ӆlzf$8T9ͩ p摡oIz؍(Q>uo l$^ ^ ht*vׁ juM櫬.~tw<'!lrL(onir>4V85o.s'jhutN]+ ~FQUޮb*gixC}9MzugS֞ck?!=}sr_kODN+=VѾӰ*tʟ&#$T>e 'yqʅּٖވC\kOuGpz,IYXup#эڕ>ҟ8uΡaP>K>RyB%[z oK#arT_9Ó]cv zVޓ&3.^?7s|Vb=#s?-6#o53Uʧ5 5]v?EYu{D kn*{V֝[7bTg rt]({2? <96.o)1>&bs1*{o GBj(baI'? NN}cܲP-ZQ}ODzRbzB]yB&($P9` TkoAwyI}ypk]Z.n:Y?zr}ixb{tc[VK.6qaѳ^w)UGb'yfuSߖGɟ )ե0/"︙=3FX:wχM:ڗкw"<@4ӰQqEYnx`{YY)[ݵ}G?hojӑ}JX(j • }j1vx Cg{Ȉuvst!oEkcl.?Y%_ںjUiU+\'U\rXŸf'{oP>=ӰV+T\Ǹ7/+80QKy!=+nFh˰}jלN,Jq'Ÿ++V>wnP-BaLyiDyq"Kv>KRDXxb!^zl)c,%nYf0v*x[9֨RHrmJ5:5ɏJy~^9iWCReݧ 7r!t {" 'j[W8:~4qq4gyVc؝,?vObT;~w86иU/tU~~g!ƯY*7LRv6lsNU]. Pwt'?v+VgY-vZm_)IwêZ)]#+V?Ë!ʜ\$咗$EKt~S廻*5t#g껅f;C(u-jgr_0,t֑~Jkaa᫮>_ZWO]>'sV(i&_&FUطq-'#N]Dx-pڂUOq>Sрv,.bV85 s=_tYG\a1TףP%L[%g=|EǤpВ1z]Q Maupg^>(0n&+3n ٚW=(g`RnjwF4Mndݲ ֦bG ;_adž|r'͈rVtsvcڽ=g5 F [rRq(>9˝8Q>a6՛P羮ʠoNaUh=v--^vkoVKA|zt=z%I65 PԪ3ax$69Bƨ=$pu犇WܢM`ZʇSC{ ݖ%44ohvc6˔sNWA]}T!Pk^VgTmOچqŔLhR,psJkϡOƮʇAٮc_>Dnjw2kJ;_;yR1_;T,NvA#.;8 SvkA{ j6<鶍 b%-~:{TJaaXL|<3tƃ\%ȔɈBU_- "ox1}zl'TަB@̓85:ѱW[_O?},Unev-Vu{.02C zwŌK,-uw nt*xݍ5d&nW|<9Mcu9K5sVuORz-1^~ɞn$vdsع$d P2͛E[HIr|(6&=m0VN/>`<|0bF2հr膒;p%//vЌɧ++V$.VidG2gp(Kl) enYjiIJERo:1l| |JBa>yŖ[lyްrGˠg@;c 9#i-^yD(Hr\H+ >}s,66Th>s pǟ?Md6#_|08/0̮S2x.o[][F<;(GC5)~Nom("f? |=Jpa%t%"Us `\Ee9̍4X{g2!;̒wM)OG &縈6*:HmqhwsQ<ʮU6){ڽ[ V$`! Rç ҵ31@T]*V޷U/r:R~sxuȾY0i&>\/_=;хTu-(k3vE ^ KT@ @b/@F ^*+l|A49c9EWrWoTqvyՙg)EݸV[tA- wNj׻)&Ip\ j=`\py0[fU19(sMSYg7N%O2t*k.BQX7\@ x c4H4O V@%X6 B9;`= <HA+q?\ĺ)ۊVߜpIJa"Ɵ|5_eqWBk" k WW$R7ܢT) Ydo9z g@4 -NL`V0KXYmW-=ג^(sQUdW]DI9 &ń(ŬWgg)5jooߓɨ ,v7\Tg\8G)ՎwڀMˀWsv1j<a'>:0 XJ,zNcy>8LǹhaF!ΙEGDzT\ΊjN(nyA|Ϫ =k ɱ4m 0x'H6S_kf|-)0SҍksfCZPVc`=B)w,£}@co(əVͪ5Jp-wפ;3.HPr:Oq|雄M~kCA @]4M,PJoO(N~S\;v¥ŴV efeyA8U ϫԔ?^4ܞ?oYIw@;`W󊝊@c;V˗)]zuH\s[d{'C`xRVu LK&ބ&w8_8B k=0e"QakdC}lYEFzK'a{9?F(=  {'W_Z'ٷ:a^;geeNJTۄ&kpB$s  I!Htʖ)orDv=cz=Ђ<5wN1a tw .:z=iꪔ"$9ui)$ B~o*w7써k/\ Eh5# t#pÍ㵇+X7w{Xua_44ޞl"M\|*ƛd&ٞ:>݊BD]z]fg OR߶+&Ӆ+SbF )1:lk 0['ܿsԔΕxMܲ H0"%tj}*sQ``?,V@g{zYeۿM3gq0&b1c.evS^K8jк 2e6?#%:>rKdm΄aT>k{S3\ Y쮋ZgWi{^ormԺu+Ӗ,gzh|քSЈ6 '?RqY K_R7ZbޕΗ<{?8g6nԎƭuWKg˽0]<95ljjPRm]kUj.7hTw^UK˞{lF8-*QȜ**ȽA~@ 3ЩW\Z;J Sq,&9`͡w%{,X%?mλ:-j.Vw~TΓ{lJoDt6}O9yNhgJ■m/vdbZpZpY@wnq?kz#1 jՄ˩Im:6+ʓˁNHҨdJ:s/{m/-& n肫,.hCηei/rQVGX&pt&'#*p}~+<XWqJi 6u؋Km ]'Ca)׬/*҆3V$mYV2GVI`nB-=Lڶf]K7g+v^U}YL<7T)I._2-|vLx&Yƥs_3wѠL[3%_Q]4Al5e0#kQs{MK~M6̸v/dy< -}Yoh]2jOoFivXaȠç ]фA3%\7Pr_r?_2}-̶FYZQRɄZ6oeW9ÂFz.V'VѨvUz^^ٍDS :R^\/ڈ(aɦSJv A|'3&$J,K"_:8^饩^R%LFyo5ke^ҹbK/AX1٨\Ak3I7v$.9Mt%5r3Tף/Cfz!LH]3_v4%ҭRcك1w sYcmN/g`Z9CYJUN?yNs Mb?5;'3)Aw~!el"D JQ꫊}wբ(¸ΔǸ;jћNgmrnf[eƦmCk%gX=Y6b9o8D4m>"52 9VMN+aXoaVo z<ʨKCTRo9V]TGTz3:r"k^>Va0%\QI&#*;\eMiG>Nڭ#wӦ=~8j>S`T  *-+0r)_%CK~=rh؈_4L눨αxgnj s]dhdEb:XZATn:%Hl`xUWI,~}ŐVc s*E=0CDs ANpo+5Y(=PJn|4SqSS;TBe-IȌ|of| s$ [i>d&xTxj{$^&x\;$#R΂kdžnPOMϯ6U5+;'ۈiqPD2l3{$97/9FB# իKbS%&ħ` bВc٘bXF,> F xĤb$󤉾YD)n7}u+'\3ҡe |G`ΗiocsUZfH@J1 Huȱ\4@6, {^%] }nKw yҦ92;lO}~5ފ%Rh];/O4))y ku_w(Qw- A7REN ڰb`Pj*d厊5@n,)&Hx=5i|5EX@͜>P!-ŶUq?\@et/?* jB=^$r|7kECOoBDIT$&lbyځJsH/m@?C` yvBbǟX>#@_/@_W:w st'h?']ᱥ|}v\ۅ `<`KHzL FA^'PV?H m<ϥI ЙCC$[kϴvW׻OwP׀G#'KA|cv|r]gj[) &>D079LI\Ѡsj7R$: S/9Zr«(@]@W= . -_b۹Q[f@}VIDZiT`ǻtf]ĞQԜѿ9N<lj4>$A%7篵pa#  +' ooIț1 `Qr_rK.JQeŭo%<[Аyc++'9 sN$$]/MVK֑QhZ@ hMZn͇@"jx hu c)ؘftiIvFU?lfWRvs;!A MtR`P3VcwB^DG#9>Kl].YJy}sUT+e .B?dAYNOօ:90Pq$aԚCES>Q>C& $> ƛqfR2"d9>EM6:9 34?9Bxޢm_2[QZ;lL_f $: mڽ#[_4inQ[O|_w):yl]ٕ%m-?/}\B+<'A6?#&4 wR:\FG{ l_|%f?VT$o "b9( [߬yrE6uaqe=9ʰƴ6*D=Ykj%E%=M5ڙnʷ\67 mDZpd\Um ڵi#EsٴԵIً.$$7>ԫ=qNͥGMrTunEyZagexk9 ^KYTqѢ\"z@1 *^C?w>g9/WleynTw_l 0X&Ln4hV|mV*t3%hv6pEѰ-*,621]f9*_sh~ʪg.c)znb;Ms'fNr#b ݸ ݵC,Or־D^m W;ëV5XvM6or~/KPM? vd=KѲȥj̱ rږZ0-c1zwF-t9xh14>oӪ75Uro}EΝ" 7X=ᶑMh7o7=q a^bM5ڲ91o"VJ6 &=JҬje*ӳuLKSkpW=Ɂrՙ*3VHһ94G)%8Gњ賆e,Z[?Xdl'N7s7{N_d6VBTޤNkRzMm*%s+%Pr4Ŷj;{^Ի!Tf T3d)$tb9D.zlԳjƟoZ,4eTĨ .FWD]QBik1*\S}݋!.`IĮ s%hR ŠkS<ہPRU*u n$Qj7^,Na!9J~ =Q6/ ;~߬k '>OP)Pa.wi% g2N5̳pOKԛ^;h $qox{bu&B"z*L@%H&sZDe6|&{SAϨ,5S~!>WZqMAHA{nI9)_zf7bYyecٟ.\)LèO-_Vp-!R<Jr,L,2 ҏşmy'әĀ^..s&ߜ8$i yL:7M$]W9jˡx;.= /wpg'9%$\Scg:OpEoG!:%Gnh"K5AʹZ*phjRSIs ߨoZfrA b"h6@ D**vj`7=KΧ:7..vbN-gr:)IEmlj_f/fe>dR@^2^,9'}Zr\в]l0*E䗋v )#9BGi.~5b/__8[:|{Rs4fIɍ kԻ CuZu]wfBVFø-!b(%%"& ek 0^Qg|0-48W; >H9Ncd 0$c},Ɇ&q q0gβx3oʵXNǵogF%i K1_Z뗡HoG' ~R^d7).;EG<t¯e֣t{Ͻ mK=LV>7QYW0g/l6ZBxᝬ•$ {M!|qwќ>Y>dwu"uÍBcsyX٥yKw^:V!ٞ:O\w%{7:s;KlpIm?QWR`w;EMf578& 5Ikptcy+vv0Ϟ$!͔]}UKipT}|φT%X9+ uuS>*}FsVY}.VJB Q6Y'ecʇOo"e?G?ҩr1;x]z5;uzo5\WVo3<\= <>Q"+5lr/jHN{$Nox{eJ%s,vD{͡ Obdt]4<ھ\ZYg/{a5G٪Ԅ ac g%errukպjN'p`Tͪ|HRe"rUVQ&Ʋ5Fqڪ{IUUQVΝyEkkgT ļʝ{l& WU}L*)w‚C9SJ92Ћj)l|.φM\~ai ׭S;N<[_N $IVCy*Cq+ʣWtœV,}fiõzqZZ4ELE}`SX}]^&| 9/Sh Y72E Wzn֢e>o~ΫDG,eIHd\L@Lȹr` 6[5Z)G)wzܱw?D-VVn63sUXXa,{2G;`BgHۊ([7g8ՙOÆXV's6+[l~/ޔTT٬`#bY3fm{ɛ8驇Uk aOu['8h$9^JmW+ů0E_uE o얉Ļ seOlw%3ܕ<3J-Fsp i[vճxA!QSJX=DrO˽|.7rPd)FI܎}qkGb\bj,I{Sy Q+RW;폺9\{}dVO3mo`/zB*(DOS)wRF[^ڼ* G[@\mZGa]/w;_N:<}MM]bm~_¨2*M=vj9+8JcVgoL=r-J粺)t|!9Mv=)> M45qRJQSK}T?DT"a>; yWmS7jq&1afn0筗euQGw\H=S%eW+mM&|X.ϮBOlն4^6IU--,ҝ_#K9[{D;+z2(qt_PMy%ʼur2ԩEPU̥3tW/0y:bdgfQI/n K;!I1JXXD7ޛw^ޝ;S2%XA~RCCJY&?H aӃo'0q_OpR㎘;at .2S[j(rʧZ/"^ Kv2eU66o'rE ~ih`6Ko)䘀odnMHؕ!bIY|)g -tߨцpۣ|䤐IKH}cÝ HEO :֘: 2ƙ ),YŞ*ЍԦݓeO2VĬ=;8e{)l6gŪmX%#[+6*irD,/a1⡇-&&ԛ$G&wY}eJZ-$(FfFޙzm/TW8U/ouF mR'*lW*R$ie_#1=o;{JI}J#l(d MYeW,jrAJ˙&$yVA]x3ntӖ{N84K9Wfr' >pw1Aa93P!+[ e BPcen5sPilTX?Y2i='S^mM?Ne6ND"}Wb\b9ETh Y#8MhT`YiKX Fr@h>s iDztZ0 YC_9{n,`dA/*'PqY!~B1$\ ~\xA,<91b)b9% 7v06ĿcuJ!1(Nr:WGS 1#a`rr>i]:}?':$H #8BΦך0[ 5Ve_x]ƲXa>@Y^ZP+3?i(BhK&-еCpRG/P\׵au8x@-phM"q(wX.y@TZ,eD  ǿ0 Q ִhZ4k71(wu !dN%)'C`P溿WB*_oҹ"@|ב@$Qg'Sئ} ;Y[})=T258^;@ M I%I W4@ID&h -#r99b@Vky #)U H&nr9@Kr5: -xlZ?8tr7b"#aIu,M䛰;*\f^Dbpד Nj*%"7MtIR43x1N2/_Io,זzeo Ҹ{q́1){^5_ qW~C}nr:IJ% O+uGkV:>ZN/;={^u#q,ORzt|a$ pն쬙0ԑxUd"-ŕLe!vPL2Ɯ@[it{ډ?GwxrcH2ÈTwxX(^VS~/o6x6'|L'!_ YP_ s2A>{Clr=8Ə/jWcVr,LX6^ڬӅLQ0qby\ѱR zgsPNTsO{7elG|:TUfŸ4%Hu(UsTe˪x=ox ?Iʧ'i -M[:{DZw-ۆqjafn E/wKn؟rg2<«u6;2~:?G\6?{2GjBs!=~V_4"b|_IL'0Ej: ܱhp"2Ɍ蒷dI2,Q/9N3du EǛ6Z⠻3@Ezk<ɫEzeaJ4ŨT֪W7$T?9s1%y> {jIX3~+hQQ? t2ݐk<TpUN7U +wh)*5[ʷ8'fz>%=O3^ s'Yʭ/~yv,d(vtmqX7,BϤ3K3x7V8~a/U[J4_ <)E%n|@T MzS b^lYDͦ8=1´ۜ4_/U)sĎO/I"{ .+߶˭G!/T3i*>Mr󫢱|G9l(Yzv֣dϷ9cS`LnwLZ/FNWQ'S:ڥ[lb,O_3q0^ӻ175Ź42sG?5T,X_ fJׇ")3؊y;܉y,?8utMʸ!MʸjO5UjYJB$\Gz7q'anOw>p|9/OMkꞧPU>Xza0gG1R`];8GkUåzCьOm﹨(w_"Jr9Y0`, 3=9̜kn,~U@bf[5No"]2]9Bsk̲m5sI3ѣPW*6;DQ>jvAj؝xbYgopIMWQ>?fpbEK?;OY6טU3ĺL(A5s1}6:`l$x1L)jUyߥ$^-rpRH&&'RIQmkK3RIԗd0&U ,Knr-b'xtleνg(vʙ!u?435ҭ dI4]NrmP70;_c^bvvo PMo;d"/ɜYjGAO7έE>2g`plDofաH5KčHN.ЫNv1GSG,-\OxaOn`+Gݤ6tX˕]S쪚rvD<^P'6ߑxdt8c߰ylXdܠmPBkl&|z!#!*[3 endstream endobj 264 0 obj <>stream *3a"`HE`r_J& =" !]BQ29@T#''ɓԵrXE܄:rAp@imX\W Uz,hV)+';||l:țQHE"¼Gjq(~p ++@+DרVi.U[P P<@;tGkY璐 itd=<\NL"[NaS'DSkNJn-ܶ2S R\*.eV 7t7mIoE}8~J2 oS8UI# ђ7Ҁ@(fc,27 j`^@a:[}U^Dq>PW˱V.\7{m, )kE L c -R%"IH 2<@.@ E2XG{D74b_f}ELh|fl-zp<`kREYoL84"5LPCEV+#ņe!;@IHzȗS-܈  r=L.PF95+z'$ҹ%<~O䋬a~o,J ArD=/ SD|^#~; TjlDf76ҡCE\N?X{]EHEL~[={/RZ$/Cm(&H$z5Hd;HDpnhU@|@@Iրv7bZO,ϗ䤵ױJ^/?4**PX/F^/7/zCkoB__2o=P ]@:d@Z-@Sڿ=DR UC^{o.Y}d BN/힯e/><pcfѻ4nb^jkuIگcc&^bd:Z_i@G_G_V+m~f9^ b{E(]D[/^1 f7>t'ExF2pD8]zoOYɸXs8˱w/a-"BWF+Z,woC*KRWA\ၟ\HX:7>{inw^=m.=U}K+ޚGjX83% 3yOf>OĊ@Z';"_HC 3ZȋB{RW1t \*\Loz]|d I|jeɶ_OM7a6t?2 [ͻ-Vi>avZ[Cn5߉V_!ATq\\F~ ϭQ\O⁦3}\]]|vԳ֟sxg)YV+xl܌#݈GRt|~y]HEi kr,h/ 0!l.qU); ZPI-e|mV١Vtj9 yZZT|[+K?N+8-+_ -wh5TCT? BjB)TCU+e>҆TH8+b*9Ԍ;N{)gAca[Mj\ߏZY-,Yi,59S^JH[+]NW7 [e5 #[fN^H49.y!cr¤,KQv֨/` 36Y$w&]4&5)Id3ר%0*ULtj}Bfw@^o)H;76kԸHf܇bJ;tYcNt^4y#v(jZD{ {XJ+[SF,JϔK9Fܓp)!ɧq8!lGI,| Q|<6c QWE꠫~nD]gŚ٘R ;vuu)-o7BR.'uM'^wqֆ>]m,7QQ39AѬ}@uѿ pF&SB\5 H,R$\E3bΉ2|We;vj+G,X~Ħ= G-Rp`XB;y;;GW'9K!B&AjEef1'-!Dڈ>ͼ8dad&36A/s˨4bPׯEdNÉP:wljܢ*3룧%OBje i$YDNW\Ci|MH(_y7㩗yoQ&z 0k`ł%B0f42 ԧx$l΢dNICGs7T}/sc|#=s)*Ü)LŹ*+!&Vprȥfg\cs˂+|e.V3Gj׹T]S}r_E!\/@dc/&z ͊Z?g+8⩷밾]"{i½r)7}gs|ɕj-01d~Ll$tF}HbKzt1'L(z"]%V~,4} AE糁0umh?9]\&2R{F䈍x bVW+ꎦg#eW`^")-R<ͳDO}4垞~06,w,,<%v:Wem2ŋu^a☺r4bo vV8MYM|:dJYiwr@Wހ`s,T ) suP3_?pŽ V6ŃH5UL5|W:|c|Jmf𲟔 96^.MWe;>9-%,V_B9iT4&H{,1~ǜ #:fČ WPnApG#& Z&VQ[ m xxF,Q" RF̅w9inTXIoƄrt׷>ҡ~ٱf3ל%O!t6 # (ƃ!%@Й,kLw 6DDHc6Qml!%>5W r?e-[h(+\:4mL)!H~>pGشc#n2D2:p<ذ9 aK&K u &J}K+C5>FUcttE׶+F)) `c|48Cb1T,Ck2d1\ta]"T0t`:]l9@3:u,U7YD [Qeq/O>wV2}3 ^Lևi$ #"1^g}X62`ۂ|茌Q+ "{ ϼY(Y90]puo/NSMfQ(_ˀ?W;Vc M8 >18󀳳?»&xNoS_H GEoBW WD/1!ޮ *\GpmGq׀Rj DyL6@p> 858U[2C<;-dq 1r+u~F`0A(RCeGXU"$ w o1 # knW>@#a0%58bTȿJU0bn{'1~zYrDϊ1X.bnъVQ5Q6uح{#s[8j/ECN߭&^$8 Wo{&"7uAb3XD^-6!G .;l+ iE+Dh h2/8g#+"~._$pz]*1K7k/J]齟pIt5i4 UvAz˄OXr;仈"yQFZ ׹O-6><R9=<-?KB*=UKGTZ'Kc޻j93}ÝהfQ[m3פ{ۚ4`ҭKŹ>޸Mo2+24Tpحބ^2־mm{?o3umkV^+'}nYJ wP7f>on #gڜZxwcI#΍`X/4zϑ̝h0:bDVZgkL,ўkoܤmؓn.o[h[",?X@&%T<%\;By/@>r-IƐE! s'ЂaHCD9>C?tQ##{v2-vvj6jHՉF5n 3ʰ-rz kFײL_.ZB|t]$.( X$"6 }oZ%& 4{h |W3t](6ϦykQ^ujɔR,^+S<_fA(.z>ΕYPRQP+J 0A64'_]?;'\c~p0,&!G2B*)(im5 k;WMZF,%^QEVͻZH*c{|Z*)8O0g]XFD oؗkqzMu,6&.NЂz{DG TV1OkRrf'/rۆbPr%5˧0C6' L"{9!@UUIfp|N +Յ~4kԝ*De a³dؿl\<2 ]j$Gt7 !v9N*uZ[*D€ ?ԫ>Q>={"H1mF5>b Q3o9*n\{u yƒDךNy5ej3p2!y^ z#<}orᇝt%Y>.?wQܤ&YkXcrt|>2,~Y FQ, pxT FݎǦ3Ty'[&LX7:?t>>V!>̀KE%ڒ5F MT523̇`phk9Ѣ)rJCvzR[UZөZkSQC"^)B>! lG؍$VG $|>pC^g$eMl4> Y Wϊ^3.;ڑ @ՎGU5NYl))'RHvY½˄{]] 6HA(e.i7/ɖbA:$kZH(8gs[,?n! V^?.>=0{2vscJ֜0wM-4ܯ٪?H;,qt8m"S1OxVdX/QO葧$~5Y{!{ָ+G:4Yl.|Ug7MoFe+U kd-0"rmX'u Ư}װG:YFo{yaTdFDHzff;;1<20ߓ&:9FGOR;/̪FMsۦJI5Z<4RmmRyOCtC(y@(k {lcybTL]r~c"E= "DrI1~KV=׹Z*/\חܬ?3`ץE5Z< ꐢ+Cb2h:5a0*k*8|F)QR R}8| !1G֮> LRpC&2z!{G>9y``\خwؼ`̐Z8iܺueeZN9ۣB'Z8cv&>&/w-[v:zBxN^j6u/ez!|8{ps^j6>}v2eBhZ@ȼA' |T@Ѝ" z~؋lh~PȬXNResmRvsޱX0y04$GJI6>D[7# ڵ!:ҡKPx8k Od(3AVqOq^@y"3-9JeFSs a ?[ۮ ?YGaHnb9W@$9C;$  G `Yi9Dy3&!x0ϯ@P/g걵Av^+OA-7û'XqrYd$;dNr3E%PPW O Ή}Px5jJ2*Pg]ޢzӘfgLwQy\6Õ'T|WtQLLõ`!=7#ћvFwI:7QZy/^(Y_M̉Ab“2_cz!1W2ҷq?5H@;]qSsVWfp+Ä5`Q0 -ç4JIRX1E4z"b]æYR<}d0GM#0z<&n$}jIyī쁸>ܳ@L@1wa ?`r$*R1#/ZO lJ_:4 8g7Je+:Jh- vyڲˑ@fU =4HSNg_W?uJi"Y8`;#oZ/@W`']~yi{˽({`.WuE՛ z iAbP߫PK(?!AޤJLj5ےaH:l*!1ALPZwiHa-{V.WP.n2a/Ap@vU@|o8ufx-sHˤ}ˬF1!_9,ҡ܋j=%I m((5Hv{~w8d{n H2Hc%#OW WicM.y;Pċn)r'_(F/-V}r>^8=ư~ZCeϦמՔqw.~ڬGKUZMQ<]R`uO%wz:Bw-S&A^[_3*oc4.g~vBOj wg…~pp7Hnn煃7U654HӏTcVۜ8Niݱ0% S܋f ]31LI3*) /ӋJ zR*:A5T%xk=[ocuXG;md8/7"ҥek;qK56 ,N8Ni.tV*vԍ5W o?ح6٥a'mi,:}9Tj"E>k社^Zp=ʟ^`ކx֩Wd[Anp?^?+tɵBOg{a!Y-cwݏ&ׇb8Ga}]{C1vq8Sb'4zc3zX kUǠnu7\U,k} Ȱ)xXfk4_VV)vUɬԸk$+;ZzxLM`Vʢ Z+BfְDkqUw;ĂquG a. r+'^]MNt}_uh]b-Wsբ|g7^˟]PZB*??B+SnH 3(io(/ga<;5'{/[+8[>NVt2EmgN~ wTO7௎> w[ m ׶_Ol}ϛ[Ұ ߻?/)/nr4?G% mhǒϣ% 剺/mz4?RSk[_׵?8Hn~*ޟj Te|MK.tr#Naco3!?y;FxbkGeek-tGbZi^dW㥣ʹY at;bR2& IU@׽6k1;[;]N%]횽mif*e%8Lsy5W'SG#TP))pIC1׶oG[u3|k[))/n}u_? ݒ3_*|k[D]vTR_u4I8αH΁ѣ4޹Wa~MeGܮum3{9`csAcM#aqd.sO?gw1y$?? XQaIW-nv -PNn5.,w^ߜF#$k ܕ9fNKYηom MNƖK?BfwHuVҡUT7G~,lG7Әnϻul&n<7x- #A1{&7+Ro@zL4zOyOC؎o~qOs-}jf^ZOdAd;qy B(؝Q=\8_ l@ϗW߄Zp:f4 D}\ifvy%q>}=vԻ믳PR7 %ۋvu&n^O&=bM~9fv#+ycN'u(]zbpFЙ5y;x?@zl6}7L.u=PI3Ĭ(RzM}ɼnԹ,PBoTGHu 9xI's4z gmq 21ސ>jV;UjY]j8xZ?C?Q|qW(VUrv0qU'kig;% ,2ʼ|(dZfw,Sӹ[h>F?q)K-F_ƹ(_MҚ !u$~w;χA[y#7iwE! њĪÕVx<r$oI=-RέrOgoǪ'.9iRWy\،>pWaz}2F^ Iu%㫣&T%W9P+zRFt>^y3vx]㛫[vY5D0^oJ%qv~[hnC0TdMWdeTy.4P\O9=s/۷~%nw5vruP|bboLͽrjC )`)c*w!<$*t?~^7ڢ҄эmWk˔03>7J 'S@;FE7z1~WT./Oi]~]Z>J[T3╙,t0q ?Qsj&}>کMQlrJ-e}A W5OO=.Vr~+ 'ƧomXjn#ֱ?VTR)k|U=Jl#.սyu>y[KZy|SnJgOfZҼ-U,ٴ*$+BN R#}Plk瀗yw4D4],CuG~z'dkܺf|[*mMh=)5EsX(IQ>]/I?`}]O|s롺j[3ɇbI\cZD?8H+΃DzJ"(0Zr&9!JŸ T =s}D yi c+KIJ^xVrrtRJtvq]]& 6fY[p[;GxRWszdԝ<Ӈ0%ylϗK߀p~]bT ϥ8:6e^Ŷ\U.nzآ:֩Jqa&Z=^ x ׫[/`ߔZ\+Tv^ZbYܨ>(>@LV4inS3dcgRgTko.?|AS8ntPҠ[ ԻiՒnr/YKʈFKiU-)47d: tZ  d3-f&G+z|B(Jy3 P6AOp]1;#t4]N-N+Ь%ƽe4xPԳ8ed4)Ҹ#U_u&bw؆K1 T*(/P2{5:[8∵;ۛ?Z;ƱîlGL37~XdF.GTS)'"P?("s`m>_G|-娌c-KJ܇~.J/pj18#S/pFߘ?QcT? JaJt8μyl]g%zeӗe&#lۛA`v9@KSQbUTP?vҠXmATG@)A?:2CsHgYm"޸ksxM>₼3Ur:Ԛ\/X䜫+SZ_1\:|QA7ʣ!{E'yUFS5[c:{0L>Ieo/-S_7T4n:!rzas=딞w&3GеU.`JК**UxbS?yl8\heLnpV<'!hq$vY3niRb% Fc=YUM[LցZ.IcML+p6>=줵| Ǩ|Й{DFJʳ {iý M-tzWz.N,:eF[ P=DxPWqڠvDnT3.`` qىyԮos=K<9ɱx]{Rf9{XTM(C u#u`FƱD ,F`Dn ?\udq{y duЉ)X n+ꮴ)FR0k8DbYWWo`A Y@+N\poؗ[O[=|N~o!.k]T2Fx(Dz7_/d;m8} ]|2\E4Z=eéGcoܭ "j `cB.o~}*g}]Ԭ-aСwM݄r Q$tXEJĦ"ܝ<fO֫oi|o#/2jUӘى^j*^")\iũET&] Z7ȹ=@N|>.o Y}6ҏ 9][n=q$3M(i:Mz ::j[=UeJո?(:}GmwIU/JA:/PZ]Pn꣓O^sR>2RcЙEzy5CIܻD!'.nX~]'#9 @1RA)ޠt>ׂ>[ӯtr~cTxiOkI0f)XzڰRW 'V.QQtי@9~Bc3ࢅb{BSjmMt;VVhY ,[^)c[Av溣kA+!_KA1ls?Hu JyrʯևW*XOq\S NGkǕ6, #zM=)b!6ʬ|\Lmu.GuVJ%PD PfIz(I͖R//qrh{V'/FHl:W+Xh/P ha5 %@yEYq.s42N*}HaW\>1j[;k(o0ܱ.o>Z.2 D ,èҚVS8:(L I}u7ڛSOgf9fZL\՞l]\ nw=760KVF8ht0_ܑ387Ʒ`&-Ny-oƎ%tA\B.]ibp _X `7m_"V,>1aa`kp&)P*MPiP*ij={_.׮}.1JxEZFz96gp >^~&k#];c Cp28GsE6SP2ȆuK",Ii |\=gtmHsNOrkծ8(9U~ҡ- :"lű"Nk5?_Фp,ŎpfB3F~@m\&^X:f?[ 3on-BtfM[$wPI7/|Rd3I(K%? T͋yl'${e>Qޙt97e5[𪒐ԢFgUfԵ_Ck*Żq>`z r%D $7O}R,צNޙALKCmwKV*nfbyIvK냕"^vW >GGd1tMV~]:p'9P\n>~_m:bg~MR=}3;o%'g*Ol&&cjʼnIFv.;aI(5n'BC?(Xv@9f5ns={cGi3qI_|m&ߌvs+ȃYYczG+1:M[3.]>"L# NMO{z23p&WR%qn[r0kT՟9}6wHt0,NU##] (2][R~FYh*,}zTw}a#U9"icF61֔mb\|(Z;Qyņ5r:#o#QRPl(B᪥bOWr{'r;~Q.%dWr."nZ*u ]L*nc$0h/ـ_<UқBG{NaZJ#,X)/v]Jԗ;a,zsHds+"9ܭJm_BgOQퟢ~'F%/p~_mtu8./"CN[vh3,J]bqMY൫OƹQ}%~@{J_z*xkqtsoܴ&~ |wsя|_P pqi{t45I_lq , /'(#s {mFM-:v+:,pGFMÊ_( (,Bʣ=p=)(N*#P3(=#OHv/JI;Ӑ^rTͅ_5 Ӷy}p}OD=Tv9 l [=MCk?SggPLBP,d+O}iN<.oW&X]kD'H_w7?zcw3;q v#oZը`÷Yz3(.,(1({PtaX@#?֦?"לw]tzLn̆Amc;6;-e _X : Q=D m6ps (ک<(-(Q=7P{=|]/Ccmu`uR%q߫,53㦁Ka^mgw+^E('R@ْ]!+OZu j02}aOU=r'['juLmY(/:6vjA"(PrfmXN].QK~$p[=2iS9 p D&Jf#ʊ/{oc"r׎ee,-}n6>ʫݡme`fSVbsQ]76KYo~G],o+P76(QB.:>:g}?$ge"MEt^9AF6LzNs!*$v'X>B,~I@)pT]!:CΧy>yX+/omvv 3w:`~SÝQFۊOD!|כ{ Re1C,lI*BnO.(f] q M?c:xsQ>S8ɆmէmU{MuQoװ& 4OԚ={"Q|8?]@w{}8"1/ʖ-JV[>2#8K$^qͼR^hŸܛzk-JÈ[Ţ9ڇ )%Yp, Æ^;yTc-m9TɨͲtn6"[nUVWd->S ^tFOVӿ}G=rp MCT}Æ/מf@\É]ᰙ?},JWE]NZyr~,*템ObaFiKGd}6/ @3~,- 'dyawc?8ym9iV{xV5rgHM+,Ae jQ{3=9RMYjf_ dNOKp/RJkPnN<ʮab#gݤT.?M]z:{&2t1['KIƉ쉓7 }lT"bվ_zf6 6=ƧCM.]zsy2O||2?zӌd@m28a=<>ZۂQ9EĆw-9X~/t vb(5;8YjtTvNM*+;C4ذ0av:_܌zI3ԱjttB2'|=\y0У[y*A8@r3VBR%bxPnp6f>V؅lFU a2{?Aj$zo)$Y%xjԬ%enXTgRq$ eZlv'/б{k_e:oDxNH\?N_[[vbĤ7ʼ.Qn:{9i}sW6e{6kB:ݴ%]TG B9k*VqZXz {kD3IATl]VfUz9՗9nxӴ^fYEllF4~I3?_@.8=@3 2;0TO)_wByPh,M!G3Z>4A|c!P炥Mh2e ^Y T&:p6T)6 $8??D'~VQmg3T +<ij =z )D/C]wۃo]`);r~@`ϒ4"}yuuhS^qpt2J۶u^=;?I 5,fAC {,qkQa @ 4Fs5GD (=㷇s%Z*{o.|^ms]tao퉹UytkhK ,#hCeH RY)HX ĒSa{R`!{\Unv:lO7C+19$Pů x:-|E P\XQD՟-HXJ"oMXBd9F](ۅ <tE/Fs9Ge ;(Lj' )@ {t\|}z ctьrsU=?. is9}⪵nkOt?^NV}~U@޺@@O:G>F t-HȎؗͧc)_Z:D E'CQiTvݦhZ*_qs+kǝ= ܞܧ% y˜ }YƖ̡o_5tX RoL\6\CȿX7p$e?^\iG(nzI@>rSÝǷKJg+=~&\HǢ0C 1qmƏԔf1gUI;'ZR۫,:q}t} Kҏ-nRr7T1Kֽ^ȽYΔ_2(6 (D\ PCzW,(U6T fMLl=cw4 h~IL }o^\x (N0< F^3 ?c_aQ}hճXŊVt^g}| c]BMLB%e;{}`"ԒW"ߓiة >: 5.zXH)r}U$Zv! иVű^evZwvX8lUwu4׿ir(nx_K|X֜Al""de[)*` "GK ǒ? \BnT% J{ 9N:ywVjًÀvJڄ"$Ccyq}SHȅAYu֜2vc߯ & X66lӓ?54?E|U`޷cjp lQ<]Եe&LWWtce{SH1%`^bփpOṍ;*YPj>#WaOOɲK(d/j.RF빕nm ߑZ0_G1,뿊cٿO~PE3!ʱ.Hlz벽!,z4ӊCM7[\83IiEf5"'x0pg#YX6Xo^/cl*.P|Б쒌3%F]=f~сl qc &T.vl>SpՙO8[֔Ʃ$ᦅ9]wG̴9<K:UwfO^*pjg:uMX*H[U\E}ތCZXvDU$#r(ݻ/وsz6Ǡ޼[vj?۽~$QŠKMKB;/QmRvf' p9ם|Idz"~&vaO'N`Yޗӥ6^o^\vŠUv*lUJTY,J].m*.oƆ̕'1W‰8N#ٜ 69|?~JVQΟt5IwbvMѹIu`trU1\Qvw!OZ$'sŦd Ӏ7Ʉ"w-#boHѐ)KXCK ,ANYGH]mѝDU i˽ORC-u\hca=П9P`C Vaagvi1Vfzm[t+3i(ɢPY}7ņjDfruŭ˳c_Z8vև֨56?(Eb ځT{:V=Mk;*44<o4t]ĭ|kĪ[>ݪ2 R&¬hrN \ rL  =!B,2vȁ~9܀M\A+h,l㟧zob6͝ߜdlTwji-?X7 S/p~i 0P5Z/<@ ( 5 x _n[ ,>y69N(A^$ پq/E{i\‚1sMEL΀'MbY7fɇl G~ Z)Cpf>[%?n! y$ 5p`'mg,VݹO/5gɞLyf4*'_7Il kI=cLl̹~A>=M밷l oP]!jQQN $9lZ=ߟ ~8j~DOm{(n]xTύIN{{JEɘΫZ0J\|AٽLxHCĵQIN-6exa|Ԗ_'3w(np}! ƞ!p;riNV_k5?ƕGn~$ wiAT~訑f0Lg|ܳƫ@IxDF(+vg_:>ƹ۾q``3"֝N'=9|[o)DIGQroJABn1Rcuqe\ZINc"˦Q l+uEc嘞ku~6ȯ$%$;'|Pᨚ۰2Q96Zw<( ({Aog1t:M|mzwX7WۨCk=+I%Ydc,JG~^hn.zzl@l9yd 9q73p]znePNT*ir2v&gG޾jOۙ'BAٹz-ohy+Ku8Igw L{^0JAeγ9i6"]7LuW[w0檻)h[w>7ZRk\x8:f\6}ޠ!7ӾZv &yAI,?<:JkN5#uL-hQ/KH[*KK7^eY;.bئ׹2߽U5NIRcwT/QU:1&t7`WΕ[PjQF=`ͬv7ۮV\Ws9#\[(Qx Dg&52IG>_ Τe]i4$>"y;7%nIt;X 59tB1-VuE#%c!:l ٴ;S8M;RѸ|9,+ QQкRŃ; =s3]mxK"ʼob+Kp~IOo}"'TSJ wOOP.)Q cK[G@b;Pxw5f|歺\(N&~=+rURU"#&w6ʥW㱰=(l uѐڐX DzTLXRQvrY G#f5NLܖJ\\,jN2o1i]tfcrٵkj2Uuס,dͬ˝ɜaI*zr0$iӒ V^s;_|IA+{u w@48 reuix'sx?< 0'&mUP`dˌ7@7 ~UOy?R@_?ЙDZACA<¶{h"!pM@{]|F\>hA>ġX'"_97f|{k8W'QZ7/:zv_\AOx+tW@.{\M\\E@Vn~|-e^5 k@F؎;<4ly\2x8by V˃9:O_!PbN[5L9\@n X'? PwJ5z%5PAqEjrcM+'~{D ҫ ,Wns&Vw%(I6BS) |@V^z]C 7>lDeb25u,zŤ#Mf/v*.{˓~2trOH|): ;#~Pd:-" W^_F':KD4./];}5TW Au/y͈֏ziw3L&AE`(-.rBbmH$k(u:fG_'꓿9yox ᴳw~?/yұPgC1UJ-(Mbg'@QPvUz"5 oۢXh-=ϣ{7`*6ǰheUjߵa*-!IᓀB͟$kX 9™㡭oj ;ϴݻuz ޭ19[:'L~^SW[;<n Lc~ayռD\xRzCyJ@\\V<)ɢeS.c`o{ٿoA1yI~ -7UIqߚt|>,_sK*!{'Ѳ@?Upg=hraV=W{l$grο݆NCܲC*M:T֝ӭh3C z\¾T `-FI柨$p M^- wI(sֶp]/ ණU;,%{]{P^&5h6=2e{f'4vgT(̫?.Ux=A_T`$l V/]:c?Z8T)ۋeFH 5UzI\gzW]sǎC>qo?[ Q)" l2B,Yg4͈dBd3cP>,fUV۰テHo3VD'3dNP=щ~Vq>'6S{kHi|DZNP=~?M>SRX?^vZJ^ꢇ5\*XnMۃ^g;;=heF$gZfw\*zWJE}ES+4ޟ$Owz ?!޵:L2(fnv^}YkT&{֌DjN4J`ctǒϹ׻&^d墇W墄ᰒ Y)&U>a\i8;s=؟\G ua?Ejʕ{ݸ3¨.[Qq/`ػtC$;h$_IJb؝V?I%֊y^Hhx5(3]='kk^f:L-i9v-L4Ⱥ{L5T"[}zzUJ :,b^6Z\jβX̿]#Te H-^7O }tD seLTu~9I5WjY%sqLHEIp[%tQV[Rsć{W|=AVBA:˥`0m=M-na10e.V fzUJRQ?rwu)=H鵎b}Լy$N>W&tMJ\sT + ʿ} ȓu+eTpv*&Z-4f?aҦO34 ۹OFղ.?"uλb;vL7[Ckk]`KE2C/ c!vcl>'^ۃM\niwnǼf 6?ǥ7bw#7硗9c| .Uы6&+VMv駖kɧ'o8=c?H7b0VΞhyњNyVoæ?Pzz>k_TKJҮHXAʣUKHƅVx=YKp~J®984V썷X3;@ ^M.@;- uE2c/2UO1IZ l oWJ2bp6ػC~ `z"'kX=B`)6 ЧAA'k'3{ۺQbliL˘c?+T7O+(w?mm\`9]r 62K݁wػ^&pL}2ŽGNk2_P>g8} īc|J-1@Ju^Fvyk)ϳ?`0/dV`2P 43D< ]szٳKY0"{{y6kAQ<y@7a`E?mO%5uzX}lP۲pJId3ۯj??ؾILߺbi{L}*}u8h䲋&0jݏQ\@8M|~❓~+D6F(FPoyajyF7o.|z~.SS {̌aP%4A.̃AM;p|_ We@ȎSGB/r] pJa3)F6^?Թܦw{.6l`nIPPq~6K8 ̕$d8 r8/Ku5G~n3TO^w{V*7 ī<Ɍ/⏃ fAjwn]GM!m <[ 6 TP{~@" ~бT wYm=D#Әmoubt S9H玬ܾRh5} i/(FW~URKhNPꪖJˀB}@I%32&yjGyrlƼ Ӣs-k+}sVͽ&]U݈է~/Y=Nb<6$ -٘OBPč#|\uҽgDµ`fz,?8ݒ:َhӋ-{ޔW]5c+U[5Eُ';n4fr"ٙIrYՄ=:,S"4_lp3dmn;UKoR(Z!?朮vy*UkgaR`1'j|R7sA`Z:נּ3kRcڎJosw2J2B/dl?u|9l[[w@ij̸YdNaLѦhN~ _ӟը|QfUw{. ;a~j(3zH:KwR#TOnSͯ Mwh31&H'XJ*{T7@ PвORB3tk`>ej{4O2]5#qybW8i:#lOYM b1EdUȏ~, (]@2[HK#= }} yFIX’8J]Xud8-ӽ~3bӱ~MuѸPƝ GTA ?:O5 uO'ˋjo9 npsO3e-wb)j+\Joc .Ǐoi;p#/ڇÁ= @O{3ݾ$?A4r;6fn?$9]՞V#r&)Cf~j ýC]w*w÷zdB]w/9A( AQoY Q/O0=m[XTAj>-OqRc3fP׾g0t8}5O㩯f r :F~83[N7jo])@g/K3ImvOP7JӹZJdŌv*:2>gnhͭn*lp}Ar1P{e&3fg+g,\+%L@K//1|;fkm&oﻣ奻f9f"[IQjJp.ۘfҤbPkz5|ۗL0,Fmg1g2H=C.&g5s%mjɇqHQ<}?Ef~Qn%νQ:ZfqHa|ʧ4!rc5)t_,G?tZԪFv.n&/Zѧa.T{M\Ny / !_΍]+=3L=9 [3jKz l{~o=tf}w/7XQlH2ʆTj"NKz1pŭ|^hlD%QC?_%P5 Uo tJNwԗlFqY; Pj$6S)W:x#COmau]uq8]_wc[K~cy Yj5mrVg]e݁mO2P =|Ġ-:hGfRO|Ln97E#9ױZ$.v4}j']S\¨Bߞ{&ؽX]Ј W,C_CλxurH+CRgBZZ;)Ie7pYNAQgt] nNa> JtN^|Y|IzjhuNTڽ=fViV5i!UY >#g7J}r+IHKS`͠C6_Pz eubʬ`9o ھ 2Wg$"sg+xtmVfV,1UwLd+R(RIzA=7ԁ礥Wv&VȄ`\=G6@O64CBS̔z#3@4@%@cЌHt6@Ϊ\>N"dK7%^dCQc|Ji:# +(_Xm؀QXՏ}a@{> _b7ں)e @ht(RN$Zgx\D Fn/C O#uYRG`` `:l)]qX8ޅuN@ Br*Z(Z9Ђ&D>`]zak2dj1%`E;:Ӕ=xE'!<`xn# <`aX>@M6[j{Uvh{7]=O!Vq};hOuX\,D_l_j:l9[f/?t [3Fɢ@+FV@]6ЩU 8(גcCL<<)#9g.@ݡ+^@[4 >›?LAU K;cNQpʋxjn2i`vNi֬ .t]^%jK*|^,K!_@ҏEpv%@\SViK[{]ix5w'ZL~V!i$"="i~4hj{(U `?by3 'Ӳ24^`sC"׊o8 qOyFЎyG~;?{`VA58XOR#YT{G6"*xpg]$P2'*,Xү7.<mÙʵ?9[g*9]$V0ѽ(;i]nw?kw#at2+y Lh$\rmT|4a^2?ڹv'Nrjfn,bEۻx {Sbws`_O1Io\gqS]X--שk l/з뭮m2,{=\ Kgoj1/KwârcэQry~4 3txȿy56!G BknYl)C@ g(-"5ǫ,} Apx@Ǐ G&g5:_d;[9Ry^岿n9v[218Ggp֚(ië\᧥)lP4"K7/Dkn:~'zފj\A%+E!zvmrRq3W<:ĕ~ 4A#:))+Cav(DRoT9:C.ݐKD]o+ȶvӮTK|r0p꛸=6]?y{j(C |wL11jОlӅT 9!pxj*]vjW|en9h_·2bvϹQ Ê/|x`>-w֐[>Ϸ^,[ No*mZO>=#qtJG9K/-\@JspVzMځl[&yͲfn:ɹnvq? Ul_d<3vKvR nb]F/RKq#.vɴCvWO`L9yQB;{mqrzr oݓ<9b]Gs̠i=*O^\F$Ey&혲pmҋ>-jF[n/d9cAvdcwnp3o;+[Ǣ\.T̀d=V\p4_{aAKN=Dj_")r1-F-=X6-;7BQ FPBkܾ6{gz5ӴuYT ^ϴeȍRJ-]_ѳEKZ /*ؼfaX~Gsr4#sk-O/R^҆a%m/{1L' 5_OFo3+Ujv>حwc칐Ī`B~U%9߼+.e{nv\˶" ĽZC67(&/.XfNqgX{)zҚdzڔA)f(p;kV97{+Sj޻TB]Ԕ)S_Sh8nsG~-*qu1ei. c֭ZzP~%|(|HNf\ŏ'4% v_5]C^sT=q8$Cjbs$&4ْfގzz[_"r;91s+X`VG;ܘ`ߟiܥwώҩQ7c9[Zd㋉F8KGVW\٬ӭzYƼG-TkRJU<A /.2_{֧Ι5|Ci;+&%z_d4Cw =9\e&j{\` ~Z> znvò0|g.]&/aw@-⨻jIo2BҼ/uFeZZ!K1J}&r٧i*1VΗbq#-r ik#r,=јcPMۋ7vGɚrTH n?%@QȴE[@ok X 7V=eBk. aWdKGqq{q J{ P3q.@֕@cE_p8n8 C!y X~!b 9ZAF;okY2*@*  Cnnh2{ßB2Q0 ht EG$@Kkd»n:@ExK |!%$4[۫B$ 4](yc q8袴hth(Eʹ9SXpSi0+l(vDW)ߏ[X𢨭}^KG2xnܽ`G6[ `ŮPg? I2jJ f6ۺƛ D{3Gy6LIn廯*u3z]%O^{ġV !%`Oh/6|GK9~lUvLx8>{0zCRɽt|ޗѨ{ 5I _Hb @;< pt/fWއxw>aɽc]6BX.L|6|U|ZY}y}73b%\޹PIݑSc n[M{\?~F~+CWFKwnGݍ,;H9*K.Cxz&Ɨ WҚ%R. BxG^nR2CkG-GQS4/:=_v?v H뾳 Z2K#9,-Pe'A T˭5:416~ivY25-Q:Aj몔XkքgKDPO6?s:iApsʏ~ ؗ$ӠwINBNI1/aҷ ]>?mx\{jcmSV.pS˫mU=şitf&7L:ڡ3l1c[Kۑ:rMpU%oC֖mπdSg*=!O\G;}\>Ac/UGU_sSzeR󣲙}%_O<\OZtaz75SI<~ h 6oz/;m oHޞ^ *V+jpVmwݟpecf@PJ 36BlZ{$ҷ~)I_ {Q93=¾DNaog.ԏghPט5[l؏|XOy"-Vf-ݜ7z(A;2ncR.Ԧ,g:^cgkVQDQPQl掵̨q]~. ySF0"ݎ2Oeɚs1Cl1<'NOg?3lf䁱} 5 N kRp@oe|*c[p[[kTFgZ,)5]& Q2[=>W(t5fZp~Pb-"Ob{@yC.bӵL@'i_gD9cQ),/vdW=rpszCoYb`zVmzA!vctRs- ;t'˞{2:MQ8\u꽖T,f]fؚiJwyUY.$^"__Y2< a٠jziE5Bl2\'n7K< ϔm^LCiU2 A{L/2SmM555gkJ1F}x}(7ϥ>aWf>7NǼ}XhQX^ͼ1evQ>#ݾ/I\~2ޜpktlUīyωfIQxR?ʧBuy.Ҙ:8\)i'=qjEAE"O .Z);|ucֵ\C<.|Vۥm`k.$Gn׶A6^}c:=ڷ55û_j.$O,/"6u0@_Hmzzیe)Q }ݵGܮn\Zz4ۻY>`l=^I_EUPQK%s?e6e_^'ekkHH—V4zlv^/sv'=˷4n>֢V3řQeޮbWE_;Ө$ T ,Pcq8P|a)la?V> !z|~K ]{Qyrl]+a].{Βb͵.4"+'CaԮbz \xTކ:r IG뒈/'U^Y[k,YY U[o<ɎM=6s,]1ousK)sΏK9E #7n"!QY[ { Yg[_>ϣFn}q7u{yfr)1݆?^wNsҝ7A;mt>a>U`OeFu{oHJ}\"K{j Wz '^ Txi_5eI?} nNy^E9{CכL .~|QER"-;}!DֳO!)C茭 L fէ3^ }*0I8V?I.{Gӣ\ao%g_ɨ#䂭̄=UNvl7)mf14ajH` pFdAv$ ^fKXՃч=xodݵ-}ocosa3#ږsվ~ȣsPO'"1Q$CX 7{Q75dJ1DU|=ӊY:V nN%lMuAp&Ѕ8LD.:"|6gf>kU5ΕHkBZu_UY:I>L Co_켞}ϵ5ΧC~@uyGXLS.IW\oo~^;PUh^~NiI8Ůigve8 5ts]q dkaR䦜F__MupD&<}]as7q?"єJ7XiKxFQyZQl񴄾@xײ9(M%gfX-9sF0oHR|< @|v?Oor;!5/_=z'ۤTL߶,\]~=jN^h|vkGRr\7eiqi嚩XrY|'q onnՉɰ 7YyC<>Ln5r-MG [q_%~2׳9rr`ҲwZۏ'^Oz =o@j$v5qPH$.'RyBcw=,:kohvlJ:7 ,_frEV oTV.JYd;^ɬ>⩵z {|#)4;c4qW ].))ߐ4{%RH*h[Bڧz˶.Q}zV-p^fngDMxWD.rijLkCV:oiw4V'w;K ctѤ5F]65 [m7$$mUoG /vԭ5ksfvN>jK5i*QT$tۑR">C""،XoRm83~V٣i9sM 8=&N1I?Nj>m0#Gkv:u8Vf0ʕk37iO۝0ؔUYG/GRю ̾znJh)zpOrr ; 8v5OzYM~y<IAg#%_*C&r^+Spm4&P,%|{ `GuYo2¸-Yv@f6r==I^gٸ=jh%eʅO($*f[Hx66^q{8 }n8Y~=K8$fb3Anb+Pf+kc'jラڒ,Z:){" rYdMQ,’NȻDNؗ>{DN*Ӳs:XoP* HG#Q;\4yl_ͣPQ_\'XJo)_#hߵ|'7 }zLg(d[GRv .uz9ߠ qQz;{u]4D沪:5ts*k«#lxwͅ蕦~oYU{<#~rC Kj j߬Vkk`zS8QD΋Թӈмy{ci*j~}`-Kgs^kzV荴9(Ji,-]{.3/p_ .2wgg<ݞ[E7 ~CRAOI>^R7 vcV9Wyyޙ5}hͬ^z嵒ԵQa@ u]LEtϳmʬU&)ks(śJaj`gdsD*ˋp/J~mTeRo0F2,@uS5>_yc o&9wյt?YDTSu>䤦?Vו B8:pRLߝ ${ Yѳ˅A4*vwMǕ&uq-diᲗr)Yy$Un EUC!A F<>6Vă[M'.+v%Ͷ+^~_~)!9䦹~0'7(oe~wZF~U7 NNCLYh.,o{ˤᑤuVwZFEts\uTJzO|>lRN~4'#d]ɑrN!,HƘ:TZvh-Pq"ؙYZ7.?E \Cy8tw7kaÖVwQ$ϰp/w-~CҖ@e'繨P㸧!n֤l-,x5U C\ Rq1B[&ǂɩ<$cȘKt% Te+C:gqw@tHrsfbneìvE̬7a:K'PR]cO#"v/6@gN,9;d[PwSq 7j_֬' ٿH染PkjBbC6=|{y@E[+*O Ya;R~ +L[(J ! hzݨL:qu(~Pw tq%'ᬫ!7F9Y+GY9;Ϻѯ<>{">nZqIS>$eu؝Mvw<Y||ퟬZHӫ8ܪ)LDm[za>#h;T@"P&ʿ:yPvK#` ׊3y߼I D^jk'.O if*S^o7]N_$$;$6?ZRm*_UFx;ZLc?@ I2?G5Y2I͜`M^*R?IvbʿN0܎n?7(V U:aԃ?0m$ױ7lVUc^sjm zWF}5ꓕwkK57L$ZT/Ȗ'ڠ%k*+&Urدm #lfŹ/J#2\) =fZoHٞǧ}!~}.0J䍱SO;8lʹϧopXF9 :5vW-P*n>*wW.lsGWIȰKX z1g%,_uRGk@Zqs_Cʗ7D}׿sL-B\+ pw(chb"S/A/:LR/ I0[zZ6_!A3w]bԖdRm;9XŦ5+[~^XU^a*+|6Y,xt L;zZTb :7qbRk(^k[E6Ȃ`qK~~q@^ڬ`j%!V%@Ov=\@& q<6lli]7>\!~ |\_O@^*xzꧥ:Gv~d~=?'?H2HҘ<4[$`GU¯pmO~t=TⷧAN}شOm:yc~#-`QCm< ikL)3)/&:DΌ{K K7d죹@.W}s4nCp@/k>g4/v>v+[G1TQ"@P`{IΎjN}yUXqKb?3ItU%Jte_N[<ݩ0Q7F -;ywt:?gz.xIzd&)$I҂JTiHd~ Әϸ,n,KU?Qs7DN5{XOiݭro0eKlmmQƓpk@@Z3fz-;|{>k A}R]7-r!R b|<]D9mWL}_̮G&cp ׋Әh䫳(/VOn?+|KV.K}B1Ѳ ~f4(lmIV_=|&)*u,}d_!+K+r;ә@ <3tiѤƪ[v4]7;;:ǵ^K!NCxSY+$Xn"5Gq2Ǡ 3b'F:ǩyZ|n vq2f6^0 `޴&ɽWS6ZK S ,=)yrJL'!݂l ͷPIW^}CyVYyֲyx`:i"83Qu_˞N $ycTwߪ^ƺq]Uc;K~ )C-;;pYgqK3 8m{dS\ϬlaCr2ܔWSCRG-=J:s!!쵅gR6|2kEH?g6\7:?J-L p~3 lң"&H%4!-,ݏ18@Chˊʈf'C_>:ڪ (#[n2\;Ein>% b,EgLr@p=$v5rI 9ǭ՘} _I7\*܊aùX~gƘG_4˳8;lnKs̔|)g3hØDW!6ƸգVv}mtc@=Xn>_rs}d74V3_קA\f>shmՠ/g7*H@f2gUj!x2I}k pQb\ڻXw\? }4S?m{eXv{F7ĂWE8{h3zggeLhƓ^WMoG,0*)U'wuM3 bscFpH3i]_ p9G{SAnYW9_EwYlR~ OgN ]q}ۑ^O|} +{6>-7`QԦHNN} 4-f <^YlŽI]gO f3G׸wUutNCC!rUҦfp1z|stjɸX)>(۱Kl3L~0{(mH9PXxkrxItZħڡʥ>i(LK E4Ə61 TmAz~Ñ޶^&~>7L}?jeEvǸ-hkŦZt ;=HfZ*9x$po$onH^d7h5u1WntV/^ k2hAmt^Ӑeczט|P2HTYvѡʁ= [ߖ sofZdᓆvlïRwKߖ,ko߸.Gכ2ug6PCkF׾N?t'z9fnADFknܥ)MwCQ\"&G:Vk}ߪEƹ[ӚI8-{c\0XaI#w:Xlwlrp0d^_|5u 3D_r h *EL9JcAKz=cz(bO}+Ta|oUXb^Zm2p 7|6 "yV@30K:gj:f~I^(mq\tZ *4봃7ZSV 42ZŅ*VC0 O;z wT(MKugV)^5V*ґ&^oj?+vfLɐtJlWo(;zcWQj>nHTP)kr4etwbz&!)O7Z%Zt;r&֍rF.C?HD榗%t-G12uqޅ.IkNQá{kw/I]F%yMeANs`Q1M'˵ w7.&U8^-1;AE/ (<rtBW%_QsK.T#;5/.jG^VpG^SE)@rdpq:bE _~dik+$NRu;Nte>AGJ,xb j`H&_6%:rs٬H~ /?|d2 <'283N( u#6/2O ZM~Lbo1+ m{/$i:(s,kT@_6(@@䃌[ ( :'Ф d9#AM` D? ȬtlӗPD'/?b$ gT"u3'0jQ#z`bt7Me@NL $(Y5Z$`wݣ ״:|,*\|(Lp6y6T^|$%} (m&R>i8:#%pW8äL雧;kL^v|=Fm9P?}=|$ݩ^az3gihB// J>:dr '013"iTZt]VN+*t_Ԯ!^xDBAzӝ2Wvn!-XhJğwZ&_O,Pf,@F8Zta[Ńp>@>݁/{sXV/ckukO%sgo"-?9x7ާ(z9wשl.w\|S qO+9dS[o `2Nl-'w୴3|lJ`x~JFK)N*muh1^mR/< 2:gOĪS|۔-=lښfg5e'Z 8@A;#6R9~+TEs7Ƶ<NnEr:tW>^ \ywamz;]wo,6 oɘyRQ (D*[g/߶OgGZGG|Maz,0j!nVC}G:}'M#xiތˍ +ţiCc^puM>  %wZc㔞p^|C=sN5vLqE 'FzuP[7`Zp~Z<kѫ׭Ys˝4s~c})|cNj0=bǯn6 5Nan˼n͹ukoYRYԺhR/V.AKmHa2Arf:ʧ(bÂgnkܰT|F9lA ]BY\5,鼹Ve\Cjʲ[-䤲|rX(X#Ug$sgܝNFϦ-b\6,;_OdU*iNk^ pYot;kzY#2H?F`.n"uks> J T5.o|sOuʫF_-7eN2%6ecB=z]\-;+W_OgK\j_W~ o3Ly2Txɠ6Yn\Va/6gUXįl=S,|c~<$>W>7rx6d+42jHOf!.ܩoWYd#}A\vk0 7,<믴/Ĩ%32KWg)W6S; dv#7dJG1t)JZ#N,y]Fus p|arrMdӡ*Oz3Eղ MgjWBۍ-;̍.)=O 5V cmM|ߗ'ni_xGwZI &Z6 .lw?'EUM Ɋ”v/2o+c)@$y*>^vÑj#7=ӰWZ?<\|T_^M״VYٵ.Z0p9KT2 NSDH,D^6Ҽ+b6h 6;Y)fBA2jыHܛyO| {EQW? gHҞ5m)*_%ԁ/®ܩ.L]>ЋQRAtB2Ɛ6nݨ]'!:!P{#qpjW 7 |QCOPbq+rVH=7:#^_O|kRnXIe,G%EMpޟ`V[/3CXXw˝>* މx˪$KB5Es1KxQPzm-C @ӏw0L: RН-W7tR±DX˂o )78Y ``BV KqhԲ48e=ƴ[i$5f7F !\齩]yӬY,jPJ8joq./4TM)DߘcwG>&D[]Esbk%`:Okg5;%j#(qM5%F7 v/5o\Ɖ&RوNFĐ)ѣ>E_~dt p-eKQ"Rz>}妥!eN~6 rw>O7_# (4TH|8w:}ӗ4XĹŹ‰Tc`6bn?y[R8!M&cǥ#rv*qgXmCL.U%]2ݗ4JJL'-FLBH=Øa@Ԓ_݄rsYDY ʯS 7'+:p f;R24<cP  V@wjf8+溶.g4 !CfK[v_Ƿ\ f2.ޞ?!ֵ͑VT\}Nͱ'hgI!WU O^~'n)qSqa9NeRrqkqwB QYL_rd6~c=ɚ:j }̏<ts~{yKo+ 9dnּ;,!U +uXe4Je1C󘬸.K0<ӲWg{bnQ3koNU2؜vώn5n6?P0^UPl/W4:Z‰%-&7RèSpbچ{|{߮DVv#TxBlǘ'~)O35eVNn|svLjW#f'JEfgjofg5?Ɉ'.gwX]XOٽX10 㰶th-(vR;vWh u’Ɯ"r WeY=jMW#IlVR)ߥRJ=KKC\.ß[Z'7T=q`l:`Wcֶ*vwXI_~q6Wz/2֮K/8SV{5kGSnk#)w; ׎V|1K\>/H~O?oПbAs@ ^gk s(>^ڳ +u C:jqW( o 5L mMk^ ^Ep=;NZ8Wl!aqt+(3>ǺSﯬU [h[ֽR_!-:Ws͎YY>aK+‡jT~qtXӈ:p76㶌'[. V ˎ:N1}{ineeNYދw{.̇Vn^{fY'TSRv_D"`8d9)3 VIڌ ټGq*16F;=cݐINPU.18LͲlkRw誴i)JDW4@5!romd}Ά lzB${nx Up(JpHv P5H/k#|j!>~q:$e5 r+cI[Z/+u@G%|6 .  ?9.\ge *z3cJrBӖEr˓ p'}?6/b%b;¼ܼv*-ln}O;֘o\EZǨʝ+:Õ3zObK/N ]cag@Zv .mdBVp{ȆQxy͏4fSu씩 ^U_ 2xz#@.t6YT*m d2DcbX `>_]?Sn4ϳM4xz$+`|˴}eqGbЙa7q k08Ǹ泭YrوΡmȩ~n/ *u&;ί/FiK=d"IJNJFٚZ: wEU _Vk:kQ1:?3?x7ryZ7ID̞#ضjʪ /,MITڲrK9с0u!G+GLSeRFf-cFڕ> :1c8̓dKyosfx@F"@=D}2&G+{ zЗ6}s=Wڱ4-e;օac!M}E`|!kL5R-#>顅0EA&iGrXd[E呲x v!]g{/i66W/"}H|ʃMG܈˄@fR ב/٣&agYu;|å~0ss5PZj~T8O'z y.A,/*m`LN7VxriSVd,A 3Mo8:!2yr0+v~koh8uQ)þ 7Y;8 Tw.qyl upK0-Pot Bď88z_ ~&fk|[2tn#<=+jM:W:X+mq9q+ykݴ]k-q[,M/y9rmŜ7&aלkO9j(b]o9!څC:1wr#&l[}R <-ʯ M endstream endobj 5 0 obj <> endobj 32 0 obj <> endobj 58 0 obj <> endobj 84 0 obj <> endobj 110 0 obj <> endobj 136 0 obj <> endobj 162 0 obj <> endobj 188 0 obj <> endobj 214 0 obj <> endobj 223 0 obj [/View/Design] endobj 224 0 obj <>>> endobj 197 0 obj [/View/Design] endobj 198 0 obj <>>> endobj 171 0 obj [/View/Design] endobj 172 0 obj <>>> endobj 145 0 obj [/View/Design] endobj 146 0 obj <>>> endobj 119 0 obj [/View/Design] endobj 120 0 obj <>>> endobj 93 0 obj [/View/Design] endobj 94 0 obj <>>> endobj 67 0 obj [/View/Design] endobj 68 0 obj <>>> endobj 41 0 obj [/View/Design] endobj 42 0 obj <>>> endobj 15 0 obj [/View/Design] endobj 16 0 obj <>>> endobj 241 0 obj [240 0 R] endobj 265 0 obj <> endobj xref 0 266 0000000004 65535 f 0000000016 00000 n 0000000284 00000 n 0000053705 00000 n 0000000006 00000 f 0001092560 00000 n 0000000008 00000 f 0000053756 00000 n 0000000009 00000 f 0000000010 00000 f 0000000011 00000 f 0000000012 00000 f 0000000013 00000 f 0000000014 00000 f 0000000017 00000 f 0001094151 00000 n 0001094182 00000 n 0000000018 00000 f 0000000019 00000 f 0000000020 00000 f 0000000021 00000 f 0000000022 00000 f 0000000023 00000 f 0000000024 00000 f 0000000025 00000 f 0000000026 00000 f 0000000027 00000 f 0000000028 00000 f 0000000029 00000 f 0000000030 00000 f 0000000031 00000 f 0000000033 00000 f 0001092630 00000 n 0000000034 00000 f 0000000035 00000 f 0000000036 00000 f 0000000037 00000 f 0000000038 00000 f 0000000039 00000 f 0000000040 00000 f 0000000043 00000 f 0001094035 00000 n 0001094066 00000 n 0000000044 00000 f 0000000045 00000 f 0000000046 00000 f 0000000047 00000 f 0000000048 00000 f 0000000049 00000 f 0000000050 00000 f 0000000051 00000 f 0000000052 00000 f 0000000053 00000 f 0000000054 00000 f 0000000055 00000 f 0000000056 00000 f 0000000057 00000 f 0000000059 00000 f 0001092701 00000 n 0000000060 00000 f 0000000061 00000 f 0000000062 00000 f 0000000063 00000 f 0000000064 00000 f 0000000065 00000 f 0000000066 00000 f 0000000069 00000 f 0001093919 00000 n 0001093950 00000 n 0000000070 00000 f 0000000071 00000 f 0000000072 00000 f 0000000073 00000 f 0000000074 00000 f 0000000075 00000 f 0000000076 00000 f 0000000077 00000 f 0000000078 00000 f 0000000079 00000 f 0000000080 00000 f 0000000081 00000 f 0000000082 00000 f 0000000083 00000 f 0000000085 00000 f 0001092772 00000 n 0000000086 00000 f 0000000087 00000 f 0000000088 00000 f 0000000089 00000 f 0000000090 00000 f 0000000091 00000 f 0000000092 00000 f 0000000095 00000 f 0001093803 00000 n 0001093834 00000 n 0000000096 00000 f 0000000097 00000 f 0000000098 00000 f 0000000099 00000 f 0000000100 00000 f 0000000101 00000 f 0000000102 00000 f 0000000103 00000 f 0000000104 00000 f 0000000105 00000 f 0000000106 00000 f 0000000107 00000 f 0000000108 00000 f 0000000109 00000 f 0000000111 00000 f 0001092843 00000 n 0000000112 00000 f 0000000113 00000 f 0000000114 00000 f 0000000115 00000 f 0000000116 00000 f 0000000117 00000 f 0000000118 00000 f 0000000121 00000 f 0001093685 00000 n 0001093717 00000 n 0000000122 00000 f 0000000123 00000 f 0000000124 00000 f 0000000125 00000 f 0000000126 00000 f 0000000127 00000 f 0000000128 00000 f 0000000129 00000 f 0000000130 00000 f 0000000131 00000 f 0000000132 00000 f 0000000133 00000 f 0000000134 00000 f 0000000135 00000 f 0000000137 00000 f 0001092917 00000 n 0000000138 00000 f 0000000139 00000 f 0000000140 00000 f 0000000141 00000 f 0000000142 00000 f 0000000143 00000 f 0000000144 00000 f 0000000147 00000 f 0001093567 00000 n 0001093599 00000 n 0000000148 00000 f 0000000149 00000 f 0000000150 00000 f 0000000151 00000 f 0000000152 00000 f 0000000153 00000 f 0000000154 00000 f 0000000155 00000 f 0000000156 00000 f 0000000157 00000 f 0000000158 00000 f 0000000159 00000 f 0000000160 00000 f 0000000161 00000 f 0000000163 00000 f 0001092991 00000 n 0000000164 00000 f 0000000165 00000 f 0000000166 00000 f 0000000167 00000 f 0000000168 00000 f 0000000169 00000 f 0000000170 00000 f 0000000173 00000 f 0001093449 00000 n 0001093481 00000 n 0000000174 00000 f 0000000175 00000 f 0000000176 00000 f 0000000177 00000 f 0000000178 00000 f 0000000179 00000 f 0000000180 00000 f 0000000181 00000 f 0000000182 00000 f 0000000183 00000 f 0000000184 00000 f 0000000185 00000 f 0000000186 00000 f 0000000187 00000 f 0000000189 00000 f 0001093065 00000 n 0000000190 00000 f 0000000191 00000 f 0000000192 00000 f 0000000193 00000 f 0000000194 00000 f 0000000195 00000 f 0000000196 00000 f 0000000199 00000 f 0001093331 00000 n 0001093363 00000 n 0000000200 00000 f 0000000201 00000 f 0000000202 00000 f 0000000203 00000 f 0000000204 00000 f 0000000205 00000 f 0000000206 00000 f 0000000207 00000 f 0000000208 00000 f 0000000209 00000 f 0000000210 00000 f 0000000211 00000 f 0000000212 00000 f 0000000213 00000 f 0000000000 00000 f 0001093139 00000 n 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0001093213 00000 n 0001093245 00000 n 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000056145 00000 n 0001094267 00000 n 0000054122 00000 n 0000446323 00000 n 0000056451 00000 n 0000056337 00000 n 0000054801 00000 n 0000055580 00000 n 0000055630 00000 n 0000056219 00000 n 0000056251 00000 n 0000056488 00000 n 0000446399 00000 n 0000446787 00000 n 0000447842 00000 n 0000472641 00000 n 0000538231 00000 n 0000567840 00000 n 0000633430 00000 n 0000699020 00000 n 0000764610 00000 n 0000830200 00000 n 0000895790 00000 n 0000961380 00000 n 0001026970 00000 n 0001094294 00000 n trailer <<33C6E19BB2EE4C4EBD47AF4749AB4528>]>> startxref 1094464 %%EOF ================================================ FILE: doc/assets/logo_with_name.ai ================================================ %PDF-1.5 % 1 0 obj <>/OCGs[7 0 R 35 0 R]>>/Pages 3 0 R/Type/Catalog>> endobj 2 0 obj <>stream application/pdf Web Adobe Illustrator CC 23.0 (Windows) 2020-06-26T16:25:11-07:00 2020-06-26T16:33:18-07:00 2020-06-26T16:33:18-07:00 256 56 JPEG /9j/4AAQSkZJRgABAgEASABIAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAASAAAAAEA AQBIAAAAAQAB/+4ADkFkb2JlAGTAAAAAAf/bAIQABgQEBAUEBgUFBgkGBQYJCwgGBggLDAoKCwoK DBAMDAwMDAwQDA4PEA8ODBMTFBQTExwbGxscHx8fHx8fHx8fHwEHBwcNDA0YEBAYGhURFRofHx8f Hx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8f/8AAEQgAOAEAAwER AAIRAQMRAf/EAaIAAAAHAQEBAQEAAAAAAAAAAAQFAwIGAQAHCAkKCwEAAgIDAQEBAQEAAAAAAAAA AQACAwQFBgcICQoLEAACAQMDAgQCBgcDBAIGAnMBAgMRBAAFIRIxQVEGE2EicYEUMpGhBxWxQiPB UtHhMxZi8CRygvElQzRTkqKyY3PCNUQnk6OzNhdUZHTD0uIIJoMJChgZhJRFRqS0VtNVKBry4/PE 1OT0ZXWFlaW1xdXl9WZ2hpamtsbW5vY3R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo+Ck5SVlpeYmZ qbnJ2en5KjpKWmp6ipqqusra6voRAAICAQIDBQUEBQYECAMDbQEAAhEDBCESMUEFURNhIgZxgZEy obHwFMHR4SNCFVJicvEzJDRDghaSUyWiY7LCB3PSNeJEgxdUkwgJChgZJjZFGidkdFU38qOzwygp 0+PzhJSktMTU5PRldYWVpbXF1eX1RlZmdoaWprbG1ub2R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo +DlJWWl5iZmpucnZ6fkqOkpaanqKmqq6ytrq+v/aAAwDAQACEQMRAD8A9U4qxHz7+Zvl3ybbKLxj c6nMP9F0yChlfsC38i1/aP0A4DKnE1WshhG/PueM6z+ZX5m+YnYi9Hl6wb7FrZj99Ttyl+3X5MPl lRyPOajtnJI+k0PL9bHZdCnv5AL7Ur6+lkIUmadnqTt+1U7/ADyHEXXHVZJmrRN9+WenWsPqrzlQ f3lGII9++2Et+bFkgLBtryJ5L0K//MvR9KuojLYOktxPC5JEhiR2VT/k8lFfEbYYblv7LiMuQCXJ 9WoiIioihUUBVVRQADYAAZe9i3irsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVd irsVdirsVdirEvzO89xeTfLMl+qCbUbhvq+m253DzMDRmA34oNz93fBI04us1Iwwvr0fPFraXk97 NrGsTNeazdt6k88m5Un9lewoNttuw2zHJt4jUaiWSRJLItBtYp7tvVUOiJXiRUVqAMQz0eMSlum9 zpMBuYbqICNonVnUbKVB6/MYac6emHEJDaiwvzl5zN0X03TH/wBG+zPcL/uz/JU/y+/f5dQS4+p1 N+mPJFflPd3S/mh5aluQymRbqAsRu6/V5OPX/KI3yWPm5HY5rMPx0fUuXvYPnT8wfPvmvzP5vl8u 6DNLDZx3BtLW2t39Np5EbiXkcFagsKgE8QN+u+Y8pEmg81rNXky5OCHK626qcv5CfmDbwfXYprSS 6QeosMU8gn5jegZkROVf8v6cfDKD2TmAsVfv3R35Sfmbr9p5ht/Lmt3Et1ZXUn1aL6wS0sE32UUM 3xcS3w8T07e5hM3RZ9n66YmITNg7e59A5e9Gll75p8s2Fwba+1eytbgbmGe4ijelafZZge2Cw1Sz 44mjIA+9MYpopo1lhdZI2+y6EMp+RGFsBB5LsUpVJ5s8rRXX1STWbFLsMF+rtcwiTkeg4FuVTXBx BpOoxg1xRv3hNFZXUOhDKwqrDcEHoQcLc53REZ3YKiglmJoABuSScVJS+z8y+XL26NpZ6rZ3N0ta 28NxFJIKGh+BWLbYLDVHPCRoSBPvfN8//k7x/wCBEn/UYMx/4vi8yf8AG/8Akp/vnvH5l+b5PKnl SfUoFDXkjrb2YYVX1ZKnk3+qqs30ZfOVB6DW6jwcZkOfR5dpX5T+dfOtjDrPmPXngW8UT28MitcM FbdD6fOKOMFTUBenhlQgTzdTj7Py5xxTlz+LWr/lr568g2T655e11rm2sv3tzCitCeG3JmhLSxyK KfFU++JgY7hcmizacccJWB+OT1j8v/Nq+avK9tqpQR3BrFdxr9lZo9mp7HZh7HLYysO40eo8XGJd erI8k5LsVdiqG1DVNN06H19Qu4bODp6txIkSVrT7TkDvgJYTyRiLkQGtP1bS9SiM2nXkF7ENjJby JKoPzQkdsQVhkjLeJBRWFmll95o8tafP6F/q1laTjf0p7iKJ6f6rsDgJDVPPjiaMgPimMUsUsayR Oskbbq6kMpHsRhbAb5LsUvAPzxv31D8x9K0upa30uyNyU7etM5qfuWPKsheZ7dyniEfL71VNEsPq yxPGCwHxSDZq+NchTijSQ4aIUtM017G7mqeULrVH+R6HEBjp8BxyPcw/zn5zN0X03TX/ANG+zcXC /wC7PFVP8vie/wAuoJadTqb9MeSTeX9RkE62jjmj14N3Wgr92BwJBl3khGl/NryvGm7Kblz/AKog c/8AGuThzdp2OP3w/HQvprL3s3zl+Y/5e+aPLnmmbzDokU0tjLcG7t7m2BZ7eR25lHVakAMdj0I9 9sx5xINh5nW6PJiyGcOV3t0VNG/5yF822gVNTtbbUoxTk9DBKelfiSsfY/7r6/dhGUpx9sZB9QEv s/HyZ35H85fll5q1VeOiWlh5hZzcIJreAySSg8y8U4WrOD8W9G75OMgXYaXU4MsvpAn7h96788fP F95f0W20/TZTBf6oXDToaPHBGBz4nszFgAfnjklQT2pqjjgIx5yYp5F/I3Ttd8tQ6xrF/cR3N+pl t47coAikni0nNXLlvtbUyMcdhw9L2XHJj4pE2Up8m6xrH5e/mM3lq7ufW02S5S1uU3EdJqelOqmv A0dS3tUb7ZGJ4TTTpsktNn8Mn03X7WT/AJ+eedQsWt/LWnTNB9Yi9fUJUJDMjEqkQI6A8SW8dvfJ ZJdHL7W1Uo1jj15qWjf8486ddeXIZ73Up4tYuIRKvphDbxs4DBWQrzenQkOMRi2Y4+x4mFkniPyS z8mvNWraJ5vk8m6lMZLSSSa3SNmLLDcw8q8CeivwK08aHxwY5UaauzdRLHk8KXL7i1+ffnLULjXR 5XtZWSytUja7iQ09WaQB1DU6hVZaDx+jHJLel7W1MjPwxyHNOdN/5x3tBosctxqlxB5g4CRXi4C3 il2IWnH1G4n9oOPGmEYtm+HY44N5Hj+x5joA1Afmdpi6k5k1BNat1u3bcmVbpQ5J2/aGVj6nVYeL xxxc+MX830Z+ZHk9vNnlabTIpFiu0dbizd68PVSoAanZlYrXtWuZE42HpdbpvGx8I59Hm+l/md54 8k2cGkeafL8ktrZKsEV4C0Z9NKKv7ykkUtBsKEV8crEyOYdZj12XABHJDYdfxsWVaT+eH5f6yv1S +Mlh66mOSO9jDQsGFGUshkXiakfHT3yQyAuXj7Uwz2O3vZnBoXl86NPp9jaW8Gl38bepHaJHHFIs ycSw9MBTySm+ToOcMUOExAHCe55f+R95caLr2v8Akq9b97bStPb12qYyI5CB/lLwYe2V49jTqey5 GE5Yj0T389vMDab5MOnwk/WtXlFuoH2vSX45SPnQL/ssOQ7OR2rm4cXCOctkn87eXR5e/ItNLIpN F9We5/4zSTK8n3M1B7YJCotOqw+HpOH3fes/Lz8ttO8zeXNP1vzW0l+TAtvp1kJHihgtoB6SUEZV uTcOR3xjCxZRo9FHLATyb7bDuASTzHocf5c/mTodzoEkkWn6i6CS1ZmYcDII5Y6ndl4sCvKpByJH CdmjNi/LZ4mHKTNfzv8AOV/oHl63s9NkMN/qrtGsyGjpFGAZClOjEsq19/HJ5JUHO7U1JxwAjzko eXfyJ8pxaQn6dilvtWnQPczmaRBHIwqwjEbKDxJ6tyr19sRjHVjh7KxiPr3kgPy48t+cvKXnq/0Z IZ7nym5fjdSUESngJIpFqRV9/TfgOvy2EAQfJr0WHLhzGO5x/j+x67lruXzn+Z4YfnHe8jWthAU7 UX4AR7775Tk5vJ9tf3vyZCSACSaAbknA3POPOPnNrxn0/TnIsxtNMuxl9h/kfr+WRJdZqNTxbR5J LpFtpd5+6lVluBuKMaMPb3wOCbTy00uxtW5xR0elOZJJ/HFjbMvyK0h9W876j5jK1sdKg+p2snZp 5ftFT/kpy/4IZbjD0vYenNmR6fpe9zNIkMjxp6kiqSkdePJgNhXelctejPJ5D5X/AD3udU82W2l6 lYwaZYTvJE0jOzyJJ/upWc8FHxDifh6ntlQyWXTYO1TPIIyAiHpWr+UPK+sBv0npVtdO1ayvGol3 60kADj6DlhiC7TJp8c/qiC+X/MNtaaH5/uIfLkrSw2N4n1Bw3IiRSp4hh14yVWvtmMdjs8pmiMeY iHQ7M+/5yRt5hq2i3BB9F4JY1Pbkjgn8HGTyuw7aB4onyQvlX8iIfMHl6x1iLzAIlvIw5iFrz4MC VZOXrLXiwI6YjHY5scHZQyQEuLn5ftR9h+Qul/pFUt/N8E11bTUeCO3QyLJHRmQgXBKsB122wjH5 tkOyY8W2QWPL9rHvz9t5o/PzSODwntYXiJ6UHJDT/ZKcjl5uN2sCM3wDILD/AJx1hvbG3vIfMgaG 5iSaMizqCrqGFD6/vkhi83Ih2MJAET5+X7Ud5O/JjS7PX7DVrHzXBqJsZhOYYIUPIRNRl5LO9N/h O2Mce/Ns03ZsYzEhMSry/awb81lax/Na+nnBEfrW06nxT04zt9xGQn9Tga/06gk+T6fjkSSNZI2D o4DIwNQQdwQcyXqwbfLa3dvefnPDdW5DQS+YI2jYdGX62KMPn1zG/i+LyfEJaqx/P/S9n/Nnz55g 8n2+mXem21vPa3MkkV21wsjFWAVowhR0pyHPrXpl05EO77Q1c8IBiBRZtp2oWepWEF9aSCW1uo1k icbgqwr/ALeTBc+ExIAjkWMecPy98kappd3NfWFvaSRxPIdQhRYZI+KfbZk48goXo1RkJRBcTU6P FOJJAHmxj/nHfUr658sX1pOxe3srkC1Lb8RIvJkHsDv9ORxHZxex5k4yDyBQH5mxt5V/MzQvOMI4 2t2whvyOlUHpSE/OBxT/AFcE9jbXrh4OeOUcjz/Hub1unnH87LHTVPqaZ5ejE01N1LJSRvvkaND8 sJ3kuX99qhH+GH4/Uyb88/8AyXF//wAZbf8A5PLksnJyu1P7g/D702/K/wD8l/oX/MKv6zhhybtD /cx9zAPz0/5Svyh/xlb/AJPRZXk5h13av95j/HUKf/OQytDqXlm9deVtG0wfuKq8TEH5jHL0R2xt KB6b/oe1RyJJGskbB0cBkYGoIO4IOXO9Btjl1+YHly182R+Vp3kXVJeHp0QtGWkBZV5LUg0Hcd8j xC6cWWrgMnhn6mSZJynz1+cUf1f83IZKU+saVE+3eksi7/8AIvKcjy3bgrID5D9LG/OOv6lfF9P0 6GUWY2mmCkGX2H+R+v5ZAl12p1PF6Y8mILpOpN0t3+kU/XgcO0bYaReQXCXNzxt4YTzd2YdB9OK3 bIdA0LXvPWoHTNBUxacjAajq7giONT1VenJiOi9T7DfJxjbsND2fPLJ9KeVfLGleWNDttG0xCttb jd2oXkc7tI5FKsxy4CnscOGOOIjFNsLa8d/MX8jJdV1ObV/LksUM1yxkubGaqIZD9p43AanI7lT3 79sqljvk6XWdlGcuKHXoxL/lXn55CD9Hhrz6j/d+l+kY/R41/k9b7PenHIcMnD/J6uuHev637WWf lz+RtxpWqQ6x5imikmtWElrYwkuokU1V5HIH2TuFH39snDHXNzNH2WYSEp9OjP8Az75JsfN+htp1 w/oTxt6tndAVMcgBG42qpBoRk5RsOw1elGaHCfg8m07yR+eflZZtP0CUSWMpLEwzWzR1O1VW64sj EDqoGVCMhydPDS6vF6Ycvh+lOfy8/JbU7TW4/MHmqVJLmGT14bRW9VmnJ5epNJ0JVt6CtT3yUce9 lv0fZkhPjyc+79bMPzN/Le285afD6cotdUs+X1W4YEoVanKOSm/E02PbJThbm67RDPHukHmtp5S/ PzSNPfQ9Odv0YQUUxz2vFVbr6bylZkG/7NMr4ZDZ1cdPrIR4I/T7x/ayv8rPyek8t3o1rWpI5tTV WW1giqyQ8hRnLEDk5UkbCg9+04QrcuZoOzvCPHP6ky/NX8rl83wxXthIlvrNqhRGkrwmjqSI3IqV IJPE+/3M4W2a/QeMLG0gwfSvJ/58pp/+HVufqWj0MXqSTW7BYyQKI6epcKtOiim22QEZcnAx6bWc PBdR94/tQmmfk35x0jz1ps9vZm50ixvrSVr8ywLyjjdHlf0zJzA2ai0r88RjILGHZuWGYEC4iQ32 e3+aPLWm+ZNFuNJ1BSYJwCrrs8brurofFTlxFh3ufBHLAxk8fi/Ln84fKU0ieVdSF1YlqxxpJGoN ajk0Fz+6DeNCcq4JDk6YaLU4T+7Nj8dDs3ceSPzw81gWnmO/FnYVAkV5IQrAb19Kz+Fz/rY8MjzU 6XV5tpmh8P0PWfJ/lPTfK2hxaVYVZVJeedvtSysBydqdOlAOwy2MaDuNNp44ocIST85NItdS/L/U TOyo9iFu7eRu0kZpT5urFR88jkGzj9pYxLCb6bse/wCcftCli0S+8w3VWudVmKRyPuTFCTyap/mk LV/1cjiG1uP2RiqBmecmUfmtoOra75Ku9N0qD6zeyyQskXNEqEkVm+KQqvQeOSmLDla/FLJiMYiy mPkPTL7SvJ+k6dfxejeW0CxzxclbiwJ25IWU/QcMRQbdJAwxRieYDEPzX8neZNe8weXLvSrP6zb2 Eha7f1Ik4AyRt0kZSdlPTIziSQ4XaGmnknAxFgMt88eTbDzboUml3TGKQMJbW5UVMUqggNTuKGhH hkpRsOZqtMM0OEvP9Ptfz58t2I0extLLWLOFeFrdySJyjQVCqvOW3bbtzVqdOm2QHEHXQjrMQ4QB Id/4ITb8vfyx1XTten81eabpLzXpuRiVDyWMuOLOWoo5cfhAAoo/AxhvZbtHoZRmcmQ3N6VljtHh vnf8s/zR8x+cZ9fjXTYIxGLazgeZ2KQISRyIjHxEkk/OmVyiS6LW9n5c87NV70nH5Q/m6ZChXTFU DaQyvxP3At+GR8Nwf5DyeXzREH5KfmpO1Jr/AEu0ToWUyyN8wvpEfecfDZx7Cn1IZFov/OOul+sk /mfV7jWCpr9VjH1eCvgaFnP+xK5MQDn4OxscfqN/Y9X03TNO0uyisdOto7SzhHGKCFQiAfIePc5N 28ICIoCgicWTsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVeReYdD/OLzfKdC1WCz0zQP WrNeQspM0cb1QlBLM5O3ILRRXrlREjs6bNi1Ob0SAjDv/BL1PS9NtNL02206zTha2kawwr34oKbn uT3OWAU7bHAQiIjkEVhZuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2Ku xV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2Kv/9k= proof:pdf uuid:65E6390686CF11DBA6E2D887CEACB407 xmp.did:43e6085a-bbfe-3a4e-bfe5-da7b5cd28e12 uuid:fc863ea5-5425-4900-821f-65a25b0f3556 xmp.iid:2a798eab-c75d-1145-b1f9-a50de0f99ddf xmp.did:2a798eab-c75d-1145-b1f9-a50de0f99ddf uuid:65E6390686CF11DBA6E2D887CEACB407 proof:pdf saved xmp.iid:2a798eab-c75d-1145-b1f9-a50de0f99ddf 2020-06-26T16:23:27-07:00 Adobe Illustrator CC 23.0 (Windows) / saved xmp.iid:43e6085a-bbfe-3a4e-bfe5-da7b5cd28e12 2020-06-26T16:25:11-07:00 Adobe Illustrator CC 23.0 (Windows) / Web Document 1 False False 600.000000 150.000000 Pixels ArialMT Arial Regular Open Type Version 7.00 False arial.ttf Arial-BoldMT Arial Bold Open Type Version 7.00 False arialbd.ttf Cyan Magenta Yellow Black Default Swatch Group 0 White RGB PROCESS 255 255 255 Black RGB PROCESS 0 0 0 RGB Red RGB PROCESS 255 0 0 RGB Yellow RGB PROCESS 255 255 0 RGB Green RGB PROCESS 0 255 0 RGB Cyan RGB PROCESS 0 255 255 RGB Blue RGB PROCESS 0 0 255 RGB Magenta RGB PROCESS 255 0 255 R=193 G=39 B=45 RGB PROCESS 193 39 45 R=237 G=28 B=36 RGB PROCESS 237 28 36 R=241 G=90 B=36 RGB PROCESS 241 90 36 R=247 G=147 B=30 RGB PROCESS 247 147 30 R=251 G=176 B=59 RGB PROCESS 251 176 59 R=252 G=238 B=33 RGB PROCESS 252 238 33 R=217 G=224 B=33 RGB PROCESS 217 224 33 R=140 G=198 B=63 RGB PROCESS 140 198 63 R=57 G=181 B=74 RGB PROCESS 57 181 74 R=0 G=146 B=69 RGB PROCESS 0 146 69 R=0 G=104 B=55 RGB PROCESS 0 104 55 R=34 G=181 B=115 RGB PROCESS 34 181 115 R=0 G=169 B=157 RGB PROCESS 0 169 157 R=41 G=171 B=226 RGB PROCESS 41 171 226 R=0 G=113 B=188 RGB PROCESS 0 113 188 R=46 G=49 B=146 RGB PROCESS 46 49 146 R=27 G=20 B=100 RGB PROCESS 27 20 100 R=102 G=45 B=145 RGB PROCESS 102 45 145 R=147 G=39 B=143 RGB PROCESS 147 39 143 R=158 G=0 B=93 RGB PROCESS 158 0 93 R=212 G=20 B=90 RGB PROCESS 212 20 90 R=237 G=30 B=121 RGB PROCESS 237 30 121 R=199 G=178 B=153 RGB PROCESS 199 178 153 R=153 G=134 B=117 RGB PROCESS 153 134 117 R=115 G=99 B=87 RGB PROCESS 115 99 87 R=83 G=71 B=65 RGB PROCESS 83 71 65 R=198 G=156 B=109 RGB PROCESS 198 156 109 R=166 G=124 B=82 RGB PROCESS 166 124 82 R=140 G=98 B=57 RGB PROCESS 140 98 57 R=117 G=76 B=36 RGB PROCESS 117 76 36 R=96 G=56 B=19 RGB PROCESS 96 56 19 R=66 G=33 B=11 RGB PROCESS 66 33 11 Grays 1 R=0 G=0 B=0 RGB PROCESS 0 0 0 R=26 G=26 B=26 RGB PROCESS 26 26 26 R=51 G=51 B=51 RGB PROCESS 51 51 51 R=77 G=77 B=77 RGB PROCESS 77 77 77 R=102 G=102 B=102 RGB PROCESS 102 102 102 R=128 G=128 B=128 RGB PROCESS 128 128 128 R=153 G=153 B=153 RGB PROCESS 153 153 153 R=179 G=179 B=179 RGB PROCESS 179 179 179 R=204 G=204 B=204 RGB PROCESS 204 204 204 R=230 G=230 B=230 RGB PROCESS 230 230 230 R=242 G=242 B=242 RGB PROCESS 242 242 242 Web Color Group 1 R=63 G=169 B=245 RGB PROCESS 63 169 245 R=122 G=201 B=67 RGB PROCESS 122 201 67 R=255 G=147 B=30 RGB PROCESS 255 147 30 R=255 G=29 B=37 RGB PROCESS 255 29 37 R=255 G=123 B=172 RGB PROCESS 255 123 172 R=189 G=204 B=212 RGB PROCESS 189 204 212 Adobe PDF library 15.00 endstream endobj 3 0 obj <> endobj 9 0 obj <>/Resources<>/ExtGState<>/Font<>/ProcSet[/PDF/Text]/Properties<>>>/Thumb 41 0 R/TrimBox[0.0 0.0 600.0 150.0]/Type/Page>> endobj 37 0 obj <>stream HUMo1 Wxcv8THBRyf.EB̌ٻ>ٻ=HJE~>]J,UTZ0y#~BZX欬Uͷ a< EN6Fb,֨e4ܸ5SRLb3apnr?Ǚ{+Y݄s2M'<նhiRsɳÀ*Q2&^CKA8k69\]}~;}^|sx/KTA endstream endobj 41 0 obj <>stream 8;Z,#5n:gU#^jIYi%^bo,sD&@rLr+O!O\B'a_n\Fc!i(qJ70;L>(>;"p)P!q*1fM@ 8k^t)`/g#^LQpO:AH`*?C*$OPjC_h5`;PQ/Dp)\F'/; G>;#>.Qfdo7b3KK+c^e6V0nAFZL endstream endobj 42 0 obj [/Indexed/DeviceRGB 255 43 0 R] endobj 43 0 obj <>stream 8;X]O>EqN@%''O_@%e@?J;%+8(9e>X=MR6S?i^YgA3=].HDXF.R$lIL@"pJ+EP(%0 b]6ajmNZn*!='OQZeQ^Y*,=]?C.B+\Ulg9dhD*"iC[;*=3`oP1[!S^)?1)IZ4dup` E1r!/,*0[*9.aFIR2&b-C#soRZ7Dl%MLY\.?d>Mn 6%Q2oYfNRF$$+ON<+]RUJmC0InDZ4OTs0S!saG>GGKUlQ*Q?45:CI&4J'_2j$XKrcYp0n+Xl_nU*O( l[$6Nn+Z_Nq0]s7hs]`XX1nZ8&94a\~> endstream endobj 35 0 obj <> endobj 44 0 obj [/View/Design] endobj 45 0 obj <>>> endobj 33 0 obj <> endobj 34 0 obj <> endobj 47 0 obj <> endobj 48 0 obj <>stream H|yTTGƿۯ_u4B{ڸD:::NGD A% 5*nᮀK {DT\@Q8Qc<{c&:ު{^ի_A| Cuᘅ] )&wݬ&͙0huhU+Grx8{D1:0*njT$nu~ = +rF>HodY0uFKb?aztBFG<~AXB7].ZuLPiE7Sl?F|Q?3v\D$>=ab̤qS&L>#񋙳fϙW'% }xҔeW\zu6lܴ[mߑs=ֽsr:|豢'N:},/pR+Wn*n:oߪ0*Y5R- CYB",22G=D(ljqxA|$`A̭I֞Ժ ɆsRۘdaY Ri4R-IbBIOKn$eI*{rv߯?I j n*t7tu\jk.} x~:p$Լnhhqˡq0*y~3T/\Sg=k><:zP[5q~ꕎU{**3+{<+UrŬTV٥2>jafbdoc7[d{bs~yVb;m;e+ݶ\[[[o[l3ٌ?nq -nһѵAVfͦ-WMp-}Ԅ+!Npsqfq<7\x] ;uoĴlmn\7FQ[?,Ew*W{2znV}o{Vc Q?^wO֯Qֿ^ϩfrnio3>o{6-:B,"  Vbav1R8Xg+K}۱9^ YdsD!Qh\%\e\Upe(G& V&n1XXLd#SL |D,O()r)R>¯E)*B:J>KǨQcjB~ƿ)q:AԌI:E o)#yԂZ9*VD2y*?>I$t.%*t5 RBeTNUMQkzP[KardX*[ְl[Q afa[Vmg;X:`l'v=̪UOf{>`Y,\|vVǩV Qv;N ;˾eX1;Jv]b2®kkԵj-B$Q% ZE&jD%Dov`;cw\_Y]Wǟs{s/ (FEyǦjj4j<&_uNX]P%*UT\]S Un[u:mZ"ОuQu u#unD(džK"%.|r9_s8jƩu9ucy\uNi:]7:Sp} BH/KRL/+J~Gyz^u:_0p*P7zi[ÿmޡww{}88( \zP~}@ԇa}D'S}c8pCnq ܘp"'q2p*qz0?X݂s;fc1{ao}8`Cq8Ghcq 81XM939qsn-: ɷ6q u qnMVfM7mzNWAW7JUeZCڪjGVUg=XO+l08B xCDBA}!b4FVߪXVVkH@2@*A:4 49ܖqb͕|kȂfZ@Kh v70›̃"X K`)~ `9~#?qOxOijZ|+`uXFH|Oe'h|Fd%AAHMLl F2Nr)DlcMI6)&ny6iP)bX.V/;!>G~ϜyjcxOK"2Kve5|Mf+{rpF9^$e|C9r\ %r\.Ur\+zAn[6)ww.WOâ\UC~nOh&OӲH2J Y)jY')be"UP*N%ƪJT*U jڪꢺn*[rTOzުjjjF^P`o5VMTT59(K.©4pD'Iw2,6NW'u:Hg3љLu;3B%Py"t+Tz =1Fmȸ7oogsw/|o~fx3WYl^ΟOg\_Wo<_Wh~6/՜4isƜ5L9oSb.Rsɔ抹j5s7ƍ Vu*.3;`[6V&۶vllߎ#(3ƌu{qVu90X x329rܛwՒD- Q5Ŕ"$PTRbO1/!JIQ˴(jƨ ZBmZc-AmLմffy>{=swd5jvuaGQvMe]ۮc׵zv}ЎٍXS93Ysֹ\t.;⮸'wC[GI+E":tdLPi:ʋxs[.|[eWfȞ?^~(<پCvvDJ4?N :^XiXY3pss`L+T\͹Ne[ۭuzn}ЍqX4Y=MrqnS7 1CMff6s-7Mw3ܞn/7mv7|| }<'=i^%g{؂zU^zռ^ /‹^f0Rqp^p~0?g&c/}~s)vZED(e8#Ίs⊸*↸)mQd@ud]-3u{Ru  ШN}TCN] xisd#X& dN<0j5Mɦ R/沅L|A??B >U*{j[qt2u,-"h%:|`횾oXV#p+NwZX XC0,jk^ӱ:,3MȬ0+*Ym֘f|j֛ fl%_?.-Uv>f$sSuJLffef9f3^Ĉқ(R_qT^q(wwm?p6A:̏BAUKZ 5Ȣqg>4/HCI<zo/U.yo!QŸԏYԚ#t/7v r( u0Eo7rAV'@{CA4h )J.q=9se'̲S/SUfdcu%Sv!&21ERSQ)Ћ1(R2q: xHa:*%P/x o>cߑѦФQS7b|*aX&e#+0ڻGjzf ]q"11CPPcy&MI @gsC.S[ Ih+=* N_P<0j%i#:b82GWajZ`  =|ZlA]t5;܋'æfEX4POA%Ԭab`IMd YuJUS^WQ j5VסتVX`f%tM^\ Qh^0#T_j8(D`pޅȸ?9C좸 $DfHU߾o}~] A_twDAY ķg@oad5_fqr,)tu79WjZUݲlk5m=j}G -JuW|<|Ls)wbREǥ)2Ulyb zVzbcbɶ2s  ͪ>ǥ}[)fhT L <*[8y.˫P_5${ /EմM)$UZI,j-o,..+:GbjɹTN*b(P-UxH-]nRW_K4ʔMW}&cCQSh.3hD4P ս q'B'2(5+"MCt8:Dd(?PϜGmz|nL(XFvY$ ̚6JA88ռ(UykY0#"k=d$|&|VJ49MZvnڰ4);Tث~R9,Te }!,ykfIfؕ;m5O4R]&PhvY=3 xbRoJ#)|, & ֒yydJd=Sqš=I=@cq",)8!賙O*;2Al I~X1ԙ=B>y*X(65 vR𮊿`^7Xj\c%\cم 6# PMJx?H_(Hph8/ [ƯǯGМ>v4Kx"|ZZ/N.{y V@ ZAxwG+ QPLx5xw%$`Vbv`P<8ߗ D~r$wtz ?"kG{F+veYZ惴}/v4}/1nl(uFGmM$*귢WR,GBv=D }{OWu]?*+>?pȯ}<UYdwowok@! '| `  #BG8me e2(2tFAPR{nCy}y~ϳ%9Mym| o=Eq8OiG@9يPXx*Oʦ@>1zbUIiIqiIĪĄp8-)]$ CAI]T\o;[V[4yu?>9Z$/ m/}xvSۦX>:4^WlŻ /Dl 믴cm_|K)/Hn&N=rN鹮*~(('xUV)ul hN ys(B^U)4^CQ]N'*+\GD^_^19auzj}.\/.`dSv]gwY.u=1ӧÈDFX( =6 cJ|$1l-)MCUR^3ŪEjl,^.vfB9i! Q+^n7-iI ̼3h%*S߳ j^@1~~wݽ8};er& Ԩ-djnVP** du9_ \U&q}adP{u?>~ۺp#;V팜pqo:miß9D0!1x)5zG$W]6~=뺕œ 1>٧ӣ68inXVl.lRF$F+(>Xj/ V(NLUiÿ2Ϣ_U]mݵ5!%Z]F*v1*}~?"Ŏ1d(O0`"Ɯ sì<ټqx{>A VR~deŴvX9W$/iQN(`vPQ;'eN< QVC T IL U;:_]rΐ{ƶjXkN2zvՔ;pg58_#rJ CY:UtwL {^C`hsH]3"kgE馤H [--]x|r~A)x+LcޛfŒeFKlzaYEA_^5 ,T)UK}joveY&)Zam6677v~c </U.o)7`<c#̀iSWp@EE}6okG2kt]5)3!/1 1T9`"'`1#Ɨ.X/:#vVRm͋&orS\]VOBVy`rTv- k^oC{7󌛨h] 46j;<9[KV\pͨ͹4[m_OK=6}k|e5v&)$ӡ(diakqhz,pp{<]eǛܙHQJeD  $8KvkpWkgE%ep[WE#7z[Wln\QVm IJ7o[xkjS=~GYyN4ѧFXî˙f. <6`n22 IG|rb GF` cl3A(`xchOyEjh 쓎x g!}"̫޽,$ W oj- k~؅= Tct'!vAρY9 D =vH:0 (㏳$d q3CgY -0OoA%ld *[xn1ޑ_yhs^60A[z@|I$9ߕ}VIcVk:obxȄx.`v뿔_D\d/'Sܹ$ĘE<8ى4c1Yv>c¯!E D6px:y[@'| ao} qa:Z(8kbIB-'ܵ)3t85${LAp˕wa,ym'Β}$ 'vY+\Uuu $( |ĈEjJ-:ڱ)P|Q`hVTdv|8uJ;-LHNkseBޙ^w?^{zC̱-VYÜ37SVxse?LF#nufY^CY+R-YA3lž-"6ӴxgD4Il wvh򓁷#h"f6_-a~HOgČXKlIUJnR ݔA\`9%8M'knl1̍uVnE:~:\t{|OYkA4Fj8w9kB?8D'aλ: p'\,{'uz}aq.ȥWG2'gkl9 4= ~4'KFcGK%R۬ZV'K$6KCѳO+ĉe~FIQ[n>;>:*;TDXcmaS-3Y zb4jЪT&܋*T|Tn~'6'ob?ׁ__P ǙLoGKZO,f\I]Qpⶰ:DkJobu:}ʯ19ONnp3ʏ +[sbw7O3~%92| ϛ-M_ob-U\r˥\r.w{f&g͸ 7BbUܭ|E]cs8Aayv\1쪂3E_>J_tm3}꼌>y;gemXJry >܊n13F}. 1Ӧv#ǘ~U L*#[K`@k8nUXknUÒO!Ogp^"_uoTK,>C9 'ɂ#}*pUb⃀}c{i+w+ mU{Ug#vX")L Nowݎm 9s>8c;y QxEZcs11h_;\л6^+G*cծs=ebx \su+cɱ8wcTgs?x^+!*!%ԑ-'7Hab烵ġm@o}~Qw!o YՕMGMɌoȿ`? = *oO{6'\a2#ޡ8Qf=[7 s|]ֈϿ+DG؁7Moqb<۲a*Ԕi϶~ؾl˺"1O_v%O%zͰzEy#;& D\G!vbO}m|L2?&w&?=>8v]Sn_ Y<[Hs@y|CsV PGky%㘧Gr՜l<^ji20ߥk&|/%x?I2.ԳQVx_Vk<,K62i@B y!9\q^՜SL%RmΟC0xnM`&WWgKKV]m A-0zj#1M(מdzMmd!}u]]⵰j6s w_Y_ʠ2gkYЛ;7joWpGoijFy4}õvl;n\r (Ak ??yM,^z~^~&|!k]$9~w2̠)Ď~f(rϩ.{ڪq92!i[7J^R*E/ĺorvk4{tsI"QnO쇩wOў'5ާr#Bj{ 1-#n=0"y"Qf~=eRma:~.cn04Z*S;W`.+"@\y1_gzt&?O;o Ggv.r8zLr|wl{M7;|E83j,ς,1/\Uuuy%>z.Cz԰Fao۷ES$ &'nF/v7HO]^e1{bpItTFSfk>2ǰ,a~ ;6:.JlM*"VIup-]--=UEu35G_Eks p_<ѝ:p3tsx_=g2|_㨵e]y͎-;[=jukfwhs癬aHNd[;=yF}}!Ltt24jл[;/]dv[? ́DdҼnƆ *C>u,Jc%dyalI/_v]ql8!Ү߬Ee;Buo:*^ϼ'Ȯf" CձN~?LgLù{_%/H̙%y]YJBTęi{j9HN.OC}|r fx7%G6Gnt\dO2OWB}pMF}Yk&\ss;gc}W:d`tMZ=L_ǭeXu=2I'+ ֞27dڴ SOͬ\h|eVu>c%Lwom1럩;F[|/!Wrfw믾flVh?GWRb֪G߄;5.7gl8}JK$O` &5RҐ&@3))}Il:(ҩ-Se&R*bg( a`AiZZ,w߆eoϹwǻsܺYnY_/_/J!Au ΞԲd7D]%pۙ_}_&` 3RيkџE`f19^,z녴[$H)H\gn9Lُ꜑p\J{D9cbX/c})g%({0KÔn~.Heq] 33zsN9经1ir(ⶬ"K8krJ; >bt2I"v8Xdİyi#,3Ex|4OUgs4dA!ݢXbos)L",0N:$JN$dV.ߋaRN}9܏}X)!̗_z+F|:yoq l @L֛q14w1| srkyt{ك+(%:.5 vMs d$b9:S.CmFej}\)in-q ʷsp&oPLCDՈ?!ۂ*>g p=O^C)k=ӵ;92u8%sG=q {fҮ>~ }ړX"5aqĻ>}~|{gٍ^X&B *s…:qh:,AۀqY20-4%fU ʖa6Vf~zmwӎE !@=@ϢCXC/,jC ̔cBZ~ݶ,V<cCs_u<-r-,T'/W9&DܙZn> R mQYVArc!$V'$k{|ߖ-ltOAk#Jd}5byq"7c\yebFYŘZ>K*6۪tF=]5?DPĚ[ ֶxjcvt,k樽Zew6w=V X|,PV?f;@;kk#mVMw0&zjl ŬpHԪ 5Cr{d;bhhGm3hGF.;cV̶-NZA;eA;c%*-1/UBoţ᏶[r8EX)羞#_# Q_}g>/go6fd@z/Њ7 ^Ц+_pZV/&3{}Ixj 4>{igM 2;HFH MҐ6U'I Sժr2'lݚu:&_i??Ccy};*[U%V K3 iu*X>2D6 ;瀨ˢt(PfǮ}Pi%+0 &KȜ\V,}q>+]3I<5G-o<}=v{{-YopO=>DmԶ=Owb*lj~OsF%jF2 jǩMm*t&]^JF':1X',^5ٲ һ<]޲;y?Ea||qE X5 6i0 ΁Yt>cػwN#cʤFQ:EYD"e[Ԗ*m__6L{M[7{'۸u<Ϭ?"QVE Iʲhۉ^OAtM|GZ!6W}FvȼaT)ܜ.NڒG 7ÿ5Y hYA|98 OkOC3÷b`5i,|O)p)P  $֪a5QTUU>D][-[&IZH8RdbŠZq*ó \;J GtbUڸoy N@ CYgYjYp*NS| Y}JPJk"u -J[.<.W/R_YzKRqV_җ+ UzPNoAsKTK ը8nkh q74H t;V$%Ju;'hyyUӼ nYӤёx,?ˏ1O-(ؑDŽ? TV K5śs<ճ 0- endstream endobj 46 0 obj <> endobj 49 0 obj <>stream H|y\SW'/&"{ XUUZ:3UX"AeTTUpĪᆻ* {EqgS&NkvHz|sν}H#vZ=.aq z\(h1;Qv\"x OJhmxyZx\U _MpO J U!B+n{]th DܵEV9]#6P9*Cӗ9<$;k8Ώț C*s "h[\-][vsh֫]{>~l0(ovz;]v{{ zW>~8hh!}dq'|:1,ELus/bɥ˥W^~歲rTTVݾsk`|S]A| rdXA_!4a% -#ĮhqDLWc%aM{/oO+ IHcҧ|T"UIfR˭dl=>r_y*'sdy|N~n /`2t1 72R  {*#32=Fh|8nW7(PT^iQ)>JY+J,UV(,%O9+RWMLLaS)9W!wIn~nQ@dc6/[-6v樋{YAv{=^s4|TAePS B0OHW rU"+eJqX.>g`~mP{bo~)~~/v, B(NRBtWFz!;I.(FˉbDmabz3E7NѯbqRE C)+f8{bfhN1S5.-}}֋Sghb {Q@WlO϶8):K%O7GTVV'@wGWUʀpLl`Myau.VUceV*XUVg>k.K=Cl0}w %v;>OxNm-͍u_įnѐ fbo-Pwei[7$mn\ ύ.>MX\]qJϳ^UmG臿iS}?17<ٯk6\%:߲pmxNv ڻS.b <—X؎}؍Ha'F,%3~xl . >C1 R\-\ Ŀ XۨDGX ј!ӐxLGf`&f!1? 0c>@_B< H*HM"lMVN4vA;)ȅM i7ʥʣO:_ph9THEtZ+bjEɍQSt<-3ty@_!P{TBȇ|ɏ.e[<$d2J ]ktnME@ ʩ*n:[H;,-g+Jfl [ֱl`&1mf[öml;vLŲ.a9,Wa{>`y gX;̎:VǎBVNbvcJ%v+*Ʈ&XN]"DU EQdFԊ-D*X%bw]5~>:8߳}g9wϑhH PwL;6F^]1LI2c;Ƙ*{(3b=e iShΘ)2s\2s\3 s9s;'N>'6&r`+J6VUm5lSlu[ær",\g,">"_Wx#{=z`kZ6ͦ ik:6Kqhgؙvmعvo؅]db.r®"G#vu]cu!6v}Ͼo?ڭȱHfۏ~j?'qeU's W5q:gpftytNv1;D_ǎ ;cݰ; {c?8p8U\pgs]_| nȍ1b\Kq.Wj\kq QȋV&,9ʏ:rn;wS]sy;%Nrʜ穓ZtQ`|@!$@"sAyw@EIP@UɐWߢXRN#4ZP@rn| &|l >4Ch A3~Sa otxfLaoa{ğgty]AWg!@WI(J YB"rӕu]UW:|P:Uפ(Qy@%QeBU%S UJ5}CiNIeQӨ.գԀR#jLM)=Gͨ9=O-tΠԊ~CjC/R[jG9Ԟr.ߣѷ>>uԅR7N='ԇR?Oh1_@HW&ݥ9~{?WzIo#}2P`zv0# fsh !4pA#%E4h4~OiLUz&Gz`1, X `5?5zal-4r1[c06E߱/c_žRy;x'ݼG7T)$MF0Mɀ[jjSK JSMT^_P_?՗dI}i,ͤ%VuBꂺn[܁pxPu x?c~OA<'r(ADiuKJmmuug]`Qz_֯7TzZz^Wzިfުw=cY\t/MIRua}LB]Kb]ea#&4fL5b&2&1٦iFinZ֦ikr&t0M't1]M70=M/1}M? 0 38!|?>UcfgAp!#%_<+{77wD-)*AR[-[ѱ؊J--dHK"RQRK'-No 6#ˊu{qޒB)jBݸ, ,=D Kh)cZ^LMO)E|/L(bhW(z 1sjKphGo} &VVJi.m\,*E50 بԗ#ȷxdjlQ D49!TL3tOiԖreT1Uȶ?OR&vd7}] \+r; Ù:UU2KV>ERuh%.*i+hH{%eX- x3ߤ?"vቦtc!]ya]TʦB.9z(kjϤUfP#DPmjx#Wi-Q+@cٚx2Yz}]p,2J=<;8wTP;n#pn,aW0rP+1]&;/8&pXBr?cj$ bt u XT1Mi]1CE&TJ6UQUMK*J wNdvgw{j}!ó>G_ACa+ڏK0Pv֔/w PЯ+ԉQ!PIހwLWDdq, އ:* w;Fџ >mM'cЀ۳{[WK-7mlJ׵1, 57~9u1vbMF4-Rٹ3ˤ EA@Y MX)y5OysB7E4PGs?"d,0tҾє႐2Gt!+1IfbQTbꀭN`l,$H*IJ@3C\: XTArTCOsI Ja4KRa1}4PΩ:ZCdtN3=FqS="2/T 8 (ԁ kASM8s/ ՖX׀-9]] / p׹ed d#0ND`'` idAt_3rDP?CPd_5E ^'˥%^$mzDISȱǐ;bej+ v?'BYFGA&s5Y@G3Hn*薹%n\,w/WGE.ѝp1ك$$'"فUR޹l[4w2g R4V(ʧu!VYLP +쬵 >emErz/|m1KKZ-o[%Ρ _:Be*ʖ HU3DȨXN%GYuMO&\}!e.*0QJ%O<r3>,WdCKZ] .,:lpVFhҰ҆:ے2Uq@# '2]K4l5dͻmNF#XM#9[YSb'2 +{Lnd+&X F~\Ldc$aB^An <MVh0dD0=198ՏO(#o/7j]ISoK syHTȨc?k;}/<)W߿Zש<~' rN? Y϶-$Iɲ)1I {V!0Yh!~6V0,}_+>wF$q0`B;qB|#᳦/ᫀ\>@/z~Ջfk# k^4~6DcIg>{0WBrgA~~/tr.LHҽ=)㽉|~$E"l<,g gx]#+ tDz:6;!EtbซM sCdP^ٴ'pظe֎xdqwķn"^s$BDmavc!pe?07#k~sڣX{[?+w?^>7l;|WO`| װ^h1]4߆T$xŢ0 Oi> Q]6=)eL1:x9.u˅/p ͹ncL<3,{u8D>13Ə4ƚ,y8tX,DDeV@RCXytof |{lqdž~U>lp8➌o~vƻƻA |qGr.q%uN˖ /te=]=#,jGv]7=o{L(웮PA^; Nʿ>Bܠb5k2rWi?MpYQ{h=:CMf]q|l#% b _nm >9&ATkm0xH6VQݳk0$: }Mr}H_LQUVRY_uMrFV̼V$wJiSg^I5GP5?|:h/Z.YØ~%vZ/c2yMzsPz:~2.]#y8N*%UgdE7Zgp,ϫ{b&J&ju*xɖ ;^L'W~TGga!\gUgYS%nF.220dا^{woꥂIts72t|U1;:撶^RmF]m;o{klhua}uȟF%3ˤآ-)sUDQ8#OX2pL:Ccl  >V55, RWuE^KLeOQ,}ҸQL5-8Zj3e 㾦qUY߱}\nu2t<-wz'sHSΝ9L3~V:)NloN˼ɲ +p_xD=+]}qSM/<5װF5} t}Ka}^E٣Ɔ ۼ7{?&݇䦸K\ȵwOzlDʽ/o ǿNիRٟ\K'Wlr_-K쮎XPǹճYҕnupCtnʯ6S TtIe6y1}rzyMYn19؞&MR_ppޝcYdbg,vw|ͻ9 W:_k/Md;52}V:.{Y&{?u#؃NKH3s|\satP鼂U{P}IU][ u<~f:.Lp=蕕WR ڻykeM`L<.a<ـ1:t/{(o? /Q]hq-W(C;;=:~\(igH^*-yK{}e;iK7 @v>ދ* 5>Nη#7|G۩`Mn>}%\veO뼟#}*?>JKXm=zbguLJl_':57y|^rf.ܤloe]r6 p1wvvtlr7[7YJHSDoj|ҽBf F? }uoC-<+wʮGe6=+_59<(bXKu(&2r9ퟦ$!/"?7~%гgLc7}?8Q3D? <ېےѿD+oJ8< 9r xS;ys1]gޓyST=N׷_.)W+Ηi9{OFoC-)&Ċܘ%xՁ;Ek[pYK]ӢFL'#O! */$cV>X~_5vCPIe;]|o{#F9N'%Q>Iֶ}o ;YϑF:$¼|FEz\rR_5$40'O0}A'뿬UU{}]YX d6Ā`]p @`H$K!T@@a))ĂXf@ eb;}~瞻|v79{~)}2(s(xֽ1{nU[}ȞٹoqNvMߚ7$cfާi|w}/&_/. wJYjl$[ⳫS{ͅi[ 8/wf>}FDh m7v9Rlq_1 *!6Ju8B({P0̤7ۯ{ǥ ?ߝJeUƘv]몗!0%$W`"LAo?vDmu{e]NR,H k4^%q$i֨ܬV%kv~͖qv kZ6ssp2w<~7jl.nd f㭷jK?m%}O %n^#?:* FM|Pz@JRm5{ꠙ]T|<\=t3F9>{vu"H%{uVdPX ql &ŝC=c^_Ý63>tcs'ȅjɅrŕwͅr#s|%摯^y+2ʯ摯^Pޫͅwt̊UwYX/?R2-DG=߷7'hKoA'vͥr^o92AZc#4 )tNit@V[MtȤ0EͲ2z}EHʓmx1&˔ʗJTWEJ5{v]2vY&;gm-X~Y|jdrYؙ{\/elSo ~,D[gSM@:ٽ~LVFCsj몕G}U+=Rl߱M؊9#9]suMk @n=j5vf,a=S&Kysl2ԆfpMl.=R@tW=&]TE8׈a(qSl3é1 ~o-\i nP^f:gVtcf\G^8ow?~}^o5zYtk6Gz%Yz8֓qءIFj9s ѹVYk8{W˵Y6~mֱ%6S}ݚmyl^oN2FTbUMcm\vXUܹq-N MCؘm} 2ꕉDOfoŖCė!=JGŽE<{(a_`YcYرƾO*/EX˸Sxh?O'eQK%" g56j,lZH8<㜷QI=d_X)9'~H+["w1ʧk^#.%XӨyImp4_G9^z|΀8=׻'[sD9D2sDh3׾T{]-R8׳Utm >D烷Џ/:OKao۶? c-uwy ~-y6~)SH㤩uz[u삘̩y^ P¼u.es hl("YlC e ѓ} ֢e4Gj"eB[&j"~+4,Z_>ڇAE~=OE9?V>{R^z(sػ`1+wTʿ+婎"3x1k1~Ơ{zKe@ٍPZh/;e8_}I'ݯky+to C}:5IΌcqk+c2;5i%rK˼m2.;5g!5gSIaFz`{.g_m6#ii?$=]erT)נR.S.+X=Il+jo6I{\ch3{TL .')yR>$CR f!`faQm o?{R>@ꐵa^4Dsts8"^A$2FΞV|&fZK?{Tyo}|A{yn-r"KO>MOd5TYb͒x=l/C%ߴHqp>T%NNExV#߁C ]Rz$z :O;otIb/W}pZf]#s)c9W_v t L&uNع&``޺vvRssYN*cA^{X~{Sż13UU!?ßyϜ^27>~Lls&SdB9ߌ7UcFkmyAbVNѺ'tW{:=U&ߦkĐe-vjtl0!:Aa`T{$Sj"dQRŌ '׭͸R^d{]j[#aR0Fԡ͖%6VKtQ 8/ ?{kGbu0z\(r4-&?stP!RvQp{YVVYAyCb.[K -<Ʒc4nE4.7 d^6%9JpF8 BEl}87PdёDV~!҈/·YIX9Xlڞ`|0xs;k e)qSԼ*5Ogi䳒TwlK&P.ȓ`J)s[7e AQ"Kfn+ 8+e+l4Cׇn?`dKc,Iw  [)O巹eQƾ_[dcL62k@) ߞ~YٱʝnI}m.Ƭ7#_C8 8 ؞p ̖9Nahu QlƙA iE:cľqc`h3,^ 7p:,<.w_7` 4w5j{jN45Os>+ H+\w%KsMJY"*iʵs~-AXGeLd2 Of"w41܃xպ EP=Y {v_&' ʏd_JiR1Drμ8G"dlY LF}j ݇)ggׄ;ɺ?u{T_}f.=Ci O1^f Uf,۝.ǟM X]Z*>ld߯ [n}g3,̫6}8xcd<;js9-yL?4Z$s٣)0U=Wo+b{|Rs/|8})F 1ɷ+xYu{e{9k;!ed?3F.BEՒT2Z;eǜ9OtGS7lRWqaj'rg+_C{KofwP:B 3%0r/ئ;|NIFb YxB*ݘ^`h,M8 0G3%iJ%H,}L{T[ ֊Bd>&:Me7mco]=?I%u:Ds}mPG콗y=,Bo}v/鿏w-`0 `0 `0 `0 `0 `0 `0 `0 `!Z56}^"/ P}ȷOApOAWpDr<|OvqKZ.O7n^WʷPҊ6?7sx7sAO7s7GGYz)D[iYFA<@J,]P[ĦDa&f;nd[O߻Gv(AI##Yn0#Y<ZȢB1ۈӑfGq7R1!HxFx-0?αN9SŽ4w#h{ nKS(`Lzuymp O U\G^}yHcl٘YxL$G=#<¡*:tp,]\6eR`h!:y'آ:9gL ftT;c2łJe'*iGCG`,U{cx&~fFjo1=EGyLNLXJ5{D :UL1wF c鄓S=Gj26wv 'RRp\2'}$B,oe?=%cޝT@qS\,厨/CƘG僿 zݍ]tk3 &0(a9`ֽ˩N4)z?{ EZ +2~wmpn /\(I|k[vn W|{_ O\]|bsֈ}[9}+AZof+`^  恔s Z y+,\oy 1 C:_i]?jy36XD\<9< yƁ$CAJeW)f< Tn%WiP4}9.KK[@?]Yީ_ 7`#—#ObKO"C# 8?@;P}\I-;> ݲVsn| ]\/A熃jNZUZ?X!>ZZ*!۰m&kةI$5GB>yr4r}VXYNo ʏ.,L`KAiwC]uUtwܐy<ڴd+T6WVk!ސ[So  f\ AGX"__qOgFZC!ֱnEcohsM|Iů5= +1K;>8؏Cĵ ީ'+t75fju3Y77MuQo"v!VEn!~ommƷu}aĊۡPv}:̸^Ը+ueTzsh#.6ng={J%#-Ȕ{]gŔ VuQ6zCڠzՀ8ȴWWN8 0'=/A85XxGvgiq 44%I@me# # # #F{/m 1c!C0` ;c6"0"0"lX0, Âaa`Xl```bCP0 ņ`( CPl```)maaa٨èèèQQQcWe+5(5(5(5VjPjPjͩx1I0&vpppVxv000laaa(((QQQQbFnh5i]+&EW9>I9g8cq,Pp N!@H |yu_7n>z3ޛE3޺W({hH6(ҸZ}/$}4.aqҩ$-ĩcu&6H:aLt;Kg> `2_TAAnA@R :$krcobե(]%8{13>x,N7oE #O=6p3\X6Jơs ,FyxlH0uAI&P &N0:k{]/u"l3Fχj9eB,ooQv?+`+RxKC;ʩVb6.`%$.uG,~3/x},fIuKNqөM M>siXI/yU()YAOU.S(ҦI!Jeض}mmaaPM$Hp=Bds/{Q5Ff.Z^s9~´}ywMg/:UJ`>%sOwtg'soN(}=%Xwgtohstvv]^T/9u-R,yh; h@#*h [ eL=PA+.YZk[%*k>7A'FF-FmHK62 \W1PKPD=@'U}5^Rk6nYYje |{ k1M,QQ6WR#jI/7$A`%JcpVĠ HmOrCa=` endstream endobj 40 0 obj <> endobj 39 0 obj [/ICCBased 50 0 R] endobj 50 0 obj <>stream HyTSwoɞc [5laQIBHADED2mtFOE.c}08׎8GNg9w߽'0 ֠Jb  2y.-;!KZ ^i"L0- @8(r;q7Ly&Qq4j|9 V)gB0iW8#8wթ8_٥ʨQQj@&A)/g>'Kt;\ ӥ$պFZUn(4T%)뫔0C&Zi8bxEB;Pӓ̹A om?W= x-[0}y)7ta>jT7@tܛ`q2ʀ&6ZLĄ?_yxg)˔zçLU*uSkSeO4?׸c. R ߁-25 S>ӣVd`rn~Y&+`;A4 A9=-tl`;~p Gp| [`L`< "A YA+Cb(R,*T2B- ꇆnQt}MA0alSx k&^>0|>_',G!"F$H:R!zFQd?r 9\A&G rQ hE]a4zBgE#H *B=0HIpp0MxJ$D1D, VĭKĻYdE"EI2EBGt4MzNr!YK ?%_&#(0J:EAiQ(()ӔWT6U@P+!~mD eԴ!hӦh/']B/ҏӿ?a0nhF!X8܌kc&5S6lIa2cKMA!E#ƒdV(kel }}Cq9 N')].uJr  wG xR^[oƜchg`>b$*~ :Eb~,m,-ݖ,Y¬*6X[ݱF=3뭷Y~dó ti zf6~`{v.Ng#{}}jc1X6fm;'_9 r:8q:˜O:ϸ8uJqnv=MmR 4 n3ܣkGݯz=[==<=GTB(/S,]6*-W:#7*e^YDY}UjAyT`#D="b{ų+ʯ:!kJ4Gmt}uC%K7YVfFY .=b?SƕƩȺy چ k5%4m7lqlioZlG+Zz͹mzy]?uuw|"űNwW&e֥ﺱ*|j5kyݭǯg^ykEklD_p߶7Dmo꿻1ml{Mś nLl<9O[$h՛BdҞ@iءG&vVǥ8nRĩ7u\ЭD-u`ֲK³8%yhYѹJº;.! zpg_XQKFAǿ=ȼ:ɹ8ʷ6˶5̵5͵6ζ7ϸ9к<Ѿ?DINU\dlvۀ܊ݖޢ)߯6DScs 2F[p(@Xr4Pm8Ww)Km endstream endobj 38 0 obj <> endobj 51 0 obj <> endobj 52 0 obj <>stream %!PS-Adobe-3.0 %%Creator: Adobe Illustrator(R) 17.0 %%AI8_CreatorVersion: 23.0.3 %%For: (inder) () %%Title: (logo_with_name.ai) %%CreationDate: 6/26/2020 4:33 PM %%Canvassize: 16383 %%BoundingBox: 230 -448 795 -329 %%HiResBoundingBox: 230.911500548278 -447.3515625 794.774999999998 -329.546242367805 %%DocumentProcessColors: Cyan Magenta Yellow Black %AI5_FileFormat 13.0 %AI12_BuildNumber: 585 %AI3_ColorUsage: Color %AI7_ImageSettings: 0 %%RGBProcessColor: 0 0 0 ([Registration]) %AI3_Cropmarks: 212 -459 812 -309 %AI3_TemplateBox: 512.5 -384.5 512.5 -384.5 %AI3_TileBox: 116 -690 908 -78 %AI3_DocumentPreview: None %AI5_ArtSize: 14400 14400 %AI5_RulerUnits: 6 %AI9_ColorModel: 1 %AI5_ArtFlags: 0 0 0 1 0 0 1 0 0 %AI5_TargetResolution: 800 %AI5_NumLayers: 1 %AI9_OpenToView: 58.347368421053 -149.378947368421 1.31944444444444 1219 848 26 0 0 259 87 0 0 0 1 1 0 1 1 0 1 %AI5_OpenViewLayers: 7 %%PageOrigin:112 -684 %AI7_GridSettings: 72 8 72 8 1 0 0.800000011920929 0.800000011920929 0.800000011920929 0.899999976158142 0.899999976158142 0.899999976158142 %AI9_Flatten: 1 %AI12_CMSettings: 00.MS %%EndComments endstream endobj 53 0 obj <>stream %%BoundingBox: 230 -448 795 -329 %%HiResBoundingBox: 230.911500548278 -447.3515625 794.774999999998 -329.546242367805 %AI7_Thumbnail: 128 28 8 %%BeginData: 5262 Hex Bytes %0000330000660000990000CC0033000033330033660033990033CC0033FF %0066000066330066660066990066CC0066FF009900009933009966009999 %0099CC0099FF00CC0000CC3300CC6600CC9900CCCC00CCFF00FF3300FF66 %00FF9900FFCC3300003300333300663300993300CC3300FF333300333333 %3333663333993333CC3333FF3366003366333366663366993366CC3366FF %3399003399333399663399993399CC3399FF33CC0033CC3333CC6633CC99 %33CCCC33CCFF33FF0033FF3333FF6633FF9933FFCC33FFFF660000660033 %6600666600996600CC6600FF6633006633336633666633996633CC6633FF %6666006666336666666666996666CC6666FF669900669933669966669999 %6699CC6699FF66CC0066CC3366CC6666CC9966CCCC66CCFF66FF0066FF33 %66FF6666FF9966FFCC66FFFF9900009900339900669900999900CC9900FF %9933009933339933669933999933CC9933FF996600996633996666996699 %9966CC9966FF9999009999339999669999999999CC9999FF99CC0099CC33 %99CC6699CC9999CCCC99CCFF99FF0099FF3399FF6699FF9999FFCC99FFFF %CC0000CC0033CC0066CC0099CC00CCCC00FFCC3300CC3333CC3366CC3399 %CC33CCCC33FFCC6600CC6633CC6666CC6699CC66CCCC66FFCC9900CC9933 %CC9966CC9999CC99CCCC99FFCCCC00CCCC33CCCC66CCCC99CCCCCCCCCCFF %CCFF00CCFF33CCFF66CCFF99CCFFCCCCFFFFFF0033FF0066FF0099FF00CC %FF3300FF3333FF3366FF3399FF33CCFF33FFFF6600FF6633FF6666FF6699 %FF66CCFF66FFFF9900FF9933FF9966FF9999FF99CCFF99FFFFCC00FFCC33 %FFCC66FFCC99FFCCCCFFCCFFFFFF33FFFF66FFFF99FFFFCC110000001100 %000011111111220000002200000022222222440000004400000044444444 %550000005500000055555555770000007700000077777777880000008800 %000088888888AA000000AA000000AAAAAAAABB000000BB000000BBBBBBBB %DD000000DD000000DDDDDDDDEE000000EE000000EEEEEEEE0000000000FF %00FF0000FFFFFF0000FF00FFFFFF00FFFFFF %524C45FD0BFF835F3434FD050B0C0B0B2EFD70FF84580B0B0B1211341234 %123412340BAFFD6FFF340B0B34343B343B3A835F5F3A3B1134FD6EFFA80B %0B123A123A123A125F5F83593B12120BFD09FFAFFFA8FD27FFA8FD07FFA8 %FFAEFD2FFFA80B12343B343B3B5F343B5F845F84343A1234FD07FFAE5834 %0B343483A8FD22FF840B3483FD04FFAE593411343483AFFD2BFFA8051212 %3B343A34845F5F345F838311110B120BFD06FF5F0B110B0B0B110B1183FD %21FF830B0B83FFFFFF840B0B0B340B0B0A34A8FD2AFF0B123A3B343B345F %5F84835F5F8434110B120B34A8FD04FFAE0B120B83838334120B34A8FD20 %FF840B115FFFFFFF0B3483FFFFFFAE5F0B34FD29FF340B123A123A123A59 %8359835F830B110B110B340BAEFD04FF340B0B84FD04FF0B0B0A83FD04FF %A884598384FD07FF84835984A8FD07FF7D8383FF830B0B83FFFF590B59FD %06FF580484FFFFA8FD07FFA8FFFFFFAFFFA88359A8FD06FFA884598384FD %08FFAF2E123B3B5F343B345F5F8483841212343B343B340BA8FD04FF5F0B %1283FD04FFAF5F83A8FFFFFF84340B0B0B3434FD05FF58340B0B0B3483FD %04FFA80B110B0B34830B1183FFFF840B84FD06FFA883A8FF831183FD05FF %5834AFFF0B845F110B110B5FFD04FF84340B120B1259FD07FF840B343484 %5F5F343A5F835F8312110B12123A12340BAEFD04FF83110B112D5F83AFFD %06FF840B0B34835F0B0B34FFFFFF340B0B5F83340B1184FFFFAE0B0B0B5F %83340B340B83FFFFA8110B84A8FD08FFAF0B34FD04FFA80B34FFA8110B34 %A8FFA8340B84FFFFA80B0BA8FFAF340B59FD06FF581134835F848383345F %838312120B120B12343B120CA8FD05FF59110B110B120B3483FD04FF590B %34AEFFFF840B1184FF84110B83FFFFFF340B34FFFF34110B84FFFFAF340B %1183FFFFFF84340B12345F83FD06FF5F0BAEFFFFFF840B84FFFF0B12A8FF %FFFF841158FFFF5F0BA8FFFFFFAF0B34FD06FF340B3B5F8359835F5F5983 %110B0B110B110B3A34120BFD07FF83580B0B0B110B0B34FFFFAE0B0B34FF %A8FFA8340B5FFF5F0B11A8FFA8FF590B0BAFFF340B34FD04FF590B0B83FD %04FFA88334110B0B0B58A8FFFFFF830B58FFFFFF2E34A8FFA81134FD04FF %AE0B59FFFF0B34AFFD04FF84A8FD06FF34123B845F845F84838412120B12 %0B120B343A3B0B34FD0AFF848434340B115FFF84340B340B3411340B1234 %FF343411340B3411340B1284FF0B1234FD04FF840B1183FD08FFAE83590B %34A8FFFFFF3334FFFFA81159FFFFFF0B5FFD04FFA83458FFA83434FD0DFF %340B3B5F835F835F5F0B110B341234123A123B340B58FD05FFA88483FD05 %FF83110B34FFA80B340B340B340B340B58FF580B340B340B340B340BA8A8 %110B58FD04FF830B0B83FFFF5984FD07FF840B59FFFFFF5F0AA8FF830AAE %FFFFA81134FD04FFAE0B59FFAE0B58FD0DFF341234845F835F8312110B12 %123B343B343B343A0BAEFD04FFA8340B34FD06FF11110BFFA8120B5FFFFF %AFFD05FF58110BAFFD08FF111134FD04FF840B115FFFFF110BFD08FF3434 %FFFFFFA81158FF3434FFFFFFAF0B5FFD04FFAE1134FFAE1234FD05FFA8AE %FD06FF340B3B59835F5F0B110B110B110B3A123A123A0B34FD06FF0B110B %83FD04FF59110B34FFFF0B0B2EFFFFFFA884A8FFFF830B0B83FFFFFF83A8 %A8FFFF340B11A8FFFFFF34110B83FFFF580B58FD06FF840A59FD04FF3434 %830B59FFFFFFA80B34FD04FFAF0B58FFFF2D0BA8FD04FF340BA8FD05FF34 %123A848383111211120B121112123B3A3B0B0BA8FD06FF840B340B5F8384 %34340B1183FFFF5F0B3459FFAF590B1183FFAE340B34A8FF83120B34FFFF %83110B59AFFF5F120B1183FFFFAF0B1158AEFFFFAE840B1184FD04FF840B %340BAFFD04FF0B5FFD04FFAE1234FFFF840B5FFFFFFFAE0B34FD06FF590B %3B5F8311110B120B110B120B34343B0B0B83FD08FF5FFD070B1158FFFFFF %AE340B110B110B0B34FFFFFF830B0B110B0B0B1284FFFFFF340B0B340B0B %2D340B83FFFFFF840B0B0B340B120B0B59FD05FFA8340B58FD04FFA80B34 %FD04FFAF0B58FFFFFF580A345F5F0B1184FD06FF58113A5F11110B120B12 %0B120B343A3B0B0B84FD0AFFAE58340B34345F83FD06FF833434113483FD %05FFA85F0B34345FA8FD05FF83340B3483AE0B1283FD05FF59581134115F %A8FD07FF590B83FD05FF0B5FFD04FFA83458FD04FF8434110B5FA8FD07FF %7D0B3B1212113412341234123A12340A2DA8FD0FFFA8FD0BFFA8FD0BFFA8 %FD0BFFA8FFFFFFA8FD09FFAEFD0AFF590BAFFD04FFAEFFAFFD05FFA8FD07 %FFA8FD0AFF59123A3B3A3B3A3B343B343A11110B83FD4EFF0B5FFD1FFFA8 %0B0A110B110B11FD050B2E58AFFD4CFF83830B12A8FD1FFF845859585934 %595859598383FD50FF5F0B34A8FD7CFFA8FDFCFFFDFCFFFD2AFFFF %%EndData endstream endobj 54 0 obj <>stream %AI12_CompressedDataxks7.&q)qOxOlDdzmى [bۜHjyqL d̘%1xOw/nr'?^^~Ǜ_|~o7*l}>>%g*|7W/.oo蛫xۛg_}귵QTrC!TA1/ E.~ʛQ߼~qua&D7mmT # [] 3JE[g0n}uy}7on^ܾls"b//_|ٿx;7w(x?|3=I|qo>wί^^b_]mi}۫/_.1)nt<> :~<<}}ywB2_n?R<+Ymۿm4!"\zs-rF濔.)|㰉%9˿_]~7חyLvw_%c0?sWo_^CYL/T  Ij3;od+ /~P/^_^sOU7n ƏVͧƭ c,7jkTFi2^z9vNM.=V8_b3ŹAcy4Ao|_}vSʍ<Ƀ.|s~qvk,YP;"y'OL|]A//7? 0+7OUruKz~o/o_K//חwݼ}sq/no~ *u/X.O6)E/)/o7=|%a_>}q-4o.(ˋ[|7/c8}ۛ]^tCi)r/z~x[!xW- +&k:x훧w߼yA}zeۻ˧nhʳ1RW/_^&:/G]kj#?| ~vl6wi|wwӳ܅GyQ^F-穉ge>ew=4jH/|^bO_^}ywOxkO^?շ%e~-V%k,7O/R{.fo_/j%2OL_/Qe}*JfM\2M*~St3{&クx^}Zz)N'u78fgy&xgg9"hOVвR۷];Qڟ7]Z}Sڗ;ٺ|-y/_Eno޾7O>Mb(͗27 Qa}s7ˋT.V9X\p/6_ k!ʿ2m~+TAzz˛׳Sˋ:hep~yYd˻??Ne9)e=d{SkON=פgG9ﮞoo|e}%4ϓ,ëܼz63%_qJ4>WMS[k-!?R+92km,j;GJd5 '6eWy`r*o.y5]SJ~=UxϭO?D๿-6NW< еfBa*?<ۃxfIϒd 5RHYK:9sxq15ѳdf.C R+8Kiܩ,oxD: <͇OW~,9mʟ'of)n)=>5iȆfV9y Ys0YÈ6q65is&28L?Cn֘4Nf]fm(C8rQ$;яan܏8cۣ6#4xvrwqvqw va?#q~6ɠ3N glvvv8;DzS29C8x9>ڣ;dz#vx<8М{$|9GB(09dZ,fl[Xjh~Ja"Ydf 5꜖mҼaR,f)ҸHa,Ĕd̒%5KÔʰ?$kYڧTb>2k˙Zf5fb9Qϣ݌+ƫ<1\b͍adS4$\NV N6INҧ^.(i ?j drJ49=@ /pEtA#= G,#&qpD70189xs,d' 84[qCj }ıqYbq9c/cV<T"38dRMG?ٽ#P(kZIUYЋIYX]º#rJ' gFW3'$0 <\hd,m+a2 !Q 9 Wy];i{f {yҗwԮQ;?®>` T0BJ--YڋD. k']"vQJ/'Ksl+j.˿(4ˢ 胎"DNމPXFfΌDG_'Nߑ y` uϔPM49?JҫɬE;Tɨ14i7XFLBQ#q 5Oצ 5{ϟx}h|F5(Er*טS΢Ƥ-?TL[LKE\)NzclIƛBNcM1]M^V=ZPuڴS,h jt )^Ai=4gAc2]iBY(^: P rUޔbO҉GRܳO?~'Bq ,RPL^XA0^ d7WWgñƓ㽘r$yq#)䣘Aِ NɰVend`|"\ Ojt%9} G;1B|)zaǀ(h4ӓy:01S5b{#z < (PB -TPDi3-uNtt՜F&8 j8%O6vK -(ے5k3ӜYkNQ+'xyj<]]t稍ƿf{gE0Y=J[n،j џ-(m<8Dv-h Ҭ%Ǎ6NG$_GdMh qD*fpvj!,wOȣ 1\AFyZ Fu01R)y'zYvݛ}/ڍ<.CgLp *-G:zt3:\(hg%k656[tX/z!ϴuB9ȃ㖱zƲ&_{E]wl玱[QCC4F`t 08 Ziy}}lo[=EtUo3EHaևj"OZ=1#o?p/lZٙM(L̴d/̘! CeH+l)} Ž8^НGIÊo- $7,a3*^in1C$^DՏ0Sh'oOeYssnfD1XZҽD7,RJ? \X*i霚=sKt={fŹTk=~?kq`~3}曞35~z}RceOs,;d3wM7 UVJ:g&H?em\:GN=)c.mY$xGCȚuGiӺ Twv.be5reEE`v,MzDKR#[V0y\C.Fܣף\V\Cx~6~N3SOTA$q#HjE8BET1 *D#|RRMb J.ƭ՜iB~n$=pT &>T40'Y#:QM&c0ξ6$X00Km{`Umn`;i②j0~r~+~FLЏƉbpf! Mq+*mĭsB)ayWW.oE1%I6WXY…}V@5,89ecᩳ]v MGCE n ̝§灸˅?أI2U2PұHɶ8UrQDRD4+i~ ~1l%\]JE 99Ŝ2#P+SZ]N1%Si 5Śv5"伱4ͽ1fd ښ"?cJv]:;jr D` @ 9Wy7vaҩYTڗ؁Y8i<ˀcg4+5S_v94AdEz'ԞUlDqy}AaE}ɘ} vYyݢ-qU}t%WIGh$yKpJ]D-3/K?& &59|>yc-[\š{]U,6>=f^R ~:yLM:KycTt&j^b'8E/qE bχD(2|5.ST2|&dc4&b.O\2r\0Ky|][%ŗR_JIL\Y'ټHF 3|&fA'44k!jSBfHKHsoC\ I"kj˴ztjt'}$G (^ehoS ]b /pП."Wmr*+'$d,Vl}N(Zעwu7tZXÞgiNv' mxкlDSE[>jeCceMn 晇,xd`UT" <y&:6^ XSS4bxw[[9Q!O>N⪉ʘ4aMcp =]QTHxG]ş bm~Q[<k9QWUcv8^A tN `,ݨMoL >N*d'LT+{1屎c8quu=bkj#u0Hٍ֝8Xu} h 4a}`_{za0h"X;:Or8h(h&$K zN.f'=y4iGSaXx]KݵИ L3HO$ 6HjQЇ䘕11K(32Y;TD_JYtIa*RHO>N7I7n#)S`۔rn7`"R) ddAP?YEH&hUtZI07%a.dO$LY^Od ~)# <.}C?aS%fa8 Xl)>Rhʜ,o\SJ2l9Cq mIEU(?S"V (JԦLuƴfAq:C^PPF~?U:f 5t=L+ZU;F<Zd=&$BԤy*|SqY&N6s |YBhv%< @"RPΓsɓO @B aN$2 ,Q%%!S*{i!`Ř7I^"m LKNs h)'VR,ýIg%]N 5>gJCv\ϏA IM\C7^l^kJkTl[4'42q$( *T9v^.GQB"SW)'8W s(gr@,,b prήC5MbnLmmnf3 Ķ0Ƶִv¨Ƭvʨ0gNUsښ)ƳZՔ|y8RhuIa W+EJQ~lR_)ʯW{g@m a^Κtwwǀ?UJX9H@{zx1>^B)qj;. `jwOF׆1jab{Cf 8ONtv:vp>Ի;u6 P܀܆4ӟ 3]3c~ƱMmMs(0lkq*X=G2+st?4΀fC?I% 6>hL1^t&Dh< 7 r$dXQ(ACN(QONxo},=*1heĦJT8ʣ <<vc$۰ENTR'8y8l&G0TYMKma5Uܗu/+_ wPu5~% I+h! =;8.ۘBz=\KtyS~0~(g:_Z|u.#-`8s6(q?;sC3žP"uV[۱)9+V؊Is/+aak~mޚqǦݒ ׸\­wQAƨn:ƓJ^Y?yQaTj*#Vê# yѝwM: OMz $۞)e LY.)`OapQz.2lgNaz65j+xm!Ν<𛹣wu \Sf>Su~?s~=v=]2\jJ.;q;q~[(89q;:#Nst;{g8:͈K[G5:q.SYjih*u! 4_(ru>dyEgs9q\pz% ۃƪ6ޗK.RD,L$ad@N<G3X -ZEs8U[A%FQgrIGBfK<j3\K}\Bw9^$䨝9hoGWz r[)702`g&y<tKӀyoҀIr-1;)W]v&tt`rJe%5f](楟xJfΒIyBY3LO_c"B/hnjs*nwoed)ƥg?C+77qiX>ۼ]E+1 Z/'%s"$w5% 5?U=k2MdH;m1_dtc^XbY MMĥmzQ;\Jgm'LCg?H$q=Y@Ĵo9Zq7UeOˠܾ7f3wkMwJ7 0b:5"R&Azq]'D߉.*0Ily9ɖήt$(rWC[¹ja1"#&.Q]Kx^q9Ť#N\N`(a #G.o' jq95.E ݙ lD3"Bo2N=fqv"R-O$D9>Tũxsi]/{)Š*Ui(So<˕V.K~F?g9Xu>Cv;a즋vb5RǐVG^*^ "qFGxB8"( 9K!h& ]:93rtNF H&(HR~Y $LWԚz9(]I[~+|&r%g.S#^~yFxHBǻL1&4RzFbV^&1g{EA^e3fa,Z\gM]X`m&H!NmrؓQ B6)0 Ơ!FW xBl#\e/=FsvB] _^<7jr{'e\A6@ ˋ=O>IeZԥ\hyI1[4n [9UԠ Zݪ*eㇾ83Qy׮Cv0E1] Sqz5 ([E9VYaa⽹}"we 3]Z<`k?ń]{q$r{| v1qMѡ!ŇNѡXeu!9TPn|?g\ho-0bHl+|QKU Gŕ&\Y.qXeY*^$ٛ~[ \XuM&~]{p'G:$?TT%'xs~:Ws|גgRޗwwןm~{Û7 mbFo, Mv A3$`n[FFؘ`K6RF?w<C_mؼx"omCW}?߯+y+OwwwW7?l>Kʙ>;}x7o^^>o7o_ce/m~Vx1]$SNJ[8wc X#C l]n3nԃXNpnƭ!&Eō4 hs_iqLDD$(C y.n W-Սf]y-' U4Mc7lAt.nP2t"BdA:r0Ŀ#t,>R-PBm ўGw"A0W1`E E o`kwTz>l7FETaBA(<@`6c75`/6ZimM6| BHg< c0Lځ]jtW!n>U[.~3n)X]XGt.yr6 9[$+B\5vۨGĂG~5d?6:1XR!]ERd۞Ej렓J_cXCh֏,.#WSքnRc7b9| !G>s}qJsᔵ2RO5<]4ȢqS<VyK$FK􊍡٘CяC5-vI6>WY(6*][?`ʊ݈cװg8E9B]J_РKai40` B+ pi@yI5MHa͝9N 7Vs-{!r#Bx+]y"} |HZ3k(}T`= eӵ-Q}Jƴg4ՎPmM *q+i@^ڴ v)xN.3nLl(yaC..(M=cg4srmMނ[ZZQ[kk(ԆN 4OO|ˋFfX \UX޼}8qnϏdw!/h'dj#+Np<C/{<)+1?Lыk"lT"2Mj aKD:E׏isq)fae::ӅSpnFz.i8vPdG9ȿE,ee9 N 3:A-~sPt7o' ?G&jG8|% mf>6Vxp4y kvl'æ\>Irx-,S  FG"<0&xWd _a&s-x-5">D`. #o*m, F7tOag@1L$"'`",j8Ԃ:JWwcR b5t~$ꘁ9,f`lP){43IQWɕTg}'H0³n{$Ӌc+Cgcph +ϟ))|0[Cָy#mFZS48}+拦ך!ͬ[f:DہI, '"L]L%# ?\;`  NƢs[*#aNa!Tǀ" 4ڌܽ\_n"3r}gךq|) ;ͳK;l:E !|$;BB8JҐHgq64*N[SNG¢!hjy3(h9`HB%Ҡ`۰Q 5N/&bd|F1A)F Wa61F9bRu9Ս؟X.JNUv0lii^)A28>>Vgdq\8NQP꠻ՖHMOtz)RF )FF,"#\(XV~VcĀlwM])9 UP#6w#| 8ՅM?mHҊr2Ѩ4Q7;& (5"$#$!ԋ`NVkJUW/?4SYN+Q,F먍y2\ dQQ6kU X:cRJF=ŭ,\YC]FB.[LTߐ |U_k4tj)oAO1BKK yZ` 1*8&-X4:-Mf|c> -$b:2x· AF TaQo[mdMRR.6@%eAQpnn$XOjcɀ-4)m8l#e cy۟K8' 7><D<#gD5ঋ{cf#Saڈb!?D'4X!UJĥi*V)6@R_Dʈ6@r*f6 1 T-+?R9h#dX }8FO*bNx^wzÐ f. ӽ ²$CY-unJ@^li:HVުX谋! P5`d# @D!+9;hvbh6#8.mspPr)9a9|38r~r1ѝ1=_( <*-3ʑړČܬ`e_fv}Gu? ͦblk;u_4ՎPi-OwsTc/Mt}n64U}bsbeGG&Ub!uȬ6cnsͬo([͍skjk'F?M胦baG& 4<[[MQS TU?Zo s<3¹"S1e5"B@?NK>l!Co &M,7]D q$`DPg^@{P#QMM.ʀ*  LCdAO,>z-#~7*;v:7у+/! |4UAGc6b8]1T({.g!PD*@oV",mD'CZVήC_L[= 2 ?M!R.vxh;\}OIaFνhWbݯuUu/]}^yb x7EٚWu%VaWC3u,(>;ރS.4dD7tJ}gG=dN-`Wl 4_NrbFӑj5v^k@ >4࿊2bTT3Kݏ\q Y;0 LvMT 9w,MK> @MMŒBrEKtԽ<<*D=NNO&g T13g dKd^8`vε62°iY*qWW/rVL՛ɍ)L/3*7xrh /#adCiukl6ۥG}2Z~_$c_\xTCgc3&Wa)۾TW\d֗+WkŠ?.jS?]iuS-켡vD&vTV UPZךr5 7>6}п}K40 80GгXNU?LK~7]6̀|NSz ۶nDPܴbļJpk" Ua bY/y45/AJtbh*ZU#D_mV3=޿xFj\Ap@G8X<t%V+חrӪ n<4S,~+o@7%RW  I ] * Ð#N4dV c_gIT'1է}EKX=iPLEO[L'ֲ#i C6D}O[-ƥڮ/Gҡyx@G I+jYK-kHAөK;`Za0?:^%C?:$N9@zs'R^])ٔ5 㴻 'WADYhe ^dA3(A( %\q[Sç[Y$1`ZI P/Q4KP-Rhx1T G :px Yh44(iG"$(*F\ZxXx4+ m RXƁ=fQ+f7\CvhsDL#hk EӴ!s!d|牙D : DG2a7Z)zƒ>=ҿ6.pqeTWJ4Avsw%С!M k3" ⢁LS6  W$$hP M5"<~2H l$Лd״Qd5 JwīŲ}/C ysl=:L=*& [lK]lrD)[+(VPQt%MuDZ$}`Ax65U qיc`å)>µ!!ͲbqȠPV%.F %ӗV@jQ ,Bѥ8J(V6!vHwQ|(B2KxFQcAaMURV*0nY'hۣNL0UIISln `D@yĮ7Q&a1c#SC &âJ):1~"kӰ4yڔ-4;w%@abłIZ#GL%w;p(_h!r5tʥWxnqᗎDo̚?5¸2n߇U#q3d`\_)SW%?Jn4ot *R_46U1)+HDDYTidc^83Gt`S0UF\b|F#9N" -NJS jULNM@'҆ϳQ|PtrIGT>= U)Z-!DjةԇQ;.k6P`a]/AƌiPb-U!6q[4Bż5y؈il@B0JKYqG.`N Os)Pi#d z)p#QTB62s @7΄u NJ( 0V!!8-n@.q/mrCm)%)߬xǭj퓚nl,ͼA9#]pcAFGF̼:Q,f"C'.k! $M7̔8c(Ò;Cz찀\W%E?; XE,2prZ/lu%58[A([K[oi]@%=WTҖ*_zph"ż@qnC2ĸ5F oȺT`M}2O-t yڐb]!$o6XQkuӔfT8 1AN@xyA.ζ; 8xRWf,rt{P-|q/I,aKwۭcc+=JROe1f?ڢ/ ~rMmKICҽ f׃g#6v@W$.9K-DCLrl5(z$ɎSN Q-8q6HޤXI`B:-W3{yA wGJdAy9U՜1VMzdFn{>W*1 a3d m.z>ƵKvJd1~ \F3`RXLjѦbj+-<mN{ t/T <gpXYd <]Ѡ1$NJ =/%rA/Xte0$TW8}~+D5/{TGVY1ʐhST[>$#3/_i{侎q-ZwW' &;E+^Mo5{+ v%bپ@w@g-iЕOh FC1Srp4_0^^[#b[v%\35,btv'J_,arJȽt%ֿ+ OיvٜsA'撏'0BG(OԀ-y G8-h^[ ̽I_4TK3~T [gYo}Ҳ@>iɃH%GQ ;;@A2:4$@2:uhC;zp% P2:0%ct:FTآctaAv}]C7aQ1Z>lQ2: JFءd,"bbTt $v0q 'vB(`8U(EY\Dz[,AXt ðqĢ3vLc_5WxE؁X<|D4 +h!A,:Dƾ̱K_b aXuh?jâznL-::JS1q]с>N50+ u%P V-$.dst72m;)+IA 5/F"9,7G[ B%3HHؗƿ RZE8A`Ps&΢8;75cRf' c<$:lNMVڌܹj [<{vB\]ߊ}SG!7tr/:s"UtJ; N_brl!ztFcE,)S32kՄ Z5F.)(5>Ͻ˵5B>%&%hP)AKܕ6/8Os_*(J4_UOXH8.E6$LUk3/,kKߖnL9)bŁ/FKD?)1s}\Emo bS3>b#6d7x ~@@W($&rr.(>-j3MG"smJ3GGbI(:Fuec&) \&^e +Yn>M+z}x:Еhb VEP]gv$4L[{chXnFIsV#0޷,.mA^n{zFk#l&9AjpJn&j@~h_M:vte(A$ TXn Եn<4@qh$Mg #ͣn6'P|s3)P0*%hS`G l;_LWj^2XP/3(!ԂW%q[O_Qپ'&dZ&SBӑW݈ -uǍ8*|H\׌@@m `zUfN@ ^I͆ #-"-aYb#zױ_Z5:lp m^_6=Țc9%\GTC%@jr=gms O d^4QewF#:EvIёMSB7}0\I){u@iAK;;YNFr2, 0Vh~|UД66xbSC Կ5(@4 GDs *UellǂΚ 9fWw@cr>/FLũkQŨEH9jr @ Ę:>dK䋠ЧZÀ 'pY̛4Ph葼tv7w{0eZPI.[YE{,.Cb|nqg{1L MƘul-Twiyj_Y 5NDXYcq !Kɭ\@!K6a 3;C 56XSrut=JYcO\qgn{JLkOSn55 NVMNдsrq];.QM-2`F0 [&!MmJ:Tm!JCS:xT6sc7 cNBtN dy* ؾEN|vJ*rҔ /v2TSש~B)TGMɕx믁6M <'.psƕzJC5c hmkKgՁh:5~)ۂDcVh ^8J.]Ao*0CSAjGJ:Meg-C94/c iuJ/9 D+BүJTXPA8IxOHbXY-LCmEx[Ņ迯-֏QÃʠwa$8]t7OXon}s?_.6O?[)߯.G^9ͳw7P뮰G% n`yywwn/솓󋿼}~y}7d{ruEf7g/}oͳ\O~:?lo|uO [x ԋPRxl T̓x߯'_gma6;~V7 OL$=jۛB9ź{3~͝gsoWߦv&q"@)֭xdE1H1,JSaI%^T:qXN.pxQ2Q$f=T#3T_N nFOwQlRNEcecRNQ T̜%Fa:t/Wijp0XdAB7zH!ie";ayם\>@V*AY%/'0U}3[+g { &ȴ2p A,5^E1ޠ@΃4C$[*c* 7AW3$&WƉya$ c TSU>HTU~X,FuȈ+Vax½SY|" 2j:V G1~(iJ |W)zaF!jZJInI{ypȐD]xB_ ޑhgV{ }'$^iQ@N3FaB X‡ 78i ~K&|)MQAC1Vdn >TaB!GQ0)@J} ,H7&tBx #U8aQ\¦rO2) i-hggCӔ+ErqU"E*}*1,a|}8ҥb]$&2gI,c&}n@lNy*qȌsC;`f-!4YSͫ KL y'.qh i[ B"Y,!_CĔ}d 4}>5$^oe.o&k@s6 pR;ˍE@*j b>|^qV9Ryprkjoa 8 tR$Iډ(:O 2C fyAMQFKVH1- ͠vpNjXRr/c XM!|H0 K뙖a2qaF%=&&ŠEYIc?RF \AЌbyxh4+x^" zynލːԞԃ3CFiL§TyTءZz$|I2۠vt>gAF*xiCf."s +u*dR-ͼ=!0`t;ympEIeØ@;M1c2Hx%C:ptb CF׈܄0 WCPcGo5 ؁d*Yqⓟ_ v78gTdtB]SN;q%lOA| o:A2zM Ùj < W$W ^ X ѧEz0# 0e&HnE HOKxm+ù / \D9A/WAU2<$ 坸Ϡw)rr;l$1ec>YbIA)^%E4G @ai-1c{-$;R ^rIy@K]'KK`Cqǁ7҇s fȥ }Ng;WT˄ Ɣ[iо*iVddl6wC6 h26|yO +>aJ(0)qHNrW?7;F>KMU c2#Y= "D_fZBHzDcߝOqAANNEɑ(IabrpV/C(B@S4rQ*Lo ;D*ɵ {6(L: ~01%R?dBfU?*cQCAlQc=f%r֢撮S}罣IuśS$Ȟ꣠@^':aU>!CP%G2r_AVN* 9tMo!+'yrd*&PUH_K'ǯCx%ƔzJ-BqML;-9cX3~+'zv"a9%=M0.eN&qhos܁Fuc16q[ )P%L !ԼY17-Tf(i( KYߘ|XTₔt:lnjs< H˓&晄I OJmR61ܞDInu:%(C!zg3qMk o\Yr.=הds-פG}3]!{$(Dz.m;ūE3%_.ẘsAS"k@ ,K3dC@uywvr'ty'x݊n5h{|&CĞc)dTql8\/BⓓZMt04t4 '4D vC- \rlÙO~<|p1?'KwdN%^C9t=ME˰~+]3dDXLqp-yL 6y.;n:kHdgTR5/BUSsɸhZq<5{i^lfDe$$QRT9\ruHJY]FVlIDœˤSfD|*JNl J/d"dpJ֔L+*Ou(Mu DKCށ"v- 1D6E *F2Cl )LSZA,dh{3), jg,Ph[`BBrU G%VI)'4A,sXCUp\ip5׻gG6[$[{?Ryb(~AAs?u/4irm[OE5X?'*]ehVH< *>-RL IU=!b7k=fBFbBTp1 β"YWn keSϗ7ТBF)vA' ^ |((!7:S(p968UKuS~^C 2frD2iE"96D{tFy|&wNN\k;-/ %h1J4@ h,1(r-(@J@ xu$xii \Qtq1c`1iH:&KEYI{'otCxۆj\-F `Av8W3cR%0&~jIJ[$() AQI%O'IY7o>ɸiA4kNƣt )jyp;ݠZP'ʦP2㚏骈ܔJoVY>hC^8#O^ܚVѣ(v?BTb o}N!߳B94`2ތٺI)Mh=5<iح'[ /=|fUp!%nE֍]RMc«Ѿ)S֒͞>G禞l\uBްtS|2 +L1ׂfh+@ka7xZG5(O(Q@u ; 4𕴔$z8?*ӑT;`~n DsDU"YLÈph& y;0<[Gȝ܀wF%PgshxaX9|B i}HhWIc+^u rwG 7Qsc[xC6nohU4賂2~XG(g32S4cdaxt[7Ҡ˹Z|r>  `b-/X覷VQz['X~wq c^{#~q[ّ TE0J֕R.{#wհ}uA0H {J^n\7O{|0nivĺ j#@D./ɶnA5dswr4dt!)Zߎ-eZj>LX7+3I)D[qWQS I5d4RݥίH@ `OI,"ިL!!E$Õ_ÒK {ȑK&%QUb#MT6Cc^GpAƚ;q=K6Lƥul=1=Ev4TC Cct y:* mZsmMK",k̐Bscc/(`:kHe}|Nw?6&N cy|s} Ha>,R՛mav]? @5.=X AW &{We)_,@B/-0.4 BoBTRt]4GhX;%i18CF`;D05MqּEGFި`N%o{r ;Wp=K˗Q(9 ")Z/aqH ~FlΩ)yaL'GhLPA?9zS½}%`š9ژj Ԑת4>>?N-NlaSJ˳([bWGձ9+9b`UFyD/1uS-xJȃ&奦ίuxcȖ-PG %|m$?KdǘGbl ogNW0pt%7I\ i*|,uX.嘲tih­!tnLEÜYTAy# ߾v ds1d\*+OME<5.m7+z EOb yiƭbSƎj JO'+_1dS/{15B$蘀fL#ЬTd{95ᇇGJ[ݧtSʼXR(iӳpᾟI$fI(E=-^_la:xf( N :. 8v 7V*X3c22eOI8""h#ZCf eB& 9:O[yiX[V=LUdc+&^%Oc#J:5cb>7`VlHz ',!l}f4N3:9;CLcRi.b-"}'ςe3ґw~L۪hKeSu !xE^KD{U0V y.4Wb{$c>dG8e{O*Ghq rTb`K)EI8wM+$TVtRč."GB5,v"| TͿ#~;3LDdL-{ߐ&J4CלcSXc\]!n[4lf*AUVcZ#6ρ<犠d B5dlj_jHd5TaV0c$ j"H8%$ʎ#j"%8hnpRX%nU($_߁n)O[#B#Hu|U~v 篿o?X?T5.EBϫ%-"QSwH2Yfab#FG(5hЁtz`[nՙݮ:dңz<2 s7lDDŽ@|aL Kގ 7n$*dTt6w#},t{N;@V4hpV|!v{Hղ"l9fBHEUPjg&,,oq %5)6# B󡒱}GV)`~wcZGl<'f|z؍ S+]-(OEϠrz1UX W#jN(9#,\{_DO% Tb$ĮP7UZZ#̴o D Svl7 \f3i-qm+pmpoEwObӼid}#cָT`c5. +AQ̦wε 1GMB@ ơ|ρ]Q8'!Ef#[Peű0X%,""sq"$U @DVuItZp@(;QE gXUWWWrP&hcS,4vU,y|{/dk-ǫelaΘl(d0V\>[̍i4.k y]h7΍cF$|am$9kbb,k&>OݳF@G˘ppϩ^ f9c?ߦrPy8dTx"Z]Z <~ ,E"u*HM.:PMb ʎP4"!hpK_XK(f ]0\[{ 72{ŽٗEMD%CdoY2o1Q d ;xMwqAhh{&lTvOJN&%h|QA%aUXWR;_>h'WhEz‡0ᵁ$ ¼0(/"'B-5䣄5[" -|$yx91GZ{*fJ:@$$H-Bͤhg| +wz=ƭ?\Oh,m/23qyVOxن=#G6! $jkɫt;BE32} 4(zS=Zf͈/ʂn s}2wѲOIaJ_qRvP5LW5_Q3NS1LԚ"(["K=#40Ve.tٙݧh*Z'hk#B$mD{z+mtw7*٣̚gB q_'9ePh:u1M34fߜ ņ1ԳR;GXE44dC 1P[B:It܀~E=FK aEeޒ{D} ΰ4ȯc A# ib&Ĝ^l>!Vo~hU+Wh#6i#򤽄<)D+w7ǝN%;PEmOay .&8%(%K\@RzXlJs^c(pVQ,(s0v*!jq,4ez4]W|1*ы4tGў*=!*@tஷ ~ ]pCs󈆴[1h%1FcYk6 +&Uˋ0-X;e|C/va9ñoSAq2FYp(hu_oMΌ<ɯ< T0!-Շh3ǟw oC{X rn &W*Iڬ@A/^Kc-lx s:f,K̈́ n -N Ӯ! mUx X3tuW@̂+%Kb7>,*q,7a8ʁڈ:VjTB}7AOOAU:j:# uMPN5Kˢalu ph*5Bi|X1B /̖6໊%spDlƘcB]IQQh<{; J¡BW'xg_.P;P\<'3ʛRe#WJQQAqgg0֡)zV8>RĹH힗wsR"}E"0cT,>?;+\Arm'N=V0Mpu5#L롒)c'nn?C'JU}o!5V-2+Nyz=QC݈,‡"ԭ D. cU8St˵ ]RWڑoagMSͧOH̊H yдbZ'"QW"4ŪѺp<}8Ue[.P_`V>~rTC-5/K4"uԐEWG\wι7rLŷLHZy},' fU&ȣYl1"1uokI;[xiSFuo8G/^5g3z ; ѧǙ `j.޴d )uh8Ȝ|ۡ3rt|B'flKӌ6L[fB~@N"CF>N&jUwT?G+]t\~kw[= oVcO s,=~MlCآNV36ȐJ3@5#>,ٞ|~$ HkFw[?FCh=?/__?o_oC?_Ogſow *? /شVHlwle8)AL`DΉELꪽY* ީrϺ_';Dm/9}!;BwTFa Ff\yYuR*[~D1Qgsqoe1j9 h,1Eb< It+/wzNJ#{G+ Rk-;`v(cp6~w'1&mrj/jdJg?Lotx^3]oa\χ ˯7ֲBBbjB0`B7|ۃFqza\#'Ow ޛhmީ<m}űT3-i^fj۹k>e>E3O{QUÕ}i f=/{^]X,q藔qŽS?Wnʉa*HᦎN|-m<=ӕ?m.+Ηz-jU/:gBrwtտӚpY$nu*I[(?s\CQZYx7-apWUPd劁}imC|w/J}|l2r8XB*U8m1Fp'ke3à% ia<|ĹԲwbg\~0a(nYJ+^0HN/Gp F  5m.Oo@w1voo؅1ݼM1t~;enn0Nxc,_uwX=0m6_^μ򷱇9率6gݩ/l >=1Tg6 a< \>Qol>H,ܴê[T^ׇ 1t Ad\wH_kY85U^vw<]}X Suu:.j;2Z}3V|Ǫˎzp^.;5?#6[9ʫ;# s=\oS6Q .cyCoDȫd>$si*0x@P)P0_x- laLyUSD䴦7N֙nά?a73kz6sK\YL>˜_˓LZVT Y0^f6bo[fo,#bܕϮ9?Ml<'[—2_a8*9WS~|$a쑹A Adf , pɻ^ 9fdr\ƴ;~!a)Lå.!t,FB&iK\1}3•wb} $l1,jx8+KRq+0 gca*>פm,|Kc0X{ m?3O8R+]_/?aFfwL˕G^g6V!|U9{0H-6ӈTWt~z-?+ P)Ҫ7zvT̉8g#a YcJ`fiNOyz~툻}.k7a>3>(r>m{=8Àke'øo;a"Β&]X] |v<~ ;o S_0NqpP O;xՐ:$NX41,~ kՊ$)VoWaN˻7\&_f\1-ö́"UyY7m&L ӄ<̄t 6~wMIOaz(5uV)7fނCMQw%7a3ʹk-r~t ݡ˜p6y[B#L;xlubzR  5mLkqMzxY2&y)# lD2>_`hQL?a!i|N`Le,&pm{ j.N4,oߋdQ}i #_B55MH`:ӗE =mOx,K[õVv.^PG/p#D>!/G8"k6=Yds$˴lp?pܔ5/^NImòR,?x$•Hj|pEcc r? yrQ y%KbmY?4n#[&_~* rI/}rdwT2e2 9&SqO˘*>pCC*N,1*5Y(˘W82[Ƽ4y Շ3/v]μ6xkCU=ys>yec1P`,zYˑFaȵ-zY9O)4[L^fH:fjzfCӫRy/z^{:`#5L%k{ڠ@G`[9Xb>:$; ՙCЯUEݺؘr1KŲ.U=[X0=Lsie31҄}&ܦ=Lmaէ(P˴bQ568LPpj)2z\zު~rCfCWhR9p041bŖ]@fj?n6XtyTBxFZVD(jz z޿Za[C2,q-;Sӽ.Xw_ӯ+ryƾ4ԟƦ?,8PU?VME4T54f+Gڶ.O*qt ԗjӪ4fd^)lQH.𨨳ŧLROTJEc9%އW*JH/RzYII Ieǧ!E2Ų Ozۮ]e4z[[(D;R rV@w{(MP$&4 ml&3Ws6(OP<(-+ՙewb゚|H3>9=6 C~{ 3e:ܷ'r Yx*vIJi(e Q*HOџ(#Ԟ-zH)3J_.-i|, DķPa%7hX4÷|h![e \j^g,>`J-Z\%ŞR_k˶<2C|Xc"0˙#&\P*7X=0Wi_Vk^r%9U/Hv HZҟ֖{sak#f.#KDA{WsK+U0eVBcCU`TJÿmN -3ȮC;n{Q= ˂yuY"1xrM$Wlo:vuZo@i>聝&"U󇱣+տƎGjC%nxBp_4,tm;%KbHy;3#EWHa2sLr`_6%L3dDr/ʥ!໛Nc"5mpTtKm9etst? iPTk4$# ~ts4Qi!e9 \j`(u?= mlwy9)O+G[c`)\ 6rt:Li䨶o8~ȑ˿1*"QO#G L3-J Rz8z46/GP-!ڍ:{JQO%oVi8 FZAOGplye ^qQ AM)F+"{nF xFZ5] 6V'7YRTxtoMi7p(ݍY~MFҙFY!~B"z7zu8TS~1y s1><i 7hCSuKm8]sah4rh%I{%Ŵ|ѡ,=ӘQQF5oPy31"ӉBәQ<'`ka.gF6Bç3#eѦ TOF:iڰl7hGX%x}5#HDGOf^OQ!9{7kAaHa]C VMbڣ0LF)7V1pmjrm4:d܀اk5oFś~-Fd<: F*ywپnc$&gݽ1>VýQf+-gFPi`]Ɩ$cS^*c&5AJK˼тk捊+U*n0: :I/ƕd>K[wÏgƛ#o! 20`0` ms.P1?HEB4WlޞW(>LJV!0\J\C~މw~W(QᏈJBL`;3;D m>\aUZZ>D0SɟW]@Z s֘wfz,ĔhL w?rE Y9tk :No?t\*t|=OĜq_5 H>qMcCϧI58˛+\%_WiDH)@rpKhٕ =`J{ kŨZQ{8Q DӅӒr{}߮xKPlᵩcGjmYmUiy2;Nw 2 49ʉ?;;QMf FoU@iZLJ~yթҶ>Or`x9HO E':mA*/t6@Zz1XsJ4\>mdrje"ǟ_=9e\f\(Ĭ_~!L"˲CXNz-Z[j! uR?e\eUgly~Ux#B$clW\ᦵ>\&Z}. u$ }]S k}|˶SFi~=Пe~=G YCsY]uA/#+z/@X: r"r2>=d _m[T-iLe G%'4]tZ uBloSOm˗0rxsUN0R0Jc~.(i81xe =>xA]Vo {m>ԏoٗ?Ay'ʪhD.ݾ~8Cdt-Pʴl,mȴfAlex_vJLm5>[ߢ|IrU~㹄'j_VK*~XpVm Gi4KOI͊JMjtstK &IM/n(-__FHQQd!m/#ԯ͍(kcӨ\>ʨ\1`TX Ժ߹ỦoHi"2u7< e=T$q8Ӧ!J PA '[(c_M3&ӿy3*~ 1@u'Y.Ld˴r+{j%z9`Fq!Y6[G}c|V!I/%M$0xc 1pMcܳIlwZ*e&qm4S;&SVmAVh#Q#a&8jF)]0bzvj%qB`d48{W-㦨Q>!} SvZ02.YMzZ_K|B2NǴգhV Z`0q!@PkjdBPd~ N酛Lά =}zR'N+ӵMޔ7]> :~X@uBvh+[s{J_݈^ǍipS^ٕM%nGiw:z\z;Qa7CgJ ޺E3-^ NU4v;'VZEgYsePl$-~"XfΠ$ݺIPU#rR +8bԧh%ѣ$ 1rGʦqzPrLS\mT[ҘV|R`WazvCX<BsTayJSxߡz5IDt5.0ˮy Q[^mSK8&D>Df9(BwE'v&!hf7h@fa8 ΝAg %Ail ~ؕPqtqT')@jEQ! }\=#{tcZfIs!nSJv;/ݘU[8a?la9Y Ol]8āilեl] xdWH?|:N]xN&$֩Jӕ o}wMF+p/D^[[{vюT%Mz ]ףz _zx[8~N} ?q/,}x(rF!U˘Mx?BӥGύJ>yu!͸-՚/Ndax?Z>,@2Wk Ec]9G&|ީ"Oxur +!l=]O"uy7F3EBk qK|R&JUi^H|*_?n(LploD=|8p10Qb喹1Wx1]k;m]CR@?zW#711,C2눣H{))ecSHUTx< L>{טX+kd V.\ 4{X3S۷[KHc1xě($㙌@Ex4Nj m{GU,kpG |6DL,Rft,VXpٍMΨ-J$:;[\ӢCr vX'O[nS@սuAZ0uHvu O@$XOۘeSB&Wq ɦ~Q9>)5K>̐2:>L5#"GL{@T߆qŮ&4ӻ vѼcDWѠأOTvouت= M|%1Jxa>z(Iż3ܲ_ Ya\>v]iqrkEӺe}]PT6|5f|Xֈy3 ĆuZDSf?GHر7D!!e\1=O;: u?V5GvD˩;CrD`z_S pI޶ظ Fڐ q*lu=JÌу zc|_P҂ meJ df p ~UIQlYPǏ{d}O덨g1SC>",?@S Fez;:5wg9^9zlx"?q=m㕮,W4Zp hROxj7Əjgj&awTo# "1GnSkʏ=E";tęUI+_Rn pH(gH5Gn Vi@v=)@㭄F将`"1vtw*XcrЬWxIP/>厙":VLrtvS'2$$/F/3qߚ `3`f]ˍB2lr%'wd8'آlպmar\>&;mjxM[bwMDZA<ށ9ғU簖b6Y)N#!(ؖpIuח1.KESh>vtokLXpCˢα=.ZF'vT|8䖂'[ȹ__7%7:1B J4*Wwjԙɯsb5DZ5aejq3tH{< GpH*n̠g\٥E!SV3$)$WL j,y;bc }8$Jg"fҹDpg0g:OM?*<C tg5⤄G]N1<ƾm3ǁ}fyjRe2P3L$jh΋sƌu\Y;`ӥ$3Mr0- u8[9[|]||d=ʢWcQїÁHwAP:DVK=lJӷ̈́H03Si ;&pR~ǔ֨ZR #3,*#p#4r4ezT`ٹ@iHޤDSo%SȽoe uٝc|T1ຓjeXP;9Ѝ"Y=b U@1dAg^$HG T9uSRq 2 D|{/4=HS:r?;}3fP%tML1FrνֈFm'*5Z|n=-m~:Pn'*ӡz)滫jԞH.ڠ9g͞* *c{v|R#2| LYs#Z8جY%75 )^DmORw; P}N3iQr_G{ ՗ܑu֪3u%lμN5́dh.rnaBh"EXW9c}mb~@ -ﵺu'x;QM. ڹ]=c1IzJ&ϝ0I1\~C$qbX-2x"m1TgZDFs?JHM,`UV~&hcٸ/mZ$2"p!dȩ@[ 1޵};A`\YІxvn3de &t6CN qhs8&=Kf0K^NQ1R(:Qs\HFۊrʼnzEtèŸA03'h&*O cصP5¸Gqavwy[Zu^6w-N/04Lnc$%H4VpDV,!V;/%̎1:X7lYtvBZ.EJ&dsEM +Ae~݀QAjAG*>ݞX^Ѳ?CVTyh9;0h"_?Y ƁY'ѕ1 t{&=;"FhD|z6~}lD=f1;׽Nz>g=~<ZGWuDy:3x&\.OF3t4vȭv[pYX 6BݯG U7Oओa_O Cƍ 'PgH5oo9" Mѭ{(pN{ٍ؁)#i䐅b( qc2SQ )  3Pk6zx{ @% S2e3N Beʘd) OAP$DO?:o_P;G;Bpe1yR:ecK Tr3fb4:+Q4pIb>2A\cC(;qBs0N2BTEՓ4qlv0kbbL6CoeQWdviG/R¨4'WBsjBnIqhqH]#P]lc;SDFV.v)> R8^ELx3AITwbm1ZWJ6Ac%1}>,@/^F Ga%Xfع 2b5@nEA职՟05˯(}gȏҭ8Uў#to pS5LXTa 09bm! [Y`^sr-yp$'=t*reDtZy`E%uȶ T}A<6<<54]FAYF͔ Y)qw drnqh57 ij-/ROUn+xIUJQsuq"4(lJ:ɞs~IADHSP*W &9i~K] hi0Y ?;eF\ ShnZD:-3^Bo@dT {6O(};gVH81&9BK$LCB_M/NwŴ3ѕ +;k+z1P>cLJV ASPgw ~enhd}ߋTMHu"і%XV ѪdEJWq5'_ )s5V$c]fLm!R=Cz($h }RtX1^6MH5RĊ"Fl"i41^y裈S pqO~3@S!ŗ12Ĥ3NKa XA”M9P>삪F^K;`EC_8ˎ^?"FPY[ԯy\{9Oa*!#QlR2t?{y}ݐL: c{R0?Y<b+)5^+Jv&v[Z{8NK?Ty+,H vʯ4El0vnx5Hg:[uz@WG]W̴Yg=^4L(@vZjiU5DgDv 8+P/p' ~*LJY ?*v`+d75˷xꊈHiaAѿ`"aD\Azt@\>LJMIWTI8UȎ%ESH&=|se ΋tc#j>4JFw\sy5x{xw楠LDG"4B}cr{LA[}G&an, ̩LUDEc"GR48Ù7" lFv}J_{Y[ֳlH- qD h9 E qԛ x0-g OX{UഛrO~PIЃKR<)Z;rp[ ,ؠk,uu: ?TUbvLEEW*H SHUR?]lc/I; ;; ڰscOQelޏp3Ԓ>)j7 (U9: }Ḷ\{ #L:gI5#!>&y7Wa IVj 2>@*͍-(V/p)5~DTQgBrmiVC:- #]5Ԫ@,ߴP7|v@>d`mzsCJjw@Zc`'*lri8Eq&,/43\Hkݠ1f-vR+N.X4 #ś7 Tmfx γ.Q[8.\BeIј׮=qP% ##bWbUmtԠ ƷCx;U ȗah LܹQr;8%9MHe U6/_*">",x]jՑn79AeQ@5jO;GJ~?j Ih1jsFAա:^$mTR 12N$-t;(;?]HMٔ!l658Иҡ PSdRϮqdDMb:r @)OVib34t+0`[+z:y**mh4Vzg[BjR-՘!)T 1ԉUqr0 JH~ Q,.~۬>f@3^{l1$uttS\]zcxyŌm s@~H2sJa>jG4m* Jˌ漞Gq;;&*V,yko+fR1n7C_n׌Ǯ,M93`MCff-ZzSid1b0H@z̕*5)AOvk;a#!RGVGTUދ$AE[@P~oDHЩd!X6-Q53Q~֖&"hҿK`ƛtr71 b8ߡZɊp굁;pk;6'jC `c=.6Z4Mc&?`k-o#F+od L[L%<>u|sZWlX+w{k uS]\'NGTHa*MW>CDΛ0M|i̪O2/\9,zLc }P<<Ms5J7%4)NVu"fM== bDDFXy1 =?$zNt[̛"[Bk43\bK"OI;17c'JLעJ5Ci A9gN3 -Ý=H_Pa_<*ɺ/e8V{x d>IT ?%ڠ/%^wQI)mJwJ<^El.5|.E!!7ܠhPTUUuSJ$x|ʔ!k.r?#V]=5 KܟU{Hx$][;#}19N,hKUv΂-<'+_3LVԢW`ysW5wU(hۈLptfNtO}mg$)DI, $,&fH@N vOV>M;̄pW!.\A.2[4FO y%x|iD1|_[U17P!LWlQ'n"_mae.c|  x WDz!%Q0iP->lʑU l.ٱa\ψ+QZr1r˩@Vd ١9,VyeHtUnKv*nh!6EV%Іm+iig\"/?|0qmޒ\QRmCo8ohNi'lOXp5D-JX&v;=OJU1 \Jљa2ȏ4yu[bo܎|ov+Otnr46w/th}?'Eֶhwu龻Hkv {#6XHPy}w>@cz@<> jBwA $ыԛWVfؗd$o\DN!s tl؉Io/R$/# z9#a#IHFI?SW0T+&Ml` jCsBD>"Y>!OIΛQw;r3ߏo VvBi\H+W0>!3Q`Et+D&SA%94#Y`IXb(Nw(Wz>!3,Cy2b8|!1ycevbӲ))IƟ^jڎU9;Ϣ{'iLV@VqM$ @&1joש&sE"bw<@^{tZ^'7#}xd,tfOT2{RbC7/D{WS c+if+&viF e Nҍ(kX#+n36I'uHfl}d$XH}h(;DyQnrf'#),fMNy CF ۻx-ͲrLAE.Wg"|lJvuK cF>Wr Y`2?v֙ JQڤʽ%}v0OJ" CGY`a?`;dji1[+ iF\;.HS3k ¶\M8G*:]rZ^TW!rf:_+L$(Kda@p{vVi^4\.`w;[@[5LK|8`5ԯ/г{\v'w!} >_狼y]2O=ta$ ;$-X452jX~ÄP)[̈y iͨ' t՛Q%qWxO2mٰRU* 0ld(=(qs!Pu|Z Z`Q HK8 e剪n!us U%"x/H0&.,<[°^yWmFV0#={T$KR[^o*%Ǥ-L^Z{n®fPR0P-A,xyfԍpLɸ6@jq/乢6C9i@R'l׋&*U *oc 3]֠a JY\,5h#4j`-5芤fyI{*"ʄ6)Wy&K,lWǹvnB LJ\92 ZL_Dmnd6fnl;ĵ&i)@т}FXF)9ms.sf f!*YgE~*nv??,ݺI.^v3Tba1oE%d&ruw|ZgK#*݈|eׇ)5u$;1 [7T!;( "K(wV,V88m4LkIğf* %` Zn਼8 7C-(, xN,ƐN S=0s0GIϡ95QqUxOƭ WPyc?:%@Z^¤T [MkzeE:ڔ2q, 5m=tI؆ӗDE_ h^&sU}1EsA 3Yٕ^F^I©K:hd l\fsmCSm *op]MA}@0lǫȴCRbNLX\kem#pmf]݌,02={05q1p(O1FP jVH>4h1AӺz${̈́Di+ͧd%Qe/-`1lUS e uL zP:Dz\-?tKȒZ9YL)5sFgmF)w4 ,E=RJL3Pv1~(O޹tv2 `Tt'* \h~~) 2ZMk) zH5Q610$X-ZQZH9-TgeƳzmFWrzJ (G7 0 0| "^Ԕdr endstream endobj 55 0 obj <>stream dG+B9jg$yry*zu'dui:HSHLoSUu -#cH; ҆oS(!JM3D_A6N@ Q$*Uā5ƻ=E*fvA8zzǍx=*G+k !(mZGXozQBU2Mb&n2c |-Kr<"{2$nϗ|CU%,D/ۭO,~7] gJJ@޺gQ(aT[{)v]:7Y h8ZVh> ;^[^c8`֖S4n)[F8$ה zAjw+Y1f%l`YxL$k쉥d!쏷*)^^[7/׳ڴ8~ qZ B7/Vx?`W/a RZ/[]IEJ3QCQx./RRF- |ombmŶ"gcCĬuf}zi*bXY?r݈$\!pY==RnO/Ba=.QZ$P jhWNu+^L} g-CA1~L鴲iouhJ\Y͢/j/VDXDGk0eց5^k=akh;iԥJ̼T6 6/&L)X)~iePKQJ<${R^7NCw! ǟg߁Y %Um0[U}2!f@v8;*{-7ʟ5iD90Z葪x&?luE\$ׄ…vdoWQLC#9yJ﬇vjn*گ Vi^/cZ"gNRChVl8P ڀ~y0B͌]Rz$4pc ]oY9`.B޻5"ƒ))#Y½#"lnvomڦT!w!vN> 5h hWlϿe޷chI@Tn__` L=vP(%08@Je[ѫ1`Lt˧7n Qi;_@``9fv\8Jxތn~VYcgj˕u^p̠xПyU 0pKZ1RSEK.(b^~54) TdPFBZ YoR6ja#aATc#M NB9]Lwׁ½T5ѐ{ѫS%: 7ٕI$ PZuE~L5YDbBZi *(ʐM28O3Ti#mRL. Պ9Pj}CgT Ȑc!n$gjML{'9MNʉ[ݪ:,[hoEQLyOZDM~(מЯk!6E;G[Cj(#6R\# ".}Rh,LfK%.j^4@G5xvZZM|E@kkycHѴ"@ &"~V);巁l,4~v1#->L x5&: 4x-jE%s.@8ߋg&K[|@BNsaubĂ^lSmhe]UQb%8t<x\|i.4\KRV\XS^p^I2PvԈe JĭՎz=+2YXͺvYA492nQk{:Rgs*:]z4`I;Ft2SS0Y2wT B[4>&% hsȉH}4D46[| J{lv i9lXEot!`M}wᑂS|_p?oBODG̼\ϼځ}%D&ŽUZC{-(jsma<oO0ITGDnbyfcf!+&fT%F5pez##paOV*~Ni\4zSUfe9`צ𛯫@csaHbη${[S%;ڳa"gZlFW`dcUuT7;"f5aڤ$aK6'37 T/;tDx~f^khveY=LǠD!4_ןl2ʚ?sJAɐ?rΗxCUQ@}+Cke%R;Va%TKx]7m>9Z|$̖Đc,T{d3í`v\?9jpH85VJ ƹj CUAטa({q7$9I,+GNHF^U& '!&r﫨pC,U 8C,9 o/z|`or]}_.A3T ρ7lji J2 @(I'[Lq<&e䠊2j>yC.e2,i HXI_-C|Q)Bx'\>|LyPSC <7XY>!7Tf2X߳_Щ aZdcF!7)Kq)\ןR'TZ\_򬹳iߺv6i.&+j"uXr+xaW~.[/4Z Jzb}IW.v YX@H/, r,Кk,C!"}O0+FЇĴd0`tz9T=XՃ"w$'SO OJ$3$J\m"PIέcCQ⑕p iϣ#ċE?%+[EZT96eyFԯyvوΣ2'zЃ dp.UDBQA*^GdRYv #ZI=-rrTS筸PbꨔlZq$-x z*3~ _m xY- *[Y0[% 9"tB R VҮ fEg9'Y2z8+A¦qMPjN[ r L9q[cb]s,EJ 'p r"#޹+,GV6Ő11k/̛[#Y=nl4,KBNc$j~AL52>eBjnz,zxHApvkhV|@gg]XL:+o(l+J85fWynҎV\e % KF>[٘[ȡb$䚃С*^ߘ!n^ݞYBֽLd)o=̐PIB؀9c߈kF^ @J!A9I#(imvWOD%U:L.:Nr ]Li`ie(WGiz!'V̂qwJ6y[:l`?\ g Uz E}BH yM*쩄s|c^iElQ\a# 5)j]{APLP,1&߰3|[\(CZ$8w\J/Jc(cQ_0YS ߛ)Ãl.hCvJ}դRDv]6h~Pnľ>6Kb|Q4Ⲿ@C(Ww&8-i/pϿ` b$j }>Ӎ` wTz4T Bi;z {p,BR( $H`Px~񔂋J|"b^a̷"蠭Bbw}*9{cg֌=\WٽW5;ݼO(ˏWlk B>G )yT/Ȭ#{1Bu:nv{ Je;9N 6ߩ2 #͊sÚHN=!4{&'] 35VP9,6>/ߓoXۥݗ0d+ tmH.1|(c)OMIXdUNP_Za9 bk4u8hwipru-v* Pʺ^o9n ~@b )(cv<#-Hɜ#`]ibu"Ӥy(,HBtJ;4#lDw>*2A{UZY r(\_:]r z9[ kdm=x LS6"s( UY%e뉈N,Ikg=\Z >g(AFX})*_ip0(f8&s[3 RXxK Tyފw8Y:?4C(}Si}Qt'/ 4)o+V8FJnlڭ菻J2(JT%q!$ `hH᭻Dd7j^ %<|=8 6}`|G&b;y"NZrf9-˨oCagc(I^2G5@b+uHb!RQGW㘎49݄V}daz Ga,pC$ of[ĵ%0w E'VH6L XE.j5?II)_K}5CrDM6DZO%[XB8Z!D|*~U:ɀu04_6Q1Cw7I0Jv'p>g}e<=?CWd 5i\M7=B0;p,-VqzDiM3d7B\8)}mk kY!zl/&.m<40HXFz2 8݂6U\VWڅJ jH:U.vD /A!ZxUa&V6AS`J f7 pds7C۫Ƕ}-ӂ; [;Uƅ=CzrBt5IdpXB.JKD VU݀Om\ņZ[޻<I"у5.mD/շ΁{8B$^z+M4cjJ ~77sMUk Pu8D2Y%2+@RNdi2'/C-mPM,kے9骜<(VP5)xR8G(vM. 5pHD BZkno'"pzL|ZwA:NafDL[R\{$-_Lc-54lxr9-xIO¯׶i״b,J!TB?ItM*& L%۠ D[҂`4g60ȚB5@7u9ib6YSBɛ0#\KM*3R][`Y89V-!>X Ti}kli,ؚ݇E!T6n&Q ;I+`}"tDhRHQf=K_~DxzNo9F֘)zFM.]{kHUJJHNNRQSx,O8?Vv>vˮ3!e@b#~zO6.Q\UmM??܃-rN#;a-{^/C1gi7+ؚÉc_XYJMwLDZ2*a9220*"I0bKX!w҈/k Tc3k tbpG=\<ʝp*: I *zD(Â- 3\a9J~#TT6(  D캊6\Ep QTb1U$=}?}Roԓv7H!]8u+;il ҂ӂSڳ״ m.k)!1j ts('FdoJ,|;=n':lCo @?D.Qd)ukLC@sb89"V.<6ۋ1Ǣ:Pdr(M+*=*c;,L fҴق!ve&HWͅ&k2ЕeLV\%vlb% PiJńDxb{Jʚj_qInOչG ^ h V&ߖ "W ]5P"']QkVX1v7Hy,/3AIol5zAǿ_(9"~ʟ:u#RZƧD%m9kM CYNND`-f45̑c)fC}-{RKQ˺EUj$J7#H$Zjቝ1[ .PEv 3(9 XaǮN* T'ȁGJPeM(̖*,z3*BQtbKnv(o>E`eC3鞤f(0{ P^=3pZkq2# IKحlZ}]b{c'pT)dq s$'Pj.@=GaJ=Qd`<' P֓,C4^`5!Mv1k4VT_` !qٞM&5:-4Rsg/YM`uꢏLdэ>3ty䓡tCB83\>LZ8TA@II [23.i CCMT.)]a$`5sȅ}hKP#u#3tqzpIXmʎTκFBK*qn=@M7+~rlK^9TEY$vSVj]Y5ӛX#7_MnL~kQ_~6eNLHBHwj,2Ӓ?^!BOYXT1oJ ·,5"Q\6M^.}RŸ;}0C %:u}%9zŮ~Xa{M:d؊!80 aHK@D6۠xx}()ǘ6 *J+6p&@mQZуY#=Gِ@`F!2d s|BD+o'(59Y9pVv+pb&Gﱍ,"]f!%)^n3@A1ۦ]Lcқh*i#]&<7Qp'j #&l=],0f8y+qLhktUItob &@3pZ=14d*(wL*&TcR ZOwa)4QWQjD1nDžg JWUk2lo,]̈́ jv} [Je+F `jwЎj/eVvSḦ(gg\`(tk&)zZ "7討KHrLO$Gve]R`MSdxr_]~$騔V KX6gE M9p58)!;ȪK g/ϰj_d֡9+lvDk*$:L5cTozJ5:8]G'aVe[p+ |[A\k!D6yndxɨ7jCZk.t0ςLsz ,#`1o&-7O\*bk$<䈤(ȰNV]U~D9 .ل fQ9  _BVGjcc*3_/8JvD@( JZm0Ģ[#-QnZCCV&+> 폣$Z BC!jfMU[J|Gy&X09 )8}u}}kUP߽.a\nG;4d)HǎyʼnN߮*deRĭ2d^*Y0T0"vpTJݍx7<ā짜JAS*q[M^WDbziD8N 2/BH \ ğ kdaxބ*zI {{Nêu7|(ʖ{n_\՞ksQLa؈@6w/5| xrioH~^9ם$Yc͞0n2E9'!:F L14+PmgI(Yb$Њbcz}Y9C-/\AEDGH$9 l>6"C&x?p.sK}-dȂ5Hb?;)X* kie'6Օl }B f=|Nq@UYO;|HQmʰDQQDݻ"lXmMQVm68od˦~Ȑlc$U˪,z$J%F ׋#4iՉdZ/0@H-_4G~Zڀw7{?bo\oY뢮FRQ-T+$%@IN9N*ޡsV6Bm85񬙒[)U%inh~n4I$mH5dަ)P=XsJEnoc @'v1Rv|:KCv/>fXr 矗i_G5 ͥG3&!&ѱpsx;g`MTHUH7|TBׁɜcN.Y5D |1-?Ԥ52ݡ?@}fp:tA1(7^r$N"hD׍̖8=znt,-GE2E#~yT!ҏǍXwelIǫ~0nu0!ᓐ=c#6^Xs.n *'qOO`G\ L/^=]H1nk9׸Ӟm>kh9痰nb=P.Xwdn)Cڗ+ t\~95<=vܗÌ$`x7S>6(AH=Fm3? ;}E8GИnF3h)}@[o i\^5P|;0jhv+f|Ix _\r=O@[|JD@8X33՞8zk/w{>w=վk\??z/{y^Ū#^}کyZOwAֆ ?_3` ;0>mu}Oe e7}-Ec Po_<)_0Njlgsc+,绝;mӘcbcΌ3c|. 64ߟYފ{&cD؏B_3^xD#h,9ûk;$܂vEXݞEXJ|r FcL-(X=(j&WVc)Uo>^EEgNіksI;G#ݞ~6+ #97Y(lbz|Ϳ}w@ ~휅N3dw(=Cu/~~$q~W}[Z1q:3J"ځc~ /ǫiD g"x6e O1(|Zwq>x?wk"qVt A} 7=U=n(m?\^dߋQ"T2Iq^qy%0.ol?w,z2 g@(uBF9t9gjl'xoA%1i|'q:Bvadω?G:k4io'W&yGEiˌ;'H3/+ +5;o1GZ̰{.hȆxh$ ]y77O /z6hZyޚ_>}L |Uph8u~f ){DTvMSI_9FbVYdb8ԓ`;({~YϕJH/X~nagmv81YYD{}BcfjFvr"ZAA59Ҩ~I!}R}Kc3PP;۔yk;x'tcgg|L4Oߑ>S{zƔa34W$ƘU3o;gV86}},9C{hƚ}T8OCR?⋽?MOY+e~گOTI.nj^`~ĎN)4ń$ﯨss'Y3G)8ާ{`f,:Nc~6H>I|_O*e3*'_I6\TFyGd7Gݯ/n E5̹ 5YrKΆgHa#ċ*y8 3G-'dZyILܑIl5Q5O I.HwQ~=?L8ΤO;s[*{&/\4PybJ&Yk/שq۲WvӦy~lpvMYn'⫟O#*]{lGv}izfrv"5-9%#sRFsH'ʩ 8:Aǽ٬O.$jRДJ8W)$(3U]tB$}9óۈ NxBhLs)h21(`i'`f֗Xi7}xu7oLr"mg-9LVS€vħvٟ74rx)^v=7Bnٵg֘!Oy5+s=t?pX2W191ʞ%~>ABٞhx]H1FIgF4nAo5 ;[g * AaU!uo׶MOIci0~ogOFn0xN;0M`3Xo>e\_gr \;!E=+KsFw=~YsVM^@6hX V֖d?\xY~#2Fr!(b\@?{;Kyѫ3 #X˥SzO%9C[S:0ߜ{9._~1pZ.^^<, [^5uh'NwiR3[;rN@{`WP+uǘZ-*} g%>;vGCi=Y'}"qYOncK9ng[vtvɝ7 =KLfv}Oծ xH-!lmr<ވ$lCB\h;#_-{MUiԐtYs0xJ'%9 *ɾ R}O R2*yh-@onz9<$|D:V;بtf^uXvD6cwtxq: 펍p XӾ][̯MƟcF;N;"ߡ7$OUvw(6bC3}R-[f|SDgsz^xvΙ|RX) S~3wvzQ'] /F6z,ߝmBy2{Gq,kË9'FƀJ]'L6Qiw$" (sH `c9Hq>ٞzߪѐ RTby7ZklQr 4jd$Aupy1f҃NUt0RβJ:.)/9 bdccу$I/Ш$-7䀏 Q; k+Ur(-AZ׃9fQɏ!G;z ;.q,3RQЃ ^l)rmIUSD-hK˃f#Ͳ=q8GZ+I"JY} Ѧ+HA֘F̏G6H8l\i&FICRJ[iwcA'ϲuJ& ;XفooKɴJ\.` Y%JFuYxJ[դtNb =W)No$4qm4ɛqDr3) AVHP$o_!{[“Cƃ\ޞڐ`ޙmoO.+Tj;,2lHzN/.5%*S-x8_N*Y_VZ0k5'.!lH.1du5 tX*Mі,e%[RؕԃR$?HI%=hl:\ph$VNp[%DB/c96pH\Jden@.G$djIvrQk#{' N~Z+%e˵!>ISJOJJ!>TԃRGiZRi W U . F6e*;YC"GHC0S b^$ HdSɪW4JAl$6O%EAkGpJ%)iyxdQ%k~rڎd)W"&@*I{@p ^njӡ7ҳRzԆ7㣔ŮMh65H'ڙv~ƆdRD>Nj$=| "ޖ @(Jj'kP>MCoOf!!g=) 5E!M\CV]o NP,{ʫ^i%*i-K3gO 'Ih f2o`5cjw+j9QFRAYFԒ,^{ y,@^7tPWe1:֭${N C4r_QZpQElySsLeR8[K4oCHe8YWS#MWlGgE$ nI.HH,"]%R*MQ> o4>EcKO<4;ڎ9r@ Ґd]"J#'I/@|j09ӴKA)s8PcjT)e H۩Mk]`zFwS43h#mACO=IWq3q@7CKE֐mJSAA4Ȇ -U\i3Vk6(sՔZ"QF7yR#lϓzH<:٠-BPLΖZS4W 8ZHM0-Z~&K 0Jd}5e4$֚*I AZRkC3F9h cc^Zi/,<y6,7%Nm~ }mjK3ﻀIYSnڼUTshp;ڴʆK Btj暒t fRgkʤ3)$)[2f64jfɡKyqESBӘ[u4e-mme%%#uEr5LA =<JR% nG2rK{܁zZ@qT} 5E FǙ /#` RR6{?5ͤR}䒁ZOa'Rv8ż1]/o2;4pMRP-RѐT|ܭBiRRS*S,$ w,GZ mk,F-1*IN#9 H=HҼKRg*gDiC#1*A$Ż4@Ȓ&3HҤIҾdl!5ӫ!`_i4M!3턭 +qLve[RƼ-ؼYi vҙ'UA7y+ bʂF1%{(r#?Z*)fo&*MO\uYHke\-ɟ8ۂ`y7T&新4Jaҁ=١$%?MJ0ȁ{QU&(M%=HZ<Ea<ȕ(ڬJ+6e6qꍩg(A(Hi:js\[\fVc@;Q8Q4e^N*Fd%H֢VI2Rx.U%&L Y;Z[SeI-Z|%gXKYt;r'I=v{R1D~@ZTR&w%H^; /TI "Ac$HSGV 6]0zXE\snR~n$Vh wC8$(}CڨMe/X)#tNE֞Pr"ֆTQ-)iQXTOr*ц$.Y 섽%`z֒ʧ/jTst@0(%b)F{[*Z,$h'`RٵZ6dSRh$r|u$jNn+2HGS'0y JIeCTH"mIT){ j-.;'Э쇨\9(#&.=u31!;RxJŕg%n%ABIvn6j)D{: McKhpeU(B7el,.BWFńD \dQBڃEM5L$,N$LH%IsԪc&;7Ѝ]IH% eK A.aq/ԁY ޯRRD2զOhOPs.n'YFШ3'̙Jnim֨rٟ7Qў䷩n5y>dP٠JNUSqf J(Thr:{ mXC6j5V9 ڒ\[=B vH5p#IPU+v#mMZ"~R+MKbxpr&3|vKI\2bq2qs9Jj#uqvd;G抪}xImZ|ޣ\n'ohoTI#;mJs9UoDx\{0"5FyCГMEu 3C\FeT̡HJ`h4rvdo9~JR>g-R)6Fwff&NIj4RdKrAUi䩗p@*1_k:IS}hQVM*jR,HH%ŭ75dmNYVmHifA{tY|jh'ژ59NvܠQC6#!7ʢY͘45"m-Mɦo'EqL$sr,y%%L8D,ګBE%T\&Q-T`0j%l_u[*%Trv-)b1ZŇrEL|א:5$Jɳ޼z i_Pe٨F1okBPGegJ =ؼ#٢yr) :e9;v{[%Ց";m$mG$!x mmr\X.c+mn5F턋mdY#ۇ~ Ȱ"3 FBЁ? W)IMzvW[@29O=*5n}U?ߎ$-8ϵO5teT7bXʦ)ROƑ(r|;9I_іur` h wVcn%#Ke~?xr~lBNzEZǒd[_@$7r}_Kfe~1H 0443]oYnq&T g1RDj/5*vE;:Z=途ґ6erHT obgjkkQ-M.\XH7l7h' pڙ VڋvR =_l$$Ꮟ`?#c[]-ꚝrv 0T~GJC\BTA3 t*rMyPL);6L r%[)nF t|I+Pe8KT)X>?Դ0*sV"uȄCI5saBΖ!GUvؐ^;:\K6v /V' # ZSYZ\>͢>n'ǛJA(KZ.|(6bq0şhy0|02F!%%TR9Vh'rÐ JM ,'gzI0r:oqޘFeJ4f#GrE.ONd/?wlT' 9W ɉZrZ?tN^Ȣ"xjs. iR~61- ͵1?$O*1ve)U!T9zPFiQGμ1PJɂ d\bOfi499$j ZYdCõfe'irz-΀#̫/&-`GHҮ$apvG)5_L+SgMLy&S1b utdiYlԒb8c܏YӪ,#"ΕB뤐9Å0=Y\0g&hȇv|FN6D19H')dj1@ZNj#II=a-]lbKZnU<BExVn>ro#.oq ӏiYOn}%ZׅkW [Vj k׺x֨}@טH0*6[~CmJּhc}qF\(PaM MpT xliLRj%VkS.X:%gd7{g2kf~5/46ofo*ZC&|\ !ZЭZ e~SĘ13*&4MS7&ԿCebBP{p6tI\=;8׌+C$jaB0z 0of{Y9V'EAٽ2GAA zy7V$yŊbٗz#ctף<И/wd ,W8fMCf3fӯ[9轺98+F5VKW(\?+f]P'KWpy+8GfC2Lod ]d0 #Fq ~ BJBRӻ|v!cU1_9O)$N36V}.~oMÇ Sh.<0><'gl$&NӇk1Q}abrD6,kч#y{^=ٸql@jGjK\pz{8sA4= YX)V.cNznzL@L_3yn™ZW`V'6xD2kA?ݜhnNg7x//^>N"t{ gt@/~Mr+1;Ⱦ~U7؝YՇKHR %#ŐaB@\aMT_1p1pыZ݋rw|ljD槟,FBs1i|xH ktHнen&sMmSŸ1Dt.oЙ\`+.8u &%Ȍdb>E:]Hm$׏CCP,oH/ÅЬ|H@sЗ^8ylH9.{dS-S` mYӚå($65P?ޘ}|1\Rx1 *a9OP1)e 0 @4u |lh.t427*; 2yD :xaB\-]XYA3H+z}$#^v;7f_w3-2‰YmE{}2z#H(Ȟl@X[\D>f90>/%=#{q9azAdbA n= 㼧^Ӡ AGk!{؇I # Y- ٻ~ D8>v\;awl/Q2OgMk ' '#`tq#MBiĈaHczu绮7s1RB;|d^?\h`t(< Ct(.G\l ;+>m=l 38fR&0@D$wY퓱,fMf3;q)C ke b#sî̮mzThX+Z41X"] 0np ?7ƃ9_dQHFihޣpQ}( /Hh9X>5}#ߠ<׸1LDPk@3'6kq">s4>|$ړE IWxc #pPߌ' e!{З$BDp{> HdOT;3 ;=A|mD(ؠXGE׌#GP-##?.t3\ K" : $3Ha k(j,R>*kl!7WX\D0=+!验 ) s1Uc@MS 0FLcswMc`JՏe㪑O\7~΄-SuCA )Y$t7Bֶ|,h,| !6fnyT2 e lx0Л-4u/tRCtS<LH/f]o.Ac:Ը d_y5}3zC] ,)96>f ࣊Gكؘ1lLTl@ gke=3إZ7M@e0dzbk1n ӝBv0x@6"s&sy{f<# 2Ŕ5??>`>&9ð cɃC~uFJ6~$gXf ^Er xz>gCh; pSClxGlN;ҥ- BT ֕?ƚ(p!&{&y<^d%}~>of!1<-:8 A  bbFrhA@~Ⱦ{rI0YЛܲh|hߤ.n^=AW}'ƫ3 X9#Bzb{ # n81g.{0lc{p.klZ{L|&j4S6*$6*y,;P:#Q+/Q: hl (ɌH{@XW8- X_e &bF/4w7/h~ ?64v}BOnu@z5X awA 5Ddg}}=}$L|([B>51/l'+#d=Ģ琿g.=> .~̶I:}O]fzz"KGpca;ڃ^ ܓ@s0w w#a X-X0~|T0Ч,/&k:E!{"'`37Q $ hWVά7!~Ȧ#,<g0HgMFp_>w X_`H$!yh3v_C:r|z=C{: h- \#4pڴ[XGj+c`AY6=q}8 n00n#2No~fh)^_Z}ʴ\.MuLF;,#!=XޅNŖe V k Nz2PAآj1Aq~ 1pi~0V@XHhr[F@zp{^>Zd}}{-dlakX|z6Ge'TLۓ/٭Mss06Af|.u Q4 ==k@wlAAW/= ʆ!C_߄|6kqL𙑎_&`d,1^&ֱh-' K-Sݢz~FXʴ~JzYkfNCXa.~a"U= ޴g|o'j'i]\NG70=j 'ug 2 ֡m]Ɛt'{=v>}? xw [.hXW6VPc̈0.] f+ο-/؄19m&77^.>5lOoyLnA'cRDEA>?.}0L>[u(x3 I;ۦa0AHm~,pBL(1&ұ_>|3 |&ȲYx>h~ӓTZ-ҙ5R1 q5?س߫ظ1Ҿ}Mtzsg &shS}Q6k1k< t+2Go5ۥw?F>lYLYҕzp +Mx)ws"ăk=<'Aك8[h}5B#`:.vQUذr[sAC@/~UXI/\8.З ¯NI?T\:1v1W6F51M|! BP.]CY{7K]?o~W:QXc,8+{Αy,)gt'a14m2oh/0R:.e pb?T⃐$Ps;~܀1H7igKς3OTO]Әl/9C_e"2!hl^bNgZ'bŤ[s1/v?! ꪖIm{"08lch=G_Vfn| /F! ְ+sR7>c+OO,ÕŔKY.,[ ͂W_P[;?\ͯIG xvMvu}x_ t 3y:^9Fs^0]rl{`Q= bj27Fš{W @-pdza1}!&$foz\k0Wt>yClr!L&V*.1Rɍa轒{^}hwx[W< ptLh 9w ΋a+q"`e̗C[%>0<p hL&pSvqs?͘c& \@'Rug홒og3L ~`|{ 'wxoITiᢎT8lpZS%m;$Bt)-]p%ٌ?ȦDtPutk>o:Gh#kdטrdj0U'5{a6n4U}Jmp~ӛym?: Wxx&}R=xu.{TxFׁC<8t(Lt ]rdpSbRkALKĺj1s!5$3A΃u9[.bɵ g]&mzɀAA@> ؒhkG\҆w'@[' 7r͝`C/taM~zi6Gp?\9[}jh@|"SMbP Mm6L{O@D68l0L>trs~ ƆV3wasvL73k*0cG V_ 0`נ0) =!z"ǻ +pA/?ǖQb(`l]-lv5V._pDiF{1f%?.j(N+;li$kXOq:~1x=a=n<}2Iq>cl(>"}0F|t f. |ˍȷ_c #l71yBp?Z>k{b9ˬt^GdNQP`xz& /)d_C3Z&ûו>@/Z%oG  )DyOX_u]{^K7^);8{wh=UYGIo|1ߍu3F:YП;vg \a[w!t r%' 3W i1u54\ҹl 4.rRauʭSLki9% W8f vquP@'7pX<{oGᢊlA>jB:O+0q]bVT1sdbh Y"DR 3E>d1obʭ`AOen xgȂaA= 73U$Hΐo \Jnzchw,3C^ K s $sKix-h,2!0al嶶ȍ1 a >gH//$L.#߱c2AGJC/soLmz)<O~G=ʬ 9<=cZj/܎?r(+G ΁a~Tp ЋN՗ił)}b F\/aѵn/˗Q '1lkODq =O0V??Hrq)poQSlQ[L6|OdnŜ}|GMk8*a|{hC1F!]:pKqaC"CU-> }$QutL?CŲ|6)z6PbΖibF{N5dLFx`4±]3h.zn) Fv [sV"syۧ3jvӳsS7/cPd3/A~@!8/"l$reڀG F~~T?}r&l¦tD0*dȧkxbŊ%I\C5!Y98, ^O/Q'4zq1SZ@ sх#pr9j4\p d1Sr#ʊ[S'xB lDA 5q"B6g`V"JF /9mAWb= _s\Ǖ7&Z|C)Bf$aw9p:2=8G̵\YV׀n`:n-e%*9$C̎IldT|cK5k{o@uZlt 6}2x(z^t[ w?.=%欟"%2dG\1C?lAn<)te>zq>z _m!v'43vN54*;edJ}әoL޶a#\l/1գ7@p^C -rK2quwqGGnM3GP'ƭS zzC(9,p `f]Pנ-o4+9i fƱ;jW3_)ӄA9GĬ SĂ!ӴL;@C1_qaEkz0DeBg&̎w w!fn=9-:vc͵#װ/[\,(p. Ķ+MחOjA0;ӕg5\JD.pqcGcќ"L5 ⢆ZM.lC6=o1]}a)>qxY9z ևcUyp^BZDا~^ 6 <1 ၔVkBSJ;ru.BOZ;B̄)چ=*?UC&m9b6%݂ S] skh\`pٸX [Ɓ\>lƴu-f}%6gҷObRкAŗra蘪.a=!f![~2=8o+ȇEvr_0QPO35>d2̔9؊1l񡙘@9K(7|N f 7@eUDOSA|bCQ@b9;aD92u`F׏ \R9A6{c*3nuҷ=s֦ CQcÕ3۬nL +k8>Y$S{@JRxV 8Srp&N)c2l=c ]5r"؈li-4 9BpHw@ =6TWb$c2 ri1w yk'{j+~Yv|F5`+ZTQS _u~Ab~w 0Ǵdۚ2b}85mmwIήV?K'[sTwpc:E[_@)_" >[K>'XϠ 6( {!TfBbL,ZCcBB?m$ A^ 62k(֣9 2Zr0qHƫ'`}]zDŗW3 716taL‹1 ᙑvw 6-t~Wx @vAM!}jh:xNIwMckآ ?NC[`h7P&}D6m5Ych O(1/~ni+6\jAd{@#.{ΗyӀ|ꔊxDtCDž<|q$pe5C 1c+Bk` ŗ}#8Y1񀅅-ӱa<_u~<ϔS%&3xG2@.M`6uSzҖdkz+c/\;p,xP7RؒJTr ΢+/SW0, r yi-c\ R#>BLln&V` z!]]wZK$h\>Bzn9j: ]qP` 6N Ą qFD?!{F^FVĨQ>);nG`;kO3l@/SHOc[l`N;ۧclvOǶ/mw0Y߃k?7w} 1)ᚎ|o%ٗTӝOEלPM?A6Kz3L6G![vh6@ߟnH5^< CԆ'Ͼ`.aA?B+SvƎ6a3O0o\A5]󍰯 0< u5~xJIBƉCqwdgdEqۭٴ F׍2 )['*/%Jm.J\oV_Z@m~E8-~{+XZci{׬)ƀKU4ftn$St̆m;߸q_}EgA1x11JA^|S$s"ߞmxr!`MC7^ ]s ;?7^ļ4[lП A0]_ \oL9s8_ttc>.;:>1=GmGot6YNgh<#7ƫ\S~#K];eË ?6t9̕?h{qp˛vVsÕݩww70T%Uֹ9'xueĭ7 KVgOiT Wpsgz8&}*ro 3p75uծ{{m.KNy_i5Ǵr1;={djb?=2_n9-71tr:uDCI.:h8_y*o=qe3~J<fO3^*\}‹>򚧏on p{vYlPl%ڹO`^16u9mEGgVtKm$u[95mb!yt`X==p7w!E^D5D vCgvAshו̎7NԞN잇4 {ă7V??tՍp槆O sLw6 c'K!^u<\ir21m7Q;n@s, 1l~>e -/;0~f+#{X7"sU.{PFP*=8fu4{..^sp?\52[V0ϗ'U` ]Aux&_-b.X`Na= A (ח\/·qܦ ]Kg+ϖy_}Tbs~@WFp)z.79o3q]Rσ⭫h_Wq' nO o(炅}׍_m{bW_B|ˏK {Ovx6ein'/ wu|C'lzpjM7h8$ {t'e6eb ՙ /eOGXW:O>V5ܵXW;%IΫc{Xꪠi/+>q{[x9ƍ5> vE[:8}w>=ӹl ͋i>5z?b!F+65e,lNYr-.fz޹lփPW?T)m;SS~'fʎ ;$VʾW]p>JNuښUUϟ^2_vO O_ev.'<F>ҵ%kKs@^[p9~<黷.w Tt)lfƳ񷪐|57^iCF1iř+{B{0pwk[ m ɁдasjlŎ /'WWqNT; 5R{?%:RpjNmÅgӫVn ZQi|~ٱJB~wooB:vu4z?S7U<1<{4Y*۵"ǻ=^tzvS׏ޏեܮzd÷olM2ĕGlT:]OxɖһiuStO޺.VXꭸ[n髷̊z86o߮e_0K{oU8:¹rmJ^Zu9iԺugW w[c[WSwC5']hZ׸TZ3IQrdzS5^OԆw5Ͽ>FyZ濞fя'p"yڀ]uuS2o7r?uQO|ʳoW.nyiݘe[/9vZ}%vùJd*wM̼XPVx{u:{y OȎ{w'>Ӑ~i+N}&}7虤ʍ?x>A6ryWX.\.3 E.V~o{0';7I=YzNl;Uŝ- ߯=tt˫/+/ -3ATsF7OaQ}BaqP1$t[wdE//Ul<]Syu's7J,& ws]^)yE sןɬx>z+j.gԁr׸9YJr ߎݍ*}?tèrwrVlIU9d2^xkŤ5}Fwߙ7W,S):tbb,jb`Q!V(V3Ua=|bxb9 cb^Ε_~r'Fy;s*;Τמ:P~\BcˎPaQ%w JDDQ 0(&r DI3%GA ̎a1bitL3ㄽg=u>s{}yZnWC}koڸRq^ҧu-WJjm y{zk m^ƣꦻS6>xϡVwg=vym}ԧ⊕w慄^1t4ƍFVq!H#COfYi G㍭ {$Z1儾SGKǂ+UH |r277/#_i*]z|a΋u.oPpjvk5[c{\wbN 95eto/ny$Bx_yZVGUr(u)n.+myM2͜@ 2u}T傺Su'Η5/oRR|amC ˯?y>ڣgj㹹jn92j*o~zU䧍Mg`,qNkA8p1? |V7zgC4H7@h44}nr-m*G!ۨp'QOw68WX|NͶK;cSsrVuaeswǪnlhWrꚯG=^ُ6~zC͓Z_׆,EVqgχ6gsҚ|j Np<.F w-L}s+xq2ȧ )wS549cY<&/m~j ,%ZK> "[*0K ˓5'+Irq] u86khZXF= Ů]˪w1z:ߟQԭ8`:_n2h6p`hd(> sb4V&͏E"# YLQ q41B#ƈp374r' i#Z}oKCN}t$fgŦoꯟ(lyp`o>{9M7vz&ITsW}?hC?G>"Smsd7OAfFӱmL m[̍!3Ex i( 4Z͈AK]^:l D ˖)ĩ;~ӗY/܋{Y]ogw<R_6%J] 'L_́"ό͑ΐ2sDc ~m 2}8?Ypf#Syx|:1)B#g!yhs *CV4nGB/KY۫)6o,XSbE5{m;r-(q}We5?Ŝ;nCG|{D imǣ ~}Stl=}7M;daG!9QhT4=MpEc!K5h[:֢[>xL,~/,__^uusEok}hE6@ΰFiGpFYT|q0|ƿ5I1"^ؗYao4a;?dl/uSܾh52m#~ LMFVjz;+_ƋjXrdWݾޒ8#cT/NPu~º3y pۮ˩9~=Ɲ̶IU_l %-=}OFVX+/Gd>ۊt# ϵQ拐%'F4nv$qCho UD0zQo#/=^ bO).:[STesEݗ/4; %D|B? p/bܯ K^662l׀o!!XI]e q?,?0+MqOECфR4nbd-~ϛFcJ1;Acg*D4]T3n7^Z^XRp}"o(wE˞쩎1ۃ1v4wY~v]sM]ϽWyƚӪZ{Y+Ƕi !y2#Cz; `/M,q 2 G3d+db` 8>^yRsqջw c~#@<0i濛;Խ5 R^aa=!A\ Z bͣ^m8q%zFks^n>=tɹf 626u>r @ qS}JdCIhZaMKڳ_әSxic3Ni-;s znCa磂=a݂cS^a= }.(| 죐|bh;ܪn3@OC1O$OV7Gd: H$h:UQ%h ͉کa_y`Q`[\[ ΋ܮ1CQZgSO\ q|$cr,n[F }w M齿wz_q`w"&~'eQYa?=Xf4x,gVR4vjFӐM\/DSh4iqGhU6?k.:wcmV>bw+%N[Siޛ_O% u G2w~t̽kJ~ܷ nol[ѥqVqY=<4,, sldOt&ZݦҋDJ\/*I*A EP$?.{-yVSQ{ADϗ!;"3F=ղվG]^kIV}@*%]?u_ݦ7~Bwfh ǹk}_8tfxQ}"&{"( N79no7 ȩmϯ˽SYx:Ps?-{zڛc-a!ObbϢf1|Q/w|<@A,^_o&[)^Z[SdpͿ~T=z))?K@TMο/v%4k5U(Ϙ6Cp՜N u%8|/($VJߥ?O5==м(>Q[Oţ+9+nL}ܼ9y~tҳ~_ 3HjD׿y߼`ՋJn2&hAt@?Òi}( we&/K@c툏 jǥڈ9h(Wslx30偰/B >XT-?-dK/N1VyQIwZn䃼 V".m7IҔP-o:7ޝ9N&o3W2+5|co^8WWÕIo0~iF uk_CsZNQS u;f'[|؋sz RI뙒O7ߖ'.ZfXD-,ĮԍX5<,d$ԉ\zϔ}TvWoNgQU&͓߅MVvz"{<4K[%L"u/;^\x~2u+m6SZ~tiɤĖ tBn 7WR RL~,YٲM覯7Km{|'^d_ֱlfK烇}y7ϖ^:wglG72jMIk w_w|Ihƴ> CmfS΅anW}>/9ҳKpd/2hM&H4ݐ\{$Zx6 A5#x ycRm'kLRt?~[59pC Sgʙi:>]{U Fݝ7wIa!kC>ZR߄4_$b^ !aWJ0GqP.Wk}8}f U2sgNh Ih~cC Sђ)s )C5’"CV HA_\|/TK,^kf+1쫕]|{{z?|5fTl^;\p!/]?WZ}vWN|Jp/.G[L푷Xdm6!G44#|iH& 6ӂז.2联vMTeqljpyǽ構VLN9`.ǶPufto}d袃Ǟṅ}W3dO0Ż1k'l$)SE^DžE wz$ꞸC0W{PQvSO@\,E "y|}(;WJdjҥ42@-WJR[i-œwNPiCSDP\#Tp{3_,b>Oŕ{ҽuv=w6"zϾ~n"z%~+`ohҥb!Q!*ECT?Gۻ OpBb !BN4Rj'w. AQP< kKGyV)]u{GyYTr,\_wGչ' uY ꙩuQFy%$sR-z~Z :c˂ؿ*g}mh8X9&cMU>\qd?-9yGGfZ6S%;UsrK]{ɘH#LnV3`2Zm5vy(ޅ:*HqnuGt./|rf|p=YހY]d@n%m{`Ϝ.@vIG6qϓĢo7Qûi~ه͜EțY.JiHU(RTkɃR4% u''Gy%얧Jƅ.ad7` @sX>Oœ{ZW>r\b;[~bdj'+^l|7~tItf \Ro$q6cuw*VG!}%aj#*"4n1FBWXF4gah-/J3WfC0prtyz)Uwit{Gh8`uIC?uj`,鴍Hy/>il8inKYFpy\$ xҥ'R `SA]lmyUHH`rF-' zFDOhcZ;v1 O\^6mŦl4S* 5u&zw|pߙ{ uusⵊ>6;buL7w{t_"=?.XMR?z_xR^,[п˾DK=d4h1("imي5+5mYjMGg7%J,3`s+d]eRaFEpq|Lh%=%}oQtj2)|F3ؕ"oUt/jи㘺LlI]}z&So2&K{>9JO7m))?e-Kl6tqBh rrD~05FMEֺ&hc4m(_nx`o-,iJ>t!~!'l[R}s=ɡo3飯1 wG1}d`:a0:{$FكL$C<F3Qy~:ԭ0=|l*P|LYyh*~c!ęʇRLxF1_A"yI[i{G\|3'ȶ|\,d+i!?Z4TÔ$t3}ү&̔vqe,CLlM!3™+!WOp|!o<[$q#5ڠZ,NI ?<+ 4\=lxvcjO.[ݚi X{r*S%;1{7j' [,kUe[e1{ǟCZqF^7M޿󝃬xIX_-ShR +2̲G3FOD :#n0"A^v஁J_ ̩1Z}ouf̘6Ky>Tt73?@/M.7"5yY#I|S{q,;4?99>)K/j ;ɅZ{2 bf4(M*ns|hɂҴ@gy٬l!<_LBPzeKEg Hp|k[ @[aSq˛aoz2&BsE''YV,f<{lώ,`MC S':-}s쮑A΋|Тi BO<.H$L.ǹe|>+] JE֚peǦ$ט*\URuƱ-} A 1&3t8#7Թr;m@OzuW{,x$3}(VqrvklCGvvk6vUu`|G/R|Rwwܝ-K1kKWLe%SD~r_싀 / ǾGN4?N&:yhq&[ߖƒ['&K vtzwmv)l2^m¦7ds̉X`6ɜhIu=k=i!,ch{4dӵN'L 05,xbv /]d{{P8S?;,St?w54Wg~r3SN멠;~Ctq/yTV9Srl3;,jA#eJ3[/r;GK'5A, @6D7xDc į,FXkLASJ_p2~]0!@[hQMAL4+W0-|gq~b[dԙriLzS*GI F*C>6_^Y4> !Pe:ZmT{'#xpGVQ%s_ x6h ICAŗ?H<@'4ahaK6[]wf6hy!| hqI[-!nqeXw2-6(hhPY:" l:hW$kˣyv$"%ןGTh_i4舃 ,'dK"<|8Fka CJ'h KY-g"_=/7h7?50EPG~u>2¹^D5w1 ^窠VJ{P<y78of/n$銢.Nh|st"~se/Uq꒐DMq( Mށ-\vZThE4*zE ۪nU tD5 e|I~r`[=ScʚofIUp5g!j?jVWfc`[ވɴeZ~~9:Ʉp`-!-c%ה.# 8͗zu؇9 hdmZw-"QI&cZK/@XS==.StB<(R 62MF"FݟŶ Wc#aҡŐ3Dg#|(" 萕Z/y)SmL,ac2t`LGAMͬ0dqQD3tx/Dv=O#WsdѼ-0;ݧyWrߤ ڐg7_Οx_~uzÃ+m &ͣVE7B+Us\Lܖ!iXLsWO q $ma1W>g_9};wO=[؜tx&b^%_ % K*&๔b<.RloKtq ͆kC3{mA Uld6Ա͗0[_v𺁯A]u`n@lh:}nP-W6%pSA3I1aba%&1.DZMXBV̪biO_zė'Wֻ2ǚf G0]8ot`uɰjP&Ƙ0 r-%{lsw+ݕ(6qgφq34nb3h)قS 0emP;~t Yڞ|v;O,b:Xu*"Uk%p'E l!7' QGkF̺㙤 CЉ-UN.5RfVxb᳁>|h q]@ئL}y sm0scL|>8Atr)F"}\i)q&s@:SJ$CA˛hDes):Tj-)!za30D-c//`ۯ./.ҔZMA[m,<8@n@㎔@I8ś)6L 7ևNDxx6X֥]i-p_gZqQ>4&b Slo̶lv+OL6=u;{::2o."„+Z@ߘ$1S|soUqP-bmOsy"e*jl3Ǧ=plo}d[jLLbv{[:)A3ŝcOvz >dǮգj!4m1-Z=!L Xl~:V"e e){@[]nDشxõPv 9 #AGK,4 pܕkKG}t.LFd+V`69dVtvI`\DV`VvKj>~H#M-nWjɵ=grW4G _q<ȕvX\n#J-( ´sUtvՁ'a7J'  6Xփ-V tƘޱ욮5>cѝ9 w1dnA7rY.F኶1jZolg`3X3KN( ׹d5dj/+~ >0BWi5'C~'ےwE-7`rlA+eMW%_MVt0[~p:/M %=D߮8pW禓 Uèbn ryDG1xIe꾞2w l/TPu"P~"ٜmTqۓdLhF<`qYT#>(#jy-`gAE+Wa{ŃhW>ZRmY 07]|K+6k.+7ulhs$,#UMW`"06Rt*u@3sO8pjhq{9y#/=v_yy!↜RCAGr [qAc®A/Yw`"^_yKc;eI+f#6ƖU.;nmA+`50Հ2S}=|#и[6|6=Q:p G+Cg^"gQ؞_#\v ۞-/bNd*Oj djCGձГ'TKw}tNNg6\i#6spku=#;خg=rFnخJ9S0qZ 7ke!R`0 "3t`)s*rv_==wn-q B4-+5l:q Tz8!!Ѵ'붏AÀ B[Xm|`Rl`(uC[Qm"#eAWdq10f]%h4@D{I v`$pҍ>&Β;K'v{ȗ";&1{)vg{\O\8s\ƨhzSs7pG`\0fTTk@{}$_)C 3Z܄?@tf :pޕlA~ou4v2.CoUc\2D McRaMBb nuxf51&ZNk|sGі1AjRqI8 {r2R\5k9+p`}Q`\#Gn%jr쬱F>Q, @"7)/TiUuT:Ճ`Mٵ#M`?ٸ4w-x?D~z#[8v`b^[Eٞ)V2zZ)hqsIF Lqm &5@Ed2:[6VaXXo!.KfTh&a9aI|ͷ;; bFc|z ʀd? ~P&|X6]W0l*i4᙭3hm|h L` ?xeqM}kԝ]j?Xs"tLXkNds'Y`NX`S:q Dz x$d $" wlLaV$W2_|+"&`*4pXC^NئwJarVjw^\9vJrΒ3b8'~]X?,0:U/(`0xm6,NK'MKjdfK֫H5 Fq |1hs>ẶqLGH(qGQ\ n#p9nx9 n**G/(<0ۨߔ켫Pl{*A;=ag~ΉTyb;vVV34|`.*ԹMvv `gΊ4 ԠX[*q1/s6Y77 &*Z `+㼃_BԀK1M%`jd@xx%='\=ߊ&s|?ir? W#yBtpIigI($ QT:"sa T2͹>r%GoW+/+62|ja~+jc'~_T?Y)Yu;ktEq;eb0@ N j, uuf@U\Gf l &7a|r]=~O[<'p~OP'cU%C>  覫󀝥}ȷaǒ|TAyCذC (?mk"qbl!8 դΥ\ J+' TUjp`}q(_6 ':X|In<4N8qއvVaX`q0Nƺ`v@rAPUeO1=EbfU3>Ķw Q򝷗*ܦ=/}>cܕeG}9C=wdG.8|,>t賂mLMg99-RfYm~m-(3ͧi5 : [,?0l\cCI6chxq.K|rR'kot\`S9\֢eZڂ멨z[TXFfPQ{⽓&U6ԒUa&#S;'Z pubK2OT}e 57+e4]]w%#uؐ-Erpz.R}Pl} ff!l۵\%ʵ;&90oqGba!En0s譏]Ks.o&хz'vb)Ej!S2G ڬ=!ls3gy"F ymeAqH|ln8o ǡ̲Apް4[o;3/v}53T~g툭h 1+|.1/a%`_{vye'tpc8-ؓk)bViɤLT61&bi_cN`<3zldXC{3*  hT='?|~ʒԚ'U>د֑'ԃK\`~ 8lr9 /0ȀW­f{k>;[NX{)E|@\nwa6ᜉ}p/-" ]'VY.6Mm`Lko {y ]%ڣs!^䃰gKYm {7`^ybEJ)`!$Ȟ#`l0~̄yb¥՚әabV av+!-Ѯ)5&&bE8fP#اba1E2vx"8Ԟ1!rb_?Ā aXuCrDgSlutL a/m1nz'>]zȓ]s\N%] B/ڂR崍&~|-a6s6#t5Td696zal-NW/E\K?nSr$fY6ԇ@npzL#J yy}xr|9_/Ǘr|9_/Ǘr|9_/Ǘr|9_/Ǘr|9_/Ǘ1a[lkpb]s'i!"R DȸT+KyZ9X' lk@VogNmg5Cme3VVeEYhpt] ѥȩ̇SqNXj$8-u*668&<̊l_;yL+!3d)|jx2j&bS'ol~-jL+?+jU~#+kk?~V19ϛ?_7-o'OV~6ɱCtg'Eq>V,-1OnV*Yyշo?Vv$f'Ί|> Da8X͚7o>D^^ rsC^~ 򓅩IeCT?TO#wW)Q |,<[J+ miXDRI.D^Qj"y7\D\]J?8^ӕ(iH8Ԕ,4QS^0HHoo r[ꅿK?LJ-`[o.RHUZpI i P^Ks̚Ӭ*3V(IP*h-PIT@I6ȬD-)<0JAl%bE"dQ@ŠȡC|*BUW7&>S1iˠP rLJ-(cRe.l4'[ωLN!&P8ʧ`>"4h.H$%e b54XGD $r.HH* <JVe 9n)7\},YKECR@.\ `$Xr"mvUJ o?9{jC lLS,@;/Ry$8A&i2xM&"E["GOX]<,0 Ez)lqsPWl4QfnPvX|*$!Vj .:YLVi0G)N/PTSeUpxp):P@PHi Bi+3RT)M@&@3 &xH j)ʨl]6!KJ82FAi1o!D9PL03u嫴A O/dWj̄"bC(W80]\odq0Ρt$֚Ҋ}ʜ1Dj|oNrY|} 9g(- !K[ Tmc%øɃI ~#%Z\f .&_O‡;zc鎤 o$M)!p_&jR!Wx$a9D&<P5 @$bٰAP| Sgf(jey:2U eϊ\= ʹAKlT`ŊB=2^Cv40NS"OOq*HJՁ׀ S.+Ҵ@@q" RfL,5rG"ߗT;SF (o(U BxjÑ٪u;'+|Ė ('6ρRal̈́W6Ją \6sI >oX5~6oG}!EqڇOÒ ~y4 sx(qdWAn ^+)Ac Gf@|$}r) + U]BCˀ(b IhkI?$0\K0Y`r\ ?2Fo".J&R"y E[ G<n;3rDg&@zK|CG)u[ %((.eDai7Z "@F ^FG>y\r8J[qCױ"wpDb7s!A}ra.*{B(y^a90R|xP ] e8:N\ W'=RqJpdD%H[QA7 "  $qIX|4bbo,fn/)n0nish+>b *@d^e{k `?ҧ=a @`cFQ̞ e{g= ;LL.wGs 1>2e<ǁ83 <K=$9ʯ"޳0j)z ๅjF~:`9_  k8m!/PZņ筁ye΃# ]K46(9̔x\ZXC%̗. "qH^R'GA+oG.4?T BI1p/FxR%/y\cÑTc'IkǥdPxd@KV] R,Q6 c%~8N QY7}<Kg^烤 Hi5Ds{+3}0@J ȑr^9 + sw{n佩c 0J,8>}2C P.GTܠa Z V COG:xu`dNJ ohȩKLxN3~ a`=2q`b 9%?HTI~ۏ>aSp\CK <2#[q}nΉh|5A {!>1x*y9V& Y7d dpEEps)BG!! T7X؏\N 3 1 8)?0_`N>ީDz6`}Rd%A ;; <45@f3,߀&XPltBjF;\8?P q Od;[#C5Im('s&h"U}zTC^eu 9CˁX >3gY%feNLR&CVCfCq>8?Axv8tmBo:94褼.e&SpFV ejQs 2~؆gPxO"a&5 p9t0z'ݧ!M0cobAiç362aBàR0Xk籀v cft<VcSF}@=6- s Fi8H(Ե< ?dU`%2/DNXrآ`nq0{P0{!vo 埽&`9̮Ln>yKb9+ tq` ==k]j yJ Y'ڏ<8Md)iFY0F!^ v Xhkc\އ(/j0caP?97 @Z1)A~ 24F ui34"Y!X}>bpz兢 p3CC }KNT yq`}Pw\ p4U bVYQ ԕ6mC_`8]C N B^4CdYB<*-$ s~<1Gpֈ ~Fxc(ɀ.}&<./׿. s8Ĺcgه("g`웾L؅L~&'ZBIl"3aKɸ[AF luGU*&`#;!ua$HSԦXlgNIrZVI&g+A"eP&Pb Ʉ+J( ox5]YCG@@/B[S0X9 endstream endobj 56 0 obj <>stream ?Pݏe돟 |C5'@n M~c;Eu HCxOSPӁ16[VA@O!&A:v놴sy{oy$&HJƽ:.w:FLo J@h89ω\Y X:+aBՏAV^1|ğg!}Y ,$ 2Зygz}6KA| }q ʩe%E8k(_,~cY=OX Hld>l*c~JR'2PH\_ 6}L0^'{+w{読G/YtԃԆ=`ḍυL&gxwQ|2XLH `D.abdŁt?pOVJ7 Rw`3 ԡSà^^_|pF`S\C8%o9?pTKv9Ą9 ʄ<[.cc+k߉h=9 m؆"jӠzzc s,@naxiC1l($P;|Quuqz&g|lϨtĽ5`? L-`U5(<Uxx7f%Fa&fX/57a3ɴw4md-C  6WDPӧ|a t ͇hPn}hRh΃]$`(Kǀ?䄡kпD) |[{F%f( ʷ7K=ķuԱ ?m(`JGWLkp|vRЗ4?{3vj Ab% Պ-{]⋙DiFb>ASt /K)5_}Ӄ{+)PAjBdPm*x Ej~Qz: @rV ! _m(<m(P6(n$:b?>|z8/̅[yw-qw<س SRTuo3u)c6֟`r"%ʭŹjj{-$'kbuQZ<*Khu@= i8p,KQāľ] m^1U`XW3_VyzPU(@ Լ Kf\SGsjp :H !`?^c $ H } ɣFxu ӓGsapD'PSc#0anp(N9 `Ӳg4#)`%{~P4t:^GLPuT, yoՠ۰"EXP^yPh]Х2;U U[ '|lCAŕn6~:FX15!w&Oa k(Sqe;m ❸gpu)99KԎ̥ 2_"+ XYTv*X䶡^a9O{M""A]#աPC/6Y TT&l'φM4ɩu5}B9CwR)5{@$ӡeHp\O_{ F6TKõsejtd&6{ `/+HŦ+ Sw1؆",o%Č6?m(ΎcAƟ;; zkh>qMS,1x}P|4,{.2VAb՘-T%O迃]` <dW[tK͚0`g6l8€M<Ȟols5@zx?H.:oXˆ6un Qv.AMxi/s1{!GG дCu,#B{26J6F. ̃܁ vIԲTj^_=SaF vky'+1FY6t07u`-$V텚)@P\`e϶Rj95&k-,xeHلOkVCi!(N@e>7`HW$簕 b%ޗa>s&` QR~6,kv`ms<G[zl(8܃9x_L_$[{:9M{>9I$ 8a,Y xw2"VQ7}2A[ZuH&$w}V}Vm&yTfjZlię: arVl 6Ң x=,Gu`]TjSùR2P9>wp}"cy&t"سOc ) Z =/3a.l ,ӏJ թX=ϤT)3i*>ImV5 +=2қO+.c@ ʕ}IsE>ȱU5Q>@5!TѡH뵤ɣ r+\BmAnjM:]|;ֺ]AZ@;I1#`[ja3]|:+yF̰v{`ʋ,7[!v˜kBQ K+,8k y& Wm`6e~ ]CDpF:'΂zQB!Pf7/=l\ڎ*u*m  k; JoWkO*qUYwk;{5HEѧ5 Qׅe:-`eGZz8l"vVˆ y r;gpZL'ᒪTEUZQY6W+ze҃}xA" M[3y_]{q*~uJqx٢^Ym#j6z*9 z-fxf(e4i+BIM$s`vd,~<Ĥ^5pذΑ{>NΎ G2?`aN{6= ثy(}`kp`Oxԅ)x ʇP`it? W+v,4FgXͨUAfGXc{Tp1r;L@c;0.^]C= v+\+{Y6xM۫ջ [(k(i'b }-ovEN ׏7X@&22&:7۪S~㡏- rxO0w|o,"3zY}ڬ%x Xv/Yju`%Q]&/xn:*ԔPw;(@.$΂uOXcL0muN kPgnY@Y_vP'8nJlhFSlOy򘇝tá|%~ OV^_.V(B}=(>{Dy`la]1aYq_L|aCۄ׭N{5X^4:qOnƖQykx8ס藍reӂX\sVAAfb=o兀?s8;+kp4ꑉ)^zUOlcһi={Mpa+fu!z*4񹲀Jk'lKvos]P'ax\Ru@h|V|%c.!vC` {CxsJtP2 ]vq BISt=wPZAxAZᕯ*:oFdCԽ u02_m=n>"~Yoͽj:I?5&ɋ$c/{6h.aCeRK.ד&9CB2Q\ll:U✈1ԕdRNψBm}drN5'L;-:&ާBuxEƹ=ؐ5Ɩ4p 'աB'kI8/ǽT_21\]DE<^CJ6 3#ӛQYE9ͺlVXK Svk vabvk2c/.נ=5}0w m0*HT Ӓ{rFVsY ٌ_Pdud۵ƭ9PZ S2Z!^]ݦ /^ZVKvád"6Cg+x%ܶԢo|%Z#q)tFYHX;~-: ?W;tkvke'D돰EW:H2_Lj!\0`n4C~]᭯:~}VE6dDwZ8Z3gDUAjNaF2z션^.um^t@lH.גk,YJ5c%75 a=¬ sPԩk›Z͏I/|Q̼|Tt[${Ao9o]c6U[UG3<"xwcᳯ,[fÕU9|{ۓ[Ur6^^r+?ټ1AAXpJ1ܾPP׶V=Fl^!紑 ޢ!az4+F񻽄o֥|</$Sz|Q$|fmAΏ=zyA3o!xC]0OXŖI}s#=~- t6wK?OWyQb{s EZŒ&-ȷ5yPlݩڶW %j2{d\6AϘnA8'T#0QuZM*CXTnpyƢu->"ۭ^ av2OХ=tI1oF_X ]m%n/wL[T΀0}0NqM; Ӧ)fJT<.`oԍn}J6{6OMk_5>j3ۅd^A7I>P_3o{3tI)Y4uӐg";ARftC c$h V9iO=L~R"Puuib,G "jV\~6S,~]q:U:JՄ7i ^|)Üߔ m K[&K^*yi_==!,tcrW 4 9&:ℛ9Ks[~3z?rJ`ӻnScϗBHO|*#/.#wCgS"޷;~Zѯ̙ 7YHg_?B WdkZ•8Z,EmgEUvjgꂋ:+ћ:+.4Ӝzu{ހR\PjVY#3nxwA\zP1xE}GD >0I_uɏkzw31,XX1GGv_:ܼ~,9|wz# #ެ^!6W>jDO͍^T*9g\ yYyҬ'*a: Ȼ0o~3D:?ѩ_U]}Ŗu6]>L;/pSE8ރ Pa[%[ n(|N(*=!i.3FQv%YSpأݷt_k ݮvE6چl >ۅم?vC.]Q.KTJ>^D2˼2{_pH|۔{rڸӸ(ؤ8JZӿ_ٷip_7 QN~T\[TZ#M3i+m«k&5l\J?M6M)'ڮ'!,̏1+6eIunquIV)%aLg[ ^ qunkNUӋrGb׸'.[.Ҹ2ѢAlG"[nɕيJmG(e1qnq^AIT{7WA(§r""/S}4J赑oxmW5n˦ia5!Vm3ͻb {g]x<^T9D]qʨp]UhsV,k{~?, ?x\TPUXcݐ}!5xKTui}S]S-}y_6EnN;ՒxݭCϢ?a?v fGxU!3{ -[PjtމsolTQ%mNࣹ:vɓu4ӯCDv9[t] o򌋯w_Ɵ⍴^dzxZ{7ʟ0ߪ/_\[yN+y]^|Ì+n7?z;ɹ&:57)#ξ).MS641*wqocd$ko\lf?|4)1΋=גXVt=;"4!쬈4kٞ%..#Χ>0@vv$;9:E杁mZq5Csg]O~ M/'V!) lΌ2Eb/?FyrVWMHn%NQye7߸.}U+pʱ}E_*u;E * h>=y mt]]jt-jA"^,^do4{uB'?`|Vn!GlݧAlܾؼm~2v.bͺGTLW%tEDe]{Y zI b."p);}o]yVE eio|2JwhWO oƾleV+sbȂ2o+--n/vɿ :љ;oq5i׫)8!',V"VZJX˩{_V̚r^~\%b"1I C0t]}!;cUF?5}%72nvAɯ`<σqOҘ蘷 }eї o{D?+p+pz=7ݤw\ߔ9FU%0_; _?o}قrpfטݼ.KQNF^U،AxCxIC&rWӈV7| Q]FZ,Z{EFfy0&ysWeHG^Dz*+hώfm b~o pi ~g;oy@j]ZiF?B|(&!j|uzC;hb bo@4kr~(GQb4XkBm\m,KeQLƽ8(tX-%)X~V'' v%vsbopr?;=S?_Xz^=A?H~*w)MM| +qQG"o]cǼ~2=:쭿Խ2<6?&WU g2ר2/WF%YTes{57&_sr8#q8z~{p7A:NLxXÂl. ?w9<-r )h͊zت1M JĽGcR~n[T \6<=δ/O|!|ĜPk{p?ΌFwh4zg bkm7^W zxíwOWeH8<՘]XRSư-GJskbms7n1(>e eE>2^n6n [.o i1G럀QND@,{% ̛ٕn_vwWqo  7=;O2&?0DIPKL0t!1^~1iJbAb7bWÔ 5Lo -R 'DUp˂hΔ"~2;I[/D]C5mKL;X5bb+qG."@̜1e+8m 0y1uZb51uXs fRNyӍ`Kn?NDgFKwf[޷^ޗg~ruImrJ,+sIk_y| x)S"& UD_M@OYKӆ.%XIL:v=1}bRT]LʐMl܎I_xSK܋;O.ƿw+/txXQ5koݢ x"JyknF>#1D438͚1{71k 1g1c!1c8K6O&Vi;[}ï=s|yk臅nRd^R3cY b%%u]_Dkj}m[a;ʭa(|hF&3F/%fLZC̚XJL,z=,YEĬ$1}>WB̜y? R7N6m}Ѫ"-8I`tSW(ǽ*r+utP_T[T/^I5f'`6'{y0D( A1v98y5e¤Uhm#fLI(NIL_1%Lßhv[~[hy}f#>}WSۀƩ)%\Gsz{Kj{cʛ2TKGw-Zk%A>,7,*$497U~.\b< 1ebؕ 36TF1C\oLlEL, $1 22XgXngo S¼^GF*Aܫ]CʊeeαqNq]c$Dw̘0=dyfx?e&G-ExnY Ă ݶBUwb~7b;b= Xk)ͿmG2Q˙`'zԿwAZgӔWꄆrG܇}|qqM~ьk\>s-({ El碈cs z*o1}bʘycrBa**Bi91o b.WbN1O#Xe~CnOݘ7[;yI }㙗򥻬][ikRwv>88%%zF9JdA_%\x1hMEc8?B(NXJq~Lj3 yZcbv475~_\o ~E e;c_".b49))scrOkjg3ꭼָ >xt-}]F(LSRGױ1w?Š=tӷ D ͵3vf&(%f%,KU܉ՂTb{r=G o!3պy]>ޯ"#CH+3 ]c}?п6NR߽׷hu QJ, 2Lb' +Y(/ZgN,W%m1%-3 EAx9k*􀜷=s+┺ļ1oX|XL∍Gȯ?Sg;~o[0\ώ1eٔ7._ˉ&*:-چsDBA/8l4DL1OXcgCwMu 䞇)];ekXmnc]x8rW@${u''ǤA)sP|k[稲RJxf%S#3C|3V1=i51&J͚XJK#N3|}@ɨ ^oFh62vMoGnݫrܾ}u={CZKx5^΋ypp&6 8 \WήU= f"1s{~Ȃ%c>M{ *R=b>kbЇX)H,o 2?gs4>U}U6A6˖_# $6Jl2l[Sc>_A_yc/E=<{0tVtR?Ҟߎ7dMwg6W,yXs+h= tx\T9A?f_Od@[aL,rXjG2&V!V:FHKVK+7faАgf_4z_V5y}^D廋yǵ }|3[ҽR*Q-)JDisbո>1~!}m Jxq˳:,>̫2$fJ q4uN5k3h>^͒""NĎICE5\o}z&A_3Vw^W3ok}O^ݼٚQCk弙N[\L7S,گw)=Ţf6AY #rHq/iMҘv:{-U[,_1~{*bo<o?5ە *=Q(inkT\ԭ&$J4z((T_[ͼA7o-ӑV|y}߫cd!a+B9J(LoKE\'rȜ͝ĺ[@7"?Q:d+jX^5nGl#8քM,(YngH|9lDXP uyV y.n9<IWyݚֳѰT{7ÏA;of=<%ĺMal:a¤盩۵{o9qyICA˄I8@GVn_5&/=e83fޒݸ׷(g\# 1CO8PK?>F9=w/項w7WbhtZ^S_@%'NﻥΡwWH^5֞7L!8;F6~;Ofl9ZӇaDyצo l[%ACzf*?WowkK?9{27??CUw|]4pk29}&}A7H(ۨjz o\9T}ؚMw/\@b&lQv=%y]jg\V-yVw6ava\&k5FCX*L\F|?Rӊiu}ā; <ˣУnի卵omaaZ1Y85uO]Ɣb2 ̛Il_8 e3 }[a\FN0`y6pۧD[8Lm,ҩ¦vSik ]m#*j>-0*_5TC9.LWW:yۑ Gs<3L"wTM9u5{q\k"_{be|٪{WѿcI;w%($ϚENZ6wftq&ӿEz~t2|_5 "X8Y٤h>{oOISᆦ Ͻt?6ʯ?/|OJ=\>Y'o=}ڽb13W!+E QUTůR{VG/V>ElhK aыP7n C/:M'Na쒧SWPWgS'ܯNMh|eGpb9De[0`abF2AM¬v|}~ h9~5FeJ]56WT\1j91Tm#r6N2]VPv-x䗏\ ^U^Y%~Qt/yD<^NzA80:=4:{TgvGc鬮!q=pL-+$ DѢa]-fbʶ3 t} ޹JI?^-_Aqk$,HePAx6j.7#e7 ~ϡ|vR[/Nܫ{PNh\I7سG٘Iz&>d0R C1u b >e˜37;2˔C֮%!;`/Xx@Y {ba`z.\j۾Aq yZ1HdEIQC4ô~-anAr&Wt{6UntrlӺZWvƯ<6kk4ZZoyM~1>5B+ircj%蔼m\bߖ-ANv #"f ѽ%3a=b@EX6f n0usIrAfW8Ṫ| .ޜo6{j&}o S[Q>WS6AO/;UMRv>oٜwM Ϡ7{ਮmw)$ @d9+ՒPB9sBrFd 6i;bƘ贝v>ι!ozUFW’Z^k̑o^@nvsns;7nL vo|3;LF3tpܑEyD9ܒh`nǚk:򙇜k0<|扟'~>䯞b7[Mnzg>ՏJ6\{ ln.25x߼>/>˩>9b8wӭ GGR8:Ã۱d uF˹ny~C{^ i*fx__f<77Sqgt`/;LG>Yɧka{ϒ?4>sBQTSݏ_Ozw_Fk,~_&o -/VkjoJk㯄YX[?wu:Λ;!28f٠U;|{pkUW|\xbO7X*M3ySĜ6o&}3 gu>Hx fvo$HT{c<;MՎbJ#_?zOfكn?<*~os__7-};G\<_-mc J,]7!ziW_9-%XSkioVzz0>&? ̏nB)1Dof0/ }?΍{|-}WW}kxB~O+ƞ[L_G<_mL4v4ï~'z-q^ZjjaJ=d8ݗ5l,7M˝snwqnp>^~Cg h+$VVbl^1Ǖ;BwKO*rTsG`fX[~/D7̔paBY`uk1OqfaԸ?֋a~:gRӬީBכg; =#T0ufĶ{'37̋ ̧SBz!8QOi ga?[ԅ;?}_h{[sc'[#[%Za~K87֣/>,2z7O|g?}>nL-9}8Cd1*6 $њK5SwmcsۺMckńCp%bfV83*2NVb정~]z[ uNƾGā?CPL6L7?VQךnM~u \|qxzBEhB pG E''׾=ۘ2m7l涬s\7| 4Ĕۭm?3r,dTn+?_vT,L~R3 xkI$s5i\6Z-n"ZpfK/7_c7OqVpĤQ.M66K?掠^iX??~wtE /„7(/95Sb'D[=;8?GF }%#4a0],a/܊xO^R|LT:B,a9G|d|g;s{IN/W|G#4LO^j8řo_?o5pQ\;|\?n&_{i_\ӑWz߷Z}WpKYm^۵kbˏ=dWzs!Vf'h¿`FZK|fIﮓ{zBO(tkN͑>/SaRle24s_tsM?c&Lhr#R8Imlxx7 PwgX~nhhi ߗn'48rl7\->ۼv'ݛM#F$ʳ Ώ'>tWh0?#fA$CE9RyG&%LO7 şͥRV}@/^JuɔJ?z{|kX~fPuqqm|?w+̆^d'4eZU6(?'8@Si=? Ky]Sk _}AŤQBb[` vTzaFAn|}볍d/P~?TX7}/^y~OX7z}!f1&m?\yͣM}Sxםwm4Nh"lB-IjCWA;xq[lvmΨXXmP4BHȶ7D$YAI/i6Bqr%􁮫9YiʡzDwzw3!䝘 T>&v?!z9W O\s⥧㩟] SxQIz~*gK'W.كfQ3]/Vw<\eLkçv{~fep5+sIƊL'ƺr뗭lhdY1,I$it1iàJv|y8;{wV:ZNvPJOLZ.5\&a_vO].l"f5.kCn'e8)D:YLgx )$Ғ<<^϶KHo9a 3 ZxQeܟm9i*o.JF@;S^gf;ǝvm+/΃;t ľoܠ!Đ5{wytIz.\6UI㺰wGIK cOI3U\1Dv׈~,p?y'vmsؚb;J=4FLmpS(q#IJBP-RhVWW񐋻9-_ |.נ'XBfk.|_n6w;@T}:tġ$:}B1b19T {է 7U^#fU]R\>x #o.3zcO[Mo7D ws,yN7KGxG[K a~3B2l=g`{H;-ZQ@C_h'DC_¼r+KM%?+7}Xvy64ZS6BJmY דFE]mg m@Hh)qg;HץU;)ЛlhHa1$sI&'p{%u*8rZ(%j,tARun>X s;pH3*nt azĂOȻ+>IE#LAV|P,VmҎSc-$0 )CӾM fF{f҆z9=q/ӚY|*&ՍBJ>bc7qnvncsE={ls[ujF@slPyz6c$N.s 8K><ÖHYƭ۸t,c&'9bjCK8iUNZn#}c3U9> T̿< bnb&ߔ;&s_UM` 5lmXz'@`Bs7;'M޽~xWtǁ;E'0;5Ψ쳄7iD猐ҔgPn`Nk1<`6JlWL։6ݟnA/lv@ZBS_}19/o:AigO^S9Rdoϳg`rͥB-JЊG 4hI's')7Rnd){ o-۟Pq׻zݏŮ/fԍKzf@:z8}coIG?Yt~KS/ADŭjӵUR';n6SNr|`&u IO/LLViW/>[O =hg$+EF?3W 6; #~R]ܖ9 `C Lz:tX]o8!, @Ku"6-[Z=o؇c#/':R]j|=txgponbv8h2Q, qŔI*/χo;rxM`ѩNsi}^>I#& a!,ц'+OUYnd %<_Y>(Wuφ㝵'6_9ڊ6LR{2I{K>tb!d$4GYw@nރ\g茉gfJYMY`챘1|BOұ S;v]#7SMyB_pe3azW{&<)} epp>nj+tB!\qvl.|7>&!ls{jO7*7]Y ;l^UPK9|7Ks\qz6۝ygEړ@e8OZ7Հ-(O:`03H{w k+e䔚1jBh5ep`-ai!#B@#go,8.~xJjE]^G chN:Mj;> z`,4^k'큧rwG'ז Fq3 _܋4I<=\=9_oaHCͤōvVC @#V,%"3oMA^xKvm5:jU\u!!Jh%" c=Bs:}̟d qr;@;XxUhpX~uXTHWw7ܾߺpnBeKYc>T9 }=vb'=9̯dY Ŏbx "qkǀ;1 "sQJX.7yஞ~h41`g/7Z-kcLR%RG=Ҕݗf+mT>88G>7ig+6N(w$na9TQyŭVǟ~n~㈠Oɓa3!NӉwÆ殜y8Qnb4X&PNdLz(ag !|yʕYe MrH) O}ӡ {ھޥt=EvָA41kjȪ|Z-$Ga9[R_@c,R[ :5>t,Zv> ]];4Ŏ'Y#yӝ|{kBB>S|']XH}CpJORXwmx익Ruq]}{V9W .ZQ \'ڂDCmXf7}|C2vm_jb]BHTh,Βb ,e9R g%@kY23ی:1$#g7^{{-iq4>+FW8Y;xzr 2OJ;rde9,g+uP"?zô kKKYhͲg]0(->a$ޚXN,fR|zSr/z)/YjJXس];2KFm!vA1ֈ/Jh-.4uNȁsӉgud9a;abL;Co_:^+mb!8F]Ԝ:g>L,ʋā_(?AGX,v F=80od V"bˇW8?%ǨrrjG}&|JJ|`[} hҳkͥ3AG]NlSĝ ; lаǙԬ<;;g%@KL̉`g1"v{w=fZ<:0&!(d$JhoƱ vJU0P9fmmlUM?l.qB|7|s 67 v"#St[wwLգ 2O au}} CU=_vr"~y|ΒZ>D쬼xK``M}t*bgΊJ&FZ-5#;}@NV)`+YݡDEYBT^[L`jdH]K5d)VsNLZ:G/SדAV8_.jT'Ħ C=T <,w~ן}JV8P4W6aoKi7MҪO-˗JJsY-d43obgA#=&;;+7vVY痩h` vWg7O. ,\L|kdzN$=֏6!piHbd5A-J׽bۄwցO0ϡZKn~sqQ\Rm࡯r͵iecksT=צ'>قLf_r%;6{/AF}@,Bx0CD Y<%; %0T=WT+jBEn8Xm~k^uy ؕjBGD/tX5xk qc\*x\uV/-&xHk[n:ځ%X|Zr8b8\T GB^7Ǚ{K!y)FF]X~4ĝVZ*UAs)\4ġb1yj5`! & <,@s'`þ0cf07uC#V(G?؈\*`ǃΏtc9&29)m}ʽ܋:W}Ef\:?-]5C9P1 {'b|=V '>Yv~h,N͒{BZ+f= vV;K9Z oe1_oF&` Z4]=S)Ikl -\ǘY}_r]WnVk'2=$1^I,<0̖ڋARh:oBOD-7|BG/ f5͹4}xA@3jJ۬FCOEe.T'ߘ#CW]Z/3K=wʁq&a5͓ؒOvtTLUoD>nx)] r4jYp=חKu-F/1Z̳Dkp_O|m8kmcLi|{ y&"\p.-i k^[ {~x\ŭѻVzz2LN*DfIlԴ8#W|'L;}mVmZ4|^\j)``IGo-.i1ԁbREȥԌ1tОB˝7zިZ_ALWw |}b[RYݲAfbD0n=N> u[#Z@fK]w⬧vkQjzw gcؽKp.zW;>rO0+ԽbX dNthb"f (kOӪr♿L9=TA"gTb4r$6|uϨ*,3Lgfc~?WndI,\/ւyD >>u6# xy"!n5eYB|×M`9TVev 6f<*!Ty⪝z`'{Jr'5 Pr9k7?` ‘;hO'[cC|)~4#N9Cʝ9|HI?`Z*`I ,SySs#ShR'#>k'GYY,+w[?\O5 :`e}.B|Ǜ[YT)&|L)6NK|8!):5Kb14A86B,mʵ̇^X d57&d:H*մڱJq/QSj&7p2pFC.;$fL;m05S;,c!~c-P̖V7g7:_wF <)Nt,CXGqWS`:OZ٭!=ڬj"J⼢gPqƐ?8\>;[cjvX}6jV>"'[ZԳ'}+^)ˉgS`NLg6Җv5lB퍅RqKۧ`NL}+G|%ԟ/Fb>wsaq`!G+uӺ??+89xw\W+aI#}ٗ&% I NpD_|y].\xg%\"pf_) ]W,[e!,8eovawKc_4DŽKׯ[q٪+ֺ_~5֯w[l1+5_=Gڋ#F`wkH.ϞȘM{g{//.HR#C|d/]Be ]b׳?κ .Juw~~e { +7`3WW:^&&5+׭n^y1_ćҏ  p{v8O/3[m1i ϵ5BSmDݮ~-{9=2+D[-}Mі{<%nkw?y4}!M@p[do* ˴2r>~8HBCMa% A^=t;쳁 #,1A3ra =v<1f1X)t84SX2F5C/Z4Pb!KWV`deJl=SjT "<ƓWؚJ$S W"&:lBXƂقWC,!U14)Òl!meb+D$ ;RB^h 0*,$GiOЪ, zf`L9@CRl @\#(g1$o1M->9Sk,;JcqbD= rB=>M&+(8>cN8ZOG!m%AN#,! fp CAɈ9}U&F5ޏ®)dabd=YaoڗnYo?Hs&sd1=}FcB<}n [%n|h[:$[`N`? u@j- bxUPSG5fٍ8nbK5a>E/v́V;ҍ$uQ5#Ǥ iZVdv};>U;rgxsW7 ?#3ۑ0n@%4B?$W:64ɜ3ROJwӫA΀9BNB >`iBHVbH IFFKIy#0ji=6#JrkGAG`KKڋBt qRX tfc0NW_]YuVb\{9F U18L#1%#`ZA :8yC2bbKNfܲYY [\=d:3䔊rTp3H,o4c#tMc0=;<DTk`eRٽLهAU8_/ioc"94C,y3)V>HJy0#Ɩ<~?j FX2{gSEvF= OV  A' c|Ccא IF:+h`U䠥7דj-:c:g9cs40F=a3} qZrIf/xO9G&*uo,$ƴ1f y {1J]J?xvF2 H/gbvE#^Iљ1J`F1vR? c?4R>e=f\1FM* ?EYu4VJ#ꥧbies 5478FJ{fhmS!c.:9$G(S1F彳Tad$$%qC2:!-qT)c#|fwe-Vρl^~j9 oMZPiy,2GG&`qgc|*tOuq?s0Bitd~Adq!*ҳb`/K_HK/kd$ G i|PcfP>$rLҪ`,gn#U,|| I/Q&ArHIa$;h#aC .cěc)bhw3,籜TgưϦqF.Q'x pI^%q$הR:ZKKH6(&1 |,PI4 i 'kV^qdú4JRm:IB!Gc'hjTA5XH"X&ȃh+b,~f i&f/GZh2"_ӫPgA%]]lge6e4'9f,pC! @HESI#ԑ_b(ϥgAzsR;gח`4YJb/"o8^$o0  dCS*GTHo8zm~[pvF`'l++>fH4*wki`IB\ Ki8sNc1X\ŐrOL )y>Bn$` y쀑a@A]J o>V2', y9,(,Sl%M4C, V7DgS.bIr2Jl&h``M?+e3Iʊ}^[y, * y.] y2w@BGbFqRڡ)6Xg$&$MfB(x'{򡭓PTx:[~jF! X#YbAX1[O@h`%p1obd^B61u{A`uH%>3gY.lV]Y yX9zI2ߏP MT#3l! Cˇ$RћJkjjz )0 $'t92g8BV㖰{<\[h}w=" i:?v;L fo K5 #ﮑ[%9I6@k@IIp^z(>>4$jYcQs)Xr$[H@ZOc%d2!1d IY7'_b%#7\_Ls( ?jFa|E0$CI%|k=P<`Ȼ;@m!ưD_(Q؟5)MN@?P^1dY9W{@ReĸB{5&X4/a١jDMr]`-3)gdC$&294 }<XMo  d_3 ˶$ujd 'gbXc>}KNVYS8_:8=#`z:r4U8|g-I A5#q! }#]_o5@n`3 q T $C/+F! oC>y+챰X*[m3CFôD@y?䧙?D,' R^[ T]@o~4}X zg=iHr1q˕/)z $ Q2S:9_^z!:XOQiÑo9YY4ZA?ANtTXr ɲEM')͔[+ ᗑ' Iw$$ޕĺK92NI:8Z ۄ#P@ .;s&jU>PƲ.d4r=5I.E)\/;Ĝ?-}Yu#õ"yws'(?TᑩGمOɐ E_2 z})襰|(Bo\Oa1cֱCRD,e~jJH^}CBd`6I\Q,H9BZRV89/iǠ*D8o,'HQ 8>%{2rA/ b鈋Jv$+%͔R띄(RXSusn:n|X`e#Je@a@r }puo.6lfγ۠^P#N =Ke4u r-ȅQjVPZ5z"^@fZ4_lxsRumvY Or6sVː.6Zqgܚ_g [LzQ#'WV8_CB#D2 2ݰM}p‘[+szW!!Yr5r▞s5PcoJcvHV\, yVD׫7sT3Y][Gk#yiJ婹v^HusCTcǀ"J%BG륆wKa%V?H3ciL'rDY?rF2_LsDLCZcwљJi%YLPh!VW Y5h|SZj#7`Dc%sZ,I,vxwt=6e!T햗:r3 ;|l=Il24XthE]3X<A!N Y-սD ncd ;mcZ9(rkˀ?B y:*rz%#$oVp|*^2[;Ρz_KuTO/ #qj}oz+S^:Íd<,Q-jḢam5@:r*ԏ$)+3ObԽT>P+:6Uzu5R" y(h3ѧZTCn֩pH _lvNz$CaR67.B\pr~<''(0?05!kP$ӑ AkC ሡR8)5N$<*#B+Rϕw< _ 5Df晋;\G&r/ڥ ~k#{g gأ^O2luʅ\#-_^G$5Ĩᐛ3m0htWkC!wi̷/,?!o) dP@V?xj>$Ui*I76__)C1g0_^=GW?ب |'oplU.Bll#"X^o dC2m&0,(EhHxH"5ʒ] 8/,hNT[?B5g ZR=?/OռFa0I\DfxVG '?дz|(ର Yk弁|l$ޅѰOiۏsus<8?9Cw]_n-2 @%R=Qc|F)2޵Rk5ۨY7p.|׃r߷h6 <\'\ąeג+ǠfdIk'8 1"}zm^}1ZZxǙ9UWCF=FnO߁O;gAt 3?e/Sk[nO}aP풏A:]yHVY:7^"$E|M:O8;?d\18{{Ɛ)")jl̂_SF/ E~# es)9P=5P;>wS[Mgo{nßxtEhǩKO+zזLPN5zV{ɨǩQǓQ>8z+zO,~g|ǥEzy\ =I5N(ןY(}~jy7xG/wox`:.2';}u͇5G=rfjtƾ(զ,=o\\*1?7M8ph40R[Y7ړ Jf.t{}$r=E.fcu* ]]垇>~= }7ߑw?ƹ_8$+N :f8׀/v=OV:vhO쥢FG/ HꅇBw䊵 z{95Q/> :Af]y{d.oBTYr̈#F דj\Z;Gm0 'k$ ە3@+ySP>  Gi5$||㳰)]yn>Clz ^[dn 8ŮS^^ &.'G'ŗ=E~}Ҏ@0~9!Ğp`62U*4hΗZt%.q..^ejڪOkw}__"< ?w\9LZc1[$ ?ODsqFhA97)]S`ͤgrw,=7 (i!8!y Gٗf}*ao= UJ\\9؃FQcatFC5$aSCV/:2M>{%3Vxjv;Q+38B}Ƕ{3<=vX_ә?}ԳuZgW NL de@(=C難ote`zb81*bO(V(qdwL‰;kco RG&e%@Uљ>Eʋsw)'QʮΧ5@k!/<5 +x!)}`VW 죢Dݝ}|>Lo>"(1lk^TѢE>,&Vu|˥ ]CAޡN_<+4I*KPVR+/̧zbš8q֊Y70꯯Tk-ŽŞ5 +>&//_KN΢L諒|$%c`z|\=i 8&ra4Uc-MzK#ȋq6t/9 Υ oG'!k[#_Vy[a8'X5R@X=x}1sҪ/rVa]]Ly,^_({^ L/ݳP w+~L<|szͥ qY ʟX<?&A>zrӤWJB+X <8bAPO~dTqr&~CS-ؓ>Xo{Q9W<^C7['t|ŬuGeղssM7S곏/6O" zUQq6wa鳵8*5}ZL?:N(-|St:1ׅ{ȇ/(p>ĞfU f adk,xs\k c"glqSŢ33#wWgW&ד6mgu UgHM;vuqv nU+0OR9i2#]|9d%cTW≍G t_x15'>2kXL${ut )#ϥu*H.4[ͬKS!Ow gj"B&u H$Lw'f-_4o c򽻁܏S:ԈAVf$ ֣ :w,^ k;#J?$H37]je,cX3Ad qC<|:&^@?0D>'y=8U,|iN=}ŐW{>iWw_V;-3}^~VW/&; a]tnTD^ #`HDiQߩ0z96r'!yJt-ԤAV?d/ DYݸ(u 2!=HTg$F#Ep}^=AZ.:ʋ;Iz]]Jc4o+± M99!~(9s/V8, HNN~リN +Zߴ]uĹMf56o̞T>lc5yJ;\qa K?f2~-3{PoerQM"WBp_ :LO^~7|ҞS1d쵠{NEұdyrĖ|$^ g>^ьTV kȗ}fD0ȚŒ{b>j@>'ǽBaa"~u~f7K8o.x0hL7Gf=f/zXC~r'8zTL}ʇld9Y5.$ IW#a{2?dke@Xep-Irhk|7b|%a uY2tM0/EWqq/Jk7k!Or|Db n֯ؑįJ ]qffpJ\Zvդü˴QA@k*GQF8Gœp ^\^/l1M2H8QO2O}Az ǫCNugIuEE\=?]8rtR" v?mwԛ +^75,m\EִUqN> +L*՝Nw6ӆˢ7ͶͶ¢ ԛ~k݀u+:QqyaIyLo *qmHvsryNe=PhC`&p=sVÌ[)V5IO!zqcG0Qwx3$&;з;36k7v5:+Q^QI^GH=c/)MuYX7C8zm-s47;o EK龓xbQXxWP@5*SC#-G^ǜ`ۙw51ή9.B{twVxQ(91k hGx1LA OS&czȷsa?Y2q´}EOĬ$Ee~C#웙#Zr-q kY5‚ 6=.zhZd `?3m)H`t z=dBM A6h5e2Q.Xj1X"al2=*-*+ڶ3%Fj dXX}p3rM46~SHYߴyIg۟uL7]=?eTnD6lJ_Ɲ}o2j4* 46&ܧy/p Y}9rYɸWm֭=CF+OteHSk=cn&x5$^LI|+\.(h98b{Jhѧ'عw?0ΏkLA+5܇]"]L2Zx=*S-|mcRW)s?6eƋ؁nH*w`'0Qs׉ _Ƌ}M&Ցv퉉>AIA RTQW Xģ-,F͘6Ϙ7IVKĽ:HިсQu7Ƙ?sq3ы37mF{ዶT~'A4|hNW/7l:s/tNN鼟,o'*>TCk~=6|MkoPwۙWD[&ȊdUy?`9}GMb~yӶw'{r,{Eyx#-ݱ]"m rn ̨u6;G5;Uۇ5\ /s jrot?_ r(v̨ ڶŚ 7X >`->9rܯ\@ߜM|fïN4Ecaa'Sx*t^Bv 8s_^a3PӱϦ֛:3o[Y =1w|~ծFgIY6ajXQհ Npޥ{DxD'zFƊ?i>;j 7V? E-L*Lʂz+"ձ[¬INXcfTFIYw_}7/lDhܞr~HE1/C:ݥ^]ɢPd^WGPwk4]r9yΑO8qppΫrHND >t{;|ō =6ªfAe Iuh1^aR0iRwl`m@"10nrS' 񋗖ƤyG;DAJJ t >>f6d yΣۦ=75!('18Ƕ-끂WsX'tw^Ys|(/eȃ=L4 m}f:,ĥ!RX36+*;ʩ>*|krԅ֔SYpi h=T$?>/VQ:2Suf%ˋ5ye9"&6UrdS!xh'^qqxCGkdIKu[Z(>g&g$-0Je\p9=- k5zF[ޗJ_2M i-zc4i8A[//S_|o >Ϯ] cn)fw+?ѥ1*%FbFZ4U4TomwZz- m"KxE?y}Kx6b$z ֮3)5?ս)"LOf,u߅?}gmPaMOV]T)m H?9_9ʹQmpAt~Hr Z3ui$<%Ձ6"FcT5G+ǏS'{K%o*# J=s{<&I/1v,WrWIqsxYUWC':? :Xth7ѷ\c:=ks9Ft&  xh6Q*jV1r}VNQT{`ޣ`~s? (c.2NifcV usLs#;行hi_tZ$80:εgJ#~&u'z0/KH}+M΁yCLy?O|( Os~ۋvn^{MÜX`E`X.;njj+t{)!y20Kn+,,V. lQض@fBWa2dk_.z[%}KDqcrȤZϘj_DЄ1ĕ_c>\rpjmeP<}Sf]|/*_ynJz-U8';EaY` R j`j18f.rLZ63} }dU`d0>%{!(.oTZNŨw+\Meb>uw GWev)MW6M>ً2h&aj0S &1Oy.9< ~2z:'9泙rsd5re^ׁˀ(I$/*zFtzOb"ϨWbȧo="̭p<*sz_q>0þ+&m'a1n1:QS`G6, 463T}Lkõd``>K۰3 }p3tiVwTI[D{r(c"^{K͆Ԅ!|RW&VDɌHw9$-mn`̅ S ۴Z|?Z2L2n:¹3@ſ{='q?+zy+'JT-eM1ވFX;M3nȗ>-vjpq~A?MG3oUJZK̃#(WglkZW19rURHw1ϋݣy>+s{T#g"r?G]XlH_Lp:XwպHUE&U{GsЗ5},P&c~XvcӘmN9NnK ̽Py> 3Wy2E i,MV> = ) >ӕa۶h^Q~ZSTɞ <=M?r]fhB+aSV,Ym3^LL=4>0Gj4f-t4P:@^7D;Xu= P#ysmQME %1-p [:J=;*j\b޾w#:7ZR_vWԦidʴ|˜8/X؃=D OUIyS#3-e=*GRϺ(%1^zմϛv1,/c*w3sbPn#I`_79փri[E:'ewt❼j1)vykbM_%>&9)0 _rзT8K:;Dރ5Cgd7J+oa=X,726WBmR,,]( EvSv3]`L{TݖIFOndp; f|`.J,[:R}{X{HkJgsBuk"jsǿ jSx)ʯ&)ŸQDOeЗ 'm Si`fiT>~yi ;W/ǕyHkJܥ U7ii4=7Jzq[GLgUMM` `P` vY<=Bjz1Rqž$9Ի4"¿4(^ m+]W]S;X"s\j|O^2g=n7QZ 'mZ q|س|'(N !; -:V=c`ؠV)_+4`3'4%{ȯ{3z k>,uXUĆ>-|ҋܣQR'/-uBk)J.aɒv,Z(Gke3VC߿(NZ *h T`ր3koܟۭ]gn`Q$XY?=<zz`Q+si!aoԽnm*rx^p5ccXcrMsbRW!mȁ}ɏ\xp-c9ȭsv0cBڠ>X pnX>,[5Z@q)Xq}:On_h,lzr[>~eף} sih1 . MI#-ɟZR\ZDf[}7?l}VTkVv%ԡPCp|l1BQ,_+6``K;N+sSz呴KJڃ4Ѥ籯ϻ"gZhfЧQmZ2c,EڴgISސ&)=ۑ`6\ZSs-tjX|w(28߶m2ӞYLl 1g0(Ar ,Q̗Gj`6d>X & ]xJOMGDI2p0tp! /AQծ%/]"":*ܒ*]n G 洿b9O+NYi`u8\8iɻ+~?uǕgv[3}_@}~3OP4EɻnSzZ3}DN%szIoy=s'+(gAʂ[}x,`]C5x|3`2^fݦ]2cBE/VVilI<؎uC#w ilzk8>>z[?)Cdܑ?^=_jO0?6|>y|Q١e?K_O0C73'3~H񤰸ɨ6V-ZA3m50 V5?u΀ZW6تsl?zj͛| 9xCʦVVk Gh37W{O׹_:>}w)_%nտK5٬*fsT{3a9"bMCo 8PD"d}ܧu9q-:z̆Rظ hEh{u>ѸwJ-T렕Tk=C͑HiĽ18R0AzC~LթXg>I1w1bdrZVuA >Jt#֮BD%p\x6|߭p>pMx_uTԷu7,X ۔y̱l^$#= i|b9m5H[A_LcF54fPI 8h<@aД+Z-[J˭݊v~8=i? Vsٟt'7jj+z?d,(,jʜZ̨ 2T, :jX40_k:s䖃SW:zX>ax8sҁ3sz=}'+E ZdG龻r݋7:rͨfYNLb]<3 pAlڻVra?awlgɈm ǂ*<#7?ƻUWo?3Nj>뿢U4.fXSgc>[w!_FzZE-6D_0Ma?ĿNx@ߠ{-{Am.`d{Iш|WfC4r00z[aVaRw#od?%Ƽt1X9}cgNﻧ wߵۋK)yn0vop!j `͒LceKy0WƵ{+-}s~k7Qc]D=7!Y6qV%t DžصE@W $@sS_s6͋-ڍei\^l>kb'y?m#%>bBKV_5 9,s@V[)j&޶`,f_h,Vۂ#h< X_@ktz4e0HM(Xu.0 35?8#e#?(xX(_cRv`Ң-7x~)`syΨsOiidĒ'kS׻Aul,(븀eLX#vRVUY5t'&9%z:g֕s4=)d}FL{{q/NhevNv烬GqcqQ;V/-<m`~tk. -/EČ?k{N ꜹO~pUh#9`>mg1h0Cf#~-}1e2Nw<%keIQ=jDW>Jo8SϽpMvly`k.ҤGo~G.#S7jyWо5"5Ɓ+ֳؑҚ`qXjb rl,u]yxaL'6% to^1i>,p3៷Iީg b(hx֯F7Þoe|P@qGM?Uy)˸{IG{ ͟n?uUam d5~M=+Ż3OCY9f٣b3lWni0RSB 9K;֗\ϻ;/ƓJ|O[M^I5xw=mB~}8ӀS uF8F.䦶&`9Z}w[XiCم;0~ {i%1HZ=A崾Z߸Xi} ϹM636?'  8XFL^pHGp/MBtN]nax ~iE.9ϙCB^Ja,({{5p^ƒPy+_Հw=w-1t>hY)./w$rǸܔCݵ%4"-g4mkѺ5S󁾂FtM!l&P? <[i#~NOPwړ@|yڋFn5W!dzF ޭe4;K7gcWC?'6#ig^D-TƘs{LD| ʛ/qIQBċx3ુqa=̩zis~ zaHz Oyeef.j=uD@[El jТ៓6GԀp [ș]pmꙺ;{ $ _8R=fy'}Bn'uK"J^bf=8wQQ%2o`\KPDwb Kq1[9WbQgke4jZfte$5.A7 r,yW&SJp02TѼ1Ld3qIsIb{iu:xj /yK1 Dܜ!uzj5f1靾 B㩛!>E+m3+<Fd˭W[~>7'm,oۄ;-ܤ9I ~XX +ZY} 5`[s.VU6k(n1%P?ԕC}!fLe0i9F38l+[ g=TfB%m9&-$wq2TpkV[xF*(1u5Q?bE;ϴPFz 4iM81n{+N y|gqGcl@{/ 7bf#|sע}&3 )3y9 +zrʤڅx="bS6b_=*?Z_-pN^=umIՕ\I*j+1zlإل ,sӅv3Q'N,MOC~v[&$1߸C|nzq{Pq +ΡDzYEs #L_U_"ni=h烵h y=u5f' [8ٟU8;s<2_^Sׄ~u 92h%ﴽuֹQ=y 4ړ'x%?j⛳ozn$E0.xF,!R64ܸܼOT0νu( 5l& HĆ޸YT%9t 9<10ftX# FƁDJ\K8ߛr>ha/ x ^c7!^G|AE^at?z@L=UyCGT%yWiޏ 1a^8?7qqؑuC'xUJLD6u9xް1/r c?/7_#Zprr]"<ӗs3pPmSY x/ȣ5 RHpRiKM&Gu]r\, ֖3q[&! Jbi/;D.mELuG,wjr4e)+:0M쑶>XO*~YkcDJ եRw6# ֮*%r;8ܬUνOjQBP~VywjS!od#"ca?O2{ yyi7>bf9K;KUƣ7உK]UPU5ZG DZ_\.|+e> 8c?1S~FdjciGs)[|xd?hr3)w2Zz;/ Q$b0Lksq۫H% KjB4 d3j:;4ߠ}Q=8F=ʿp ?vk1f)c5Ȟ3(פehps>sicCjX&/(uFvG"G1{ƲnC2G>O(nƘ*gĝCq[5EU\Ru5C}fjQC`qGH5s9ZZ%e{^S|@]KH_ؔW*ؗ+ Ps8O\ı8+4;:%Ճ@쟨[>Ƶ{*eX0VpJpڏDD?EJՇD\*FUId g&FGqWE7oVځ*l KD/sw/qw}d/ʀg0HP#3t_ sO]+XϳrQ-5j߆FfmdeI!p.. at@geIh+> 噼* uCxHi3!i=1\{Ba~E5bF!pw3y=y5b=ᒷ{iNsu mnja4gÌB3fR+Pނ }CưA5"2"E 4#Y|睲;:Y u#h|YV"8gg ,'$lZkS<)bb!+nvabɐ ?;cRA777sfX)׻M[»͘֏_]2˯tkQ!,iT0) C{DDOb; ܴ᣼8|]X 7{4@zĢslKYoZa2bOYJi0^3+ >?e7mZTӝKJ"oCoV"|!=sʈ;I0c H{PEu5$.ޘܳgz mGɫż0(8Ɍ'c1qirc@{_S@\F.X|0k1(cXcH_тEzob1gzR3=C1a%$c6$􍄕 i}u !t n7mbhHs-m~@gƦdΆg #Pbt1vⒼ,W48'krNH0]#mN$d*}!&I \r[H24gxU;A!.Ҭ"O8u!~нLs^7+ȴzu!f( q0mS-$W~aMDJ\X@Y^GZ]l K6e.rh y&.;%`r0zzfcfˑJGZ]\#qJaBxf'9# w괄MlAr)y%9DlA2JՃn#,]riaYsy O$ NNO3Ŭ&cgP_(XCMU4a7'!N1lO+<RMgjϫ3Gߣ4ۀpn.u^Ojk +f  )F i2d.\F9iey)!_rDVdk<⃑1eȤ e\1VlA<a/z'A:uǕf5rm<3s߄쟑 9Gd3>Ca1YONvǁへTгmx|~xĊC:jhZb}ڌ)7sR5eN%tSzE)"B8-RW!!ߘpO_?QI}=QZ#4#ՠp.oxƭ-&S*609mk`FxRQ2sL1Dh ULܿ; ᚳw .b>f8 ;qu24Q=V蚐v\i#ܮ8L!ne#4*0h3ڴpݢ"bqO16p\LHAzJDzuMyB lu^s3Pn([#s֐ҖMF{0fE\>k͋LT#k! Hi5ʼ棈DuDb1Qr.ڧ^qׂ)hޣ5'ah."NU6l$Ix\!JoC;:F-}~vQK9Fn`֏gcv9,qAa Zq!l.f{c&ua tY A.Kg8H3<.kexH[ E;>7R l K&eaJ~1Ta!x=f#3/,,ez- S#yY(…?lS4NW䒴Rt%\1P.1+ZFs[gG? Lh2oHU-ŎQKEۑƗ%qe KMfo7oN_WC,n]U=`d`qUk2j Բ Ք)ʰލ^t 濌"3po^䝶z $nomgr-Q݃K9kw0lBb1yDHOmqHٿ0QSخ++܊_e%c(n +2^=K!dk(LЮ/{q5$0IY켼|(I$1waПEUet!v0}}~kͤvYūWv'8w'WۉD"9tHZ>!2[jmMW9CMe֛z&ɣfR;apiwKsA0"*r3Y6h 8GfR`NxMZMBڲ<<-xܢs޳E'g )kn?b3|Fs7ҙ'7"Fw6[蕱.pYHSEp3m,n_F,[[i[Q7lFO ~4.F@ &FV.ڧIhg 9I",Ȓv/:-#ɕ~hg0Z%YHцg+YV ~rcpsHY-5F/D3e:'4i җ5iS~Y.i) p0Ġ{I Kuk##خ cÝ7;>%*贰(y&haA0H;cn>YW *JD݇9V'!1(ZXhV(d.DZrLǰOtmyi͕}.4d|``~g>K1cE0Ey,)/s9FI1S^3x01k"|`'OMBLp_cXHg it_eX\۶-Z@ddw,> ,HBpww( J,r>:{}eU5}z;zu_I;' ٗŝs{&Xb f[pC K(,4sN=0؏F{]{uՁƇ9$]YYR+LMg:]Xw^I;k(_g,꠲oČs&J$Q~p=PP HK9L3\r d\EԾ3/I<Ug5s#;P>ѲɘMM3yw;WދG"b44Qb1JI({^㡛eڨeXi){y;\!+?h1-X+-ssQAa՟j{GBG]zm񇦲K=ЖιRa:eKZJOoQ#BL41V+Wb$zɎD;_OhhqS}쯤🜛_/{wA-wa4~(?W V`}JSĴ3Ĥ}Sf[tvGA\A,yO%MˁIQC$7B*EjTflܺQ-'3iUִBYW`س<ze΂^2rΊR|ʥz[Pv[w]pK[RP=3HuPHG v6~=yw0|zRtXpnS>` ͥr饕) g" xd m9M KƲ [FB,aţxr娬'xn:X3Jvm=25zzw(Vw2N.l\D~+T;+jg@;KsQrCwW7զY2ѥ%vgm=XKGGrKv`^hLJ77I&{ǒ5I!8[9[-R3P>4%]<%r/ETѫ4LΕyܮʲrr<<ի'cma0gHNnclRX7j )CPGDAE *[F)B܆k J6MqH)v2|)$q[?#>2o~+UUraGt^jg|ꥦY{gK[cX(pZ5BJ=2|oMW@ti(= 6y?`]›?e ْ?1B°?2e.>\pcߣpd:tIƂKK7SF~I.ۥ/٥R柄gv3AF~@00=|tU=?kgW>AJ7C OB}>}41|9thW}W su9T9>K|SEǕrt`>X7:vtӋP_k W3Bښ1j8S{uxP1,rU\L>˂VZj#vR N!k8F-ƿ>'|ځiR}#ʃMA=y~*Mb),8&|Cc$kTg5Oa]%5_>9:_R8q2),A}KS8ePC 8j@` `M;0 = Fp9:[ȹX*Ў. t~㳨mB[&ƢBeO:I͆5O'tLhMu[bcMcºa]:;ɿYVYhSG׎C*F˚5sSvBPJ"[I\/" K!Fz+rHh5߇f 0wzA;[![5]3A>ϻյŚ֘@/adnP޸))&zt')L/$k+\Dkۖ,N$ǢFB͟ܫKḼz[7ڧ;j>Gx?JξzwJˊzS%1 X!)וĶYY #ԍG`T4PO..|BhhhnccBL;qW/݂>aTؒ3𾜭`bI\%ѕcm!W/F䍂f5sɼ%S T7i(`&c*dMr4}c\]ϑ{Os#$vIӽ/vh:j|AU4-7wS _QЬtހ\#z?igqѼ-`q|𸲤i1w_w%O0h8IGvVT61Qu)rǸ@sAP'N3 .,"6>O1 Y%p1 P)2GW4-51̾Һ%c5w)Lo7NNnn0cׯ r3|\2%r*'`S7~Y,N,T:|>t8b?q%稭TZlh0W+X{Ǹ.6%H>B%kQ-OYb\8[a$J>5ɽ#Д}$E-spхzbrxjќcp/ :XCcO(tTEc6zɄfE5QÄ]*Ej9Loxͅ.lIQD9 =kԛQ jBe3yϰc@/n9z=EqC{ك՝>:+#_t')v4-|&bXrNcvT\_]7^G4&% ?,%F 8%(+9D \X99 :-DWmWPMWϋ)IT7M#ds){'se9OC`7qȠn]\8sK>+kYއ,yi>ՁÀYǐK=a_{]!/>M[ EvnؓZ8t8-]#kk`=BBk>iOLA kgD?Eb40M9I>19?)7k(Dpz/t,}8)B-7BZg'.W4,0!sK߸kzȅ`:NBlxRޢ\UЄI *viJok..:=k݄wRm",J_Ux}ޟ{-sޮϤ*DUpu*|r.ɹ&P}8! +8[BZUć$9W@oJtm'z'"**E|$gyM f*p4r仸IXozQۆ4gbD7#lԓϳO.^Aْ +֋Eaȝ῁#nW Z>iCHB<^OAә cbp!얀}s`D}9T5C1]ڧQ+hŢNM]3I4>;L]( IĠ ayP'HJ &>X}tKC[ТzeX H< u>?F'M! b~Q@-Z KgPMpr||Xj 㘴SӘS8!sڰ' pO~R~j|jt2{'GntzHB#bج/Ǘr|9_/Ǘr|9_/Ǘr|9_/Ǘr|9_/Ǘr|9_ Vo]eպބXg̭lgLs*Gn=}8 O`ki9r'/_?g%y3ןeng?M<7'-l]>ތE .9op悹-9E.s_<o>.'oWϬbS9_';ܣ^[6Zo%׳ӳ+U7[ӧ l}/oϦ8_d@s"yϙo/ʳm 9sz?ߔy\] rϗ^_Nx|5.vK>}Om;2G[Wo*m6v+xC}XHN|Ov]0w^sI,k/^w ޶xlrI¤ϟpwIJ[*V4R7bFZ6FFm:CAfPT+7iY؅tG>>6]SC{r)֯ƌCX鬽ƀSZnXT.8Sy]3kwk)ZORadQ+ ݔv=̭|Y60{X(V/]O+xm` VoxG-޷;8]dnR~NRb=>[}؃T9` znl&O/.zȥf?tk _E'^;Z2Wrыɧ`zsT?ٓ|W pA)G_w V3j-h ^&p,D4o*^v=q8xR.p΁7 .Y Q/3~7?=)0ukZ1=fk zdo"XO)dQ-\ӏۚJf aB{ɛu_˻!Ay#~x=P}.} X:06x"Mem^ZTR{= 4 *N@誣$ؐ{J~'U-rSn x\MJ8@ -#綤410+֚(k]؜ҟߚ6sܴUaު(gLۺ{7ާ[v #z ΀͍2a߽\/9m'}TVN;yx*)o݋q.~E"9FI ŌS٧z+(GKD f(+{}rSl  z.xX:6u)?s}QwFzQМ{& \M[$g6wM)| ʹPP9. #LƂJ}ѕc)Au6ўArX+vݓԠKw˾]#z vkFaΒ&of.(g2[\p62Ɔ\zα&sFTVwZ6Xט>"lX-'MCx/ S~baA =]=݄Stꭔu,$G-ݺN%g 5 =ja z>ߗL=sg=BƁ$ & B&ܖ=Sfrrz>ѓ\їWpqߋ9fd9\9~rZTM ڇ@?O&>=C+ڟKƊs{،9==6UcDtbR{#~Yޖ4X;P ?`CKwLvN\ƃ뫫ϱf퓋#:z,;xB G}Tۈ쑂Pʃ s`=C9FgO.z-*8p%}Kg>9+5& pY)9bB)x*e{mʿ߬va\D0lY;cH/c|t󌹅^1A9?s#nK % Cܚ2WU$mCaz*+n)|$R> #$g?=pE9Bo[6-'O5W;$1dgU'Ȓ9i \.JدW0Ӧ OrVlONqŇ@57헥ם5b@H˃ & E9{AޚaBV>/o$$cHdFHS(bQ4zA$|,PPHڋ.ArDٷRD>i?'D" xhDkQ Γ%!xhgX>ׄm٫2% ~pp_3io)z2*Fߖ[8=F|9ľ KAbv B-賤6Ilv[ FU9dmw/#r;-/X-=po/!tz3$t K!,:HH|tr&{1~fXN>6q G'r~3n]SO;~ԃ3#O]Tշ!|+^d\H"Q Jc<|gI\P/ł<8ئ~[=dh/.o6h_9qO1 C{rBGa<^yw=)O6HP;Os=C$dhGI<  G3t]bo:4H mQpP̓<%Qy9jX=*{cccчys1=]GU;wO >@|Žov q146u++2}lgkXn"$Wf9ɿR ܏wl*8ioo7&~6X3K91 \PA…T:PwƂàz#/KnLM`p[S; Q"2[OB9-Jۗѯd|U@SI?5|OCylErokHN]c#q$$SAlje=8D0u!y"$v򟞧2"6 ?D{?s Y.vs =3)'!~ qho. }-ə-]t3r$1З#˪,=hG{}QcyGs:y`۝!bO~Khh>TIrJ $BkEۜw y6xwDgg %& .\3?i%vw領F*G9˻ 6nXyu3E.n1am!}SZ˒r0F1N5 N*kyc%O*<Xͥ|幼W .j87 c9Eskߖ 'o0iAvd ,hPl'>f<~l@9S ]̘rܸ|["B{b{k.Nkht `]W.׏߃`Aй d4&1Gݟۤ> >~㮴 endstream endobj 57 0 obj <>stream E }ݮMzVڼs6O| >|FRpzH^mFrS K`E5}&bv|*S2dc*tsX-;I׍?B;u`d$VF}89/,|O:AHr\pQqZɺ;"L,T4cNLG,blg+V .[ԞԡEcEL6ԇ\4_^{2CFKccQ3S~]A#)^ǔH5Iy0b ' V2UOWP˂QpX c`3y7W#Yǀ_g,f[ܲ . *ǧru˄Bqrtq7V8 y)0w'`"S|Lbi|\΍Jj #CeWN sꑐr;ys ptnG=@nܫHwtE<~x5a&g/x g;<=aDZ8QۥR.^䁮mG9\F|Q%ߨn,R`o~  3Q79p<'~seiFњ>jHz|nTe9-Z $_j#x~%~Pq?7 C[Ho\;Q6T13>%w@W9C:?@יON;>ڃW/ >42.;.E!h$}bR1ۿ嶥bH"8'R@,lX76k.3a}ŐBƁå̽3_Y=Vsc7"_琯#N܄g|3y@X >XH/t#bLbNsq>={AM_%L-o8}ճY'kM0͖n-i< G~)!4gDࢥ~SOD?Kȱpn2pW;ShLƯk01 烯I;9|t:|"Spc~q ׃WFU~ |;laQ:ɻ:\¡I{J?4Ԯ$Kq$ ol+IRЁVbɃLƀn8&q"+B.2<ֿ`8 z(T¼u`5שƆ3zO[!l1T_ /Cq/=@)<.tCBߚm$Wg#4ؒ?|}֟E?u>Lh= ԕcO?]_#; >V EukC-;Ԍ2OH9+i )Gh͕r*iAUf􊡏-E홨(ї#6m(WcE\iR >xYO9MjްSL; k4|Is. >VqcQ6^qDLB]u n].J2s蹡p$)?xMA T#TOmDhct ÿ"6p$O,lm):r{':h =`g,Zp Ɛs 8S1FBצaس|9j$`5RY3$ M6\Yn߂O<>UߴY]ߥQk c >J&Lf)'}PLm).C/O6JNsKJ;] {-,jgz@,eɜ%Iس-K4 'B<ȁSq ?IfRrآ;߫ڗ.l8 KS׮J[0jc7`DVm}⳨hmkMWtӶaAC+N,]+wuɱU)oO.m=) s ZIn~]n1h 50Մu{Ϯ<t]HGݙ$Rٺw%⿵1ʛVֳ?kc([u͠2)1"1HnԾ$ryv1GSw<;~#ЌUXƖ?[f^C^1Wa^a}+Q?59o=<ȩI/3Q.xp"b6i(7*Hޯ<ȇ1In-XPu hɢjMa/F kXx``ʱL GHP D^RhS L/ߴcrߛdN-681LփR  W@?6`(M= !'7xu ͺ.B|{3ۨ"/s s 31o`sO]MAQ#Jy*@~k(j_Nj  iUQwT>aDUeYL1HƖ cMPKDG} ޥ!6gm #:iS5aقy;i qds Oʟw4`*-G]Z=N*ys48%#5#e dSv.}g;1J|OsQ{jkך)Lx' ɖ[ ]𸣆e@ɣ;'RŞ/(ɫ;Ori\T^˺@"R?mG~T#t،Om O[{TcLh H[fA'kA_ 6s6tӇRN~칈_{~E) ngeܞvaG![-kͅU| \pQvI5qȿfY^5LڷkU;Z5l ]6[D2):fh|'LH;]<3_J?`;1Vl|yJ2/jl֕XoL\щlG%M.qD`:ͱ݈|~|q2<0 XGC<r}'5jʇY{64֯x81Hk68+-3bA8| &Z֋L \('1-_zbݕByt?젷iЍ,O΁6Gk`ϑ:*[9r|tttaѴ:жqZWt7nਭȅM[?v$~cǁr SM45hY򩻦psMSuв4\A-d*Xq@Uz;e p͍g7]wy?cEU$7%8\¨v<^m sQc'f f^ Qe)}ep]3j c?k8jt[T_O _PT#PY}=5ROۛ(.b#wYq`-@̖k'}Ҝ6z"W|{TҸF,m\nYoWٿ=X}?äs #b'?eE?BEE=Q5é)} ?@nUvOZQ{B7e E{aXv^X3H, +jW8]7h<;zVh@c=bByVUNK iwAhlvKe6HV֚agSӱU1_g/f5PU--uLp̏пӶe ׊!lmSUyRU5sK?O#un>O=2<79AXXZ(c ]PQ],\Cdn`msNZ1թ/~!1]Kb}뼨i`?su>[ry5e cHJkw!d1kQ="2&uL7B}2PMkߤShG({޼S4}=az؋:ҩkO[7Ԗ,,tP3VmS9u:Z\lE a#y؛K^6 Wؗ§urF"s3YM"h|V5ZFy)?OHN,5Ůz೯.8/k8}}rGQ0/~{3σ&_'u2tR/mh٧5hMkBXwD8| O$1wb-Vn1u~1,Ӹ-\]Hu Ά_f LzMJ<0gUKo*K~uUQbØ~)i}qh-dJ'1k!0QI~<5o1zx)4>,ZS~ _=j^VY;hT>_T\eY=\yO.d{[xXfxZoD_ry^CܦH=ګg} Q $h`O2|pQ#̦#b>w,|t*|.0_0W@&tkJS7O:vcݿV1s7K챷<󣡪*j~ t|tu3wU\2t57t*gо$tzf6_޺Rc][/nWYWԙ7Rf!bzW2%~@}FL;72{?@5O7p 7+ntl_B'U~Ki'g2OV÷pˠw ؙo*+gyz{{ m[ݶNm3[6@RUG#78wUq 용x {G؝ }=UYTIYh5ܙ⍧[k\3ܑG<-mJ@y#xՊ?c2a2nV|g^Rs^>,g,#jVկ+k-GM(5ro,V G JqIjDcRs}6y7[־p{vlկkk,&I,kZ'#z_w|捅NL85_ԕLʗQbO$6f4s*XsLwV𛬺WkoѢӆ}Ki1mx_}{sSD[I:E}{6fsi\`W޶1L\eҎv31LVc場V[bYFVOv|VAtܞ'&޷f<C<(~ l/~050%WV=_ybzqd Uޙj`|g|²^X':Dp$k:kxъd/2~i66xFzdZ lXQm5LE2>ҶXPU>]~bU7ٽ/Tm`}0b|0NUg^?g}6Ǟok<=TK7;$ssԘ-j<,?T~G^R]cś۬N,lTs;ۍWqn|4|Ĝ G_|5<w߲K4cf ZX rW}]]V\iQreѼKMu:Gz{?Ex<^/C$p4W5NK/&=Lz~WbAc4{׍Nҭ -Y}]׍.Z!VZYhX&~kSpo1 5wSZo{O||-T=[0Y;[\Au%{ZC‰;Z͞x gW;q?ڱ:pw^zp_svd͎/s5w.^~r;Alȝ}j|dk߯W=QUqw"Iڬ-}Y8_ ґVkckf}U'/5{W;Kw-n\ӭ\3O;;ClbνOovf=X%oIUw7w3-?o>.cv}\f7y4/'*{3wcp,_?y[.K;1bkU3T^SzZe2Bߖe׫k:TևnYxZ;ҹfy(>1XeXoLP 1ZYؼHySխl?JoH&H>$򿾉޽ե9=ոWԧfݍl_.aқ4G ^H:R'mIg~s)k~_|L>nundp{=Ý~!I[}:nJu¥vW֣-F?NSUӕXv|ΆxtՓ̞|t!WjyfU/X$`^<6?ix >~~q7])]|/i^&XOk;a󇽲G@}K)|c[*ԓRymʼ>M8ɹωg[-/oS>`'lTi'4(onm|sEٱݍ ,,k-V:\KZow8^bVwG}t{Ճˉ [m~6\~;IyAr,_ɔ?^Kyu\@CmNXΡ;9oG溷n^q|/_~ۘ&mO}~!kyg~sSZeɋ﹨S:ݼU>QUTigg7TS?jJvpӓSE/oϛ[\W?ZZKȅkxlmA\^]ѿ57ϴb_j|мoN ői%[K_ɗiȔ~~*||,(-ENvZ9eqLm%#߭Oovj_Ҙ{CCeY@)=6~bn.dv~X+]xjP!5^Y+kB}k* sYUMO;8[~V">m|_$~Ji)rǭ8ooj[2eDU7jj6d͉OM0?oWQ읷nwB|ힷtő?{;_ǹ*lQ޾Ե}_^"{gg;)+jNSkL*_,0axѶnھN_6qIʿ}]in{};ZBodj m Ik)"ޘxh_\f&wUV[߭0P_b +fQ1c2&`Ձ)kta~yRB\KQ9YWs .'V^d\I΋%@x*EC֦ .*ʨK(SvD. ,Zlr Ŀi<7,-F>.0 Xi(*W6L1F1L1J1XyJ1 +Dz)P{:BgbQ#_TbD7}Ũg*Ǝ2ebbE|u:7jËi+\MM׃ κRyfpvi}D^^]LA\crIDczqtL#47cnm|}Zzux`kf~sO42Tain3dW5(0Ô~?\b`2sGxY#)'0TW[GKGOC:C+f-2SdlEGi\kKf?:,ߞK_HZGm/cOˏmJ),Sy%JZŜѹ'oh.q+"ckKWCΪS޾-{/Jx%Yy_;W]\2%?9KV*&MWL1YC{}aCV;LUttuZ O?w#!WG;I>ׇ칢{&I{Ձi›ɚI9+ + 9y7cs nJTΡ9箄i] >y1< ni͹^ߘ\,AaꏏyI ~䝑EN#';Dփ6 Moj)M;WCk O]ib銙 lb#q}gQvқdr^ ͪS{3"73R _ʈVpڟtφ;X1s_tc> SV|!1WrO]?u#мW]Ȼx9<'f&!#?.!/>.]u٥uQvzعpu!g\ݟ`/zjsP;y ]؝{]P~zW;^U;+ZWjyعF`'RvNQ|ؽ" y%^9 sN]ptld>Y! rj4^f-O@#YΑ!i 8Fs4i7VW*TOW%Ym˻/vb6;_)hFa;vF6I}S)l_|q$GG8`i[ KB27}ldfd0^MV"SܬBM[fV#NUٹ]S- ^hrϔw޿XnvÜa{]YkfU3_"2~ό-2sTc dp~Es[,at6&-A˒T4#l- ?T-b^̸XEKKqu[NwZW7 :.|u'E[Swz\V7?{T\{*>IM p62ױ7j4elĢt4ýMY=rd\d'iE ;UrR5[NNxEk}eZ?;}W ]Pjͷ;Ӟvw^u[ydR* XIr-qıL~[d9 )"'4&"79[_dclGUEhz`+[;V>[}@5/OW$t;J%YNE\TOd0Mqs.-=bb;*GϿE=T}b;;\ } ~sXKjy,M_0炳f+jYLqǾb 2DG3tW7u&<⇭n4~w'Zyw*fxn| Hl̉#9 9KS-kXhaL T?dɧ5W5}7aՔ5 \sB5ke?,ޚ,o|cT|K)U5\f}r A"A(t2O7?QjVvN sOtAfS<rKFJ4B dMhI dž+GTkZvWoE#e1 dAٮGkUM/L\;~Z{B'~QH~t]wڿswpT翬cH ÕA㿆~ U(O܏_|\us܃ic\1l#AS [843K*<44U Y<'L@:'8\uiTTAoUOF}Y*/dQ?B{uoߛy~{U7Ub + O>翺_on}e}JAm={=wn4ia4e]CՆs`O8qZ\Eݏ[xi=zg u06ʹj#p)37^qctzsf{AU֕넓_r-w/pǞq)U/`祋8 +4~526h? 9tx[~W'Y*GϩtF48E/B2N$~q5d@K2VPSW$Fkt?R qm4ˑB˕uj.[zZ5j_UJ귯_{\L+߽(g~\%>79wC2Er:8;!N!. >^ $5Mu\XLzNJu_JvM+&kg7W?b_yByapKz-ycv4v}fi@4?M%IǬD5[uF^Q p,AR$ A<^?Szc_w>_NB-Jx̞<yAvzk^"&v;':@ -Y﵇Ɠk>x.I]yBZϾ̥颯UwTk60vXnu\cw<>Σ䤉Ka/Nm۟ֈ;,\tD4ꠌ.+]Ih%PXBnTNI't';O"t_=#3ZciLS٣y𠃿{Hkdr&Rt-*&Ez# 9y;9£ {NBI݉{[[|M^Efo`b_V2MS;wW{NVmоpJ-7Ч>pϮUF*[WU:g >UQr]DaKN-<79­_x*u5>H, EG3;TϘVY\Q<\#"8*$,h2a" +xBRBKjjКm-}_<%P];2{_"&לNas['H`wY*i>?[421jmߜ2=*ϋ*gw@߿^TUWU~gU~AT)"$D _/4g rstD@Ddhs)E HR5_p4LDzp֜K,粶N^b+wذE]l Břqa7NW2SUIs#'_D(.}ʫWsكNlilZ1UJB"S'e_oucmAoP;}u%=K_#ɍ=-_y,\x'ZcȦUۭ$=*U|}|(3G3w Alz`f*/iduIx)f֫:^3M [̅%i*L -x37]{/AByBwj [u"W>4z?ߴy¿8d;ްkJe; >n:=ޕ=Z\Iы>MShdJuNNW"j:KGI's5DL8BJb,d&Mc&idb-rw^DDXDъX I8^s2t)%Br> PSftEw.LŁiilc^(?xu[.,`KFnH$:C?{umVKkLﳖuO+`\?&!ǶnTIӕA_t]|E D24A+3tAψ zP{yBo}g+=S^+.фl0brLvww_ u䝘>F~F}_2>N*9'sLnt|^&Iր-f׏>S߾\~=V!>OUӽht4` j<9$і O֤#34M7˜ N5d$Q+ ]yz1aFdቚB|h%#OO졯.+&X:ș t :#!ќz8y5*ʩ{x=h|ilGlji>m88 %#?H\[n,)9I\wVih2w$jg)qiluMt4oZȯ/<Ŧ|Ug !J@2Vh #bŒo?꿳0ҮC?e;^R3 LG+G+o\o8hn>zs)eK Ɩ=:291љ.e-+1^,t!eR3C@7*#bcHPg6a[̖GзŽ͢ ur}x8bfȆs[+n~ܾD̮o<7'HOj=m/=VIZ/K^ɪG%밵gQ]@7;8dvudg_H3:CђY 48}Zp1Zv-Dkqlo|Y)B޾b$OԠZA{ h"hqL:-T'2]AN*ckwM< T#媇mI%}HɎ=RG_ߊ=]?yAOu Pdnx$z!7/IY;<[Ǡ{#B/&Ԥ %9gKݐ7 /߸N{Sqk5p@ %XDxE-/BGd͜-뱦z`}w4}ftz\F1+D/]ڣ SSӥ;^}FLoɝx2$ N]IVA9L4!Z̺\A%W}Ԏphb2R7I6dt鸼ɕ﷥=q"=MS}7ǿ`ű' ;GCQחp8S? пƞ2\gWiA4eh1Gg ֑,e4 zm Ü5|z }|;d>I޺JƜ]z ;lH qؖ+jH7CCzO|#-FtF1=n55UsxSc!:20qu D*O`}I+=Re:I4:ٸ1JM=;=xsQ>O;S Ɠ6~#{Hw~/dsLӁ6r[!NrEDS'׃8/1+ Z٭\&L|jG7yc@k}Q%] }Ҡ-}6I*#N( {-aHw.΋=ރx`&hN;4@{հG^{/C/(K ^+ӹ;qb }z>TtQY[L:3ڑR9katN PV1mWec}\y"?5C@/j X?V) Of}X= $7}u@h\NQcӕx>:[N,:/.gznU:Jj 0[]3 12\7r@SJÃX{)nF(}h̦5 Lrc0pqB8u.[w+K&VQs::_ x6h q@Åp\~P 5hF kUـvn1hut 3a&H/Zb-pwp-50FACN( (';>C-˔잌 @q8XDk e&8 z}B6@ǣ.F _ kMW&4Ed8Ӗh׍#3BD+,"K!ŘSC_䇞= S]w~|d}XwW1Z9w95h:\} m'aG'.h*ѝ疰K?I;~rf+ق=[sʖrM&AH5}g%h?ahi gqe 79 1$'X# ZB82NcgrM@ 8-Tzu8jdZw-"QcZ ,X੶]y&D ѠCFc%r.cHaFqQc pLz8qpY)`0 WsY鏜f-Gk$"SKH8 UMnПRgSL:AKj0ekML}ŞP<]X=O829>ʓxd;'HKKpgp\m/ H8e8pL/6' W 4DRaB \ K]#"CD\Xѫj8@^tY$jNyI7 G | 37xr3 %좭);DL^MXB6JCIO_z$g4ֻtĖf:Dpnt`uIqa86ń0 {U툞s[ | N-C]wb LGOМ>wb6L -CllX'HNGeiqp-2|D텋K_ȟXzSl` hg}cxȚ5pז __*BJd~"e]EMdGs܄6INsrTf=jC`b2OVqn뤠VL!q?{f [{2QIo1 u".TuZrh5`t|5uX!&2^ 3VgLشxõ\^.m:|j!Y,ۆbCYb.~2;=ѝm4MFl=mC6nWۂRSZ>@,2V0m𸄌dCw}_uf>se @ßK-31| 7l h>C<1|T.gp =3ƃ.3+!^K]7ȝ 5/0`]U OW<7n}{5uycj* 5:We{V=aZʜzs>|{Xs7]qgew=Nlۃ_זp>-OJnx<8W}/Eʣ#< f/'1RI 5 pE:5ALDG1$\[ uG*+vjdI5ey\σ5{\eyn9B zJ q΢+Vg'; (Fx > 4E|栁,j zk s[',J#岣|{Λ.\im9:_!lYZ\l~CܝkQ-d;WV-n)5bzDؐù { 7M(Mg25 -{_"+=UVg*a\VhwAstwDa~́p{-A;yi<2m8kCv\'.7ONWN;tz>.X`}:w_/~U<4DOޜ"ԐJԂHYuFJw0Ӂy'O-'8MY <7/Տ`g+ӕk[4(.V冊b=`Mٰ mhMMDFxpgF0+mTiHe;MI(4l?e;g#/'F\;WyɂQNSD-^Wфc6 uNv ,߻C}i+*+mR MADvh2?Βw?^+=_Kq Na<qguC<¤7u-Z$2MC\W >~+9 8DGK$#뒵Dx5HՖcl0sUZ`g.@^ 4c8*$8XK!P4x-Z6׃>cdM$r^LMtYJ4<.8i|ӈza9Z5#%y_92bpΔP'ČjqPg3 gйp4ka?茮gƇ2HQb] \"X_29QÛ\bΚ kDEJ!%J"2{":WG#L#De ,n§.l$/%f@' |mgCj!o\9򶓎ef[Ev=O{kͧ75ose1EB=7BXSa ٴMDMr1?Y}܄ް'; ,Yȭ3^ JGYJ\iB|Q7L"ҵa|C1@s ,Bh}DBxl}>iM}kwRw3GWo{&,؍f;,cGc'969]OV"`=y RQjP;zvTc5]XXe$E, Vȭ5So t N~;X; ,ت}3@@Jևo9x |:(G,",~wEZᦞilW-PmV|&>Gr M7eA"H/$"85v۔rl 1]w ;̤.vyÙ :sQn¦޴OvV(;K?Yc õ;h5{r`)2ly>;h4\*_wt.yS%:;>YL`~:,Ko+HDkr?V#uB|X&4\G$ #i$U 97x]X4|'sWln?O Pm.&f y.;`mFsq-#d5췢?sv<',Hґ;+MGIY˚N9SKP5ۆ{ܖ=5s'=+΄Wpi= uӈs!r]WW }w<؞sˀO`el<{W\RBgK3*L8M\]\YVBZv/9b ;jak? ؃ aa$3Qc$љZtL?YW?C^^^hI{I ߸wsEy%^1n(ڏ/s|@16;d?PyabO2XH+5051r)c$FF86؟륣a[h;BŶXEvD+첆\EU뛣LzQ6t K\  z, upmG 헜 W*. p~ m[&ĢB-O~7ܯ> +N(u."laL|]/Ŷ|鸶 YOXjK`gq͆b4p Y|z6ag+ֳj}V s 쬲QvRrd|Ea55f SB>BTš!o=zn9=J#e!W=btz07W1k5 <;|I^s|"Âͅ5 Edmc }q;5G ]WW [.9C$"FɇfjÚ<8,]EF)w=x඘dr `p=$:mU `1h`]`s(v|BQ507ӠDŽ3r`~X+$CF0ؖdu xO(1"2I9,nUgXU N7o8` uzҭV&}.͗^Pva*j'3Cfq>@^TokOj#fv݀|xi=+QrwMJ" E5E- 8oŗagqgJh:qTT6s|. Xi?Xx ';w*xS c]^vu&=Z8ufMγƜ3$g!kVu#5=~?o\=0{o:;.=>*'/ =~e}ԱI.G)w bnz>3"yA5K|o{±3IyYb&UWgqNM%C6S\:EP%b@~ nb)KzrV'~/A[[%$;9r})0G)Gp- kQ\tz>]2!Fs1:"Y8p]Vy`ᣐ{5Xۆ^2ܳ"CX:>G^gp}6o;;[y4IQcC at]JX8PKe LWϫ=,7$Fyp~%OHxpF獁 {ϸϽ+sK`.@jl'nS15w : rVx]cXK=` +SɽH {!병Z'SąlВJG[&S Q5|}pN+Clp9s(ڽa AB(K)5$L,!GϦ%k= gdWgL=\|, 8wO8)}b_ -/k p['BM#C>Blnk{ 39ރZ-@`zf[>OVs *A|Ȟx*uO WOX_d 5}v|%ko {˙=L;vwY{-FsɰLr'jx:E)6h@-*9 68f.SVջfK,ɽcksp u8o.;5z,aC6L>;3yirqy:td |8CRk=ys`ʀQn;MKޔ,)[Pe(˨3J"|f"~\#K$(HJ#ȣ6z;Ip6'd4fɰGHӕClc3q {i+u۴{& ֘B::|d.\w:wn"0a /#k JQ3ĥC ϙ7iN[fɹL\f)s`KugOpȗO G _1 fq`6L2$c Tx|<>x|<>x|<>x|<>x|<>?3fx$D M 5 tg, ?nHL1 ? ^6&<5&1!4%ƙ }8fE/ ^ ;C1jLT6((&k2tC,Q+փNhNkv$Cs t@$)iPBXypx!tkBgK)WlY(4Q,Τuc ʄrCE ~cP*abBd:- W"P`#mPЖQjAv% #rMh$p]d;(8ac5>DTŊhuPufQPX'#PbBGTe((R,^Äk kt+SKTRaZ-USQ(5@7P` 7/ ,m!~و/קDu 7)S5|bo("ip&Hd2M`sSdMf ?,TRQo :SPm$*WYզ`?|\-隄 * Z:ɚN:( -oxl;|BtŒ=?T5l>8BgHSd֘QjPbJl-P%u&8F O"j\J>tY*XAZj(!AEЉ%Ϭ7uuAt`g4d-PebH4NzEfB| v}R"{/Ռt?ĕmʋ5˅Λ+/U`K <HGr*gʭ 򖐧K:Pˍ=Sa.8->ϒ/ .Gz" "]S"$iPB,TM?$z@qdeQs>\ML+W.` t##Ԃh5:>DGHр LZ7q *@Ab֗Q$ DM=yfEJ<>OOz3>: L:@I:bړYk*O6D:S^D耄mPe BrbŊM{fˉn^쓱τ+ҭNJ `32#h%]qR}tմJ#bo/+kMUF"sZD- %]@QAeT r|-}eX.̀2*GlA$!'$@!luDI~Zj@ˆcJi_Ǥ @0 DR2 k_x>sNR NHTgȰ۔k&PC$x!"xF@ytYC7-\HGA jWkET;R %W#|d΀?5XpI%+7]\ 肱0@:VB]|R!TPǃ Epm[yn6HG ĉ -+<}VqQjEr*}.%?S7u!;Rp1~PwW\P;& 4\V)Wm<^ $9:(sU@(!'%dJ쿣r@ fQ-žC64VJҤ"5IMdqL,~ǡG@ s\O1z8Vk@|y$M‡àt* `Lpq?''P=5E# PlC&˳H5 K69_/s.BLGaײ;vr-fJ_|Me ؃58V`߇\ }GTxp} )qQTHøJI1 6IkA |⺠k`E?l9=@iZ(q]CpN(̈@]V1!sJa^m9I<>v=AQ:(Sʠ~`X"˟5s,-!wb7?RF#'h$WBEuD?3b ̕cf^6`3ʉ)|){EnA9p+ģ[8RIqޓ5~&`Qߠ q@p?(O|PP|e\}ƃ-q𽲐_Rcg(, W[t J9tRVto3*O,o&::En;q}} y}FBjp+DYυc>d; 5r&x#Ұ(st)#\Ulj5I2-P1W2u-Nx)Q%KK♮F1~HrpAQ&" cCQ?_qks`O xC'PD#k=y;?Zy Cp/ Ok@ S[ xz 1 c]Z+qO\+<bT~&:Pp.w:MWxcӞcEcWOT|zGQF&: LzP 8QvuJzCpe'O4P (0w z)o`D5bJ Ư8Ou/DQP~4٠lڇLl19saW3cA^.DŽ? u'OX 矻I~FUl`=.uQ?JW/'}{:h<}׼Sx:ĕk1 [\dzr{%d8rp!n.r"EZQJf4vy8 ,u7A/{l&u~c$|Xc9(L\<^.Y,>ڜ ʺHOGO[.p9 AboG&֋d0O;~ \d!=Bn{O nM1t#dIzSg2?%s7%qH@mG,Pf% +@T7T-üȷDݪ(;Ϛ"›+SM =|k-N/Y 9-\φXKcBf>b4~n3̱PӠw-9{,ki%qMu3pAc15DRҸZmpy\@k\p\b^QyD[X3pJѥ۶5N'5~8QVzo3IM<9TI܊"a,Dp{& s%P0 &v븣cH_r| 8*`~e֓5<'n鳥Ѫ[anxnI's.Ib SD (UZمJd.'*gC&J^cOQ8.eG}F֠;!O O w&ngS !IgpHԖU31*9E'I@!p{p9SgSqǍsC='[Lč#um.!*ҩɤk1a/V~)1NSn` ֯pZk+{P8 T*MJ}Jf a?xG y\Ew1h&C}NΪk }3Â00Qk& ͎8s@/x9ySɡnK.Dbz>^S)"LA%b< ?(S҈8T8_侳CaɾAͱAp$pwۣ#轧I"":I-$/^'8w?Z*DS ~)d3^ Qjcx*.!o)s%i;eB6Sb._8Eu^O&x#q[X Q%pK@ClYB""q/Sʴ'<[.aIz~WB!>JE}cҶe@iA_Zqe)a:ٮ .l"6%8AXr..lrW Ξ&9;TvpOPe"ޮ/R}F;}2d,ħ@_IOii!}=sd ;* PUsa ip%:>{(`si4D _'ŵ'?8 jijTrV.M8E,aSɡ2q}9(KO5!^O4=XS3]g7=;zmJ]JτL" p\U$*jr5O迃OlP:<\^m2_j4)kJ4dYeqrNLrUkл$A@/{߰gSK"j/vH2[L^=R&w.s.x)o"ż \/yk8RqRKt2R1b.ZOSZ)Y3蟃ǂ:d1q @xT@ByI=g~:A%}e0pOQ#la:\ťV~Ҩ{&nwtvO8c"`9ę8c4% zLh*;dOw`/8:Ҡb$^M[=|qTvr &r6Ik_"?%5q(30" K8Cgу@~ %D%luQ ~Kdng_{ΊA|zcH0ɩ̣̕<\80S8aC!v9;?W\ztQQd_"h_ɾWLp5>!+^nsaԡs#p7K4qL\%'_NP'<^ȍeLuPeY_%7c>VCX+I.j$k G8j|E 8wD=8#c N!. 7c/6qWgGEH;O^ a& {fb\GI +BU#srbxSgHA-#} /)Ux `v3]8  o}]g>(ଂ3Y+W؃1_M`BzCZ{ ߕpgG*ئA( g ;Ɨo (ӓ׹ ș?gs8J*M.dGjgaSiۨ̅kIp&ߥL\%BN*Ompف߭Etu*7gFCO] .2߫ə>8ga휿1N!1{]@׀Y0gX.DEr0x-Br? 0=aϏkO>1sPr9Yh'Y19~n8zOQnjࢵ;Ni6Ee ;0B!Q'%8ӉeqR< \?PswpaV(O_7612fU82;pvINvia:B~3l5٨C&ub1~#<C<{UܮΖτ3d;L2#$ym6R .v2u*l4qϞIE|\1^#aBfA}rOTtFwS(}ԸWe~إżlq.iK$^ AWr) ˵UVdWZeZ"jS @Ft ˠ#sجlZܺM.F$ϥ5i0ڸ)!}i. ȟtF6I=L{}1饒AyLأ:uv:q;ѸSh*Ψ7G*n+G{*6A@\өmpvހ$i;JֽM߲>{~Xi/{_[Ff#Z(IN%wCWѡoVR1Y5N,,b22[+C5` ;w[.[}4s>ܼ]z%}(Lrŝ02!V;r̖PA.ȅC %8DQAp՚IҺŞ3)[ L;wY\*;zFoe5Q<ə)+9N^V!-v?aIǽRӺCG5-nWY/V d:ڝlJ{6ü!I-KM斗jiIRr eNS-o1O{ψ{>Gj){לi帜f1mQ^Vɕ6ӥ6.vhrWVsH3n}5a}4osv,irw[>+Yr’Mol;Cb ybԡ+b6MjZ~\^֥S5!qi8%5jNx]asGiL34o~4eʹ8tqӏ2}܎]xZiqn\ziwY@Yx( 3}+zwKW唴śӖݪ޸P H]~Q&qYrIڂ]ͭO gJ e+RvLrB-^oȲZh:Fd 22 8K銻p{YNE~KVx(e^~%: -??!,߬-=y;Ur*(eJP[MѱU=cqf CfKU"{,UɁo2im:v=f@h)yVs@F/"Q\8[Oڬlާ=̫޽lqqm1t{,DϿOVli1YkY[_IE#s뽈O%n.~끽pڤF+N=fRSG7YF9sX|m?n}5n}4殷RtNIzL=ܛc\iΣllE id6O%w"~H|I7fO;},z߆K?cjކ|kg_v?3a:DO^{$c\ Ue+dj[E%8Tf:bw胭5ŝK;]8ѐ{%Kaї~x%:A1Ny\3.@n%r{2,:[aeּH=폣wW6: [嵄Of v; 66@ c$q6KSkmBqFҩ-|q7hsIN/ߞ(h&,<õ5xj͊1[W 1vŇ0٧W!E:F*v]Sq)%ZvOgN1ފ`ioS NYDYK_vZ>>){ؾcv r7VRg$n_xpqs®7Qf]4[rJTmYsBRUhŪ5f}M0 8ܜZPP}!:8dSJMٗ0 ɷ]~nH?Вy./Ҝ\ľp!kY]ibwȨɒ[ mtuG=%rV_"VP "߮}d5%=N$^^(yWc$tCbwM`WM`br{OM`UQNuA>en7k"jlCKVڇ;WۆTTن*ŠsK#2JܣJ|cէXU[w>j8.| 7 {]ؔ: {#.i?-n w4pd5OeL{6?yoqewE$$|zW7=/ }QPw!dSOB=]Obuފy=ڮ9"&Kq).6Em+5.4.qcD^wRwEr§" F)?ش/^w$];WM',<- ڊ"$=ҬF~٫\UxVEZFZRW3&֭]>얈s[z[iY#Ȣs8~e~ўenIE^1eL{7P؇SPo|lOtz©6 WRRh}\_Ʀ/ycQonRX}Eh^\Vzҳ&4 +"U9GEܮv( Kwu+=߼3O+#b*.{FezE9VDEK:Q})+RUuHٻ@YS/k7?eucIԉԣMY {݊4jmڱ]oA{o({G͛>eU9m^q٢6 $:3Mƛ:'M4In\e^p>q9636Cay=Wqoa2^l˳xk )[7қŸE5.0ѯ5[VUh\Uv>1#־!6UU kpj$\+#+bx*v9I,ܺ ڦ3'tSR_[uxsL{βpܘíYqbccu|/VWUPWn:ErW|_G]#xE50_7.ENaY8gW؅|h<? qUOTSx6aCdNCTpO{i{EC"6|k8wLyIB)ڰݴ o܎Vkkeh)Ҡ8>E.ե$,"yŵ^Q~<_EZWdh~@96dƊZtkqQ|hoBkl˼c ~c^Xz9oln؆l ~_06r-QUCNF3d&i_f&2WLkE%a8G$yDGĞHIHzs̩Gdܛs1oG~Irvwln<R|&xGA=o3;78d7j_V}=~AL|M~4Wm=eR<aFhcO*h"RSNPC+֛maʚgԊ0C3Y> tw>xJ|]m]9W'*-++H2 \"_u TqW/ \ :z(}⚚NB%sp;^봽^N*j6Z0_ -]XW<3c~{y3X4 EC 4|RƟҟ?όģk0I<74jZZ6J;nK="cGEFFFE9[p^^u)+vG||/#ny-nk v_:_Ogc^4r&45zg'oZ8u!{ߕw:?h7 BC_GJOДqKuɂ>\7>.8G4lU3*G1 üʃb? . ؼ%"糖Q_\*ߝ or0?CRuV#6rPwߏc#n<_F?Oߛm_$kb>~GVd$bslulm" eqLƾ:yyx^J r|"R"YHn2A 1*yE࿃fCgg61:NSé~/)y<,t5Y{/ܣBFFWdcUy5"K!ʟ}ܘecb:40.ϘS"_)|c!d{xn((44MWفn>ԭҔtx]sUB"?š(SˀWb勿WGEo͎ߺEa^Wm֏umCkc{r"1j 2ck??u=Uq&Wf8Bz3jRs\ܛ<زW e.o \c_r-*o\wv+xz,MIfCIc< y>a-RMU݆A3昡sh\32]MTha({^ WlU{LxXsm5i.)uɯK\0 Ӯ["2P+ME1Cj^sgfxDŽ!sФKVSq|j4AV[Ьh?ZţEtZeHiKȯ4JULw3!^^Fث[\J%1eαNy\OVG7O3ߞ{TUYxLpq+="߻X;wz^8Cpo쩆JIR9͛SW۵| Zqk5 q#r sd,k{}*-8E`ԍ^/uxlyᏊ"Uڅ~qmrH.rJL*0uz:g?~+caxF6TR̕ mhßVO+YSLHeRTVZ3DR^R?je~F9Z_oC6 /SciM NIݵNɟk*\S˝̛Qo͛1OZh>&:S 4y S㜉^kSӶ*Hu)AWG urQ Z/WiGUM7=ڒc~^'o*`zexxȲ</=’Lw1(kk0o4NP5WmF!deAy~~@[TmZʠY+%h $-gb}Wpkh㷛|mLMfFq!+6ؤb4^Nz_VZZjSn_W6gc #A|9q ATzTAC M Meh`||=^gj<x/ؒ"'lѭtƳn+/QkO}⏊X> |89`] O8G;7u+wM~G}itv};r'6Y!> T-GezB- CKr.}=xak^]q)οi:ߺ Uɫ|5k͠^ϰF^,=SƕkEX)c#Ӑ]m9_D^7i `Jd ORxx&i o)4Ӱ (aո,p!}3qqIɓoe\r賕8[&P_|;s݃~Cga&@X9vh-y -ߺ-YKKu…hS6|0d}i[kݼ廋M۵07߾ي>p0l湝.5H\jx_k\›󌠜jwUG[w>WH>05}LTYX^bUal5#.ashsZ0ZOy|(q74^u3ϒOܨ_gUv?mM~~M>+e[ W5<1L&hQo ,wd=ޜiu;z5noؓ in&#vAlU:}-5KNT∹6}z4h6ޔ«۰3O61K7J5:]>R׈rHɇC1_w_5mqQoUAܗw&l@:sTZ&bᢪH8k&wCqnY>K萩bh8dj34} T8 c1 ub"IiKP;>;'~a=5`׳VUYWii ϮLeU4v 4~,̣סY c;ϗr?iWz2ߺéÍu2?} n{/J辁?l=H;Ǩ7w6xs Nfn/ ]ͮmu凾Xh.AlxEaY5WtHyV6ѕo:Tf6}风]tޗ]Oy?g`1׌+Dh#7@٘G^n?a[|-5u̓}6h!ɱv4q=ޜj;WAs7-a|4}6 46c.>u&fℲQCD' Rf0+L/ S>l=?6nщ[OYSגLAh2kS֠Eh<`kS~vW^.ΑՓϪYq^LM=ԋߤl#ҚOYI+'&j"M+dė5{=7+gF}ТTV.sG(Z'ry'$KŖlȬe䯊,K Oksڤٱ-^'6dHo8p䱋ORch˱Cv/AΠ:yY5oi|o87D;\`8RiA)[{Lhihh <MC#*6oEQ;'_ECΟg31ֶAKVܮ#NeÞfR;9󔤰*2t=L]i~n9qxƽe ܲ/k!gq8agė>8Z@uu4kt4K.c~63qlD+Ԧӧ#N=6Γv;ün/-/wE گ:ENЌSrF{kϧӧ O?1ijE^i+P])x04탺IbJۥJ+5h>ӖhZVhZ*ez/mzyqu=O;5d˳RgEܘkVM g() ] *sNm}ez%0CRHnxM&䯥c%\Getkw[9I?uOA_ӓ=:`QRn]2UWuJxWA9FL4=}~g$ ϦnQ^BF C1^H?\h m ZRjx.vw+wB' |,6YOչMt.m=n jEW bZ׈>H]OR8c"& S73W>]AR:AEi]fm~IFWnt_oM ~zxc: oh74/9gQ#5㣮M):l6._ LM`H:9:ז8͗9G=FѓD)5歌%55n*㦘*¹PЮ>Bݹ҇ͻeepRsW(Li\͜ / xЬP8.ouz8NXp39ctOfԿ˯|Y>Aom9y4cA50J8ތNown&#']@(ToqHJLU F XsTy )LPbDZop ǥ{% 2YN..׀o*Jj¦}Ё375'7~nB._"fd^e\1Uˤf_c\7c)3!Ye騱:u62)CzHsZuG ڲ )$rke-=RZy]^}&C^m*R(u@p囶0̽ Wr\rHxvI)єEUAbz{{ \G31cYj쒫d?2`+Mk 7WX; YhFPڤ }o(C ֍ g143@ultDi5tj(r{y'h@oTʄ],5%z > >NG/oHU!1a,e.<]D]b-[D>Q[ hЉkS$3ߛ3M,0͘/?K`(uP6O+ :/9t =dyӊ?!;-Д,P=x"?3td'G;{*#Ynf {\4|J٫I{ߘ ^]fCJUIn.f}\G*7g}} 130!2㖦Jmb4A7􌈞ЭFZtWb7S&m_9qKReφL#'p&Um0ҫ)kF3O:enK.m/+K?:=˜`fy\ߢ>({.[<떙CWx71 |=WO-U"]# ln}| `@:hΟ7?6h 3åύ'QqGZ fқRV9Jnb.uꁮ!s6`)DIՖ=|cc]}p˧%'Kzpqc6Y =F=_\q"/E sifOƎ|afE]imډ}C>_ 掚UE[1|e/T8}{;(x.@7?>mϏnx:#S ¾_}^ݫOtV?e2 K湏<ϓ~g+1oa(xO`,\] ʎxO4nrǖv'ל]y>|wYGn{M̟c_&| Kq{C_܅a,g?G^][Ց5i[{+tw7G?K1o{774ڵ=_W>l{a {e"02b,'NL-ax763k{r{*-us\m.܃_m̵|*=H/'z|S9M+z73O}ıx|+1'V\W>m{9};a}8Pw\|l8sxzgJַƸ~pkΈ3}s3V/X=~p7m^5o -Z]se_jrg9M1"17|hc79@5C \5^ \ |6=q#G v=yW:ϳ~?'{voe^}[.&-{%ovM n~y7)Ϯ7uO] c_gx_K)soxq˨ISۿj֢D܆C1s֢g?zo|_~3or{74-9sG֜zKyyOlu໕`j_>5?0s=/u߰>2M׼C]SĹeԮ ]^;\G~8צG{¹׻>zII h,o/ 'b}9l&ҟX$鍵9Sɸ{SG>=кB⡁5~covx|wW{ߎ݀X;G?|IߊG 9uxp!⫏][$?$$V%v6d `E9K\XUι{ҘoϿ镱-?{V?]oւ_5ԿU8_Og?KLrvn{2̱<9'4u^o'Q}'`9Gu?S5{+p]%-3s;>qsG:C0f7o%6:=fsVwϔA-jӅ GܔGY^_{[ze@U]r|;͹x,wwZ\sb~1W6|k#/:1[k-߆\wFYzn-\9W ռޜO;{]9=O+fG?1bxcW޻~wB|]]RGx]ÿ5h9{_ 0ǐo 0?^*zj91FѸvBrm~foA+zy3 8Lu?)N99?\qc#oqDSww&2!o3Q'ǜy\k`nM3*,b) ~#yC zs6,8e20'd|$H.䍁GG< ɕ&"OiJu/*ꛩ~9䣀ko/܁as|cp1߈#ݵg7U[{^YI[$T 09+gLYgյ S{qO_N$sv[p.~_3ǿy0{۬?c3޻Q޻|]?| v\23釾oݳb\a@w˶ 3W 5ktqM.{kN̓L0`ЍH?_âsц³`CYH䀘g3gOl:<m(3*{"~)V1О#q`\' >B?WAޖy,>O1W]7;1o]]$݃/BO_Di[֐ܐfqC[76a y\ m pʳK1F(wr&2閪MLصJ F v+srci_=uNϜmFx7 u m<>_g꧿t+]k=OiZj=okgÏGd^X^YeAY|1,4ej")?dC 愪p,0З|UG`#ynHUm{y\hs6˅Uhb}-P7\~~(BF| |T %+ `2X}{O9n%bT]#놺["V.c ev?AKh!yYܿ^-oq z:9ͬ{5>LW̕X]Nm~] F֟CI:W|89䧕Lyqg/%ߖ`<< /r51e\tOd9<óu]m:q2';Pg m1E~y,⍼a,J0/~9֝{xax̥Jp[7^Z.B<A }"l +GA>.\D7%:$ϛ {_#昷ipA5}ǂcs2'LqY‚xWZ@)ㄘ5x;dqf\$Gtbᖁ$õ4M{S)W~f?]CO>uy˽b!۶>|-2ሉ9?n뤘ڻ'AOoxz4x?u+agM[.T +} wZpOh{N?r\`ރ_݅y| Y<ƁQs`l`=-a_jp:ƿڃVyzZ_>FrDž c;b@]y-{. @$$b_~;Fl_Xef-/ 6m0d:ݾr/#Ѿb#ƙ_r?~sW^TH; (Oa9&)2zفk s,{d$E[K07b^|hE]?\~pw]Ye;b|զcc` nsq?Ye5??cQsq`rZ{s<#ޑ(資.Wa`n|moALߦǺ}yW4Խ7>[?<;޴{|gD"?0!vwd{,S&^:7Wݴj!1"ͺwE d0λPj`1*&Y<%x*;{\A쬹8 @lY?v7?N^]91NHN٭$IN܈ѺBFMGmܴn8-\ hV|pT >p5wp7O~7ze1sJ Drqo:uF=7SO}UY{ 'F,gh+;U]OB:,SY!o(w_IZKMj0Ħ $/k03=;+H1*_c-Ě2!joۏП:t-?g`dɹ/_ӉCMF,#-čcHĭO<$̫?՝`HyΌWiyyrս֊ГWK a͓z켘+n}Vo^i]>y9B#.b";C#;q^ Aקa,6sf:Z U6#vwM+j[잂勭Z~._W Ϫ}q Z؜uYI707{yqN vɎ+CKɏ7 BO~R>2ey_34sq}'yf\c=oÜx67X^^D0>j8B%Ce]#}'>VΪTV I빈Ӆnv(Ըpu9bg|*vVdy5K^&WC`Hy!]]I0o8+\K@B{x\~\~ᑘǷ/cu8zc ? 7X;Q?uUǼ_bwAiZGuY8$\;߲b5=_gV?⨭~zT`$s]< rzg Z` 8k. Xk/ xtdu /=D%E'=-8\Iw,?հ5"3j,3&U}i:9WG#-: >WV>:/h0{w??$?}˦o zОقOL0{xJ1P'\>)+bˇuBmyw]brIluOƵ; me%8@2BdYdg}YGpbP֋B[k}{Y Ё-:`oA/o]_ڃcmهƞ_8v7l>#3܍c8 #رGʃ5ޟr(' V"z yMw,HΠcG\Ϟ Y:÷4a3+Ö^K*f9Ջ7_Zrmvg1v@`?;sYu[,#v <+Y.E,㨟vTxC͗66tVt0h 7 '`? tr`19=޿] w_Z1*~7 |@ ss|_=a __<".Ǒ߃AiͲ=WW_1tV+j@\w2ȗO%C|+⒆}:#|mK|u5xj߆>bY_RO3w­]lZv>9&+X}?n̺Ǭx/ڎ qm}~C˷_8gG !|v=w\tC#s~u_s~k`Q/(SߺVŃHWm}Ŝ6gY5AՍc|[xy:󷄎|6AƽKCO]89_%ۃڱe!65O|%GyE_zC|Fݩ]Ж B8)c oXơ~9~C?` s' ߱@`7bԡ2x񈋂8?#ce[tc݅БOD\鈟П@9xmy}M,R]?=/ vVS_z]O|wbg|v,_{zWc.k/Ys~s,B\ s E2vV࡟𾽿FV$&}lA\`u#Xk~?Dpmq% Ɠc>( h{| !=6j^ކƁmނkg>_D6~}_  b,kl}x'UT-ᅃp't54mHu-=J%sߎK@<4umo1php]`tɐe{F.+D̍'wL:Ӫ|8^q2lHF0``,Xp 7yo<1~@!QW=r5bV\vE[ju fC;nC>Ah!dOb#]|8U0݇|V\Ek|?*ܿ"5+v]I0 voYݿWi]?v`jX4>oK+-^ℸ~;ḱȏs7 zhQr@bAġr&mo0?'> Yrr,A/@tNmXwO}4;sm~NM pg_kxmyÙޣ/xی(lHKdh<柳S7y[ニv]꬙ >5q|0~p|.W-NmXt.ȗq]s~pd[%Iu9r=eq-7'6Pm?-6]6]}Q=n"(d1qpm% 9{V0q\КG#uʃV<[yt/pܓ&Z\vr@t``3Ϩ~`N =@p_|sp&iusc|!GB ,Coc_.7wckٛQϣ/l[sS>teV'ݳ-kP>3+ 3,7 Gb#`>;9-,U~Oz,9ۍg|O~s7bzp6soQ_Yг_x};]dbD@_K{s]:M#`܋6FL#NoV@gUSy½1oü >mhq{ D3={3L,#a۪ם)Mg`AB_o#<k\6$؆8P?ꯥ]v2XCq2g1 bρ{ #v!ƪ0nB ρ<yK _yU:qbp`}-x19{kxhcE|cFˇ?ݵ7g[ Y1xUd#𒇮}}9wXw]@Wu9K n [̞C﹊yxEܧ-T=lٚ'}X?ٯY apz?;}G~ỨؒklhgH?q}` CO݂cbq M 5lrI`Q&|3xʡ_bZemA?cwQ'~Ƭ?h0ۈ1 2 :p ė = iaBqnq&BW\3?>BSq bqXd '>wτ6$xbø΃ _DJ'B|O Ƴ@nUӍO<---=9{y7q>9M'p?4϶goi}7qz%뇓vyʸc~̏1?c~̏1?c~̏1?c~̏1?c~̏1?ѓé! ]2JU9I2HJѺT4'۬ 7=uuuLy-jڱ 1%SiJc⽔"u\~V$sq :±rϰ!6Q`v9(:8;3yΊ ] Pz:;lwؐ!3 qXnjҝd$J-s+)'\D296B/_@M729#2}GQg$#{f/tX~A-H31tf2X}2'w0qST[CcERx=QX8:MNxP|NfXR.7En-蜙l˝`3p L-5 93[c o*O4eFsr+`5Z;oަ`oiKD S}i;psdr2roK$^g@+V(V g*fm&1#QPZsf3inPWd%c)3r\Cx!gW\u(ISd.QL$"SᦹѺܽnċDDLODcS924NScH`s;M4X'&,8 N:8}opicӆd&MlF"lF 3:5ޡGԌN3Fs!&Z+,Ĝs>sgU&d rhs~bdA42Kٱnlaτ/t>[xin>YoȀ/74E&#O i1+܏4$ulf+TFd =B<[%[vZf6C˷jnUt3ъx4% 'T8nǎ% -eI H>Kidc(,EhQWkhCLo'kPtt*x:̭5Nen+f> crGRV0EkEH|6{ 5rY+ bXPz hjnc$e@1},¸Q,&9MUEMΧGzIc= Z^IΉ\2<%φN: ƗlFNc D"VDag+7<։dgH-tzz> S/ȹo^̝bZ7Wy"47K91Z'=Mw4𾘩 FP,Wѩ~(SFHfRϋ7G# lJ3]Yiza%¾}T4Glf>M`-Wr,#1g$3ֱd-}:on;,Ymn"xjzmeUgsGgbAbs_+6r?TTqsޭ,tPh oxszWM`4)6Kcx$ u' &֕(T'#̙p}}4]`tB񘻾3a%gn3x"nOZ[?ɑL 7)*wf#4$ړ U8n˽ƧIcN\`,)a ' Vd9rHL2x.٥:RY"q}R.pq]T*Ku}̳K%i?Dugb1٥b̳K[Y"g٥{vkE4NZ12ƕN!;O$ 5rv3=5MimL/hH_LZ2v"I6UkpO6 jmyJAAs^yLZL$LhES-M8) Y< Mh*ܔ*}WTR4bNJQVkA֍Kċq՜iy4ќi KœiL~<,*#gUuE A+ #X}X}Fh[@mMpj0"&LH3f:l<>U E3 m oɨ|ڦd1ѝZI 3լ<¿ Ng…OyvmZ_Ƙ=|azw$3_%)Pק7 "QMvDcS9TtzF9R}l~7G=,X˝n28⊂n3#K| hjnc$e@QKq6i>#)bm. :No/|O7|s77wx7P~1Jo@Pe6<%2 jEbDkΌƢs[f Ν 20GZ#e8xUqC,[dDofPEb1gQ$wsU\U,UE#ހXTK&jh.-lœd$ld9 X$5!H]0 / GbpelUNW`fAʝhcK< (( ѹ=$X*Wb Ξ&sh_"u'k2:=Ta|1cbJ'-lX/v%͕(s%\2WNJT;͕(s%\!Fם(eY,H+Qh+Q=U8wлgS [O:ϺL9Z![HPI}d&Mqբ1˱h2nôɽ&zM66dúXq~f$pmr10f endstream endobj 58 0 obj <>stream ^А,MO8ggEލߙy7zWRT\;67PGݘ |*Uhhhp$#ƴ} Y.?]glKz /K͏ΙM];C, ޥNOzlT47b(v 07rtX\c(̑xsDb}eFs782oslXme.ޘ7M1/@*la_?PwZ9JƊTxe "e=qpsdr2roK$^g biWPT4UbO:ƌiFBZRe5kHaf$^ؑʢwfG, f34sg/U7SpFqP|Le=wT€(y8 (Gӵ jIn*>H0P$k*g,R!z\q6WaA QU&GGQqA#̝Us?S Ż%g7@R9m?|Atjhfm4w׌O7jso|;fxjO<3@5T3@5T3@5x}.D5RD~N2l8Ϊo)Wzif,Eq<2f92"Ŧ9$)^Mأq V%gu'XeR)\M7RޜjXoTn/$Lىg"wq_7v&$pY_Vg~2Pƈb4}]1˱h2.D6mri{?GEb :&&lX9o4}$2[TؙШEI1眩շ0WQw &[lO :CUtXL9n%"vE:w-G_C$99<#[ŸXs#Kg"K 5H# x]U1k!hH#qJQDT}e #B$>]pTN/}|'/X|_*E>]t9>YmsH'z`czACC]2j'5t=BT;#ZH2B/̉ 2It5NĠI%$*adkD iR v8%H4KVNs;@9('p)JK"s "K򌃣i,(I#r<(0V_B=mZ4en VSzhje8VK<[H vAqpijc8;`%+Вǒ D 2(i.Hh-G"gyf(;Ey)IТPXA[fBT#02K(4i )Hnsd VCIZ4ǒkѴ,`9; H:YglY4-j7Зf x!_Qꪘ$bZ/<ˤ lh4ce 4ϐ,/I!0IhNAI8;K\WȠ8 Y;ZVD lAB&aNmwH,C\xBp=#VD9pq,t ^eBF(mOX -D`ުÄ&qz2`+JJp4In&дn72 Ƙ9>,iPrçdc#lFT P%W!@hJHYm&E+#AaN0@L0)cFF(< Փ`4Q/0X3 VU;A ÆQYnjk2 ͠h6}<5ܘtKkkhѵuKͦdqGur@͢F\]GjCہwkx0yxGɓ,/Ei>$`(~k?跎ptm^:ptm1%D`Ys3&KIhV`/}C@0AԥJwp)@iԣԢ[ ÉJCuZ4 T !YH;thgyF$LD;%M= ;ltT]d%đnVVbF,rJ3ڔEV&ɍDSZ^ Jb+!^$U+5K r0о4 qUpL>%2ju8cQ92fSi6fc0#l2{:T^H2ohv6<(R-T"^Xaj-(ԉd,dElBQPEE='V"H :o$4~x!*Uq +@+]B|)-_`BZѿ')oN*U:dBi4n$adDx!(|SZDL^4M+-#wZap SJ$Dڅj=2t6>8K[$Pv,p;ֽ_AfY Jd5v,]Y u?4`Cdpk름N}~uU߫uMa,ìL?}Qh,=DgujP?xJr]磈1>nph;{S:]Ytl(1s|mhʒhZN0GhuI2 ٤*\Ð.XMGMP_d+$פ=#2L֤DaPgE"hiFK;L |#IlӕU{x)AUQ R0.f/sb8t4TivR1`r,$1;yNaS`hGz 9Q: .MP,2 2DT$wv <ˊ+ oӗ,J "{*wQ1ׂW$C/GiPB'>W10p D?BG'+m Inobs4LSL$Ш ~pz*cD60FdCgF(pH cU\+9 삒pRPz/d(U"kk_n`2pL)|l;Yr+ jϪc+W_Vմ:iu0/=7EI3Ď,%J0(P t9ȝfiWcX2fio˶7_k~)e2ʰdqJp3+. w|pmW#3̋cRH;y|g3legEޢGcU:CӓCyM9>)U_ZԿ&g Y1Y1Y!CG/=VӳCl61&ٔ^fEP<}ś(9QH}C&yA[ϱ|K;S3j"1m15 Jk]O[KqtzM"HM :+ =>-N.&tY$gwulTJSXzXK/DS}-U _c;~tzǏB̓1IiMs䋞&C"هEr.S*PP FE&G*mB#$OמT]Ҵ':vy#]ʣJR^vka>-HalxriiI]]KcU"N.'Y-]Rx@M] ?h]4[D,J$%W _ѺPJ ZUXSPk t|*IeEVU Y{V+ %EgG ёUu$F(Z^aGֿY|M˵JYvt֝Jy"kfzjܩu1PW{'q3H]"YG6deȹF5h0(ّڒZh3Ԧ^ xh&UMv6Öx)ؘ&Enw_&;V-BW4MivPJ'J)CQu9mwhHlnUܨ24MeT Zth,I_+X=VIRk"U3ݝT3!9- #,K /( 9( 41(1%6Č<%4_9|T5tNk rg,[.=9ujz]E ӇqY@1 |@+tD _IeZE%) ribLdhe0:$]:iRBd4 2ȊdkM(&BhQlLj|k(edvMCw\\c;\=Ҽ ]"ox!HRfqW;젅DVW>ML>RmZ+]6WSZ4Jzd&-@gROexV''f C/rB(B3VWR$V5ih- Rk^KZ4Jz&S$MW$DeLqzi"ĵS8Δ$yDDH}),D0D®V&Hmo&RJk"uUiM^j4TaiWE4sM$-IC`@۩' sMd`XI73($uĴ$3jzUV$5W'H'Z[Jd! QO#lP y4'djIl,ň2d%Չ<鴆 0MKOOCUh OkNx/*-ͭ\Z^) |L#%V%2ZZ2'Vd3dD vǷUviZZvzrլ@}NtZ#u8Qii^ת:HմD/:=0Hi/O^\RI${尃g!]FZ14Ø.8}xH[AiF/'*F<_%\ETh5 eYOusSi AqS2TM+:vZ*A/<&V A}^xJ3SYiPk4ᵗR;_P/Nt'4_MX#DEU h׉NmNr*'*I ԫV^>Nj*IeNuTUI'5շ MT꟩:it$—Zʷ`餥r4Zuת%ӟIK%|IV$oJ',RICV^넥ґ[j*suRVTVe^*NV*IeKuyE:YޓNXR<0VZ',$/+|Cy:ihTZת˥D}SG*>X1Ku݄$FK"m:J_-W}!Kόsz7iu\*V$0ׁ蔁 iy:N;r;ʶq\.O;},qG8l2$5eivd{@P2."E;# "u "ԼɰKg(jG6UEmr>E` L-#YM#Bk(xvd#;J:˞!緫DvWjG6D븷Dy.ebB϶I} -gz"i|¢Yڑ l@a[PpPYץNeRsߟ·:P{QaTԜ;UHc*ԺqMLjXxFIV긙Iu; 긡<Ȭk$^W&5.(eP":yeRs",u"T;_Y֍)%ӹٺe)mhcLL떧2ok*νleZ2βs;ӹә2kL3g8=)ѲH-7:SymL!͖3%VqΔp^mYdNSs3%n+r^;r:OgW.r3džCΐp~v:78Cmܥ] yQ^9c_/g6!<ҾpP( 2c 0f6`i[!h!0Y#P|C!hh킆j "$y"6gl'OVr,'~ RH^8#B Rr: }ڡ䢈&KEMPQVIFK%6 }Bshr!22}ƽDɓiO[9.fGKG1 #ڲX9"ÌU䚂 {qar} dxI1ϓ|RS 29UI$?L9->5TPt=XtQIN*4 /4<ҷ$rr rW$3edFtE$E@RיJC DNtI-t DwW;MRs;{"gV0Q7ԉ̞Lw*|"}_u/]X+Jѵ@0uGt`O𝦪utW:<4=-K Lg0yc`WOjs .? UQFFRO,Hjfu? ,w9ljcOְl nP]׭e5r \ \<& P=l8XI4 bY]%)qN='ʖUVIo: *ym|k'xX~-OOn2QǓCկ-jQliDbcFJq41) DFNeHN'J)Lb)nvBit2**-5J:W=Z;O)5`WUl*2A=ꫡ#eAg vWkk7;ylh$}TӺ!E!*݈n5G2n:pXwÊx4i<9@錄sz~Hִrh|6u*%=l^AU2 24^􃩳~{LGnsR;\ߡόgHd= 72UiyB2_YZJO2(D[t_;tC Ӻt8zI!r ژ+ ti'y|x iQm,E_ W^F iRy'aE3VL6K @Aޅ*F$w@VU%;PrR9\βgJG7־׎|+噲%gKn %OVag"g?9N֊)YAZ2\k $:}2y9^$]#ҭ+n=O N6\ɩ>Y߁8rR!I(\g)YI8iLH*D}uFQ+.,Em2ZE4\oY)B[ DU.՞.`K&_yu$>Pkc`xhbz:\ZU=貞@f<-?[bznT,& NYi84٠"[<_MzȄ:&pǡjQ H O8R 6':Anv`\u77=uuuL]4YԲc0o(?; ԢDCȱ2eg!dXNChg("[gxQ: =TV"V9GK @6$<Y9wBAWRT-A}1Y wݔb)`UBsEwXn !Mh9C&  <4Fb J#R,+I;>inGjOwzt·bYcJ"jà HmZ;{tmFwNA +"~c<ڊO~ՑMity*ϩj;'6ȫ n)71k{R49Մ 7S8Kh#X^_.iyBXR$Vxl 3D 1ap; V;,sMu:!" Srzh9j/"A'TX>3[F Pr+"hN FG{Ce^<#Ahl Xk< O:7A2{X`{+m6D2/hkM_ bD/<8$ǃxƥ | )YE%Ndfi44J6 < PBfI$9Ar=IXU"2'qǃrT ~¦3"$D`K&Ӏ!HP%KT Q5 uU ^+2/\ kMUNvzX7cBkP/5J^iBQ BVAds-XXej>DkAN9Whж %^9+HI|K)&EAYPom$Z_tD)I b,YK9@sfS$J(hvEʵXh8jXg99{HV&)xHQٟ V@Lr͑GB8"Qj 3?CkyYID|c~D!24# D) 89n u G !԰rЈrd@h̪Tiy@P(6ˣԒKcYHB=uh !b_\B.A]d$\ېki4&{U$~Dd%­JEÍL9J}|L $dVhD+aYPy4+A\Yԋfy GΖ:Lcys+zJhgakHcXS$=D=8H 894:_ D~*c-ߊ"u)=tkiyuz8IZ+lvU,E^5͍)qVڔ[-qk,AS]ɼ6u@ɱpDK?u4U誈C([67KcV2.v%©{|NKDY,G:"vFZJAWgvirkmטXf|qeYK֖&a"leNS9L)3[.%T$D^8ΪT8u*'z־Q2赝I;WOy$='Ysg# LX+ ՙLδ4:Qȼ^kT9{Mu<϶<0i|8OSQ~6o5o\B`riJ>!jE7-h+$ƔښõScfG<qe&&^/h*"mrϭdD`IY*qu URneԐL̏!?*JQU]H]~=UQ%7nb::lu.L[ 'SY,K[*ݐĻ VL6vЉ5H"K$ D 4Wmm2 &"am 7 -.t*($sD^ I ñ! a9Gg*q/(75b"g&@Vf$P4+P,C㱂j>9*Ly^oƦX^)$R"c$g=G} 䠄`2& 1` ;ouK$ݝٝsyl]]9S()``0< C N A)o<L\C',89_jOU[]Vק:u$ѿޔE(bmcpt,XU^1^OQI]w0w)װ?3cMQE-C೹p;)SyTxV fɟ8.h[?jny%"_|D ?o4`,(D(9n]?$)h!i"y*@r4O-B,V#rQHk  q3Hv&J ]I§TQ&)@(B&" _: V+/Kbe9@e2!X@$P+3]%+# ߈K.d!6NGtɠB$ O91>,c{]A3MV5&:!߳xDhBch/QD ݿ$L 4`3~3/%M#f-dK^vqG0+z/6:C,YtH.jK:k% J 'E-&3*7Lg X<1LlPtM0yD6;: ?,'W9PP 4R8_-tw SBj1]GSkF,l2uNGvW#gԎiK}+!LVu{@?5,0 * B|^+'AKgh*=X+jec>t\Qz)fslY1tM}'O0('qd8]퐷_ܪV`Y뫽j$7 ?"|{ \Qgy!>$<EǝH2Pbjr;Ĉm_c@b-*=EfYͦ:][ >_ H7 ? a-%x,b2_2X\-L>=sFisFG:uӶ)tѿkW+i(V ?-|)Z,Nfpļ#!łɮcg7zPӭvsѦI^IN2bK1egRF';4m(:WR{oYM6ι -ݞeN9Xw˟s[I,t2$h]+h 1,dyn@'L]{C*BhtT(g.-Hn:/E03 ?-QlȺ_) }hm~QH?vykةEN9ߘI|&9 鯢*F C]L@XjY$ŅQs"gk4 v\AJЏ.gA?%C B<め!C!T#:Sd`)TE8x,AI{or`$H 5H r4㾋P1!TB^% uVA;Dn`=aC9\Bq%d;dC"T \aHQrQ&eҪ+DE[Mq,"< !a`0T;ZF$GRQ/9qѬ<9tB0 ;$sZH~^YK3b zY?A`5F!VH5Cʥ0+FBPB3!Q!Dz$s9lCbBq^ >Sނ! P1b2%0AC(DGP@fB!I#Ƒü=d Qɀ& i\!Z <7T0ɏbaA,ͳ PYBbGwQ.D <8-;XvJ7 Pr~qN2 ' $SC v% Ű e H& 20 $>_*P&t@ JůE"*%^n<*JFuj*'+ ,""%B'|PnM0a#BP03#;G:b!&ThD aQ([und:|jT2* Ld06 "^8ɍ,lY id 4|Q4j#3TY #$>ڰ@h.Zj y`Щ "1GH60cBhT%L!BX5;*ѭ`TX4WWa 6( 0Ƀ ^`F  H6H T3X!Pl@Mk 'i3 - l$nfeR px;-hetxOҷ" =D [!JV4xV @!*ѱ:#X( YM>pVԆ:Ʊ$5(@2`CrH9N/E`63i7\8dHbL28u !@YjHX}X.6}=5HpMɣ(L"0p] B@Bh˧ L=V(BK^!QU`% S-0s]q{&ĩd1WesEG&GUVѸoK. .j<nGŴ?䭅.Nd}nĶ;Cՙ[u~'zW%QLtGƀNG #l1l(#XN( 7 ?trȓVL=()k2[i783hahLFh`$\ܤ jAA(/Ðp\!vxK3VXt2xYSOaqX'Љ.$9<(RaZ;P 1p O@? thkpDv@u,tU~g8Cp%oaB e FqE*E@wX”(,H8I hB0`_C0/ko>垳jRvri(\T y'8kF K~޴9?4fatoJ$VN{mIu[>DHH`NMдk oOU:@}j57g _]-K0ڮgYb%lz;]Ys6il0P.ځ%5Χ2 t;; ֋Vܴg(ȳ}fU( +3.ڤT{C:+֣Zg d5o"hph_1Rg`KFr:KAR@ `/e k^(x"Q꿺EAϜtư(*(bSyWl]!ѻE5Q@L `XX /Q(G'|IJr頷[rG6?cLo imN -h #Պ^7KKat@>pO`tYQwiOU5olF:h˜=v=xG$߮ރ]_.6*/;@2b`^0V~q~"DhM6;nFNbJJ,;dkLv?Jk,4qv|4lG'W30*Bl!!FcFRs<@ fffxlgtюf;t"g-4'|YWY 쎧/)Z"%кmmshE48=v VeK!#]]Qu(v`іQ5>53S^;)mYi`&YTɧ10FdI2~d |N;kALGFXQ!i}7+Ԧі ڑ\գ^k;Xӊ`&$64!u`ڊ5x&׭{^2\.V4l-(dpt@)p7fzhvhCޅA`.GKa~>-}9\S:`?yAְFu;A2HBH ;^Y t<-=<[$ %'+~27`M!ZBƊ$` y˕<|%oJ!A)uJ  c]cZiO7}&aA;_p77#d]]bpw*;|d}t6o럳`iVvWC A_͖ڽF?6ro:"өɴ~i8&Tu>L4)@i} &%oDs+&:]&@21]ӟ$#4F#gY.¿ ퟃ<{5X9/%𺃬$@AcR|SGk<]O@I{=b0AǮtu)Shϓ$xA&Dq9L$9珯H}H&OO4KSaGGq~|b?,8Ԃޢ)~7 / s 6>Xb?0ۃ8)j2~ho*Yg:Q'9=bs;~/~ilYbY Bv.:mV߱!Jp,Lwh4=Z,; >mYr|Gchl:lbgzu Ү9w%j9:Sq3|ћ,hg}zn"ՙYXuY݅_QY?t{DzI *>8)DCRg@}AT&!K0/F++<ȳL~:03NNw oOKgH֜"WLp‘F\ZeiA j4 o5mWp!LX3trOLUЍNڸ_awKUg<֒Zg(jmaF2-B}Lr+쐙n';ڍ@88یg}!l=ؠ?FٺXLv?c|05~*;}=[N 1hwtX{ag]lA;gH<ҁ'^~sP-l;rZK]V㥆Bm~d>QYxA촞AkT?$i( ͯ!h;;}dž,1@:81T*xlN $e075 @4 kS HJ4T#X{*"bYGtL9%`- bFpA}.hc!O7_ `)߇uǃy42d%^) c\ȕ6J~5%jb1өawh縍/Tg)cSIG(fل2Bθ]l@z(vtV ԾaȦmPc.?Qa ),gᕼxYLBLfv}x{J'gBLW5wv!P0=@Xosي:"{oW|*7Bu:R}]gX[8~pQ=.ʡ alN߽yMj= =r`~% }'>\}0ԈiG.ݓy)]65غc͂m:ؓFht-<~-Ӌ+oy&Ju'+)hF/ļyH=GXĪ'=0ﲗ.91Z4S,2Y:}ArUgJTx :38bm1.x\6Oak{0붡eyb[*XH~ _ltsiZ<+E/^q/ !<IJoLQhfބި*|OxbJ_GŎZH~_%܁V~/HgZ ,hI\1#K40Pt:$۽NcƨIT{D $|ܿ]mXWAԿ-"=go"-aP&&=$s_W.* +z UMߡJX\x[g6=%D!9b,tcx&Ք~+ z@2?NQӾtFl1%.j%hd?#{trv4w{"-#JaUCY1QyMa!Dn?$X\rF[R}L^7lQ cPI&u{ M íxzw[Z<07*D05^Y,:}OUe_K tͅ ; hx{Qr6,Q+3+t0t\Fyo6l&t{`.=Dyeƻv6| 3ɧ}3ʴ#i2HԦKmDj#?Q|RD5h*}6H qÄUjleቀ] }[d^y?Ģ,&<5,I#],^ ųywυr"9-~^Or7z]׻mg(k̥Jq[ (n:SWg1Hh?HBO^n(|!%,kTqAM.FѓV: >_Dp4* tf* !Epo܇KhZP;.J"-ȕM1{v:F̥|6 bVCKOJ('j:' (4Ex" US4M0Aqi?/^Ԕ+F_QavJF+ZxU,٣1-aeWᤤo[H ($ō2,#b\~͢[6CXG$p<% HiΓMS~F`E" B>/.e3uB;B\THV0ٍ>\$nol߽h~a'ɶ[%JR q&5z=\?,=`WQT59>`@)t BAN-P7r&^o=WBGVp1GeD=!RDxCɴzW `^;Gci/nCps^w+A~ .>Eqh/5\ŦM1,+%lccCϨtQJwuiFwQ&SWTV|t:pL4JqďpoZ%<~ a[n62GOM f]P V hPw'^,3矾sTn)OI}U./9C # W%ONNlrۅ[nM}itdSvh;dr9+Shras@Kxw#ڻIF@l8Z^ً{R7ldw^dZ`_mP&mY*ٍh r+yb~0\xO4vK] m8X {t__fǥ,jٶ^z3[4tn' =SѰ[$s[Z"mYJmTlxEio#3 'L#wyK`IB K|socoh^M◱%&JN]Ar̃:8LU8$If!g@6@0 m⑗!MP$d_5Z3ޫ9AW&4Dm~CGz{|Vtn1T @T9],^r̆5nU[NP&bIbL;ӋQ4;/5$Xbފ]*Nn8iϻYK{>) *$v;]5WB'!ug ~|c˦zbɀi t򑑘R7 nK%uwiA9d~VlLe )_8^JRaCQ#ǺX*nkeIJN!OxsϹKf5ޔ/@slNlCr_]ʈ7zY˂u\=Ȁ5}i&uzN"[Q$%#xd޼3Kg2hԆ+9\DE;їA(vzkM]Yg2)6hDLK76k㲔$--5B_iOw!ݽԀb׹ =`@bd1 ܗ+0@`?xL*u d7{A:yːAh$05DąU#JŃMoQ'C(mDH825T<-Dmo3;FfRʎRwds:RF*M-p,K(P8۷`Ezj-(=ݜgz]o%+jGFܥ"F#m:EbF'B;x. 2B@}zQ#/a +5JK)oM-نX|h ,$Sc˓L9VAKM03p͡pIK2Ϡaɟabblj!ٶFS cc3 #/5{ Ot2&l&ΗL oM*~@g&9^hwCq ݓG c"\{!`+Qn;k%?+JXk۹nlX91ZuKڝa$OYw̶|.!m[7;fmvjds-^H!d i-4mo-^xo uJIV}el`s9l/jT;+>}PcR#ڞx?Voyr7;۟rJk:;/{Zw=où@z ۞·|Nr_X"[ra)]x o: v\\cIbXȍnGbuQu:ً[b9~;&[>[=>N؁=6dnoؽQmkc>>߾Zs{|;"gG`J>Ǯsxݎʎ!qJc3_!iE-Nw<vF?Bg&wAF:9e9Ms,Z] ]L\mB\;Yڈ{Eto 1EމSqh5"L-8쒩96q[dT1@ݻ9?sg]f U.Y_8Qz1V2MD/9;SxX{<5}z&Moq`.*`N?nuWl|f@mm;uNOι]}*{-s5[)WZeJ2X&4.?x[%NԠX撿͌.9p~vn*~dnDh8]DT.IH#R[Gd!Z>ƗQ/ =.?oQC*s]ћΨm7ϋe!HǼ*Mw_b7Ew>~,#-Kϗ\nV/WezzWe*@<юxyage2IНq"xO[ć3GuodqIɜ-\<߸T*Rͦvs*7t_ZDzVg8 ]gK"3v: `rV6%悾k.A`r/rZ]|1u&Lz枾罍b,p>wOTϭ -uS lB1z)XYyZbmAx7%ghP)_X;ҨQvf96Nʍ1UHF,7}t`3O7fvH%JP'[LUjԱUWj{󺹵 z[va]5gJԮ jB=0sZZq-볧j\]6agvܽ.T =wFd Ffy+7G˕3x޵Cjܳ/3r>)_gWgyN]؋ŏr^"7mhLW^+sߵwwvIrb۷Ԝ>mz|so:jWoZmݭB7$ӫɧޢYGGk}LHmH_v8w>Y/,#vUh:Y̳+2 ?7%d~^}, o't?e64}ؒ~/+-N18y/@D^οYbԍ].)b[Vc)[,w+ rkŐWg++|!1@辧}Q"juP‡PmJ)jm/øP]b%JI 1L>xh[Q:Hk$zj! DlM3(<ծú`lhF(JǢ7*Y>1=,]&.: e14 {q:e堽Vz;*t?wd׸i7mj wGڨXu՟cjPР{{~ 4ʮA5zSB96,E; ђ-;*9p`@~ RJຣw9?J8RsP7cԍֽ|mUh=>X~3LN_3;6mx> ig0Vv bn5;ݔ4++bhd$ _H67f*Nz4: O#5>%447>*H>&E[ĉ!=t ;(5!@i=|F8AG^ty^WZ@+IiJ)͡PFMI}Cx] }*lgz0]?~^MBQ¸#Lɝ`#iI+ʨaW< ̻7]e& kLN1&KTcuim$L"W7JZn9 &c!0Rs5P3|q* &M `-G;.V<٣]ӛi:RhK^}Wkg%݆ Q?X8-v:^7]LH+ھl@>;Juu'f bJɥ۬Rc|?[ c Ofg Du˜NZlF p1wf%?j V鴣:IO A+w#ʐq`U}7Rz2^dڟ|lB&/^@E,_`y4bHpFG&fLJ8SD"#ۙy{kgOIz^?|Xv'|hC(6L_/"UcLVvC!Ky5vdq,/MXON>gqѹ.O,g[ ňXÒی\jyK}6=഻t'ʷL%ONAKu+v~]u 8j0ɥu[1>w 6\GI[|D"'Z(du{Zbn»)ċECח-v43G"}hdh&͉@ep*(P-f^Ǩ) 㭡ߒsC_d3qkˋ#M9bִ`:lׯgnJJK*n"`NBNC TBoNT$L^R8Ch+s*zP_W/Q]M(} ~&W6^ZP|jO6ꈅSO-Hԁϯ 0pOd wY5gϹF *m:PY yQ'eވNjw{M ?tXjՁ6gTKKG6> (aBnT!Ah9B^^/`bA^M;w4 `]'^h=a \;8J *p9^빌lGN*Dd]5/T"t˕AN * [ۣ6oZ$ЬTnv; ?*I2<*#JyFPs|rLтJfÁTʙ׮0CDeZkC-:~pc44b%}8R)2:Ptn7RSAE`0&"1 # ;N ќn״Bm%1荾"0s;5te]QЁ|E&.t+ ^}ԅj}xuTP1QXVluzB=e?iKΛ" VAls``8uU=&zY5_皹Y"͕Cr8o Cl%2,WAxaq @nB1UYQh+PB6=8k~O_j"&!yxv:S B[H. @Woe߻{[?J'ITkwkߖ-w[҈2s|迭7y^-(Q[Tt߮6^jrlܼ>eH{Vs|OmwmW ޲N[%Zף'ۗ!:} *?4}#QN$0oobid3Jz4 3/.}Iĵ'hM`i"۫ b?P/[RIs̭B!y8Z)U]043ֈᴷe}{ 5ClE9{Gٓ AYMLr~]oxeǼ` vyv4h>O XON{~Ti-@ibHZ]ōƛt-v"@ڽ%ݕRIcy e@c{vƶ֎"mMUVU> 1>eJ_5?,՘̊$l .0 hj`oټl/"^5C(f("dď3.eD!yBTuF" y|1EvqC| |8,ZS0;}/ǝ㱨YAt`QЁ>; # ,ھ `VP_ԝrqBHԚ@^ =ln痦Zζ¸=$*}ӃDj}'Ktb;;Rlǘ%I(,*lZj`ik՝f}ra$e`[J6Ћ[L{{[0ͲԊa0y{@$ ҁ&E`,J QaU[<|FzI+?ߥL )И#.YFj -C9ߘҢޢKlqS@aZGJgӰvXp7w::ޣPMxZ,v*K=sqL)l7g-E;جM`Srtk.<;.݄Mc#{MTkDK7誤\IAުPw?!`t@nmtV6T0E3HQȣ e(ty[a}bCKJF*L]Mӱ4!qR EfM{Fz5t+S1mXSL7yƴ=%e3Bn\w}^MCqv.8)<Ȏ;DAwm6Z>$~ϸO[/3ܨT Hѥav~؃/g<&sbV)]JN?x{ 1}9@V[gSfP%շRhs1v$KsZ+&L uM 5u$e0&$v6I{'_nk8dxuyY2b#S>x~z UQO|ӌQt%z3>ԛQiҎT윈4/ Mśا*.zI)Ml_|u:$:rI`\'#:y9&kIZ¸ur떧-=\%q jؕ6J;R8FuԯMd\4%8FXC #UKY饴0kƂTOtJ~zGnYVsvH ǜ+t̽Z}Q/&b6 7 \3 QY鈙r>@,i%H)k*%_"َu`|얩*mTlGd[?U$2qΆ>~DNkE %l+QkTvgL%Eyjf&W Ey̜v<{XQ޷|OEyFI}?X'uy)d_,ٗ:8!YU(=z=O;^ j9n <2:h٤!Mx/r#ᒣg RHЁkqMT]ƤO8:h2Ubct&U][|۶|EZ| +t| `>G oB?ZVUSԡJUVI1V*k]ZFew~h~KV*k-OzewFW})uB0YAUFIB}sU_6-E$G,O@em_`XfoM]];ҫmA-9tw1؏Y/bi>M\ W?3u;+2u}hH>Q.lwo[ۢLLOcUwzUY8Չwift)O8^qz1X2GZ>aL#y68S;ht"[[+w7𗙻'stB]`w,WdUlD3U'mGsM-2 -9OPaV 1c}Z^n}{_-rZG 4Uxiϯ_'fbO>Rاϣ>_~OG +V~]*ӪoParŸ,j' ~i^>5iu%,>>튕o}iGڏiU}ZU}v l_U,ӪSHaf¾9>6XbaT#+})A((RatU4w|ĝL^]r6Sw-*75}GyLw[v}+tLr% =Qu0CHIG#GƤ't ;\ץ2u*0;U%٬Oݨ]&Tu,[3=}xnwAY{RH{͹8%RwJPK/!r^RyRv_ )Jх__? #5o'xWw#MZlƱ\F'd8~!^)Y$V̱zG3U㊆ɍ$ǝqmLzIj꒦NqPi(B孝aj/Wg?jTUuf6ʸ:͙jFtp/$`#;..#2Ul"q ̦&}]Bo_>N"olre; [iXBqx4* ,5vwG.H:Σ͔$1szB88녘_1Oc; ܑUcŢ B:fC fTRSL&'uLҽMo'3յ;4{ZEdFyHNO6foG%X5imi?2>ڷuﭛ8j_ہvG&_G3D}z1>{tQV!ʲQc{uȏ0v *{= B4RkZTr޽dCMAg{e*m*dl:{{Ì#a  pPݪ3>uגquƗayoT61w|4F Sͅʲr탉~@a*S=}?u&I#m{5r=TYeOvW#gPoXTy";p/ӔHv[ޅxFyT4/z召bhvv.#@$`ӅjywzjA/UKxԇv%۾v@%lZ+Q.WB8ѬRFvL =gS2KO d6fDs3 򨠈{f3.Uyq,\~  m͑$.ݜ,0aag.e%zX q@75dzM P zJPH(⪷6Mo]f^}M4&G$L=Y֙-FRÊS#sڔđALGl§$ڡ]F0SJb(%kb=)a'(aS#~61M7)a'kҡR#iF5}Bba?X0'F\u X mxXkm~XIoO͘վ*V8@ W|sS7WCWR˘j3Dۍ/xli W;?,x8g T؂ 9 T ࡇ,Fzv=<O(Hwq03< \ 'F~ -x<%x?o0?P0ڡm7g`2vs< F Ÿ|1ӛ5\lA`hV®/x vNlY>5j#Ft\00\z c,u9DC{`c4 yL.0`ՊpLVŝ=,W:+x8FT0HP¹kzujtTa?L}?Q08' RpFRpB/]J肇sEt)x8SiapC3s#Xm_~0eƦM \+}˧b^M_勭;y7u:ʟ4&+B%^fue+_ldvBy]*FBrsSٯcm] Ga/ ' E8+l gy,}gI(<]f5\OO/ k`?|nި~MCN_4 /~DllkhVsx~y_ Jw}t19&Gk5c'%&jrNšdL~wL1 Ku_XU>rrY[˭8BQxQ^șfVex](EE.E%O3|JK,C\>x{'3jq(`2in [`+rS Zb4ݻhĒU9z[\^cQGS̆1W7݋L)UIֶAM}ܥv !Kl@/_1*\%od%+P܌ȰgNּ,r^r||ިpVEJwSqfakc8zVpK6ïdj˴s}_i9*e7}_=|VG 9zvy},yyS#m钾ڹM)>W}QwqWJ/)u fvq68& o*n$WK~X1pW g +T~U;-U KOMةfX$ʆ'@h39nGDSI `_+Z,*a"vLR ijgwICEZ$:1y:K0};~#vCWg* mƐcnv%C iq((30W$zu dHQ;Be ifm^]I! j_Y=Kr ebA-0ѭCx۝wOX=ۚ`x0Bە7B i3IjCեdn$5]|(}t"ִXᵸHO")Kׄf}M>| x-݇.*5*>^ Z5ě^z ?ozacxs.g7yݍrOR˱Iˏo9$lq1.YU GNJ`(O[(n.Cl9ۄrl S\*0*2hHMU텅|*{%Phi JeM]R@"E|W"pRɞ=@sݻj1ѻN۲ -`O: T DOYiʷ畽}JnC3@ez|1~i/NM)жRf=ibngTRxطGe@(}@vB#-^o%28\  + svIFCzBL W]ߴ`WY 6p _bHdn|aռ@CؙG!vry-v?;XH9lAgǑw"%@1!xEAL&켨uA>#,0\HG$lZo℉fXt9"Iݢ)va[ Kod\|x/ԇғ1-t0Xbw@1md"+|X6E|u 0A&ZA+ 0 M#by2'RM&3A^<}yuoɞK%{/w$w$5dȎ+@h7>^YϺCn崰rYr5uVl=EUT![gL9f,.zBfU_oo7 :Ku5^E5.v(YUSr@6MY3ҴvRԖlYST^65*ƙ )qWۤ`L9=tI\ܓ[b-xlN9^H-/~eVYTY-6m_TIʩoHjyi/U]eL}o@Lj+UUjz%11[ͣUiw;v^lZ(2'B}}Αg1op\BBc+|7^'{ȩh{\ZI4vXj i |nxskbz]rei[ndNbL2㍼t.nۺG|+J[> r1xNMjfwWERx^׾*NG&bn />*R|_]K iT`ZgHi@usL=V #-m1S8mo|Q<iEkJDU ٜKi.>bvq9QHWIV`S 4𽪘MInXfٳ֚X}+W4[}ZR91xN9 ]ao9g*Z S~I{+@U%:Z]#B];VJI**Bgxm*I-5I*Y &ŸmA7v+EF&R}M @qٍ2[TNF{s p=7v!nvvSOo9o7%O5U3jQ38th64-gv2tN;;,:at%ouy;QqGDQWqhǨ[?B3H~҅-_,cXTNbg/̑DZUj%6v&eGCy~C h _ 4 |."։V?,_ ;Bٳ%V}"/>΁uKC,&^{;.|I!ތ;4HcͩRV賨3qM~Ȋkd|QZuDPG.]cp-c.Ɏ/K(8y><}5r`ant `59o߃ɞ`o7iB (;)[1'kjOEVT&dT]\չ=l3kUpO!m&.@x3W٩ph;p^EK aV? @@tk7 :1mz̈)HV DŽ>a'⬍:D|Q}¯񜠘?@ay7<1؏}?Ќ8V|sj{GOH赍M{vܘvқ:é56?ǴfF < u2h"ϳBӅi/_Hn2R#L nkX_Xw**NeNn(O#0 .Cl#8>qzKp6i8:A<:sn~ÇAs_>..x.V& j$/'2m{=k0^rPMd)k$pWmΥ-s;(Oy3"`hF,h)<ӸQlIn!fce `~2mQ٤>3oF>8yb-uy.eWv<+G +>)N1k)NL-eWo,;W揧,;k)N2|&t2Ω#bM=aZYoXZ݅ø͵>->á3)NS̸hz`2ZֱDbyk4 OO.α ?:YƇ BvMaqšwMGL䘇z[]~_ t@6v2tʎao.)d2;e-Wاxݢw^p3zMSWo3#F?_͈DE 0I:*f;~s0,u~5Otk϶Kѕ({Y&ad4G)ꓽ¦(D7O_  pAQ$I *QhK J*(M ]4TEE]Yp*h,F&(N$(jܑ m 0 Bt/zu#DxxHGlXEs2Q9MUVTMФUN5 +"5N%((!@TDsq.c r0 AQ&)4yh2gЉ$iЊ!;2d D8Y XDN^$IW MaMaD$p EiKd"_8(%i,*@FP8HeHN%ɂKeFN=k_;?2X pVLMʪj fNTC6"LQʢ*FX1W|ƞĀ5įI*&0k@ﰘֺ!c:ދOeJMI6 1tK_G",P-DIAӡ%,Eya{ "!*8-McQ4FILUX_"vs؉.J0~a4YS-$K "tb_Q'*1LUPD:"]F%WDSUEְt+aFÀD6(ld.&߀h B ":!Q@* "#t؁]xhAah0,Fr[ /ibv$"XQ{ Cg- l:k (n0rv0 pge'Lb?u S5$LX(4 d(y  `N ^WG:r:prBtB eb6Q/o$d>Xp@" (RqI,#5Ch-?/d@iî/ -TQ5NA)FQI+`X*6l۬Fg9⃡.[PYҀ!Ѐ(A_"7ChJ4D `GIe(@"*r Ul` ;1X]0 lVf]@)l ;7Bk N0ejʺ(Q`Eme Q̓  RZꤲ{4: ^MN ºdHL@MtRͣQTpITu%1괠=VTQE()FGJg`b4R?C`p_n~g3d*ڸ0#L(P[gydǾ_c/߲`gQQ%6k$΢4BF՘`.?QJD( endstream endobj 7 0 obj <> endobj 17 0 obj [/View/Design] endobj 18 0 obj <>>> endobj 36 0 obj [35 0 R] endobj 59 0 obj <> endobj xref 0 60 0000000004 65535 f 0000000016 00000 n 0000000159 00000 n 0000042405 00000 n 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000402576 00000 n 0000000000 00000 f 0000042456 00000 n 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000402646 00000 n 0000402677 00000 n 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000000000 00000 f 0000044861 00000 n 0000045063 00000 n 0000044674 00000 n 0000402762 00000 n 0000042898 00000 n 0000088806 00000 n 0000086122 00000 n 0000086009 00000 n 0000043676 00000 n 0000044112 00000 n 0000044160 00000 n 0000044745 00000 n 0000044776 00000 n 0000066045 00000 n 0000045302 00000 n 0000045549 00000 n 0000066298 00000 n 0000086157 00000 n 0000088880 00000 n 0000089142 00000 n 0000090338 00000 n 0000095838 00000 n 0000161427 00000 n 0000227016 00000 n 0000292605 00000 n 0000358194 00000 n 0000402787 00000 n trailer <<5F75722AE9B1A5469C5B25782C9498E1>]>> startxref 402971 %%EOF ================================================ FILE: src/angular/.angular-cli.json ================================================ { "$schema": "./node_modules/@angular/cli/lib/config/schema.json", "project": { "name": "seedsync" }, "apps": [ { "root": "src", "outDir": "dist", "assets": [ "assets" ], "index": "index.html", "main": "main.ts", "polyfills": "polyfills.ts", "test": "test.ts", "tsconfig": "tsconfig.app.json", "testTsconfig": "tsconfig.spec.json", "prefix": "app", "styles": [ "../node_modules/bootstrap/dist/css/bootstrap.min.css", "../node_modules/font-awesome/scss/font-awesome.scss", "styles.scss" ], "scripts": [ "../node_modules/jquery/dist/jquery.min.js", "../node_modules/popper.js/dist/umd/popper.min.js", "../node_modules/bootstrap/dist/js/bootstrap.min.js" ], "environmentSource": "environments/environment.ts", "environments": { "dev": "environments/environment.ts", "prod": "environments/environment.prod.ts" } } ], "e2e": { "protractor": { "config": "./protractor.conf.js" } }, "lint": [ { "project": "src/tsconfig.app.json", "exclude": "**/node_modules/**" }, { "project": "src/tsconfig.spec.json", "exclude": "**/node_modules/**" }, { "project": "e2e/tsconfig.e2e.json", "exclude": "**/node_modules/**" } ], "test": { "karma": { "config": "./karma.conf.js" } }, "defaults": { "styleExt": "scss", "component": {} } } ================================================ FILE: src/angular/.editorconfig ================================================ # Editor configuration, see http://editorconfig.org root = true [*] charset = utf-8 indent_style = space indent_size = 4 insert_final_newline = true trim_trailing_whitespace = true [*.md] max_line_length = off trim_trailing_whitespace = false ================================================ FILE: src/angular/.gitignore ================================================ # See http://help.github.com/ignore-files/ for more about ignoring files. # compiled output /dist /tmp /out-tsc # dependencies /node_modules # IDEs and editors /.idea .project .classpath .c9/ *.launch .settings/ *.sublime-workspace # IDE - VSCode .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json # misc /.sass-cache /connect.lock /coverage /libpeerconnection.log npm-debug.log testem.log /typings yarn-error.log # e2e /e2e/*.js /e2e/*.map # System Files .DS_Store Thumbs.db ================================================ FILE: src/angular/e2e/app.e2e-spec.ts ================================================ import { AppPage } from './app.po'; describe('seedsync App', () => { let page: AppPage; beforeEach(() => { page = new AppPage(); }); it('should display welcome message', () => { page.navigateTo(); expect(page.getParagraphText()).toEqual('Welcome to app!'); }); }); ================================================ FILE: src/angular/e2e/app.po.ts ================================================ import { browser, by, element } from 'protractor'; export class AppPage { navigateTo() { return browser.get('/'); } getParagraphText() { return element(by.css('app-root h1')).getText(); } } ================================================ FILE: src/angular/e2e/tsconfig.e2e.json ================================================ { "extends": "../tsconfig.json", "compilerOptions": { "outDir": "../out-tsc/e2e", "baseUrl": "./", "module": "commonjs", "target": "es5", "types": [ "jasmine", "jasminewd2", "node" ] } } ================================================ FILE: src/angular/karma.conf.js ================================================ // Karma configuration file, see link for more information // https://karma-runner.github.io/1.0/config/configuration-file.html module.exports = function (config) { config.set({ basePath: '', frameworks: ['jasmine', '@angular/cli'], plugins: [ require('karma-jasmine'), require('karma-chrome-launcher'), require('karma-jasmine-html-reporter'), require('karma-coverage-istanbul-reporter'), require('@angular/cli/plugins/karma'), require('karma-mocha-reporter') ], client: { clearContext: false, // leave Jasmine Spec Runner output visible in browser captureConsole: false }, coverageIstanbulReporter: { reports: ['html', 'lcovonly'], fixWebpackSourcePaths: true }, angularCli: { environment: 'dev' }, reporters: ['mocha', 'kjhtml'], port: 9876, colors: true, logLevel: config.LOG_INFO, autoWatch: true, browsers: ['Chrome'], singleRun: false, customLaunchers: { ChromeHeadless: { base: 'Chrome', flags: [ '--headless', '--disable-gpu', // Without a remote debugging port, Google Chrome exits immediately. '--remote-debugging-port=9222', '--no-sandbox' ] } }, mochaReporter: { output: 'full' } }); }; ================================================ FILE: src/angular/package.json ================================================ { "name": "seedsync", "version": "0.8.6", "license": "Apache 2.0", "scripts": { "ng": "ng", "start": "ng serve", "build": "ng build", "test": "ng test", "lint": "ng lint", "e2e": "ng e2e" }, "private": true, "dependencies": { "@angular/animations": "^4.2.4", "@angular/common": "^4.2.4", "@angular/compiler": "^4.2.4", "@angular/core": "^4.2.4", "@angular/forms": "^4.2.4", "@angular/http": "^4.2.4", "@angular/platform-browser": "^4.2.4", "@angular/platform-browser-dynamic": "^4.2.4", "@angular/router": "^4.2.4", "@types/eventsource": "^1.0.2", "angular-webstorage-service": "^1.0.2", "bootstrap": "^4.2.1", "compare-versions": "^3.4.0", "core-js": "^2.4.1", "css-element-queries": "^1.1.1", "font-awesome": "^4.7.0", "immutable": "^3.8.2", "jquery": "^3.2.1", "ngx-modialog": "^3.0.4", "popper.js": "^1.14.6", "rxjs": "^5.4.2", "zone.js": "^0.8.14" }, "devDependencies": { "@angular/cli": "1.3.2", "@angular/compiler-cli": "^4.2.4", "@angular/language-service": "^4.2.4", "@types/jasmine": "~2.5.53", "@types/jasminewd2": "~2.0.2", "@types/node": "^13.13.0", "codelyzer": "~3.1.1", "jasmine-core": "~2.6.2", "jasmine-spec-reporter": "~4.1.0", "karma": "~1.7.0", "karma-chrome-launcher": "~2.1.1", "karma-cli": "~1.0.1", "karma-coverage-istanbul-reporter": "^1.2.1", "karma-jasmine": "~1.1.0", "karma-jasmine-html-reporter": "^0.2.2", "karma-mocha-reporter": "^2.2.5", "node-sass": "^4.5.3", "protractor": "~5.1.2", "ts-node": "~3.2.0", "tslint": "~5.3.2", "typescript": "^3.2.2" } } ================================================ FILE: src/angular/protractor.conf.js ================================================ // Protractor configuration file, see link for more information // https://github.com/angular/protractor/blob/master/lib/config.ts const { SpecReporter } = require('jasmine-spec-reporter'); exports.config = { allScriptsTimeout: 11000, specs: [ './e2e/**/*.e2e-spec.ts' ], capabilities: { 'browserName': 'chrome' }, directConnect: true, baseUrl: 'http://localhost:4200/', framework: 'jasmine', jasmineNodeOpts: { showColors: true, defaultTimeoutInterval: 30000, print: function() {} }, onPrepare() { require('ts-node').register({ project: 'e2e/tsconfig.e2e.json' }); jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); } }; ================================================ FILE: src/angular/src/app/app.module.ts ================================================ import {BrowserModule} from "@angular/platform-browser"; import {APP_INITIALIZER, NgModule} from "@angular/core"; import {HttpClientModule} from "@angular/common/http"; import {FormsModule} from "@angular/forms"; import {RouteReuseStrategy, RouterModule} from "@angular/router"; import {ModalModule} from "ngx-modialog"; import {bootstrap4Mode, BootstrapModalModule} from "ngx-modialog/plugins/bootstrap"; import {AppComponent} from "./pages/main/app.component"; import {environment} from "../environments/environment"; import {LoggerService} from "./services/utils/logger.service"; import {FileListComponent} from "./pages/files/file-list.component"; import {FileComponent} from "./pages/files/file.component"; import {ModelFileService} from "./services/files/model-file.service"; import {ViewFileService} from "./services/files/view-file.service"; import {FileSizePipe} from "./common/file-size.pipe"; import {EtaPipe} from "./common/eta.pipe"; import {CapitalizePipe} from "./common/capitalize.pipe"; import {ClickStopPropagationDirective} from "./common/click-stop-propagation.directive"; import {FileOptionsComponent} from "./pages/files/file-options.component"; import {ViewFileFilterService} from "./services/files/view-file-filter.service"; import {FilesPageComponent} from "./pages/files/files-page.component"; import {HeaderComponent} from "./pages/main/header.component"; import {SidebarComponent} from "./pages/main/sidebar.component"; import {SettingsPageComponent} from "./pages/settings/settings-page.component"; import {ServerStatusService} from "./services/server/server-status.service"; import {ConfigServiceProvider} from "./services/settings/config.service"; import {OptionComponent} from "./pages/settings/option.component"; import {NotificationService} from "./services/utils/notification.service"; import {ServerCommandServiceProvider} from "./services/server/server-command.service"; import {AutoQueuePageComponent} from "./pages/autoqueue/autoqueue-page.component"; import {AutoQueueServiceProvider} from "./services/autoqueue/autoqueue.service"; import {CachedReuseStrategy} from "./common/cached-reuse-strategy"; import {ConnectedService} from "./services/utils/connected.service"; import {RestService} from "./services/utils/rest.service"; import {StreamDispatchService, StreamServiceRegistryProvider} from "./services/base/stream-service.registry"; import {LogsPageComponent} from "./pages/logs/logs-page.component"; import {LogService} from "./services/logs/log.service"; import {AboutPageComponent} from "./pages/about/about-page.component"; import {ROUTES} from "./routes"; import {ViewFileOptionsService} from "./services/files/view-file-options.service"; import {ViewFileSortService} from "./services/files/view-file-sort.service"; import {DomService} from "./services/utils/dom.service"; import {StorageServiceModule} from "angular-webstorage-service"; import {VersionCheckService} from "./services/utils/version-check.service"; @NgModule({ declarations: [ FileSizePipe, EtaPipe, CapitalizePipe, ClickStopPropagationDirective, AppComponent, FileListComponent, FileComponent, FileOptionsComponent, FilesPageComponent, HeaderComponent, SidebarComponent, SettingsPageComponent, OptionComponent, AutoQueuePageComponent, LogsPageComponent, AboutPageComponent ], imports: [ BrowserModule, HttpClientModule, FormsModule, RouterModule.forRoot(ROUTES), ModalModule.forRoot(), BootstrapModalModule, StorageServiceModule ], providers: [ {provide: RouteReuseStrategy, useClass: CachedReuseStrategy}, LoggerService, NotificationService, RestService, ViewFileService, ViewFileFilterService, ViewFileSortService, ViewFileOptionsService, DomService, VersionCheckService, // Stream services StreamDispatchService, StreamServiceRegistryProvider, ServerStatusService, ModelFileService, ConnectedService, LogService, AutoQueueServiceProvider, ConfigServiceProvider, ServerCommandServiceProvider, // Initialize services not tied to any components { provide: APP_INITIALIZER, useFactory: dummyFactory, deps: [ViewFileFilterService], multi: true }, { provide: APP_INITIALIZER, useFactory: dummyFactory, deps: [ViewFileSortService], multi: true }, { provide: APP_INITIALIZER, useFactory: dummyFactory, deps: [VersionCheckService], multi: true }, ], bootstrap: [AppComponent] }) export class AppModule { constructor(private logger: LoggerService) { this.logger.level = environment.logger.level; } } // noinspection JSUnusedLocalSymbols export function dummyFactory(s) { return () => null; } // Run the ngx-modialog plugin to work with version 4 of bootstrap bootstrap4Mode(); ================================================ FILE: src/angular/src/app/common/_common.scss ================================================ $primary-color: #337BB7; $primary-dark-color: #2e6da4; $primary-light-color: #D7E7F4; $primary-lighter-color: #F6F6F6; $secondary-color: #79DFB6; $secondary-light-color: #C5F0DE; $secondary-dark-color: #32AD7B; $secondary-darker-color: #077F4F; $header-color: #DDDDDD; $header-dark-color: #D3D3D3; $logo-color: #118247; $logo-font: 'Arial Black', Gadget, sans-serif; $small-max-width: 600px; $medium-min-width: 601px; $medium-max-width: 992px; $large-min-width: 993px; $sidebar-width: 170px; $zindex-sidebar: 300; $zindex-top-header: 200; $zindex-file-options: 201; $zindex-file-search: 100; %button { background-color: $primary-color; color: white; border: 1px solid $primary-dark-color; border-radius: 4px; display: flex; flex-direction: column; align-items: center; justify-content: center; cursor: default; user-select: none; &:active { background-color: #286090; } &[disabled] { opacity: .65; background-color: $primary-color; } &.selected { background-color: $secondary-color; border-color: $secondary-darker-color; } } ================================================ FILE: src/angular/src/app/common/cached-reuse-strategy.ts ================================================ import {ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy} from "@angular/router"; /** * CachedReuseStrategy caches Components so that they are not * recreated after navigating away. * Source: https://www.softwarearchitekt.at/post/2016/12/02/ * sticky-routes-in-angular-2-3-with-routereusestrategy.aspx */ export class CachedReuseStrategy implements RouteReuseStrategy { handlers: {[key: string]: DetachedRouteHandle} = {}; // noinspection JSUnusedLocalSymbols shouldDetach(route: ActivatedRouteSnapshot): boolean { return true; } store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle): void { this.handlers[route.routeConfig.path] = handle; } shouldAttach(route: ActivatedRouteSnapshot): boolean { return !!route.routeConfig && !!this.handlers[route.routeConfig.path]; } retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle { if (!route.routeConfig) { return null; } return this.handlers[route.routeConfig.path]; } shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean { return future.routeConfig === curr.routeConfig; } } ================================================ FILE: src/angular/src/app/common/capitalize.pipe.ts ================================================ import { Pipe, PipeTransform } from "@angular/core"; @Pipe({name: "capitalize"}) export class CapitalizePipe implements PipeTransform { transform(value: any) { if (value) { return value.charAt(0).toUpperCase() + value.slice(1); } return value; } } ================================================ FILE: src/angular/src/app/common/click-stop-propagation.directive.ts ================================================ import {Directive, HostListener} from "@angular/core"; @Directive({ selector: "[appClickStopPropagation]" }) export class ClickStopPropagationDirective { @HostListener("click", ["$event"]) public onClick(event: any): void { event.stopPropagation(); } } ================================================ FILE: src/angular/src/app/common/eta.pipe.ts ================================================ import { Pipe, PipeTransform } from "@angular/core"; /* * Convert seconds to an eta in the form "Xh Ym Zs" */ @Pipe({name: "eta"}) export class EtaPipe implements PipeTransform { private units = { "h": 3600, "m": 60, "s": 1 }; transform(seconds: number = 0): string { if ( isNaN( parseFloat( String(seconds) )) || ! isFinite( seconds ) ) { return "?"; } if (seconds === 0) { return "0s"; } let out = ""; for (const key of Object.keys(this.units)) { const unit = this.units[key]; if (seconds >= unit) { const unitMultiplicity = Math.floor(seconds / unit); seconds -= unitMultiplicity * unit; out += Number(unitMultiplicity) + key; } } return out; } } ================================================ FILE: src/angular/src/app/common/file-size.pipe.ts ================================================ import { Pipe, PipeTransform } from "@angular/core"; /* * Convert bytes into largest possible unit. * Takes an precision argument that defaults to 2. * Usage: * bytes | fileSize:precision * Example: * {{ 1024 | fileSize}} * formats to: 1 KB * Source: https://gist.github.com/JonCatmull/ecdf9441aaa37 * 336d9ae2c7f9cb7289a#file-file-size-pipe-ts */ @Pipe({name: "fileSize"}) export class FileSizePipe implements PipeTransform { private units = [ "B", "KB", "MB", "GB", "TB", "PB" ]; transform(bytes: number = 0, precision: number = 2 ): string { if ( isNaN( parseFloat( String(bytes) )) || ! isFinite( bytes ) ) { return "?"; } let unit = 0; while ( bytes >= 1024 ) { bytes /= 1024; unit ++; } return Number(bytes.toPrecision( + precision )) + " " + this.units[ unit ]; } } ================================================ FILE: src/angular/src/app/common/localization.ts ================================================ export class Localization { static Error = class { public static readonly SERVER_DISCONNECTED = "Lost connection to the SeedSync service."; }; static Notification = class { public static readonly CONFIG_RESTART = "Restart the app to apply new settings."; public static readonly CONFIG_VALUE_BLANK = (section: string, option: string) => `Setting ${section}.${option} cannot be blank.` public static readonly AUTOQUEUE_PATTERN_EMPTY = "Cannot add an empty autoqueue pattern."; public static readonly STATUS_CONNECTION_WAITING = "Waiting for SeedSync service to respond..."; public static readonly STATUS_REMOTE_SCAN_WAITING = "Waiting for remote server to respond..."; public static readonly STATUS_REMOTE_SERVER_ERROR = (error: string) => `Lost connection to remote server. Retrying automatically. \ ${error ? "
" + error : ""}` public static readonly NEW_VERSION_AVAILABLE = (url: string) => `A new version of SeedSync is available! \ Click
here to grab the latest version.` }; static Modal = class { public static readonly DELETE_LOCAL_TITLE = "Delete Local File"; public static readonly DELETE_LOCAL_MESSAGE = (name: string) => `Are you sure you want to delete ${name} from the local server?` public static readonly DELETE_REMOTE_TITLE = "Delete Remote File"; public static readonly DELETE_REMOTE_MESSAGE = (name: string) => `Are you sure you want to delete ${name} from the remote server?` }; static Log = class { public static readonly CONNECTED = "Connected to service"; public static readonly DISCONNECTED = "Lost connection to service"; }; } ================================================ FILE: src/angular/src/app/common/storage-keys.ts ================================================ export class StorageKeys { public static readonly VIEW_OPTION_SHOW_DETAILS = "view-option-show-details"; public static readonly VIEW_OPTION_SORT_METHOD = "view-option-sort-method"; public static readonly VIEW_OPTION_PIN = "view-option-pin"; } ================================================ FILE: src/angular/src/app/pages/about/about-page.component.html ================================================

v{{version}}
Source available on Github
Icons by Freepik, Yannick, Dave Gandy, Google and Smashicons from Flaticon
================================================ FILE: src/angular/src/app/pages/about/about-page.component.scss ================================================ @import '../../common/common'; #about { display: flex; justify-content: center; #wrapper { margin-top: 50px; text-align: center; #banner { display: flex; align-items: center; img { max-width: 80px; max-height: 80px; } span { margin-left: 10px; color: $logo-color; font-family: $logo-font; font-size: 300%; cursor: default; user-select: none; } } #version { font-size: 150%; } #icons { font-size: 80%; } } } ================================================ FILE: src/angular/src/app/pages/about/about-page.component.ts ================================================ import {Component} from "@angular/core"; declare function require(moduleName: string): any; const { version: appVersion } = require('../../../../package.json'); @Component({ selector: "app-about-page", templateUrl: "./about-page.component.html", styleUrls: ["./about-page.component.scss"], providers: [], }) export class AboutPageComponent { public version: string; constructor() { this.version = appVersion; } } ================================================ FILE: src/angular/src/app/pages/autoqueue/autoqueue-page.component.html ================================================
Files matching these patterns will be automatically queued. Wildcards (*) are supported.
Add/Remove these patterns below.
Patterns are disabled. All files will be auto-queued.
To restrict which files are auto-queued, enable patterns in Settings.
Auto-Queue is disabled.
Enable AutoQueue in Settings to queue files automatically.
{{pattern.pattern}}
+
================================================ FILE: src/angular/src/app/pages/autoqueue/autoqueue-page.component.scss ================================================ @import '../../common/common'; #autoqueue { padding: 15px; #description { font-size: 100%; } #controls { &[disabled] { opacity: .65; } .pattern, #add-pattern { display: flex; flex-direction: row; flex-wrap: nowrap; align-items: center; margin: 10px; .button { @extend %button; flex-grow: 0; flex-direction: row; padding: 8px; margin-right: 12px; height: 35px; width: 35px; span { font-size: 220%; font-weight: 900; } } } .pattern { .text { font-size: 100%; font-family: monospace; white-space: pre-wrap; /* break up long text */ overflow-wrap: normal; hyphens: auto; word-break: break-all; } .button { background-color: red; border-color: darkred; &:active { background-color: darkred; } &[disabled] { opacity: .65; background-color: red; } } } #add-pattern { .button { background-color: green; border-color: darkgreen; &:active { background-color: darkgreen; } &[disabled] { opacity: .65; background-color: green; } } label { margin-bottom: 0; } } } } ================================================ FILE: src/angular/src/app/pages/autoqueue/autoqueue-page.component.ts ================================================ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit} from "@angular/core"; import {Observable} from "rxjs/Observable"; import * as Immutable from "immutable"; import {AutoQueueService} from "../../services/autoqueue/autoqueue.service"; import {AutoQueuePattern} from "../../services/autoqueue/autoqueue-pattern"; import {Notification} from "../../services/utils/notification"; import {NotificationService} from "../../services/utils/notification.service"; import {ConnectedService} from "../../services/utils/connected.service"; import {StreamServiceRegistry} from "../../services/base/stream-service.registry"; import {Config} from "../../services/settings/config"; import {ConfigService} from "../../services/settings/config.service"; @Component({ selector: "app-autoqueue-page", templateUrl: "./autoqueue-page.component.html", styleUrls: ["./autoqueue-page.component.scss"], providers: [], changeDetection: ChangeDetectionStrategy.OnPush }) export class AutoQueuePageComponent implements OnInit { public patterns: Observable>; public newPattern: string; public config: Observable; public connected: boolean; public enabled: boolean; public patternsOnly: boolean; private _connectedService: ConnectedService; constructor(private _changeDetector: ChangeDetectorRef, private _autoqueueService: AutoQueueService, private _notifService: NotificationService, private _configService: ConfigService, _streamServiceRegistry: StreamServiceRegistry) { this._connectedService = _streamServiceRegistry.connectedService; this.patterns = _autoqueueService.patterns; this.newPattern = ""; this.connected = false; this.enabled = false; this.patternsOnly = false; } // noinspection JSUnusedGlobalSymbols ngOnInit() { this._connectedService.connected.subscribe({ next: (connected: boolean) => { this.connected = connected; if (!this.connected) { // Clear the input box this.newPattern = ""; } } }); this._configService.config.subscribe({ next: config => { if(config != null) { this.enabled = config.autoqueue.enabled; this.patternsOnly = config.autoqueue.patterns_only; } else { this.enabled = false; this.patternsOnly = false; } this._changeDetector.detectChanges(); } }); } onAddPattern() { this._autoqueueService.add(this.newPattern).subscribe({ next: reaction => { if (reaction.success) { // Clear the input box this.newPattern = ""; } else { // Show dismissible notification const notif = new Notification({ level: Notification.Level.DANGER, dismissible: true, text: reaction.errorMessage }); this._notifService.show(notif); } } }); } onRemovePattern(pattern: AutoQueuePattern) { this._autoqueueService.remove(pattern.pattern).subscribe({ next: reaction => { if (reaction.success) { // Nothing to do } else { // Show dismissible notification const notif = new Notification({ level: Notification.Level.DANGER, dismissible: true, text: reaction.errorMessage }); this._notifService.show(notif); } } }); } } ================================================ FILE: src/angular/src/app/pages/files/file-list.component.html ================================================
================================================ FILE: src/angular/src/app/pages/files/file-list.component.scss ================================================ @import '../../common/common'; #file-list #header { display: none; } /* striped rows */ #file-list > div:nth-child(even){ background-color: $primary-lighter-color; } /* list separator */ #file-list > div {border-bottom: 1px solid #ddd;} #file-list > div:last-child{border-bottom: none;} /* Medium and large screens */ @media only screen and (min-width: $medium-min-width) { #file-list #header { display: flex; } /* width */ /* NOTE: make sure this is in-sync with ".file .content" */ #header .status {width: 100px; min-width: 100px;} #header .name {flex-grow: 1;} #header .speed {width: 100px; min-width: 100px;} #header .eta {width: 100px; min-width: 100px;} #header .size {width: 30%; min-width: 30%;} /* header and content elements */ #header > div, .content > div { padding: 8px 8px; text-align: center; vertical-align: top; } #header .name, .content .name {text-align: left;} /* header color */ #header div { font-weight: bold; color: #fff; background-color: #000; } } ================================================ FILE: src/angular/src/app/pages/files/file-list.component.ts ================================================ import {Component, ChangeDetectionStrategy} from "@angular/core"; import {Observable} from "rxjs/Observable"; import {List} from "immutable"; import {ViewFileService} from "../../services/files/view-file.service"; import {ViewFile} from "../../services/files/view-file"; import {LoggerService} from "../../services/utils/logger.service"; import {ViewFileOptions} from "../../services/files/view-file-options"; import {ViewFileOptionsService} from "../../services/files/view-file-options.service"; @Component({ selector: "app-file-list", providers: [], templateUrl: "./file-list.component.html", styleUrls: ["./file-list.component.scss"], changeDetection: ChangeDetectionStrategy.OnPush }) export class FileListComponent { public files: Observable>; public identify = FileListComponent.identify; public options: Observable; constructor(private _logger: LoggerService, private viewFileService: ViewFileService, private viewFileOptionsService: ViewFileOptionsService) { this.files = viewFileService.filteredFiles; this.options = this.viewFileOptionsService.options; } // noinspection JSUnusedLocalSymbols /** * Used for trackBy in ngFor * @param index * @param item */ static identify(index: number, item: ViewFile): string { return item.name; } onSelect(file: ViewFile): void { if (file.isSelected) { this.viewFileService.unsetSelected(); } else { this.viewFileService.setSelected(file); } } onQueue(file: ViewFile) { this.viewFileService.queue(file).subscribe(data => { this._logger.info(data); }); } onStop(file: ViewFile) { this.viewFileService.stop(file).subscribe(data => { this._logger.info(data); }); } onExtract(file: ViewFile) { this.viewFileService.extract(file).subscribe(data => { this._logger.info(data); }); } onDeleteLocal(file: ViewFile) { this.viewFileService.deleteLocal(file).subscribe(data => { this._logger.info(data); }); } onDeleteRemote(file: ViewFile) { this.viewFileService.deleteRemote(file).subscribe(data => { this._logger.info(data); }); } } ================================================ FILE: src/angular/src/app/pages/files/file-options.component.html ================================================
================================================ FILE: src/angular/src/app/pages/files/file-options.component.scss ================================================ @import '../../common/common'; // Common dropdown template %dropdown { button { background-color: $primary-color; color: white; height: 34px; width: 100%; cursor: default; user-select: none; display: flex; flex-direction: row; align-items: center; flex-wrap: nowrap; .selection { margin-left: 5px; .sel-item { display: flex; flex-direction: row; align-items: center; flex-wrap: nowrap; .icon { width: 20px; height: 20px; } .text { margin-left: 5px; display: none; } } } } .dropdown-menu { background-color: $primary-color; border: 1px solid $primary-dark-color; color: white; padding: 0; cursor: default; user-select: none; .dropdown-item { color: white; height: 32px; display: flex; flex-direction: row; align-items: center; flex-wrap: nowrap; &.active, &.active:hover { background-color: $secondary-dark-color; } &:hover, &:active { background-color: $primary-color; } &.disabled { opacity: .65; background-color: $primary-color; } .icon { width: 20px; height: 20px; } .text { margin-left: 5px; } } } // Applies to both button and menu images .icon { // Set flex on outer div to center the image display: flex; flex-direction: row; align-items: center; justify-content: center; img { width: 15px; height: 15px; filter: invert(1.0); margin-top: -2px; &.downloaded, &.downloading { width: 20px; } &.stopped { width: 13px; } } } } %toggle { background-color: $primary-color; border: 1px solid $primary-dark-color; color: white; height: 34px; cursor: default; user-select: none; display: flex; flex-direction: row; align-items: center; flex-wrap: nowrap; &.active, &.active:hover{ background-color: $secondary-dark-color; border-color: $secondary-darker-color; } &:hover, &:active { background-color: $primary-color; } &.disabled { opacity: .65; background-color: $primary-color; } .selection { margin-left: 5px; .sel-item { display: flex; flex-direction: row; align-items: center; flex-wrap: nowrap; .icon { width: 20px; height: 20px; } .text { margin-left: 5px; } } } } #file-options { position: static; z-index: $zindex-file-options; background-color: $header-color; padding: 0 8px 8px 8px; display: flex; flex-direction: row; flex-wrap: wrap; // Controls whether the div is sticky // This class is enabled programmatically &.sticky { position: sticky; // note: top is set programmatically } #filter-search { flex-grow: 1; flex-basis: 100%; display: flex; flex-direction: row; align-items: center; flex-wrap: wrap; position: relative; img { width: 20px; height: 20px; position: absolute; left: 7px; top: 7px; z-index: $zindex-file-search; filter: invert(0.8666); } input { border-radius: 5px; padding: 3px 15px 3px 30px; width: 100%; } .input-group{ flex-grow: 1; display: inline; } } #filter-status { @extend %dropdown; .selection { .sel-item { // Selection item for 'All' option &#sel-item-all { .icon { display: none; } .text { display: inherit; margin-left: 0; } } } } // Applies to both button and menu images .icon { img { &.downloaded, &.downloading { width: 20px; } &.stopped { width: 13px; } } } } #sort-status { @extend %dropdown; // Applies to both button and menu images .icon { } } #toggle-details { @extend %toggle; // Hide the selection icons for this button .selection { .sel-item { .icon { display: none; } .text { margin-left: 0; } } } } #small-buttons { flex-grow: 1; padding-top: 5px; display: flex; flex-direction: row; justify-content: flex-end; #pin-filter { @extend %toggle; width: 20px; height: 20px; padding: 0; display: flex; flex-direction: row; align-items: center; justify-content: center; } } // Margins for all the buttons #filter-status, #sort-status, #toggle-details { margin-top: 10px; } // Margins for the non-first buttons #sort-status, #toggle-details { margin-left: 5px; } } /* Medium and large screens */ @media only screen and (min-width: $medium-min-width) { %dropdown { button { .selection { .sel-item { .text { // Show the text labels in selection display: inherit; } } } } } // Margins for the non-first buttons #sort-status, #toggle-details { margin-left: 10px; } } // Large screens @media only screen and (min-width: $large-min-width) { // Bit of extra padding at top when title disappears #file-options { padding-top: 8px; } } ================================================ FILE: src/angular/src/app/pages/files/file-options.component.ts ================================================ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit} from "@angular/core"; import {Observable} from "rxjs/Observable"; import * as Immutable from "immutable"; import {ViewFileOptionsService} from "../../services/files/view-file-options.service"; import {ViewFileOptions} from "../../services/files/view-file-options"; import {ViewFile} from "../../services/files/view-file"; import {ViewFileService} from "../../services/files/view-file.service"; import {DomService} from "../../services/utils/dom.service"; @Component({ selector: "app-file-options", providers: [], templateUrl: "./file-options.component.html", styleUrls: ["./file-options.component.scss"], changeDetection: ChangeDetectionStrategy.OnPush }) export class FileOptionsComponent implements OnInit { public ViewFile = ViewFile; public ViewFileOptions = ViewFileOptions; public options: Observable; public headerHeight: Observable; // These track which status filters are enabled public isExtractedStatusEnabled = false; public isExtractingStatusEnabled = false; public isDownloadedStatusEnabled = false; public isDownloadingStatusEnabled = false; public isQueuedStatusEnabled = false; public isStoppedStatusEnabled = false; private _latestOptions: ViewFileOptions; constructor(private _changeDetector: ChangeDetectorRef, private viewFileOptionsService: ViewFileOptionsService, private _viewFileService: ViewFileService, private _domService: DomService) { this.options = this.viewFileOptionsService.options; this.headerHeight = this._domService.headerHeight; } ngOnInit() { // Use the unfiltered files to enable/disable the filter status buttons this._viewFileService.files.subscribe(files => { this.isExtractedStatusEnabled = FileOptionsComponent.isStatusEnabled( files, ViewFile.Status.EXTRACTED ); this.isExtractingStatusEnabled = FileOptionsComponent.isStatusEnabled( files, ViewFile.Status.EXTRACTING ); this.isDownloadedStatusEnabled = FileOptionsComponent.isStatusEnabled( files, ViewFile.Status.DOWNLOADED ); this.isDownloadingStatusEnabled = FileOptionsComponent.isStatusEnabled( files, ViewFile.Status.DOWNLOADING ); this.isQueuedStatusEnabled = FileOptionsComponent.isStatusEnabled( files, ViewFile.Status.QUEUED ); this.isStoppedStatusEnabled = FileOptionsComponent.isStatusEnabled( files, ViewFile.Status.STOPPED ); this._changeDetector.detectChanges(); }); // Keep the latest options for toggle behaviour implementation this.viewFileOptionsService.options.subscribe(options => this._latestOptions = options); } onFilterByName(name: string) { this.viewFileOptionsService.setNameFilter(name); } onFilterByStatus(status: ViewFile.Status) { this.viewFileOptionsService.setSelectedStatusFilter(status); } onSort(sortMethod: ViewFileOptions.SortMethod) { this.viewFileOptionsService.setSortMethod(sortMethod); } onToggleShowDetails(){ this.viewFileOptionsService.setShowDetails(!this._latestOptions.showDetails); } onTogglePinFilter() { this.viewFileOptionsService.setPinFilter(!this._latestOptions.pinFilter); } private static isStatusEnabled(files: Immutable.List, status: ViewFile.Status) { return files.findIndex(f => f.status === status) >= 0; } } ================================================ FILE: src/angular/src/app/pages/files/file.component.html ================================================
{{file.status | capitalize}}
{{file.name}}
Remote File
Created: {{(file.remoteCreatedTimestamp | date: 'EEE, MMM d yyyy, h:mm:ss a') || "Not Available"}}
Modified: {{(file.remoteModifiedTimestamp | date: 'EEE, MMM d yyyy, h:mm:ss a') || "Not Available"}}
Local File
Created: {{(file.localCreatedTimestamp | date: 'EEE, MMM d yyyy, h:mm:ss a') || "Not Available"}}
Modified: {{(file.localModifiedTimestamp | date: 'EEE, MMM d yyyy, h:mm:ss a') || "Not Available"}}
{{file.downloadingSpeed | fileSize:3}}/s
eta: {{file.eta | eta}}
{{file.eta | eta}}
{{file.percentDownloaded}}%
{{file.localSize | fileSize:3}} of {{file.remoteSize | fileSize:3}}
Queue
Stop
Extract
Delete Local
Delete Remote
================================================ FILE: src/angular/src/app/pages/files/file.component.scss ================================================ @import '../../common/common'; .file { padding: 10px; } /* selected file */ .file.selected { background-color: $secondary-color; } /* content */ .content { display: flex; flex-direction: row; flex-wrap: wrap; padding: 8px 0; } .content div { /* break up long text */ overflow-wrap: normal; hyphens: auto; word-break: break-all; } /* width */ .content .status {width: 100px; min-width: 100px;} .content .name {flex-grow: 1; flex-basis: calc(100% - 100px);} .content .speed {width: 100px; min-width: 100px;} /* .content .eta is hidden on mobile */ .content .size {flex-grow: 1;} /* content elements */ .content > div { padding: 4px 4px; text-align: center; vertical-align: top; } .content .name {text-align: left;} /* Name */ .content .name { display: flex; flex-direction: row; img { height: 20px; margin-right: 8px; margin-top: 1px; } .text { display: flex; flex-direction: column; .details { display: flex; flex-direction: column; .details-text { color: darkgray; font-size: 80%; line-height: 120%; .tab { margin-left: 20px; } } } } } /* Status */ .content .status { display: flex; flex-direction: column; justify-content: center; align-items: center; height: 62px; max-height: 62px; img { height: 35px; width: 35px; } // Optical weighting img#downloading, img#downloaded { width: 45px; } img#default-remote { width: 45px; height: 45px; } img#stopped { width: 30px; } .text { margin-top: 3px; font-size: 70%; } } /* Speed */ .content .speed { font-size: 90%; } /* Progress bar */ .content .progress-bar { min-width: 2em; } .content .progress { margin-bottom: 5px; height: 20px; } .content .size_info { font-size: 80%; } /* Hide the outer eta */ .content .eta {display: none;} .content .speed .speed-eta {font-size: 90%;} /* actions */ .actions { width: 100%; display: flex; flex-direction: row; justify-content: flex-end; flex-wrap: wrap; /* offsets the button's margin-bottom */ margin-bottom: -10px; } .actions .button { @extend %button; /* bottom margin for when buttons get wrapped to multiple rows */ margin-bottom: 10px; margin-right: 10px; width: 60px; min-width: 60px; height: 60px; &:last-child { margin-right: 0; } .text { height: 25px; display: flex; flex-direction: column; align-items: center; justify-content: center; span { font-size: 12px; text-align: center; line-height: 12px; } } img { width: 25px; height: 25px; filter: invert(1.0); } .loader { display: none; border: 5px solid white; border-top: 5px solid $secondary-dark-color; border-radius: 50%; width: 25px; height: 25px; animation: spin 2s linear infinite; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } // Show loader icon when loading &.loading { opacity: 1.0; background-color: $secondary-dark-color; border-color: $secondary-darker-color; img { display: none; } .loader { display: block; } } } /* action toolbar show and hide */ .actions { display: none; } .file.selected .actions { display: flex; } /* Medium and large screens */ @media only screen and (min-width: $medium-min-width) { /* enable row hover */ .file:not(.selected):hover { background-color: $secondary-light-color; } /* width */ /* NOTE: make sure this is in-sync with "#file-list #header" */ .content .status {width: 100px; min-width: 100px;} .content .name {flex-grow: 1;} .content .speed {width: 100px; min-width: 100px;} .content .eta {width: 100px; min-width: 100px;} .content .size {width: 30%; min-width: 30%;} .content { /* single row */ flex-wrap: nowrap; /* no top/bottom padding */ padding: 0 0; } /* Re-order columns */ .content .status {order: 2;} .content .name {order: 1;} .content .speed {order: 3;} .content .eta {order: 4;} .content .size {order: 5;} /* Hide the inner eta, show outer eta */ .content .eta {display: inline;} .content .speed .speed-eta {display: none;} } ================================================ FILE: src/angular/src/app/pages/files/file.component.ts ================================================ import { Component, Input, Output, ChangeDetectionStrategy, EventEmitter, OnChanges, SimpleChanges, ViewChild } from "@angular/core"; import {Modal} from "ngx-modialog/plugins/bootstrap"; import {ViewFile} from "../../services/files/view-file"; import {Localization} from "../../common/localization"; import {ViewFileOptions} from "../../services/files/view-file-options"; @Component({ selector: "app-file", providers: [], templateUrl: "./file.component.html", styleUrls: ["./file.component.scss"], changeDetection: ChangeDetectionStrategy.OnPush }) export class FileComponent implements OnChanges { // Make ViewFile optionType accessible from template ViewFile = ViewFile; // Make FileAction accessible from template FileAction = FileAction; // Expose min function for template min = Math.min; // Entire div element @ViewChild("fileElement") fileElement: any; @Input() file: ViewFile; @Input() options: ViewFileOptions; @Output() queueEvent = new EventEmitter(); @Output() stopEvent = new EventEmitter(); @Output() extractEvent = new EventEmitter(); @Output() deleteLocalEvent = new EventEmitter(); @Output() deleteRemoteEvent = new EventEmitter(); // Indicates an active action on-going activeAction: FileAction = null; constructor(private modal: Modal) {} ngOnChanges(changes: SimpleChanges): void { // Check for status changes const oldFile: ViewFile = changes.file.previousValue; const newFile: ViewFile = changes.file.currentValue; if (oldFile != null && newFile != null && oldFile.status !== newFile.status) { // Reset any active action this.activeAction = null; // Scroll into view if this file is selected and not already in viewport if (newFile.isSelected && !FileComponent.isElementInViewport(this.fileElement.nativeElement)) { this.fileElement.nativeElement.scrollIntoView(); } } } showDeleteConfirmation(title: string, message: string, callback: () => void) { const dialogRef = this.modal.confirm() .title(title) .okBtn("Delete") .okBtnClass("btn btn-danger") .cancelBtn("Cancel") .cancelBtnClass("btn btn-secondary") .isBlocking(false) .showClose(false) .body(message) .open(); dialogRef.then( dRef => { dRef.result.then( () => { callback(); }, () => { return; } ); }); } isQueueable() { return this.activeAction == null && this.file.isQueueable; } isStoppable() { return this.activeAction == null && this.file.isStoppable; } isExtractable() { return this.activeAction == null && this.file.isExtractable && this.file.isArchive; } isLocallyDeletable() { return this.activeAction == null && this.file.isLocallyDeletable; } isRemotelyDeletable() { return this.activeAction == null && this.file.isRemotelyDeletable; } onQueue(file: ViewFile) { this.activeAction = FileAction.QUEUE; // Pass to parent component this.queueEvent.emit(file); } onStop(file: ViewFile) { this.activeAction = FileAction.STOP; // Pass to parent component this.stopEvent.emit(file); } onExtract(file: ViewFile) { this.activeAction = FileAction.EXTRACT; // Pass to parent component this.extractEvent.emit(file); } onDeleteLocal(file: ViewFile) { this.showDeleteConfirmation( Localization.Modal.DELETE_LOCAL_TITLE, Localization.Modal.DELETE_LOCAL_MESSAGE(file.name), () => { this.activeAction = FileAction.DELETE_LOCAL; // Pass to parent component this.deleteLocalEvent.emit(file); } ); } onDeleteRemote(file: ViewFile) { this.showDeleteConfirmation( Localization.Modal.DELETE_REMOTE_TITLE, Localization.Modal.DELETE_REMOTE_MESSAGE(file.name), () => { this.activeAction = FileAction.DELETE_REMOTE; // Pass to parent component this.deleteRemoteEvent.emit(file); } ); } // Source: https://stackoverflow.com/a/7557433 private static isElementInViewport (el) { const rect = el.getBoundingClientRect(); return ( rect.top >= 0 && rect.left >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && /*or $(window).height() */ rect.right <= (window.innerWidth || document.documentElement.clientWidth) /*or $(window).width() */ ); } } export enum FileAction { QUEUE, STOP, EXTRACT, DELETE_LOCAL, DELETE_REMOTE } ================================================ FILE: src/angular/src/app/pages/files/files-page.component.html ================================================ ================================================ FILE: src/angular/src/app/pages/files/files-page.component.ts ================================================ import {Component} from "@angular/core"; @Component({ selector: "app-files-page", templateUrl: "./files-page.component.html" }) export class FilesPageComponent { } ================================================ FILE: src/angular/src/app/pages/logs/logs-page.component.html ================================================

{{record.time | date: 'yyyy/MM/dd HH:mm:ss'}} - {{record.level}} - {{record.loggerName}} - {{record.message}} {{record.exceptionTraceback}}

================================================ FILE: src/angular/src/app/pages/logs/logs-page.component.scss ================================================ @import '../../common/common'; #logs { padding: 5px 15px; font-family: monospace; font-size: 70%; display: flex; flex-direction: column; justify-content: center; .log-marker { width: 100%; height: 2px; } .btn-scroll { @extend %button; position: sticky; display: none; &.visible { display: inherit; } } #btn-scroll-top { top: 0; } #btn-scroll-bottom { bottom: 0; } p.record { margin: 0; /* break up long text */ overflow-wrap: normal; hyphens: auto; word-break: break-all; &.debug { color: darkgray; } &.info { color: black; } &.warning { // copied from bootstrap alert-warning color: #8a6d3b; background-color: #fcf8e3; border-color: #faebcc; } &.error, &.critical { // copied from bootstrap alert-danger color: #a94442; background-color: #f2dede; border-color: #ebccd1; } span.traceback { display: block; white-space: pre-line; margin-left: 30px; /* break up long text */ overflow-wrap: normal; hyphens: auto; word-break: break-all; } } .connected { height: 12px; width: 100%; text-align: center; border-bottom: 1px solid #e3e3e3; margin-bottom: 15px; span { background-color: #f5f5f5; font-size: 10px; padding: 5px; } } } /* Medium and large screens */ @media only screen and (min-width: $medium-min-width) { #logs { font-size: 95%; } } ================================================ FILE: src/angular/src/app/pages/logs/logs-page.component.ts ================================================ import { AfterContentChecked, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostListener, OnInit, ViewChild, ViewContainerRef } from "@angular/core"; import {LogService} from "../../services/logs/log.service"; import {LogRecord} from "../../services/logs/log-record"; import {StreamServiceRegistry} from "../../services/base/stream-service.registry"; import {Localization} from "../../common/localization"; import {DomService} from "../../services/utils/dom.service"; import {Observable} from "rxjs/Observable"; @Component({ selector: "app-logs-page", templateUrl: "./logs-page.component.html", styleUrls: ["./logs-page.component.scss"], providers: [], changeDetection: ChangeDetectionStrategy.OnPush }) export class LogsPageComponent implements OnInit, AfterContentChecked { public readonly LogRecord = LogRecord; public readonly Localization = Localization; public headerHeight: Observable; @ViewChild("templateRecord") templateRecord; @ViewChild("templateConnected") templateConnected; // Where to insert the cloned content @ViewChild("container", {read: ViewContainerRef}) container; @ViewChild("logHead") logHead; @ViewChild("logTail") logTail; public showScrollToTopButton = false; public showScrollToBottomButton = false; private _logService: LogService; constructor(private _elementRef: ElementRef, private _changeDetector: ChangeDetectorRef, private _streamRegistry: StreamServiceRegistry, private _domService: DomService) { this._logService = _streamRegistry.logService; this.headerHeight = this._domService.headerHeight; } ngOnInit() { this._logService.logs.subscribe({ next: record => { this.insertRecord(record); } }); } ngAfterContentChecked() { // Refresh button state when tabs is switched away and back this.refreshScrollButtonVisibility(); } scrollToTop() { // this.logHead.nativeElement.scrollIntoView(true); window.scrollTo(0, 0); } scrollToBottom() { window.scrollTo(0, document.body.scrollHeight); } @HostListener("window:scroll", ["$event"]) checkScroll() { this.refreshScrollButtonVisibility(); } private insertRecord(record: LogRecord) { // Scroll down if the log is visible and already scrolled to the bottom const scrollToBottom = this._elementRef.nativeElement.offsetParent != null && LogsPageComponent.isElementInViewport(this.logTail.nativeElement); this.container.createEmbeddedView(this.templateRecord, {record: record}); this._changeDetector.detectChanges(); if (scrollToBottom) { this.scrollToBottom(); } this.refreshScrollButtonVisibility(); } private refreshScrollButtonVisibility() { // Show/hide the scroll buttons this.showScrollToTopButton = !LogsPageComponent.isElementInViewport( this.logHead.nativeElement ); this.showScrollToBottomButton = !LogsPageComponent.isElementInViewport( this.logTail.nativeElement ); } // Source: https://stackoverflow.com/a/7557433 private static isElementInViewport(el): boolean { const rect = el.getBoundingClientRect(); return ( rect.top >= 0 && rect.left >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && /*or $(window).height() */ rect.right <= (window.innerWidth || document.documentElement.clientWidth) /*or $(window).width() */ ); } } ================================================ FILE: src/angular/src/app/pages/main/app.component.html ================================================
{{activeRoute?.name}}
================================================ FILE: src/angular/src/app/pages/main/app.component.scss ================================================ @import '../../common/common'; #top-content { margin-left: $sidebar-width; transition: margin-left .4s; } #top-header { position: sticky; top: 0; z-index: $zindex-top-header; } #outside-sidebar { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: $zindex-sidebar - 1; background-color: black; opacity: .25; } #title-bar { background-color: $header-color; display: flex; flex-direction: row; align-items: center; padding-top: 8px; padding-right: 8px; padding-bottom: 8px; } #title-bar #title { flex-grow: 1; font-size: 26px; cursor: default; user-select: none; } #top-sidebar { background-color: $primary-light-color; width: $sidebar-width; height: 100%; position: fixed; z-index: $zindex-sidebar; overflow: auto; box-shadow:0 2px 5px 0 rgba(0,0,0,0.16), 0 2px 10px 0 rgba(0,0,0,0.12); animation: animateleft 0.4s; #logo { margin-bottom: 10px; display: flex; flex-direction: row; align-items: center; padding: 5px; .image { img { max-height: 32px; max-width: 32px; } } .text { flex-grow: 1; padding-left: 5px; font-size: 140%; cursor: default; user-select: none; color: $logo-color; font-family: $logo-font; } #sidebar-btn-close { text-align: right; padding: 0; font-size: 140%; &:active { color: red; background-color: inherit; } } } } @keyframes animateleft { from { left: -$sidebar-width; opacity: 0 } to { left:0; opacity:1 } } .top-sidebar-open { display: block; } .top-sidebar-closed { display: none; } .outside-sidebar-show { display: block; } .outside-sidebar-hide { display: none; } .sidebar-btn { background-color: inherit; display: block; padding: 8px 16px; border: none; outline: none; white-space: normal; float: none; cursor: default; user-select: none; } #sidebar-btn-open { text-align: left; &:active { background-color: $header-dark-color; } } // Small and medium screens @media only screen and (max-width: $medium-max-width) { // Sidebar is hidden, remove content margin #top-content { margin-left: 0; } } // Large screens @media only screen and (min-width: $large-min-width) { // Always show the sidebar #top-sidebar { display: block; } // Always hide the outside-sidebar .outside-sidebar-show { display: none; } // Hide the sidebar open/close buttons .sidebar-btn { display: none; } // Hide the entire title-bar #title-bar { display: none; } } ================================================ FILE: src/angular/src/app/pages/main/app.component.ts ================================================ import {AfterViewInit, Component, ElementRef, OnInit, ViewChild} from "@angular/core"; import {NavigationEnd, Router} from "@angular/router"; import {ROUTE_INFOS, RouteInfo} from "../../routes"; import {ElementQueries, ResizeSensor} from "css-element-queries"; import {DomService} from "../../services/utils/dom.service"; @Component({ selector: "app-root", templateUrl: "./app.component.html", styleUrls: ["./app.component.scss"] }) export class AppComponent implements OnInit, AfterViewInit { @ViewChild("topHeader") topHeader: ElementRef; showSidebar = false; activeRoute: RouteInfo; constructor(private router: Router, private _domService: DomService) { // Navigation listener // Close the sidebar // Store the active route router.events.subscribe(() => { this.showSidebar = false; this.activeRoute = ROUTE_INFOS.find(value => "/" + value.path === router.url); }); } ngOnInit() { // Scroll to top on route changes this.router.events.subscribe((evt) => { if (!(evt instanceof NavigationEnd)) { return; } window.scrollTo(0, 0); }); } ngAfterViewInit() { ElementQueries.listen(); ElementQueries.init(); // noinspection TsLint new ResizeSensor(this.topHeader.nativeElement, () => { this._domService.setHeaderHeight(this.topHeader.nativeElement.clientHeight); }); } title = "app"; } ================================================ FILE: src/angular/src/app/pages/main/header.component.html ================================================ ================================================ FILE: src/angular/src/app/pages/main/header.component.scss ================================================ @import '../../common/common'; #header { .alert { padding: 5px; margin-bottom: 0; border-radius: 0; button.close { top: 5px; right: 5px; cursor: default; user-select: none; outline: none; opacity: .2; font-size: 160%; padding: 0; &:active { color: red; } &:hover { color: inherit; opacity: .2; } } } } ================================================ FILE: src/angular/src/app/pages/main/header.component.ts ================================================ import {Component, OnInit} from "@angular/core"; import {Observable} from "rxjs/Observable"; import * as Immutable from "immutable"; import {LoggerService} from "../../services/utils/logger.service"; import {ServerStatusService} from "../../services/server/server-status.service"; import {Notification} from "../../services/utils/notification"; import {NotificationService} from "../../services/utils/notification.service"; import {StreamServiceRegistry} from "../../services/base/stream-service.registry"; import {Localization} from "../../common/localization"; @Component({ selector: "app-header", templateUrl: "./header.component.html", styleUrls: ["./header.component.scss"], }) export class HeaderComponent implements OnInit { // expose Notification type to template public Notification = Notification; public notifications: Observable>; private _serverStatusService: ServerStatusService; private _prevServerNotification: Notification; private _prevWaitingForRemoteScanNotification: Notification; private _prevRemoteServerErrorNotification: Notification; constructor(private _logger: LoggerService, _streamServiceRegistry: StreamServiceRegistry, private _notificationService: NotificationService) { this._serverStatusService = _streamServiceRegistry.serverStatusService; this.notifications = this._notificationService.notifications; this._prevServerNotification = null; this._prevWaitingForRemoteScanNotification = null; } public dismiss(notif: Notification) { this._notificationService.hide(notif); } ngOnInit() { // Set up a subscriber to show server status notifications this._serverStatusService.status.subscribe({ next: status => { if (status.server.up) { // Remove any server notifications we may have added if (this._prevServerNotification != null) { this._notificationService.hide(this._prevServerNotification); this._prevServerNotification = null; } } else { // Create a notification const notification = new Notification({ level: Notification.Level.DANGER, text: status.server.errorMessage }); // Show it, if different from the existing one if ( this._prevServerNotification == null || this._prevServerNotification.text !== notification.text ) { // Hide existing, if any if (this._prevServerNotification != null) { this._notificationService.hide(this._prevServerNotification); } this._prevServerNotification = notification; this._notificationService.show(this._prevServerNotification); this._logger.debug("New server notification: %O", this._prevServerNotification); } } } }); // Set up a subscriber to show waiting for remote scan notification this._serverStatusService.status.subscribe({ next: status => { if (status.server.up && status.controller.latestRemoteScanTime == null) { // Server up and no remote scan - show notification if not already shown if (this._prevWaitingForRemoteScanNotification == null) { this._prevWaitingForRemoteScanNotification = new Notification({ level: Notification.Level.INFO, text: Localization.Notification.STATUS_REMOTE_SCAN_WAITING }); this._notificationService.show(this._prevWaitingForRemoteScanNotification); } } else { // Server down or remote scan available - hide notification if showing if (this._prevWaitingForRemoteScanNotification != null) { this._notificationService.hide(this._prevWaitingForRemoteScanNotification); this._prevWaitingForRemoteScanNotification = null; } } } }); // Set up a subscriber to show remote server error notifications this._serverStatusService.status.subscribe({ next: status => { if (status.server.up && status.controller.latestRemoteScanFailed === true) { // Server up and remote scan failed - show notification if not already shown const level = Notification.Level.WARNING; const text = Localization.Notification.STATUS_REMOTE_SERVER_ERROR(status.controller.latestRemoteScanError); if (this._prevRemoteServerErrorNotification != null && this._prevRemoteServerErrorNotification.text !== text) { // Text changed, hide old notification this._notificationService.hide(this._prevRemoteServerErrorNotification); this._prevRemoteServerErrorNotification = null; } if (this._prevRemoteServerErrorNotification == null) { this._prevRemoteServerErrorNotification = new Notification(({ level: level, text: text })); this._notificationService.show(this._prevRemoteServerErrorNotification); } } else { // Server down or error is gone - hide notification if showing if (this._prevRemoteServerErrorNotification != null) { this._notificationService.hide(this._prevRemoteServerErrorNotification); this._prevRemoteServerErrorNotification = null; } } } }); } } ================================================ FILE: src/angular/src/app/pages/main/sidebar.component.html ================================================ ================================================ FILE: src/angular/src/app/pages/main/sidebar.component.scss ================================================ @import '../../common/common'; #sidebar { .button { background-color: inherit; color: inherit; text-decoration: none; font-weight: bolder; display: block; width: 100%; padding: 8px 16px; text-align: left; border: none; outline: none; white-space: normal; float: none; cursor: default; user-select: none; &:active { background-color: $primary-color; } &.selected { background-color: $secondary-color; color: white; border-color: #6ac19e; img { filter: invert(1.0); } } &[disabled] { opacity: .65; background-color: inherit; } img { width: 18px; height: 18px; margin-right: 4px; margin-bottom: 2px; } } hr { margin-top: 3px; margin-bottom: 3px; border: 1px solid $header-dark-color; } } ================================================ FILE: src/angular/src/app/pages/main/sidebar.component.ts ================================================ import {Component, OnInit} from "@angular/core"; import {ROUTE_INFOS} from "../../routes"; import {ServerCommandService} from "../../services/server/server-command.service"; import {LoggerService} from "../../services/utils/logger.service"; import {ConnectedService} from "../../services/utils/connected.service"; import {StreamServiceRegistry} from "../../services/base/stream-service.registry"; @Component({ selector: "app-sidebar", templateUrl: "./sidebar.component.html", styleUrls: ["./sidebar.component.scss"] }) export class SidebarComponent implements OnInit { routeInfos = ROUTE_INFOS; public commandsEnabled: boolean; private _connectedService: ConnectedService; constructor(private _logger: LoggerService, _streamServiceRegistry: StreamServiceRegistry, private _commandService: ServerCommandService) { this._connectedService = _streamServiceRegistry.connectedService; this.commandsEnabled = false; } // noinspection JSUnusedGlobalSymbols ngOnInit() { this._connectedService.connected.subscribe({ next: (connected: boolean) => { this.commandsEnabled = connected; } }); } onCommandRestart() { this._commandService.restart().subscribe({ next: reaction => { if (reaction.success) { this._logger.info(reaction.data); } else { this._logger.error(reaction.errorMessage); } } }); } } ================================================ FILE: src/angular/src/app/pages/settings/option.component.html ================================================
================================================ FILE: src/angular/src/app/pages/settings/option.component.scss ================================================ @import '../../common/common'; .form-group { margin-bottom: 0; margin-left: 20px; margin-right: 20px; } .error { background-color: #f2dede; color: #a94442; border: 1px solid #a94442; } label { width: 100%; display: flex; flex-direction: row; flex-wrap: wrap; align-items: center; } span.description { color: darkgrey; font-size: 80%; line-height: initial; width: 100%; white-space: pre-line; // renders newline as
} input[type="checkbox"] { width: auto; margin-right: 10px; box-shadow: none; height: 20px; &:focus { box-shadow: none; } & ~ .description { margin-left: 23px; } } input[type="text"] { width: 100%; } ================================================ FILE: src/angular/src/app/pages/settings/option.component.ts ================================================ import {Component, Input, Output, ChangeDetectionStrategy, EventEmitter, OnInit} from "@angular/core"; import {Subject} from "rxjs/Subject"; @Component({ selector: "app-option", providers: [], templateUrl: "./option.component.html", styleUrls: ["./option.component.scss"], changeDetection: ChangeDetectionStrategy.OnPush }) export class OptionComponent implements OnInit { @Input() type: OptionType; @Input() label: string; @Input() value: any; @Input() description: string; @Output() changeEvent = new EventEmitter(); // expose to template public OptionType = OptionType; private readonly DEBOUNCE_TIME_MS: number = 1000; private newValue = new Subject(); // noinspection JSUnusedGlobalSymbols ngOnInit(): void { // Debounce // References: // https://angular.io/tutorial/toh-pt6#fix-the-herosearchcomponent-class // https://stackoverflow.com/a/41965515 this.newValue .debounceTime(this.DEBOUNCE_TIME_MS) .distinctUntilChanged() .subscribe({next: val => this.changeEvent.emit(val)}); } onChange(value: any) { this.newValue.next(value); } } export enum OptionType { Text, Checkbox, Password } ================================================ FILE: src/angular/src/app/pages/settings/options-list.ts ================================================ import {OptionType} from "./option.component"; export interface IOption { type: OptionType; label: string; valuePath: [string, string]; description: string; } export interface IOptionsContext { header: string; id: string; options: IOption[]; } export const OPTIONS_CONTEXT_SERVER: IOptionsContext = { header: "Server", id: "server", options: [ { type: OptionType.Text, label: "Server Address", valuePath: ["lftp", "remote_address"], description: null }, { type: OptionType.Text, label: "Server User", valuePath: ["lftp", "remote_username"], description: null }, { type: OptionType.Password, label: "Server Password", valuePath: ["lftp", "remote_password"], description: null }, { type: OptionType.Checkbox, label: "Use password-less key-based authentication", valuePath: ["lftp", "use_ssh_key"], description: null }, { type: OptionType.Text, label: "Server Directory", valuePath: ["lftp", "remote_path"], description: "Path to your files on the remote server" }, { type: OptionType.Text, label: "Local Directory", valuePath: ["lftp", "local_path"], description: "Downloaded files are placed here" }, { type: OptionType.Text, label: "Remote SSH Port", valuePath: ["lftp", "remote_port"], description: null, }, { type: OptionType.Text, label: "Server Script Path", valuePath: ["lftp", "remote_path_to_scan_script"], description: "Where to install scanner script on remote server" } ] }; export const OPTIONS_CONTEXT_DISCOVERY: IOptionsContext = { header: "File Discovery", id: "file-discovery", options: [ { type: OptionType.Text, label: "Remote Scan Interval (ms)", valuePath: ["controller", "interval_ms_remote_scan"], description: "How often the remote server is scanned for new files" }, { type: OptionType.Text, label: "Local Scan Interval (ms)", valuePath: ["controller", "interval_ms_local_scan"], description: "How often the local directory is scanned" }, { type: OptionType.Text, label: "Downloading Scan Interval (ms)", valuePath: ["controller", "interval_ms_downloading_scan"], description: "How often the downloading information is updated" }, ] }; export const OPTIONS_CONTEXT_CONNECTIONS: IOptionsContext = { header: "Connections", id: "connections", options: [ { type: OptionType.Text, label: "Max Parallel Downloads", valuePath: ["lftp", "num_max_parallel_downloads"], description: "How many items download in parallel.\n" + "(cmd:queue-parallel)" }, { type: OptionType.Text, label: "Max Total Connections", valuePath: ["lftp", "num_max_total_connections"], description: "Maximum number of connections.\n" + "(net:connection-limit)" }, { type: OptionType.Text, label: "Max Connections Per File (Single-File)", valuePath: ["lftp", "num_max_connections_per_root_file"], description: "Number of connections for single-file download.\n" + "(pget:default-n)" }, { type: OptionType.Text, label: "Max Connections Per File (Directory)", valuePath: ["lftp", "num_max_connections_per_dir_file"], description: "Number of per-file connections for directory download.\n" + "(mirror:use-pget-n)" }, { type: OptionType.Text, label: "Max Parallel Files (Directory)", valuePath: ["lftp", "num_max_parallel_files_per_download"], description: "Maximum number of files to fetch in parallel for single directory download.\n" + "(mirror:parallel-transfer-count)" }, { type: OptionType.Checkbox, label: "Rename unfinished/downloading files", valuePath: ["lftp", "use_temp_file"], description: "Unfinished and downloading files will be named *.lftp" }, ] }; export const OPTIONS_CONTEXT_OTHER: IOptionsContext = { header: "Other Settings", id: "other-settings", options: [ { type: OptionType.Text, label: "Web GUI Port", valuePath: ["web", "port"], description: null }, { type: OptionType.Checkbox, label: "Enable Debug", valuePath: ["general", "debug"], description: "Enables debug logging." }, ] }; export const OPTIONS_CONTEXT_AUTOQUEUE: IOptionsContext = { header: "AutoQueue", id: "autoqueue", options: [ { type: OptionType.Checkbox, label: "Enable AutoQueue", valuePath: ["autoqueue", "enabled"], description: null }, { type: OptionType.Checkbox, label: "Restrict to patterns", valuePath: ["autoqueue", "patterns_only"], description: "Only autoqueue files that match a pattern" }, { type: OptionType.Checkbox, label: "Enable auto extraction", valuePath: ["autoqueue", "auto_extract"], description: "Automatically extract files" }, ] }; export const OPTIONS_CONTEXT_EXTRACT: IOptionsContext = { header: "Archive Extraction", id: "extraction", options: [ { type: OptionType.Checkbox, label: "Extract archives in the downloads directory", valuePath: ["controller", "use_local_path_as_extract_path"], description: null }, { type: OptionType.Text, label: "Extract Path", valuePath: ["controller", "extract_path"], description: "When option above is disabled, extract archives to this directory" }, ] }; ================================================ FILE: src/angular/src/app/pages/settings/settings-page.component.html ================================================

Restart
================================================ FILE: src/angular/src/app/pages/settings/settings-page.component.scss ================================================ @import '../../common/common'; #settings { width: 100%; #accordion { width: 100%; display: flex; flex-direction: row; flex-wrap: wrap; padding: 15px; #left, #right { width: 100%; .card { margin-top: 20px; &:first-child { margin-top: 0; } .card-header { margin-bottom: 0; .btn { font-size: 80%; cursor: default; &:focus { box-shadow: none; } } } .card-body { padding: 10px 0; } } } #right { margin-top: 20px; } } #commands { margin-top: 10px; width: 100%; display: flex; flex-direction: row; justify-content: flex-end; flex-wrap: wrap; .button { @extend %button; flex-direction: row; padding: 10px; margin-right: 8px; height: 40px; font-size: 120%; img { width: 20px; height: 20px; filter: invert(1.0); } .text { margin-left: 5px; } } } } /* Medium and large screens */ @media only screen and (min-width: $medium-min-width) { #settings { #accordion { #left, #right { width: 50%; .card-header { .btn { pointer-events: none; } } .collapse { display: inherit; } } #left { padding-right: 10px; } #right { padding-left: 10px; margin-top: 0; } } } } ================================================ FILE: src/angular/src/app/pages/settings/settings-page.component.ts ================================================ import {ChangeDetectionStrategy, Component, OnInit} from "@angular/core"; import {Observable} from "rxjs/Observable"; import {LoggerService} from "../../services/utils/logger.service"; import {ConfigService} from "../../services/settings/config.service"; import {Config} from "../../services/settings/config"; import {Notification} from "../../services/utils/notification"; import {Localization} from "../../common/localization"; import {NotificationService} from "../../services/utils/notification.service"; import {ServerCommandService} from "../../services/server/server-command.service"; import { OPTIONS_CONTEXT_CONNECTIONS, OPTIONS_CONTEXT_DISCOVERY, OPTIONS_CONTEXT_OTHER, OPTIONS_CONTEXT_SERVER, OPTIONS_CONTEXT_AUTOQUEUE, OPTIONS_CONTEXT_EXTRACT } from "./options-list"; import {ConnectedService} from "../../services/utils/connected.service"; import {StreamServiceRegistry} from "../../services/base/stream-service.registry"; @Component({ selector: "app-settings-page", templateUrl: "./settings-page.component.html", styleUrls: ["./settings-page.component.scss"], providers: [], changeDetection: ChangeDetectionStrategy.OnPush }) export class SettingsPageComponent implements OnInit { public OPTIONS_CONTEXT_SERVER = OPTIONS_CONTEXT_SERVER; public OPTIONS_CONTEXT_DISCOVERY = OPTIONS_CONTEXT_DISCOVERY; public OPTIONS_CONTEXT_CONNECTIONS = OPTIONS_CONTEXT_CONNECTIONS; public OPTIONS_CONTEXT_OTHER = OPTIONS_CONTEXT_OTHER; public OPTIONS_CONTEXT_AUTOQUEUE = OPTIONS_CONTEXT_AUTOQUEUE; public OPTIONS_CONTEXT_EXTRACT = OPTIONS_CONTEXT_EXTRACT; public config: Observable; public commandsEnabled: boolean; private _connectedService: ConnectedService; private _configRestartNotif: Notification; private _badValueNotifs: Map; constructor(private _logger: LoggerService, _streamServiceRegistry: StreamServiceRegistry, private _configService: ConfigService, private _notifService: NotificationService, private _commandService: ServerCommandService) { this._connectedService = _streamServiceRegistry.connectedService; this.config = _configService.config; this.commandsEnabled = false; this._configRestartNotif = new Notification({ level: Notification.Level.INFO, text: Localization.Notification.CONFIG_RESTART }); this._badValueNotifs = new Map(); } // noinspection JSUnusedGlobalSymbols ngOnInit() { this._connectedService.connected.subscribe({ next: (connected: boolean) => { if (!connected) { // Server went down, hide the config restart notification this._notifService.hide(this._configRestartNotif); } // Enable/disable commands based on server connection this.commandsEnabled = connected; } }); } onSetConfig(section: string, option: string, value: any) { this._configService.set(section, option, value).subscribe({ next: reaction => { const notifKey = section + "." + option; if (reaction.success) { this._logger.info(reaction.data); // Hide bad value notification, if any if (this._badValueNotifs.has(notifKey)) { this._notifService.hide(this._badValueNotifs.get(notifKey)); this._badValueNotifs.delete(notifKey); } // Show the restart notification this._notifService.show(this._configRestartNotif); } else { // Show bad value notification const notif = new Notification({ level: Notification.Level.DANGER, dismissible: true, text: reaction.errorMessage }); if (this._badValueNotifs.has(notifKey)) { this._notifService.hide(this._badValueNotifs.get(notifKey)); } this._notifService.show(notif); this._badValueNotifs.set(notifKey, notif); this._logger.error(reaction.errorMessage); } } }); } onCommandRestart() { this._commandService.restart().subscribe({ next: reaction => { if (reaction.success) { this._logger.info(reaction.data); } else { this._logger.error(reaction.errorMessage); } } }); } } ================================================ FILE: src/angular/src/app/routes.ts ================================================ import {Routes} from "@angular/router"; import * as Immutable from "immutable"; import {FilesPageComponent} from "./pages/files/files-page.component"; import {SettingsPageComponent} from "./pages/settings/settings-page.component"; import {AutoQueuePageComponent} from "./pages/autoqueue/autoqueue-page.component"; import {LogsPageComponent} from "./pages/logs/logs-page.component"; import {AboutPageComponent} from "./pages/about/about-page.component"; export interface RouteInfo { path: string; name: string; icon: string; component: any; } export const ROUTE_INFOS: Immutable.List = Immutable.List([ { path: "dashboard", name: "Dashboard", icon: "assets/icons/dashboard.svg", component: FilesPageComponent }, { path: "settings", name: "Settings", icon: "assets/icons/settings.svg", component: SettingsPageComponent }, { path: "autoqueue", name: "AutoQueue", icon: "assets/icons/autoqueue.svg", component: AutoQueuePageComponent }, { path: "logs", name: "Logs", icon: "assets/icons/logs.svg", component: LogsPageComponent }, { path: "about", name: "About", icon: "assets/icons/about.svg", component: AboutPageComponent } ]); export const ROUTES: Routes = [ { path: "", redirectTo: "/dashboard", pathMatch: "full" }, { path: "dashboard", component: FilesPageComponent }, { path: "settings", component: SettingsPageComponent }, { path: "autoqueue", component: AutoQueuePageComponent }, { path: "logs", component: LogsPageComponent }, { path: "about", component: AboutPageComponent } ]; ================================================ FILE: src/angular/src/app/services/autoqueue/autoqueue-pattern.ts ================================================ import {Record} from "immutable"; interface IAutoQueuePattern { pattern: string; } const DefaultAutoQueuePattern: IAutoQueuePattern = { pattern: null }; const AutoQueuePatternRecord = Record(DefaultAutoQueuePattern); export class AutoQueuePattern extends AutoQueuePatternRecord implements IAutoQueuePattern { pattern: string; constructor(props) { super(props); } } /** * ServerStatus as serialized by the backend. * Note: naming convention matches that used in JSON */ export interface AutoQueuePatternJson { pattern: string; } ================================================ FILE: src/angular/src/app/services/autoqueue/autoqueue.service.ts ================================================ import {Injectable} from "@angular/core"; import {Observable} from "rxjs/Observable"; import {BehaviorSubject} from "rxjs/Rx"; import * as Immutable from "immutable"; import {LoggerService} from "../utils/logger.service"; import {BaseWebService} from "../base/base-web.service"; import {AutoQueuePattern, AutoQueuePatternJson} from "./autoqueue-pattern"; import {Localization} from "../../common/localization"; import {StreamServiceRegistry} from "../base/stream-service.registry"; import {RestService, WebReaction} from "../utils/rest.service"; /** * AutoQueueService provides the store for the autoqueue patterns */ @Injectable() export class AutoQueueService extends BaseWebService { private readonly AUTOQUEUE_GET_URL = "/server/autoqueue/get"; private readonly AUTOQUEUE_ADD_URL = (pattern) => `/server/autoqueue/add/${pattern}`; private readonly AUTOQUEUE_REMOVE_URL = (pattern) => `/server/autoqueue/remove/${pattern}`; private _patterns: BehaviorSubject> = new BehaviorSubject(Immutable.List([])); constructor(_streamServiceProvider: StreamServiceRegistry, private _restService: RestService, private _logger: LoggerService) { super(_streamServiceProvider); } /** * Returns an observable that provides that latest patterns * @returns {Observable>} */ get patterns(): Observable> { return this._patterns.asObservable(); } /** * Add a pattern * @param {string} pattern * @returns {Observable} */ public add(pattern: string): Observable { this._logger.debug("add pattern %O", pattern); // Value check if (pattern == null || pattern.trim().length === 0) { return Observable.create(observer => { observer.next(new WebReaction(false, null, Localization.Notification.AUTOQUEUE_PATTERN_EMPTY)); }); } const currentPatterns = this._patterns.getValue(); const index = currentPatterns.findIndex(pat => pat.pattern === pattern); if (index >= 0) { return Observable.create(observer => { observer.next(new WebReaction(false, null, `Pattern '${pattern}' already exists.`)); }); } else { // Double-encode the value const patternEncoded = encodeURIComponent(encodeURIComponent(pattern)); const url = this.AUTOQUEUE_ADD_URL(patternEncoded); const obs = this._restService.sendRequest(url); obs.subscribe({ next: reaction => { if (reaction.success) { // Update our copy and notify clients const patterns = this._patterns.getValue(); const newPatterns = patterns.push( new AutoQueuePattern({ pattern: pattern }) ); this._patterns.next(newPatterns); } } }); return obs; } } /** * Remove a pattern * @param {string} pattern * @returns {Observable} */ public remove(pattern: string): Observable { this._logger.debug("remove pattern %O", pattern); const currentPatterns = this._patterns.getValue(); const index = currentPatterns.findIndex(pat => pat.pattern === pattern); if (index < 0) { return Observable.create(observer => { observer.next(new WebReaction(false, null, `Pattern '${pattern}' not found.`)); }); } else { // Double-encode the value const patternEncoded = encodeURIComponent(encodeURIComponent(pattern)); const url = this.AUTOQUEUE_REMOVE_URL(patternEncoded); const obs = this._restService.sendRequest(url); obs.subscribe({ next: reaction => { if (reaction.success) { // Update our copy and notify clients const patterns = this._patterns.getValue(); const finalIndex = currentPatterns.findIndex(pat => pat.pattern === pattern); const newPatterns = patterns.remove(finalIndex); this._patterns.next(newPatterns); } } }); return obs; } } protected onConnected() { // Retry the get this.getPatterns(); } protected onDisconnected() { // Send empty list this._patterns.next(Immutable.List([])); } private getPatterns() { this._logger.debug("Getting autoqueue patterns..."); this._restService.sendRequest(this.AUTOQUEUE_GET_URL).subscribe({ next: reaction => { if (reaction.success) { const parsed: AutoQueuePatternJson[] = JSON.parse(reaction.data); const newPatterns: AutoQueuePattern[] = []; for (const patternJson of parsed) { newPatterns.push(new AutoQueuePattern({ pattern: patternJson.pattern })); } this._patterns.next(Immutable.List(newPatterns)); } else { this._patterns.next(Immutable.List([])); } } }); } } /** * AutoQueueService factory and provider */ export let autoQueueServiceFactory = ( _streamServiceRegistry: StreamServiceRegistry, _restService: RestService, _logger: LoggerService ) => { const autoQueueService = new AutoQueueService(_streamServiceRegistry, _restService, _logger); autoQueueService.onInit(); return autoQueueService; }; // noinspection JSUnusedGlobalSymbols export let AutoQueueServiceProvider = { provide: AutoQueueService, useFactory: autoQueueServiceFactory, deps: [StreamServiceRegistry, RestService, LoggerService] }; ================================================ FILE: src/angular/src/app/services/base/base-stream.service.ts ================================================ import {Injectable} from "@angular/core"; import {IStreamService} from "./stream-service.registry"; /** * BaseStreamService represents a web services that fetches data * from a SSE stream. This class provides utilities to register * for event notifications from a multiplexed stream. * * Note: services derived from this class SHOULD NOT be created * directly. They need to be added to StreamServiceRegistry * and fetched from an instance of that registry class. */ @Injectable() export abstract class BaseStreamService implements IStreamService { private _eventNames: string[] = []; constructor() {} getEventNames(): string[] { return this._eventNames; } notifyConnected() { this.onConnected(); } notifyDisconnected() { this.onDisconnected(); } notifyEvent(eventName: string, data: string) { this.onEvent(eventName, data); } protected registerEventName(eventName: string) { this._eventNames.push(eventName); } /** * Callback for a new event * @param {string} eventName * @param {string} data */ protected abstract onEvent(eventName: string, data: string); /** * Callback for connected */ protected abstract onConnected(); /** * Callback for disconnected */ protected abstract onDisconnected(); } ================================================ FILE: src/angular/src/app/services/base/base-web.service.ts ================================================ import {Injectable} from "@angular/core"; import {StreamServiceRegistry} from "./stream-service.registry"; import {ConnectedService} from "../utils/connected.service"; /** * BaseWebService provides utility to be notified when connection to * the backend server is lost and regained. Non-streaming web services * can use these notifications to re-issue get requests. */ @Injectable() export abstract class BaseWebService { private _connectedService: ConnectedService; /** * Call this method to finish initialization */ public onInit() { this._connectedService.connected.subscribe({ next: connected => { if(connected) { this.onConnected(); } else { this.onDisconnected(); } } }); } constructor(_streamServiceProvider: StreamServiceRegistry) { this._connectedService = _streamServiceProvider.connectedService; } /** * Callback for connected */ protected abstract onConnected(): void; /** * Callback for disconnected */ protected abstract onDisconnected(): void; } ================================================ FILE: src/angular/src/app/services/base/stream-service.registry.ts ================================================ import {Injectable, NgZone} from "@angular/core"; import {Observable} from "rxjs/Observable"; import EventSource = require("eventsource"); import {ModelFileService} from "../files/model-file.service"; import {ServerStatusService} from "../server/server-status.service"; import {LoggerService} from "../utils/logger.service"; import {ConnectedService} from "../utils/connected.service"; import {LogService} from "../logs/log.service"; export class EventSourceFactory { static createEventSource(url: string) { return new EventSource(url); } } export interface IStreamService { /** * Returns the event names supported by this stream service * @returns {string[]} */ getEventNames(): string[]; /** * Notifies the stream service that it is now connected */ notifyConnected(); /** * Notifies the stream service that it is now disconnected */ notifyDisconnected(); /** * Notifies the stream service of an event * @param {string} eventName * @param {string} data */ notifyEvent(eventName: string, data: string); } /** * StreamDispatchService is the top-level service that connects to * the multiplexed SSE stream. It listens for SSE events and dispatches * them to whichever IStreamService that requested them. */ @Injectable() export class StreamDispatchService { private readonly STREAM_URL = "/server/stream"; private readonly STREAM_RETRY_INTERVAL_MS = 3000; private _eventNameToServiceMap: Map = new Map(); private _services: IStreamService[] = []; constructor(private _logger: LoggerService, private _zone: NgZone) { } /** * Call this method to finish initialization */ public onInit() { this.createSseObserver(); } /** * Register an IStreamService with the dispatch * @param {IStreamService} service * @returns {IStreamService} */ public registerService(service: IStreamService) { for(let eventName of service.getEventNames()) { this._eventNameToServiceMap.set(eventName, service); } this._services.push(service); return service; } private createSseObserver() { const observable = Observable.create(observer => { const eventSource = EventSourceFactory.createEventSource(this.STREAM_URL); for (let eventName of Array.from(this._eventNameToServiceMap.keys())) { eventSource.addEventListener(eventName, event => observer.next( { "event": eventName, "data": (event).data } )); } // noinspection SpellCheckingInspection // noinspection JSUnusedLocalSymbols eventSource.onopen = event => { this._logger.info("Connected to server stream"); // Notify all services of connection for (let service of this._services) { this._zone.run(() => { service.notifyConnected(); }); } }; eventSource.onerror = x => observer.error(x); return () => { eventSource.close(); }; }); observable.subscribe({ next: (x) => { let eventName = x["event"]; let eventData = x["data"]; // this._logger.debug("Received event:", eventName); this._zone.run(() => { this._eventNameToServiceMap.get(eventName).notifyEvent(eventName, eventData); }); }, error: err => { this._logger.error("Error in stream: %O", err); // Notify all services of disconnection for (let service of this._services) { this._zone.run(() => { service.notifyDisconnected(); }); } setTimeout(() => { this.createSseObserver(); }, this.STREAM_RETRY_INTERVAL_MS); } }); } } /** * StreamServiceRegistry is responsible for initializing all * Stream Services. All services created by the registry * will be connected to a single stream via the DispatchService */ @Injectable() export class StreamServiceRegistry { constructor(private _dispatch: StreamDispatchService, private _modelFileService: ModelFileService, private _serverStatusService: ServerStatusService, private _connectedService: ConnectedService, private _logService: LogService) { // Register all services _dispatch.registerService(_connectedService); _dispatch.registerService(_serverStatusService); _dispatch.registerService(_modelFileService); _dispatch.registerService(_logService); } /** * Call this method to finish initialization */ public onInit() { this._dispatch.onInit(); } get modelFileService(): ModelFileService { return this._modelFileService; } get serverStatusService(): ServerStatusService { return this._serverStatusService; } get connectedService(): ConnectedService { return this._connectedService; } get logService(): LogService { return this._logService; } } /** * StreamServiceRegistry factory and provider */ export let streamServiceRegistryFactory = ( _dispatch: StreamDispatchService, _modelFileService: ModelFileService, _serverStatusService: ServerStatusService, _connectedService: ConnectedService, _logService: LogService ) => { let streamServiceRegistry = new StreamServiceRegistry( _dispatch, _modelFileService, _serverStatusService, _connectedService, _logService ); streamServiceRegistry.onInit(); return streamServiceRegistry; }; // noinspection JSUnusedGlobalSymbols export let StreamServiceRegistryProvider = { provide: StreamServiceRegistry, useFactory: streamServiceRegistryFactory, deps: [ StreamDispatchService, ModelFileService, ServerStatusService, ConnectedService, LogService ] }; ================================================ FILE: src/angular/src/app/services/files/mock-model-files.ts ================================================ import * as Immutable from "immutable"; import {ModelFile} from "./model-file"; export const MOCK_MODEL_FILES: Immutable.Map = Immutable.Map({ "[AUTHOR] A Really Cool Video About Cats.mkv": new ModelFile({ name: "[AUTHOR] A Really Cool Video About Cats.mkv", is_dir: false, local_size: 123644865, remote_size: 243644865, state: ModelFile.State.DOWNLOADING, downloading_speed: 512000, eta: 3612, full_path: null, is_extractable: false, local_created_timestamp: new Date("November 9, 2018 21:40:18"), local_modified_timestamp: new Date(1541828418943), remote_created_timestamp: null, remote_modified_timestamp: new Date(1541828418943), children: Immutable.Set() }), "Super.Secret.Folder.With.A.Long.Name.Separated.By.Dots": new ModelFile({ name: "Super.Secret.Folder.With.A.Long.Name.Separated.By.Dots", is_dir: true, local_size: 123456, remote_size: 487241252, state: ModelFile.State.DOWNLOADING, downloading_speed: 1212000, eta: 1514, full_path: null, is_extractable: false, local_created_timestamp: new Date("November 9, 2018 21:40:18"), local_modified_timestamp: new Date(1541828418943), remote_created_timestamp: null, remote_modified_timestamp: new Date(1541828418943), children: Immutable.Set() }), "Game.Of.Big.Bang.Last.Week.Breaking.Valley.Episode.8.03": new ModelFile({ name: "Game.Of.Big.Bang.Last.Week.Breaking.Valley.Episode.8.03", is_dir: true, local_size: 970712825, remote_size: 970712825, state: ModelFile.State.DOWNLOADED, downloading_speed: null, eta: null, full_path: null, is_extractable: true, local_created_timestamp: new Date("November 9, 2018 21:40:18"), local_modified_timestamp: new Date(1541828418943), remote_created_timestamp: null, remote_modified_timestamp: new Date(1541828418943), children: Immutable.Set() }), "Green.Archer.Dude.And.Fast.Red.Streaky.Guy.Show": new ModelFile({ name: "Green.Archer.Dude.And.Fast.Red.Streaky.Guy.Show", is_dir: true, local_size: null, remote_size: 882722050, state: ModelFile.State.QUEUED, downloading_speed: null, eta: null, full_path: null, is_extractable: true, local_created_timestamp: new Date("November 9, 2018 21:40:18"), local_modified_timestamp: new Date(1541828418943), remote_created_timestamp: null, remote_modified_timestamp: new Date(1541828418943), children: Immutable.Set() }), "OneLongFileWithNoSpacesNoDotsNoHypensJustLotsAndLotsOfTextOhGodWhoNamedThisFileDamnIt": new ModelFile({ name: "OneLongFileWithNoSpacesNoDotsNoHypensJustLotsAndLotsOfTextOhGodWhoNamedThisFileDamnIt", is_dir: true, local_size: null, remote_size: 7086311523, state: ModelFile.State.DEFAULT, downloading_speed: null, eta: null, full_path: null, is_extractable: false, local_created_timestamp: new Date("November 9, 2018 21:40:18"), local_modified_timestamp: new Date(1541828418943), remote_created_timestamp: null, remote_modified_timestamp: new Date(1541828418943), children: Immutable.Set() }), "My Local File": new ModelFile({ name: "My Local File", is_dir: true, local_size: 86311523, remote_size: null, state: ModelFile.State.DEFAULT, downloading_speed: null, eta: null, full_path: null, is_extractable: false, local_created_timestamp: new Date("November 9, 2018 21:40:18"), local_modified_timestamp: new Date(1541828418943), remote_created_timestamp: null, remote_modified_timestamp: new Date(1541828418943), children: Immutable.Set() }), "This_File_Needs_To_Be_Resumed.exe": new ModelFile({ name: "This_File_Needs_To_Be_Resumed.exe", is_dir: false, local_size: 11111111, remote_size: 44444444, state: ModelFile.State.DEFAULT, downloading_speed: null, eta: null, full_path: null, is_extractable: false, local_created_timestamp: new Date("November 9, 2018 21:40:18"), local_modified_timestamp: new Date(1541828418943), remote_created_timestamp: null, remote_modified_timestamp: new Date(1541828418943), children: Immutable.Set() }), "Deleted Folder": new ModelFile({ name: "Deleted Folder", is_dir: true, local_size: null, remote_size: 1024, state: ModelFile.State.DELETED, downloading_speed: null, eta: null, full_path: null, is_extractable: false, local_created_timestamp: new Date("November 9, 2018 21:40:18"), local_modified_timestamp: new Date(1541828418943), remote_created_timestamp: null, remote_modified_timestamp: new Date(1541828418943), children: Immutable.Set() }), "my.local.archive.rar": new ModelFile({ name: "my.local.archive.rar", is_dir: false, local_size: 28000, remote_size: null, state: ModelFile.State.EXTRACTED, downloading_speed: null, eta: null, full_path: null, is_extractable: true, local_created_timestamp: new Date("November 9, 2018 21:40:18"), local_modified_timestamp: new Date(1541828418943), remote_created_timestamp: null, remote_modified_timestamp: new Date(1541828418943), children: Immutable.Set() }), "NextEpisode": new ModelFile({ name: "NextEpisode", is_dir: true, local_size: 1500000000, remote_size: 1000000000, state: ModelFile.State.EXTRACTING, downloading_speed: null, eta: null, full_path: null, is_extractable: true, local_created_timestamp: new Date("November 9, 2018 21:40:18"), local_modified_timestamp: new Date(1541828418943), remote_created_timestamp: null, remote_modified_timestamp: new Date(1541828418943), children: Immutable.Set() }), "PreviousEpisode": new ModelFile({ name: "PreviousEpisode", is_dir: true, local_size: 2000000000, remote_size: 1000000000, state: ModelFile.State.EXTRACTED, downloading_speed: null, eta: null, full_path: null, is_extractable: true, local_created_timestamp: new Date("November 9, 2018 21:40:18"), local_modified_timestamp: new Date(1541828418943), remote_created_timestamp: null, remote_modified_timestamp: new Date(1541828418943), children: Immutable.Set() }), }); ================================================ FILE: src/angular/src/app/services/files/model-file.service.ts ================================================ import {Injectable} from "@angular/core"; import {Observable} from "rxjs/Observable"; import {BehaviorSubject} from "rxjs/Rx"; import * as Immutable from "immutable"; import {LoggerService} from "../utils/logger.service"; import {ModelFile} from "./model-file"; import {BaseStreamService} from "../base/base-stream.service"; import {RestService, WebReaction} from "../utils/rest.service"; /** * ModelFileService class provides the store for model files * It implements the observable service pattern to push updates * as they become available. * The model is stored as an Immutable Map of name=>ModelFiles. Hence, the * ModelFiles have no defined order. The name key allows more efficient * lookup and model diffing. * Reference: http://blog.angular-university.io/how-to-build-angular2 * -apps-using-rxjs-observable-data-services-pitfalls-to-avoid */ @Injectable() export class ModelFileService extends BaseStreamService { private readonly EVENT_INIT = "model-init"; private readonly EVENT_ADDED = "model-added"; private readonly EVENT_UPDATED = "model-updated"; private readonly EVENT_REMOVED = "model-removed"; private _files: BehaviorSubject> = new BehaviorSubject(Immutable.Map()); constructor(private _logger: LoggerService, private _restService: RestService) { super(); this.registerEventName(this.EVENT_INIT); this.registerEventName(this.EVENT_ADDED); this.registerEventName(this.EVENT_UPDATED); this.registerEventName(this.EVENT_REMOVED); } get files(): Observable> { return this._files.asObservable(); } /** * Queue a file for download * @param {ModelFile} file * @returns {Observable} */ public queue(file: ModelFile): Observable { this._logger.debug("Queue model file: " + file.name); // Double-encode the value const fileNameEncoded = encodeURIComponent(encodeURIComponent(file.name)); const url: string = "/server/command/queue/" + fileNameEncoded; return this._restService.sendRequest(url); } /** * Stop a file * @param {ModelFile} file * @returns {Observable} */ public stop(file: ModelFile): Observable { this._logger.debug("Stop model file: " + file.name); // Double-encode the value const fileNameEncoded = encodeURIComponent(encodeURIComponent(file.name)); const url: string = "/server/command/stop/" + fileNameEncoded; return this._restService.sendRequest(url); } /** * Extract a file * @param {ModelFile} file * @returns {Observable} */ public extract(file: ModelFile): Observable { this._logger.debug("Extract model file: " + file.name); // Double-encode the value const fileNameEncoded = encodeURIComponent(encodeURIComponent(file.name)); const url: string = "/server/command/extract/" + fileNameEncoded; return this._restService.sendRequest(url); } /** * Delete file locally * @param {ModelFile} file * @returns {Observable} */ public deleteLocal(file: ModelFile): Observable { this._logger.debug("Delete locally model file: " + file.name); // Double-encode the value const fileNameEncoded = encodeURIComponent(encodeURIComponent(file.name)); const url: string = "/server/command/delete_local/" + fileNameEncoded; return this._restService.sendRequest(url); } /** * Delete file remotely * @param {ModelFile} file * @returns {Observable} */ public deleteRemote(file: ModelFile): Observable { this._logger.debug("Delete remotely model file: " + file.name); // Double-encode the value const fileNameEncoded = encodeURIComponent(encodeURIComponent(file.name)); const url: string = "/server/command/delete_remote/" + fileNameEncoded; return this._restService.sendRequest(url); } protected onEvent(eventName: string, data: string) { this.parseEvent(eventName, data); } protected onConnected() { // nothing to do } protected onDisconnected() { // Update clients by clearing the model this._files.next(this._files.getValue().clear()); } /** * Parse an event and update the file model * @param {string} name * @param {string} data */ private parseEvent(name: string, data: string) { if (name === this.EVENT_INIT) { // Init event receives an array of ModelFiles let t0: number; let t1: number; t0 = performance.now(); const parsed: [any] = JSON.parse(data); t1 = performance.now(); this._logger.debug("Parsing took", (t1 - t0).toFixed(0), "ms"); t0 = performance.now(); const newFiles: ModelFile[] = []; for (const file of parsed) { newFiles.push(ModelFile.fromJson(file)); } t1 = performance.now(); this._logger.debug("ModelFile creation took", (t1 - t0).toFixed(0), "ms"); // Replace the entire model t0 = performance.now(); const newMap = Immutable.Map(newFiles.map(value => ([value.name, value]))); t1 = performance.now(); this._logger.debug("ModelFile map creation took", (t1 - t0).toFixed(0), "ms"); this._files.next(newMap); // this._logger.debug("New model: %O", this._files.getValue().toJS()); } else if (name === this.EVENT_ADDED) { // Added event receives old and new ModelFiles // Only new file is relevant const parsed: {new_file: any} = JSON.parse(data); const file = ModelFile.fromJson(parsed.new_file); if (this._files.getValue().has(file.name)) { this._logger.error("ModelFile named " + file.name + " already exists"); } else { this._files.next(this._files.getValue().set(file.name, file)); this._logger.debug("Added file: %O", file.toJS()); } } else if (name === this.EVENT_REMOVED) { // Removed event receives old and new ModelFiles // Only old file is relevant const parsed: {old_file: any} = JSON.parse(data); const file = ModelFile.fromJson(parsed.old_file); if (this._files.getValue().has(file.name)) { this._files.next(this._files.getValue().remove(file.name)); this._logger.debug("Removed file: %O", file.toJS()); } else { this._logger.error("Failed to find ModelFile named " + file.name); } } else if (name === this.EVENT_UPDATED) { // Updated event received old and new ModelFiles // We will only use the new one here const parsed: {new_file: any} = JSON.parse(data); const file = ModelFile.fromJson(parsed.new_file); if (this._files.getValue().has(file.name)) { this._files.next(this._files.getValue().set(file.name, file)); this._logger.debug("Updated file: %O", file.toJS()); } else { this._logger.error("Failed to find ModelFile named " + file.name); } } else { this._logger.error("Unrecognized event:", name); } } } ================================================ FILE: src/angular/src/app/services/files/model-file.ts ================================================ import {Record, Set} from "immutable"; /** * Model file received from the backend * Note: Naming convention matches that used in the JSON */ interface IModelFile { name: string; is_dir: boolean; local_size: number; remote_size: number; state: ModelFile.State; downloading_speed: number; eta: number; full_path: string; is_extractable: boolean; local_created_timestamp: Date; local_modified_timestamp: Date; remote_created_timestamp: Date; remote_modified_timestamp: Date; children: Set; } // Boiler plate code to set up an immutable class const DefaultModelFile: IModelFile = { name: null, is_dir: null, local_size: null, remote_size: null, state: null, downloading_speed: null, eta: null, full_path: null, is_extractable: null, local_created_timestamp: null, local_modified_timestamp: null, remote_created_timestamp: null, remote_modified_timestamp: null, children: null }; const ModelFileRecord = Record(DefaultModelFile); /** * Immutable class that implements the interface * Pattern inspired by: http://blog.angular-university.io/angular-2-application * -architecture-building-flux-like-apps-using-redux-and * -immutable-js-js */ export class ModelFile extends ModelFileRecord implements IModelFile { name: string; is_dir: boolean; local_size: number; remote_size: number; state: ModelFile.State; downloading_speed: number; eta: number; full_path: string; is_extractable: boolean; local_created_timestamp: Date; local_modified_timestamp: Date; remote_created_timestamp: Date; remote_modified_timestamp: Date; children: Set; constructor(props) { super(props); } } // Additional types export module ModelFile { export function fromJson(json): ModelFile { // Create immutable objects for children as well const children: ModelFile[] = []; for (const child of json.children) { children.push(ModelFile.fromJson(child)); } json.children = Set(children); // State mapping json.state = ModelFile.State[json.state.toUpperCase()]; // Timestamps if (json.local_created_timestamp != null) { json.local_created_timestamp = new Date(1000 * +json.local_created_timestamp); } if (json.local_modified_timestamp != null) { json.local_modified_timestamp = new Date(1000 * +json.local_modified_timestamp); } if (json.remote_created_timestamp != null) { json.remote_created_timestamp = new Date(1000 * +json.remote_created_timestamp); } if (json.remote_modified_timestamp != null) { json.remote_modified_timestamp = new Date(1000 * +json.remote_modified_timestamp); } return new ModelFile(json); } export enum State { DEFAULT = "default", QUEUED = "queued", DOWNLOADING = "downloading", DOWNLOADED = "downloaded", DELETED = "deleted", EXTRACTING = "extracting", EXTRACTED = "extracted" } } ================================================ FILE: src/angular/src/app/services/files/screenshot-model-files.ts ================================================ import * as Immutable from "immutable"; import {ModelFile} from "./model-file"; export const SCREENSHOT_MODEL_FILES: Immutable.Map = Immutable.Map({ "A Really Cool Video About Cats.mkv": new ModelFile({ name: "A Really Cool Video About Cats.mkv", is_dir: false, local_size: 123644865, remote_size: 243644865, state: ModelFile.State.DOWNLOADING, downloading_speed: 512000, eta: 3612, full_path: null, is_extractable: false, children: Immutable.Set() }), "My Important Files": new ModelFile({ name: "My Important Files", is_dir: true, local_size: 123456, remote_size: 487241252, state: ModelFile.State.DOWNLOADING, downloading_speed: 1212000, eta: 1514, full_path: null, is_extractable: false, children: Immutable.Set() }), "That.Show.About.Dragons.8x03.1080p": new ModelFile({ name: "That.Show.About.Dragons.8x03.1080p", is_dir: true, local_size: 970712825, remote_size: 970712825, state: ModelFile.State.DOWNLOADED, downloading_speed: null, eta: null, full_path: null, is_extractable: true, children: Immutable.Set() }), "ubuntu-17.10-desktop-amd64.iso": new ModelFile({ name: "ubuntu-17.10-desktop-amd64.iso", is_dir: false, local_size: null, remote_size: 882722050, state: ModelFile.State.QUEUED, downloading_speed: null, eta: null, full_path: null, is_extractable: false, children: Immutable.Set() }), "My Local Folder": new ModelFile({ name: "My Local Folder", is_dir: true, local_size: 86311523, remote_size: null, state: ModelFile.State.DEFAULT, downloading_speed: null, eta: null, full_path: null, is_extractable: false, children: Immutable.Set() }), "My Remote Folder": new ModelFile({ name: "My Remote Folder", is_dir: true, local_size: null, remote_size: 7086311523, state: ModelFile.State.DEFAULT, downloading_speed: null, eta: null, full_path: null, is_extractable: false, children: Immutable.Set() }), "Dont.Download.This.Song.mp3": new ModelFile({ name: "Dont.Download.This.Song.mp3", is_dir: false, local_size: 11111111, remote_size: 44444444, state: ModelFile.State.DEFAULT, downloading_speed: null, eta: null, full_path: null, is_extractable: false, children: Immutable.Set() }), "Folder Deleted Locally": new ModelFile({ name: "Folder Deleted Locally", is_dir: true, local_size: null, remote_size: 1024, state: ModelFile.State.DELETED, downloading_speed: null, eta: null, full_path: null, is_extractable: false, children: Immutable.Set() }), "Folder Containing Archives": new ModelFile({ name: "Folder Containing Archives", is_dir: true, local_size: 1500000000, remote_size: 1000000000, state: ModelFile.State.EXTRACTING, downloading_speed: null, eta: null, full_path: null, is_extractable: true, children: Immutable.Set() }), "archive.rar": new ModelFile({ name: "archive.rar", is_dir: false, local_size: 1000000000, remote_size: 1000000000, state: ModelFile.State.EXTRACTED, downloading_speed: null, eta: null, full_path: null, is_extractable: true, children: Immutable.Set() }), }); ================================================ FILE: src/angular/src/app/services/files/view-file-filter.service.ts ================================================ import {Injectable} from "@angular/core"; import {LoggerService} from "../utils/logger.service"; import {ViewFile} from "./view-file"; import {ViewFileFilterCriteria, ViewFileService} from "./view-file.service"; import {ViewFileOptionsService} from "./view-file-options.service"; class AndFilterCriteria implements ViewFileFilterCriteria { constructor(private a: ViewFileFilterCriteria, private b: ViewFileFilterCriteria) { } meetsCriteria(viewFile: ViewFile): boolean { return this.a.meetsCriteria(viewFile) && this.b.meetsCriteria(viewFile); } } class StatusFilterCriteria implements ViewFileFilterCriteria { constructor(private _status: ViewFile.Status) {} get status(): ViewFile.Status { return this._status; } meetsCriteria(viewFile: ViewFile): boolean { return this._status == null || this._status === viewFile.status; } } class NameFilterCriteria implements ViewFileFilterCriteria { private _name: string = null; private _queryCandidates = []; get name(): string { return this._name; } constructor(name: string) { this._name = name; if (this._name != null) { const query = this._name.toLowerCase(); this._queryCandidates = [ query, // treat dots and spaces as the same query.replace(/\s/g, "."), query.replace(/\./g, " "), ]; } } meetsCriteria(viewFile: ViewFile): boolean { if (this._name == null || this._name === "") { return true; } const search = viewFile.name.toLowerCase(); return this._queryCandidates.reduce( (a: boolean, b: string) => a || search.indexOf(b) >= 0, false // initial value ); } } /** * ViewFileFilterService class provides filtering services for * view files * * This class responds to changes in the filter settings and * applies the appropriate filters to the ViewFileService */ @Injectable() export class ViewFileFilterService { private _statusFilter: StatusFilterCriteria = null; private _nameFilter: NameFilterCriteria = null; constructor(private _logger: LoggerService, private _viewFileService: ViewFileService, private _viewFileOptionsService: ViewFileOptionsService) { this._viewFileOptionsService.options.subscribe(options => { let updateFilterCriteria = false; // Check to see if status filter changed if (this._statusFilter == null || this._statusFilter.status !== options.selectedStatusFilter){ updateFilterCriteria = true; this._statusFilter = new StatusFilterCriteria(options.selectedStatusFilter); this._logger.debug("Status filter set to: " + options.selectedStatusFilter); } // Check to see if the name filter changed if (this._nameFilter == null || this._nameFilter.name !== options.nameFilter) { updateFilterCriteria = true; this._nameFilter = new NameFilterCriteria(options.nameFilter); this._logger.debug("Name filter set to: " + options.nameFilter); } // Update the filter criteria if necessary if (updateFilterCriteria) { this._viewFileService.setFilterCriteria(this.buildFilterCriteria()); } }); } private buildFilterCriteria(): ViewFileFilterCriteria { if (this._statusFilter != null && this._nameFilter != null) { return new AndFilterCriteria(this._statusFilter, this._nameFilter); } else if (this._statusFilter != null) { return this._statusFilter; } else if (this._nameFilter != null) { return this._nameFilter; } else { return null; } } } ================================================ FILE: src/angular/src/app/services/files/view-file-options.service.ts ================================================ import {Inject, Injectable} from "@angular/core"; import {Observable} from "rxjs/Observable"; import {BehaviorSubject} from "rxjs/Rx"; import {LoggerService} from "../utils/logger.service"; import {ViewFileOptions} from "./view-file-options"; import {ViewFile} from "./view-file"; import {LOCAL_STORAGE, StorageService} from "angular-webstorage-service"; import {StorageKeys} from "../../common/storage-keys"; /** * ViewFileOptionsService class provides display option services * for view files * * This class is used to broadcast changes to the display options */ @Injectable() export class ViewFileOptionsService { private _options: BehaviorSubject; constructor(private _logger: LoggerService, @Inject(LOCAL_STORAGE) private _storage: StorageService) { // Load some options from storage const showDetails: boolean = this._storage.get(StorageKeys.VIEW_OPTION_SHOW_DETAILS) || false; const sortMethod: ViewFileOptions.SortMethod = this._storage.get(StorageKeys.VIEW_OPTION_SORT_METHOD) || ViewFileOptions.SortMethod.STATUS; const pinFilter: boolean = this._storage.get(StorageKeys.VIEW_OPTION_PIN) || false; this._options = new BehaviorSubject( new ViewFileOptions({ showDetails: showDetails, sortMethod: sortMethod, selectedStatusFilter: null, nameFilter: null, pinFilter: pinFilter, }) ); } get options(): Observable { return this._options.asObservable(); } public setShowDetails(show: boolean) { const options = this._options.getValue(); if (options.showDetails !== show) { const newOptions = new ViewFileOptions(options.set("showDetails", show)); this._options.next(newOptions); this._storage.set(StorageKeys.VIEW_OPTION_SHOW_DETAILS, show); this._logger.debug("ViewOption showDetails set to: " + newOptions.showDetails); } } public setSortMethod(sortMethod: ViewFileOptions.SortMethod) { const options = this._options.getValue(); if (options.sortMethod !== sortMethod) { const newOptions = new ViewFileOptions(options.set("sortMethod", sortMethod)); this._options.next(newOptions); this._storage.set(StorageKeys.VIEW_OPTION_SORT_METHOD, sortMethod); this._logger.debug("ViewOption sortMethod set to: " + newOptions.sortMethod); } } public setSelectedStatusFilter(status: ViewFile.Status) { const options = this._options.getValue(); if (options.selectedStatusFilter !== status) { const newOptions = new ViewFileOptions(options.set("selectedStatusFilter", status)); this._options.next(newOptions); this._logger.debug("ViewOption selectedStatusFilter set to: " + newOptions.selectedStatusFilter); } } public setNameFilter(name: string) { const options = this._options.getValue(); if (options.nameFilter !== name) { const newOptions = new ViewFileOptions(options.set("nameFilter", name)); this._options.next(newOptions); this._logger.debug("ViewOption nameFilter set to: " + newOptions.nameFilter); } } public setPinFilter(pinned: boolean) { const options = this._options.getValue(); if (options.pinFilter !== pinned) { const newOptions = new ViewFileOptions(options.set("pinFilter", pinned)); this._options.next(newOptions); this._storage.set(StorageKeys.VIEW_OPTION_PIN, pinned); this._logger.debug("ViewOption pinFilter set to: " + newOptions.pinFilter); } } } ================================================ FILE: src/angular/src/app/services/files/view-file-options.ts ================================================ import {Record} from "immutable"; import {ViewFile} from "./view-file"; /** * View file options * Describes display related options for view files */ interface IViewFileOptions { // Show additional details about the view file showDetails: boolean; // Method to use to sort the view file list sortMethod: ViewFileOptions.SortMethod; // Status filter setting selectedStatusFilter: ViewFile.Status; // Name filter setting nameFilter: string; // Track filter pin status pinFilter: boolean; } // Boiler plate code to set up an immutable class const DefaultViewFileOptions: IViewFileOptions = { showDetails: null, sortMethod: null, selectedStatusFilter: null, nameFilter: null, pinFilter: null, }; const ViewFileOptionsRecord = Record(DefaultViewFileOptions); /** * Immutable class that implements the interface */ export class ViewFileOptions extends ViewFileOptionsRecord implements IViewFileOptions { showDetails: boolean; sortMethod: ViewFileOptions.SortMethod; selectedStatusFilter: ViewFile.Status; nameFilter: string; pinFilter: boolean; constructor(props) { super(props); } } export module ViewFileOptions { export enum SortMethod { STATUS, NAME_ASC, NAME_DESC } } ================================================ FILE: src/angular/src/app/services/files/view-file-sort.service.ts ================================================ import {Injectable} from "@angular/core"; import {LoggerService} from "../utils/logger.service"; import {ViewFile} from "./view-file"; import {ViewFileComparator, ViewFileService} from "./view-file.service"; import {ViewFileOptionsService} from "./view-file-options.service"; import {ViewFileOptions} from "./view-file-options"; /** * Comparator used to sort the ViewFiles * First, sorts by status. * Second, sorts by name. * @param {ViewFile} a * @param {ViewFile} b * @returns {number} * @private */ const StatusComparator: ViewFileComparator = (a: ViewFile, b: ViewFile): number => { if (a.status !== b.status) { const statusPriorities = { [ViewFile.Status.EXTRACTING]: 0, [ViewFile.Status.DOWNLOADING]: 1, [ViewFile.Status.QUEUED]: 2, [ViewFile.Status.EXTRACTED]: 3, [ViewFile.Status.DOWNLOADED]: 4, [ViewFile.Status.STOPPED]: 5, [ViewFile.Status.DEFAULT]: 6, [ViewFile.Status.DELETED]: 6 // intermix deleted and default }; if (statusPriorities[a.status] !== statusPriorities[b.status]) { return statusPriorities[a.status] - statusPriorities[b.status]; } } return a.name.localeCompare(b.name); }; /** * Comparator used to sort the ViewFiles * Sort by name, ascending * @param {ViewFile} a * @param {ViewFile} b * @returns {number} * @constructor */ const NameAscendingComparator: ViewFileComparator = (a: ViewFile, b: ViewFile): number => { return a.name.localeCompare(b.name); }; /** * Comparator used to sort the ViewFiles * Sort by name, descending * @param {ViewFile} a * @param {ViewFile} b * @returns {number} * @constructor */ const NameDescendingComparator: ViewFileComparator = (a: ViewFile, b: ViewFile): number => { return b.name.localeCompare(a.name); }; /** * ViewFileSortService class provides sorting services for * view files * * This class responds to changes in the sort settings and * applies the appropriate comparators to the ViewFileService */ @Injectable() export class ViewFileSortService { private _sortMethod: ViewFileOptions.SortMethod = null; constructor(private _logger: LoggerService, private _viewFileService: ViewFileService, private _viewFileOptionsService: ViewFileOptionsService) { this._viewFileOptionsService.options.subscribe(options => { // Check if the sort method changed if (this._sortMethod !== options.sortMethod) { this._sortMethod = options.sortMethod; if (this._sortMethod === ViewFileOptions.SortMethod.STATUS) { this._viewFileService.setComparator(StatusComparator); this._logger.debug("Comparator set to: Status"); } else if (this._sortMethod === ViewFileOptions.SortMethod.NAME_DESC) { this._viewFileService.setComparator(NameDescendingComparator); this._logger.debug("Comparator set to: Name Desc"); } else if (this._sortMethod === ViewFileOptions.SortMethod.NAME_ASC) { this._viewFileService.setComparator(NameAscendingComparator); this._logger.debug("Comparator set to: Name Asc"); } else { this._viewFileService.setComparator(null); this._logger.debug("Comparator set to: null"); } } }); } } ================================================ FILE: src/angular/src/app/services/files/view-file.service.ts ================================================ import {Injectable} from "@angular/core"; import {Observable} from "rxjs/Observable"; import {BehaviorSubject} from "rxjs/Rx"; import * as Immutable from "immutable"; import {LoggerService} from "../utils/logger.service"; import {ModelFile} from "./model-file"; import {ModelFileService} from "./model-file.service"; import {ViewFile} from "./view-file"; import {MOCK_MODEL_FILES} from "./mock-model-files"; import {StreamServiceRegistry} from "../base/stream-service.registry"; import {WebReaction} from "../utils/rest.service"; /** * Interface defining filtering criteria for view files */ export interface ViewFileFilterCriteria { meetsCriteria(viewFile: ViewFile): boolean; } /** * Interface for sorting view files */ export interface ViewFileComparator { // noinspection TsLint (a: ViewFile, b: ViewFile): number; } /** * ViewFileService class provides the store of view files. * It implements the observable service pattern to push updates * as they become available. * * The view model needs to be ordered and have fast lookup/update. * Unfortunately, there exists no immutable SortedMap structure. * This class stores the following data structures: * 1. files: List(ViewFile) * ViewFiles sorted in the display order * 2. indices: Map(name, index) * Maps name to its index in sortedList * The runtime complexity of operations is: * 1. Update w/o state change: * O(1) to find index and update the sorted list * 2. Updates w/ state change: * O(1) to find index and update the sorted list * O(n log n) to sort list (might be faster since * list is mostly sorted already??) * O(n) to update indexMap * 3. Add: * O(1) to add to list * O(n log n) to sort list (might be faster since * list is mostly sorted already??) * O(n) to update indexMap * 4. Remove: * O(n) to remove from sorted list * O(n) to update indexMap * * Filtering: * This service also supports providing a filtered list of view files. * The strategy of using pipes to filter at the component level is not * recommended by Angular: https://angular.io/guide/pipes#appendix-no * -filterpipe-or-orderbypipe * Instead, we provide a separate filtered observer. * Filtering is controlled via a single filter criteria. Advanced filters * need to be built outside the service (see ViewFileFilterService) */ @Injectable() export class ViewFileService { private readonly USE_MOCK_MODEL = false; private modelFileService: ModelFileService; private _files: Immutable.List = Immutable.List([]); private _filesSubject: BehaviorSubject> = new BehaviorSubject(this._files); private _filteredFilesSubject: BehaviorSubject> = new BehaviorSubject(this._files); private _indices: Map = new Map(); private _prevModelFiles: Immutable.Map = Immutable.Map(); private _filterCriteria: ViewFileFilterCriteria = null; private _sortComparator: ViewFileComparator = null; constructor(private _logger: LoggerService, private _streamServiceRegistry: StreamServiceRegistry) { this.modelFileService = _streamServiceRegistry.modelFileService; const _viewFileService = this; if (!this.USE_MOCK_MODEL) { this.modelFileService.files.subscribe({ next: modelFiles => { let t0 = performance.now(); _viewFileService.buildViewFromModelFiles(modelFiles); let t1 = performance.now(); this._logger.debug("ViewFile creation took", (t1 - t0).toFixed(0), "ms"); } }); } else { // For layout/style testing this.buildViewFromModelFiles(MOCK_MODEL_FILES); } } private buildViewFromModelFiles(modelFiles: Immutable.Map) { this._logger.debug("Received next model files"); // Diff the previous domain model with the current domain model, then apply // those changes to the view model // This is a roughly O(2N) operation on every update, so won't scale well // But should be fine for small models // A more scalable solution would be to subscribe to domain model updates let newViewFiles = this._files; const addedNames: string[] = []; const removedNames: string[] = []; const updatedNames: string[] = []; // Loop through old model to find deletions this._prevModelFiles.keySeq().forEach( name => { if (!modelFiles.has(name)) { removedNames.push(name); } } ); // Loop through new model to find additions and updates modelFiles.keySeq().forEach( name => { if (!this._prevModelFiles.has(name)) { addedNames.push(name); } else if (!Immutable.is(modelFiles.get(name), this._prevModelFiles.get(name))) { updatedNames.push(name); } } ); let reSort = false; let updateIndices = false; // Do the updates first before indices change (re-sort may be required) updatedNames.forEach( name => { const index = this._indices.get(name); const oldViewFile = newViewFiles.get(index); const newViewFile = ViewFileService.createViewFile(modelFiles.get(name), oldViewFile.isSelected); newViewFiles = newViewFiles.set(index, newViewFile); if (this._sortComparator != null && this._sortComparator(oldViewFile, newViewFile) !== 0) { reSort = true; } } ); // Do the adds (requires re-sort) addedNames.forEach( name => { reSort = true; const viewFile = ViewFileService.createViewFile(modelFiles.get(name)); newViewFiles = newViewFiles.push(viewFile); this._indices.set(name, newViewFiles.size - 1); } ); // Do the removes (no re-sort required) removedNames.forEach( name => { updateIndices = true; const index = newViewFiles.findIndex(value => value.name === name); newViewFiles = newViewFiles.remove(index); this._indices.delete(name); } ); if (reSort && this._sortComparator != null) { this._logger.debug("Re-sorting view files"); updateIndices = true; newViewFiles = newViewFiles.sort(this._sortComparator).toList(); } if (updateIndices) { this._indices.clear(); newViewFiles.forEach( (value, index) => this._indices.set(value.name, index) ); } this._files = newViewFiles; this.pushViewFiles(); this._prevModelFiles = modelFiles; this._logger.debug("New view model: %O", this._files.toJS()); } get files(): Observable> { return this._filesSubject.asObservable(); } get filteredFiles(): Observable> { return this._filteredFilesSubject.asObservable(); } /** * Set a file to be in selected state * @param {ViewFile} file */ public setSelected(file: ViewFile) { // Find the selected file, if any // Note: we can optimize this by storing an additional // state that tracks the selected file // but that would duplicate state and can introduce // bugs, so we just search instead let viewFiles = this._files; const unSelectIndex = viewFiles.findIndex(value => value.isSelected); // Unset the previously selected file, if any if (unSelectIndex >= 0) { let unSelectViewFile = viewFiles.get(unSelectIndex); // Do nothing if file is already selected if (unSelectViewFile.name === file.name) { return; } unSelectViewFile = new ViewFile(unSelectViewFile.set("isSelected", false)); viewFiles = viewFiles.set(unSelectIndex, unSelectViewFile); } // Set the new selected file if (this._indices.has(file.name)) { const index = this._indices.get(file.name); let viewFile = viewFiles.get(index); viewFile = new ViewFile(viewFile.set("isSelected", true)); viewFiles = viewFiles.set(index, viewFile); } else { this._logger.error("Can't find file to select: " + file.name); } // Send update this._files = viewFiles; this.pushViewFiles(); } /** * Un-select the currently selected file */ public unsetSelected() { // Unset the previously selected file, if any let viewFiles = this._files; const unSelectIndex = viewFiles.findIndex(value => value.isSelected); // Unset the previously selected file, if any if (unSelectIndex >= 0) { let unSelectViewFile = viewFiles.get(unSelectIndex); unSelectViewFile = new ViewFile(unSelectViewFile.set("isSelected", false)); viewFiles = viewFiles.set(unSelectIndex, unSelectViewFile); // Send update this._files = viewFiles; this.pushViewFiles(); } } /** * Queue a file for download * @param {ViewFile} file * @returns {Observable} */ public queue(file: ViewFile): Observable { this._logger.debug("Queue view file: " + file.name); return this.createAction(file, (f) => this.modelFileService.queue(f)); } /** * Stop a file * @param {ViewFile} file * @returns {Observable} */ public stop(file: ViewFile): Observable { this._logger.debug("Stop view file: " + file.name); return this.createAction(file, (f) => this.modelFileService.stop(f)); } /** * Extract a file * @param {ViewFile} file * @returns {Observable} */ public extract(file: ViewFile): Observable { this._logger.debug("Extract view file: " + file.name); return this.createAction(file, (f) => this.modelFileService.extract(f)); } /** * Locally delete a file * @param {ViewFile} file * @returns {Observable} */ public deleteLocal(file: ViewFile): Observable { this._logger.debug("Locally delete view file: " + file.name); return this.createAction(file, (f) => this.modelFileService.deleteLocal(f)); } /** * Remotely delete a file * @param {ViewFile} file * @returns {Observable} */ public deleteRemote(file: ViewFile): Observable { this._logger.debug("Remotely delete view file: " + file.name); return this.createAction(file, (f) => this.modelFileService.deleteRemote(f)); } /** * Set a new filter criteria * @param {ViewFileFilterCriteria} criteria */ public setFilterCriteria(criteria: ViewFileFilterCriteria) { this._filterCriteria = criteria; this.pushViewFiles(); } /** * Sets a new comparator. * @param {ViewFileComparator} comparator */ public setComparator(comparator: ViewFileComparator) { this._sortComparator = comparator; // Re-sort and regenerate index cache this._logger.debug("Re-sorting view files"); let newViewFiles = this._files; if (this._sortComparator != null) { newViewFiles = newViewFiles.sort(this._sortComparator).toList(); } this._files = newViewFiles; this._indices.clear(); newViewFiles.forEach( (value, index) => this._indices.set(value.name, index) ); this.pushViewFiles(); } private static createViewFile(modelFile: ModelFile, isSelected: boolean = false): ViewFile { // Use zero for unknown sizes let localSize: number = modelFile.local_size; if (localSize == null) { localSize = 0; } let remoteSize: number = modelFile.remote_size; if (remoteSize == null) { remoteSize = 0; } let percentDownloaded: number = null; if (remoteSize > 0) { percentDownloaded = Math.trunc(100.0 * localSize / remoteSize); } else { percentDownloaded = 100; } // Translate the status let status = null; switch (modelFile.state) { case ModelFile.State.DEFAULT: { if (localSize > 0 && remoteSize > 0) { status = ViewFile.Status.STOPPED; } else { status = ViewFile.Status.DEFAULT; } break; } case ModelFile.State.QUEUED: { status = ViewFile.Status.QUEUED; break; } case ModelFile.State.DOWNLOADING: { status = ViewFile.Status.DOWNLOADING; break; } case ModelFile.State.DOWNLOADED: { status = ViewFile.Status.DOWNLOADED; break; } case ModelFile.State.DELETED: { status = ViewFile.Status.DELETED; break; } case ModelFile.State.EXTRACTING: { status = ViewFile.Status.EXTRACTING; break; } case ModelFile.State.EXTRACTED: { status = ViewFile.Status.EXTRACTED; break; } } const isQueueable: boolean = [ViewFile.Status.DEFAULT, ViewFile.Status.STOPPED, ViewFile.Status.DELETED].includes(status) && remoteSize > 0; const isStoppable: boolean = [ViewFile.Status.QUEUED, ViewFile.Status.DOWNLOADING].includes(status); const isExtractable: boolean = [ViewFile.Status.DEFAULT, ViewFile.Status.STOPPED, ViewFile.Status.DOWNLOADED, ViewFile.Status.EXTRACTED].includes(status) && localSize > 0; const isLocallyDeletable: boolean = [ViewFile.Status.DEFAULT, ViewFile.Status.STOPPED, ViewFile.Status.DOWNLOADED, ViewFile.Status.EXTRACTED].includes(status) && localSize > 0; const isRemotelyDeletable: boolean = [ViewFile.Status.DEFAULT, ViewFile.Status.STOPPED, ViewFile.Status.DOWNLOADED, ViewFile.Status.EXTRACTED, ViewFile.Status.DELETED].includes(status) && remoteSize > 0; return new ViewFile({ name: modelFile.name, isDir: modelFile.is_dir, localSize: localSize, remoteSize: remoteSize, percentDownloaded: percentDownloaded, status: status, downloadingSpeed: modelFile.downloading_speed, eta: modelFile.eta, fullPath: modelFile.full_path, isArchive: modelFile.is_extractable, isSelected: isSelected, isQueueable: isQueueable, isStoppable: isStoppable, isExtractable: isExtractable, isLocallyDeletable: isLocallyDeletable, isRemotelyDeletable: isRemotelyDeletable, localCreatedTimestamp: modelFile.local_created_timestamp, localModifiedTimestamp: modelFile.local_modified_timestamp, remoteCreatedTimestamp: modelFile.remote_created_timestamp, remoteModifiedTimestamp: modelFile.remote_modified_timestamp }); } /** * Helper method to execute an action on ModelFileService and generate a ViewFileReaction * @param {ViewFile} file * @param {Observable} action * @returns {Observable} */ private createAction(file: ViewFile, action: (file: ModelFile) => Observable) : Observable { return Observable.create(observer => { if (!this._prevModelFiles.has(file.name)) { // File not found, exit early this._logger.error("File to queue not found: " + file.name); observer.next(new WebReaction(false, null, `File '${file.name}' not found`)); } else { const modelFile = this._prevModelFiles.get(file.name); action(modelFile).subscribe(reaction => { this._logger.debug("Received model reaction: %O", reaction); observer.next(reaction); }); } }); } private pushViewFiles() { // Unfiltered files this._filesSubject.next(this._files); // Filtered files let filteredFiles = this._files; if (this._filterCriteria != null) { filteredFiles = Immutable.List( this._files.filter(f => this._filterCriteria.meetsCriteria(f)) ); } this._filteredFilesSubject.next(filteredFiles); } } ================================================ FILE: src/angular/src/app/services/files/view-file.ts ================================================ import {Record} from "immutable"; /** * View file * Represents the View Model */ interface IViewFile { name: string; isDir: boolean; localSize: number; remoteSize: number; percentDownloaded: number; status: ViewFile.Status; downloadingSpeed: number; eta: number; fullPath: string; isArchive: boolean; // corresponds to is_extractable in ModelFile isSelected: boolean; isQueueable: boolean; isStoppable: boolean; // whether file can be queued for extraction (independent of isArchive) isExtractable: boolean; isLocallyDeletable: boolean; isRemotelyDeletable: boolean; // timestamps localCreatedTimestamp: Date; localModifiedTimestamp: Date; remoteCreatedTimestamp: Date; remoteModifiedTimestamp: Date; } // Boiler plate code to set up an immutable class const DefaultViewFile: IViewFile = { name: null, isDir: null, localSize: null, remoteSize: null, percentDownloaded: null, status: null, downloadingSpeed: null, eta: null, fullPath: null, isArchive: null, isSelected: null, isQueueable: null, isStoppable: null, isExtractable: null, isLocallyDeletable: null, isRemotelyDeletable: null, localCreatedTimestamp: null, localModifiedTimestamp: null, remoteCreatedTimestamp: null, remoteModifiedTimestamp: null }; const ViewFileRecord = Record(DefaultViewFile); /** * Immutable class that implements the interface */ export class ViewFile extends ViewFileRecord implements IViewFile { name: string; isDir: boolean; localSize: number; remoteSize: number; percentDownloaded: number; status: ViewFile.Status; downloadingSpeed: number; eta: number; // noinspection JSUnusedGlobalSymbols fullPath: string; isArchive: boolean; isSelected: boolean; isQueueable: boolean; isStoppable: boolean; isExtractable: boolean; isLocallyDeletable: boolean; isRemotelyDeletable: boolean; localCreatedTimestamp: Date; localModifiedTimestamp: Date; remoteCreatedTimestamp: Date; remoteModifiedTimestamp: Date; constructor(props) { super(props); } } export module ViewFile { export enum Status { DEFAULT = "default", QUEUED = "queued", DOWNLOADING = "downloading", DOWNLOADED = "downloaded", STOPPED = "stopped", DELETED = "deleted", EXTRACTING = "extracting", EXTRACTED = "extracted" } } ================================================ FILE: src/angular/src/app/services/logs/log-record.ts ================================================ import {Record} from "immutable"; /** * LogRecord immutable */ interface ILogRecord { time: Date; level: LogRecord.Level; loggerName: string; message: string; exceptionTraceback: string; } const DefaultLogRecord: ILogRecord = { time: null, level: null, loggerName: null, message: null, exceptionTraceback: null, }; const LogRecordRecord = Record(DefaultLogRecord); export class LogRecord extends LogRecordRecord implements ILogRecord { time: Date; level: LogRecord.Level; loggerName: string; message: string; exceptionTraceback: string; constructor(props) { super(props); } } export module LogRecord { export function fromJson(json: LogRecordJson): LogRecord { return new LogRecord({ // str -> number, then sec -> ms time: new Date(1000 * +json.time), level: LogRecord.Level[json.level_name], loggerName: json.logger_name, message: json.message, exceptionTraceback: json.exc_tb }); } export enum Level { DEBUG = "DEBUG", INFO = "INFO", WARNING = "WARNING", ERROR = "ERROR", CRITICAL = "CRITICAL", } } /** * LogRecord as serialized by the backend. * Note: naming convention matches that used in JSON */ export interface LogRecordJson { time: number; level_name: string; logger_name: string; message: string; exc_tb: string; } ================================================ FILE: src/angular/src/app/services/logs/log.service.ts ================================================ import {Injectable} from "@angular/core"; import {Observable} from "rxjs/Observable"; import {ReplaySubject} from "rxjs/ReplaySubject"; import {BaseStreamService} from "../base/base-stream.service"; import {LogRecord} from "./log-record"; @Injectable() export class LogService extends BaseStreamService { private _logs: ReplaySubject = new ReplaySubject(); constructor() { super(); this.registerEventName("log-record"); } /** * Logs is a hot observable (i.e. no caching) * @returns {Observable} */ get logs(): Observable { return this._logs.asObservable(); } protected onEvent(eventName: string, data: string) { this._logs.next(LogRecord.fromJson(JSON.parse(data))); } protected onConnected() { // nothing to do } protected onDisconnected() { // nothing to do } } ================================================ FILE: src/angular/src/app/services/server/server-command.service.ts ================================================ import {Injectable} from "@angular/core"; import {Observable} from "rxjs/Observable"; import {BaseWebService} from "../base/base-web.service"; import {StreamServiceRegistry} from "../base/stream-service.registry"; import {RestService, WebReaction} from "../utils/rest.service"; /** * ServerCommandService handles sending commands to the backend server */ @Injectable() export class ServerCommandService extends BaseWebService { private readonly RESTART_URL = "/server/command/restart"; constructor(_streamServiceProvider: StreamServiceRegistry, private _restService: RestService) { super(_streamServiceProvider); } /** * Send a restart command to the server * @returns {Observable} */ public restart(): Observable { return this._restService.sendRequest(this.RESTART_URL); } protected onConnected() { // Nothing to do } protected onDisconnected() { // Nothing to do } } /** * ConfigService factory and provider */ export let serverCommandServiceFactory = ( _streamServiceRegistry: StreamServiceRegistry, _restService: RestService ) => { const serverCommandService = new ServerCommandService(_streamServiceRegistry, _restService); serverCommandService.onInit(); return serverCommandService; }; // noinspection JSUnusedGlobalSymbols export let ServerCommandServiceProvider = { provide: ServerCommandService, useFactory: serverCommandServiceFactory, deps: [StreamServiceRegistry, RestService] }; ================================================ FILE: src/angular/src/app/services/server/server-status.service.ts ================================================ import {Injectable} from "@angular/core"; import {Observable} from "rxjs/Observable"; import {BehaviorSubject} from "rxjs/Rx"; import {Localization} from "../../common/localization"; import {ServerStatus, ServerStatusJson} from "./server-status"; import {BaseStreamService} from "../base/base-stream.service"; @Injectable() export class ServerStatusService extends BaseStreamService { private _status: BehaviorSubject = new BehaviorSubject(new ServerStatus({ server: { up: false, errorMessage: Localization.Notification.STATUS_CONNECTION_WAITING } })); constructor() { super(); this.registerEventName("status"); } get status(): Observable { return this._status.asObservable(); } protected onEvent(eventName: string, data: string) { this.parseStatus(data); } protected onConnected() { // nothing to do } protected onDisconnected() { // Notify the clients this._status.next(new ServerStatus({ server: { up: false, errorMessage: Localization.Error.SERVER_DISCONNECTED } })); } /** * Parse an event and notify subscribers * @param {string} data */ private parseStatus(data: string) { const statusJson: ServerStatusJson = JSON.parse(data); const status = ServerStatus.fromJson(statusJson); this._status.next(status); } } ================================================ FILE: src/angular/src/app/services/server/server-status.ts ================================================ import {Record} from "immutable"; /** * ServerStatus immutable */ interface IServerStatus { server: { up: boolean; errorMessage: string; }; controller: { latestLocalScanTime: Date; latestRemoteScanTime: Date; latestRemoteScanFailed: boolean; latestRemoteScanError: string; }; } const DefaultServerStatus: IServerStatus = { server: { up: null, errorMessage: null }, controller: { latestLocalScanTime: null, latestRemoteScanTime: null, latestRemoteScanFailed: null, latestRemoteScanError: null } }; const ServerStatusRecord = Record(DefaultServerStatus); export class ServerStatus extends ServerStatusRecord implements IServerStatus { server: { up: boolean; errorMessage: string; }; controller: { latestLocalScanTime: Date; latestRemoteScanTime: Date; latestRemoteScanFailed: boolean; latestRemoteScanError: string; }; constructor(props) { super(props); } } export module ServerStatus { export function fromJson(json: ServerStatusJson): ServerStatus { let latestLocalScanTime: Date = null; if (json.controller.latest_local_scan_time != null) { // str -> number, then sec -> ms latestLocalScanTime = new Date(1000 * +json.controller.latest_local_scan_time); } let latestRemoteScanTime: Date = null; if (json.controller.latest_remote_scan_time != null) { // str -> number, then sec -> ms latestRemoteScanTime = new Date(1000 * +json.controller.latest_remote_scan_time); } return new ServerStatus({ server: { up: json.server.up, errorMessage: json.server.error_msg }, controller: { latestLocalScanTime: latestLocalScanTime, latestRemoteScanTime: latestRemoteScanTime, latestRemoteScanFailed: json.controller.latest_remote_scan_failed, latestRemoteScanError: json.controller.latest_remote_scan_error } }); } } /** * ServerStatus as serialized by the backend. * Note: naming convention matches that used in JSON */ export interface ServerStatusJson { server: { up: boolean; error_msg: string; }; controller: { latest_local_scan_time: string; latest_remote_scan_time: string; latest_remote_scan_failed: boolean; latest_remote_scan_error: string; }; } ================================================ FILE: src/angular/src/app/services/settings/config.service.ts ================================================ import {Injectable} from "@angular/core"; import {Observable} from "rxjs/Observable"; import {BehaviorSubject} from "rxjs/Rx"; import {Config, IConfig} from "./config"; import {LoggerService} from "../utils/logger.service"; import {BaseWebService} from "../base/base-web.service"; import {Localization} from "../../common/localization"; import {StreamServiceRegistry} from "../base/stream-service.registry"; import {RestService, WebReaction} from "../utils/rest.service"; /** * ConfigService provides the store for the config */ @Injectable() export class ConfigService extends BaseWebService { private readonly CONFIG_GET_URL = "/server/config/get"; // noinspection UnterminatedStatementJS private readonly CONFIG_SET_URL = (section, option, value) => `/server/config/set/${section}/${option}/${value}` private _config: BehaviorSubject = new BehaviorSubject(null); constructor(_streamServiceProvider: StreamServiceRegistry, private _restService: RestService, private _logger: LoggerService) { super(_streamServiceProvider); } /** * Returns an observable that provides that latest Config * @returns {Observable} */ get config(): Observable { return this._config.asObservable(); } /** * Sets a value in the config * @param {string} section * @param {string} option * @param value * @returns {WebReaction} */ public set(section: string, option: string, value: any): Observable { const valueStr: string = value; const currentConfig = this._config.getValue(); if (!currentConfig.has(section) || !currentConfig.get(section).has(option)) { return Observable.create(observer => { observer.next(new WebReaction(false, null, `Config has no option named ${section}.${option}`)); }); } else if (valueStr.length === 0) { return Observable.create(observer => { observer.next(new WebReaction( false, null, Localization.Notification.CONFIG_VALUE_BLANK(section, option)) ); }); } else { // Double-encode the value const valueEncoded = encodeURIComponent(encodeURIComponent(valueStr)); const url = this.CONFIG_SET_URL(section, option, valueEncoded); const obs = this._restService.sendRequest(url); obs.subscribe({ next: reaction => { if (reaction.success) { // Update our copy and notify clients const config = this._config.getValue(); const newConfig = new Config(config.updateIn([section, option], (_) => value)); this._config.next(newConfig); } } }); return obs; } } protected onConnected() { // Retry the get this.getConfig(); } protected onDisconnected() { // Send null config this._config.next(null); } private getConfig() { this._logger.debug("Getting config..."); this._restService.sendRequest(this.CONFIG_GET_URL).subscribe({ next: reaction => { if (reaction.success) { const config_json: IConfig = JSON.parse(reaction.data); this._config.next(new Config(config_json)); } else { this._config.next(null); } } }); } } /** * ConfigService factory and provider */ export let configServiceFactory = ( _streamServiceRegistry: StreamServiceRegistry, _restService: RestService, _logger: LoggerService ) => { const configService = new ConfigService(_streamServiceRegistry, _restService, _logger); configService.onInit(); return configService; }; // noinspection JSUnusedGlobalSymbols export let ConfigServiceProvider = { provide: ConfigService, useFactory: configServiceFactory, deps: [StreamServiceRegistry, RestService, LoggerService] }; ================================================ FILE: src/angular/src/app/services/settings/config.ts ================================================ import {Record} from "immutable"; /** * Backend config * Note: Naming convention matches that used in the JSON */ /* * GENERAL */ interface IGeneral { debug: boolean; } const DefaultGeneral: IGeneral = { debug: null }; const GeneralRecord = Record(DefaultGeneral); /* * LFTP */ interface ILftp { remote_address: string; remote_username: string; remote_password: string; remote_port: number; remote_path: string; local_path: string; remote_path_to_scan_script: string; use_ssh_key: boolean; num_max_parallel_downloads: number; num_max_parallel_files_per_download: number; num_max_connections_per_root_file: number; num_max_connections_per_dir_file: number; num_max_total_connections: number; use_temp_file: boolean; } const DefaultLftp: ILftp = { remote_address: null, remote_username: null, remote_password: null, remote_port: null, remote_path: null, local_path: null, remote_path_to_scan_script: null, use_ssh_key: null, num_max_parallel_downloads: null, num_max_parallel_files_per_download: null, num_max_connections_per_root_file: null, num_max_connections_per_dir_file: null, num_max_total_connections: null, use_temp_file: null, }; const LftpRecord = Record(DefaultLftp); /* * CONTROLLER */ interface IController { interval_ms_remote_scan: number; interval_ms_local_scan: number; interval_ms_downloading_scan: number; extract_path: string; use_local_path_as_extract_path: boolean; } const DefaultController: IController = { interval_ms_remote_scan: null, interval_ms_local_scan: null, interval_ms_downloading_scan: null, extract_path: null, use_local_path_as_extract_path: null, }; const ControllerRecord = Record(DefaultController); /* * WEB */ interface IWeb { port: number; } const DefaultWeb: IWeb = { port: null }; const WebRecord = Record(DefaultWeb); /* * AUTOQUEUE */ interface IAutoQueue { enabled: boolean; patterns_only: boolean; auto_extract: boolean; } const DefaultAutoQueue: IAutoQueue = { enabled: null, patterns_only: null, auto_extract: null, }; const AutoQueueRecord = Record(DefaultAutoQueue); /* * CONFIG */ export interface IConfig { general: IGeneral; lftp: ILftp; controller: IController; web: IWeb; autoqueue: IAutoQueue; } const DefaultConfig: IConfig = { general: null, lftp: null, controller: null, web: null, autoqueue: null, }; const ConfigRecord = Record(DefaultConfig); export class Config extends ConfigRecord implements IConfig { general: IGeneral; lftp: ILftp; controller: IController; web: IWeb; autoqueue: IAutoQueue; constructor(props) { // Create immutable members super({ general: GeneralRecord(props.general), lftp: LftpRecord(props.lftp), controller: ControllerRecord(props.controller), web: WebRecord(props.web), autoqueue: AutoQueueRecord(props.autoqueue) }); } } ================================================ FILE: src/angular/src/app/services/utils/connected.service.ts ================================================ import {Injectable} from "@angular/core"; import {Observable} from "rxjs/Observable"; import {BehaviorSubject} from "rxjs/Rx"; import {LoggerService} from "./logger.service"; import {BaseStreamService} from "../base/base-stream.service"; import {RestService} from "./rest.service"; /** * ConnectedService exposes the connection status to clients * as an Observable */ @Injectable() export class ConnectedService extends BaseStreamService { // For clients private _connectedSubject: BehaviorSubject = new BehaviorSubject(false); constructor() { super(); // No events to register } get connected(): Observable { return this._connectedSubject.asObservable(); } protected onEvent(eventName: string, data: string) { // Nothing to do } protected onConnected() { if(this._connectedSubject.getValue() === false) { this._connectedSubject.next(true); } } protected onDisconnected() { if(this._connectedSubject.getValue() === true) { this._connectedSubject.next(false); } } } ================================================ FILE: src/angular/src/app/services/utils/dom.service.ts ================================================ import {Injectable} from "@angular/core"; import {Observable} from "rxjs/Observable"; import {BehaviorSubject} from "rxjs/Rx"; /** * DomService facilitates inter-component communication related * to DOM updates */ @Injectable() export class DomService { private _headerHeight: BehaviorSubject = new BehaviorSubject(0); get headerHeight(): Observable{ return this._headerHeight.asObservable(); } public setHeaderHeight(height: number) { if(height !== this._headerHeight.getValue()) { this._headerHeight.next(height); } } } ================================================ FILE: src/angular/src/app/services/utils/logger.service.ts ================================================ import {Injectable} from "@angular/core"; @Injectable() export class LoggerService { public level: LoggerService.Level; constructor() { this.level = LoggerService.Level.DEBUG; } get debug() { if (this.level >= LoggerService.Level.DEBUG) { return console.debug.bind(console); } else { return () => {}; } } get info() { if (this.level >= LoggerService.Level.INFO) { return console.log.bind(console); } else { return () => {}; } } // noinspection JSUnusedGlobalSymbols get warn() { if (this.level >= LoggerService.Level.WARN) { return console.warn.bind(console); } else { return () => {}; } } get error() { if (this.level >= LoggerService.Level.ERROR) { return console.error.bind(console); } else { return () => {}; } } } export module LoggerService { export enum Level { ERROR, WARN, INFO, DEBUG, } } ================================================ FILE: src/angular/src/app/services/utils/notification.service.ts ================================================ import {Injectable} from "@angular/core"; import {Observable} from "rxjs/Observable"; import {BehaviorSubject} from "rxjs/Rx"; import * as Immutable from "immutable"; import {Notification} from "./notification"; /** * NotificationService manages which notifications are shown or hidden */ @Injectable() export class NotificationService { private _notifications: Immutable.List = Immutable.List([]); private _notificationsSubject: BehaviorSubject> = new BehaviorSubject(this._notifications); // noinspection UnterminatedStatementJS private _comparator = (a: Notification, b: Notification): number => { // First sort by level if (a.level !== b.level) { const statusPriorities = { [Notification.Level.DANGER]: 0, [Notification.Level.WARNING]: 1, [Notification.Level.INFO]: 2, [Notification.Level.SUCCESS]: 3, }; if (statusPriorities[a.level] !== statusPriorities[b.level]) { return statusPriorities[a.level] - statusPriorities[b.level]; } } // Then sort by timestamp return b.timestamp - a.timestamp; } constructor() {} get notifications(): Observable> { return this._notificationsSubject.asObservable(); } public show(notification: Notification) { const index = this._notifications.findIndex(value => Immutable.is(value, notification)); if (index < 0) { const notifications = this._notifications.push(notification); this._notifications = notifications.sort(this._comparator).toList(); this._notificationsSubject.next(this._notifications); } } public hide(notification: Notification) { const index = this._notifications.findIndex(value => Immutable.is(value, notification)); if (index >= 0) { this._notifications = this._notifications.remove(index); this._notificationsSubject.next(this._notifications); } } } ================================================ FILE: src/angular/src/app/services/utils/notification.ts ================================================ import {Record} from "immutable"; interface INotification { level: Notification.Level; text: string; timestamp: number; dismissible: boolean; } const DefaultNotification: INotification = { level: null, text: null, timestamp: null, dismissible: false, }; const NotificationRecord = Record(DefaultNotification); export class Notification extends NotificationRecord implements INotification { level: Notification.Level; text: string; timestamp: number; dismissible: boolean; constructor(props) { props.timestamp = Date.now(); super(props); } } export module Notification { export enum Level { SUCCESS = "success", INFO = "info", WARNING = "warning", DANGER = "danger", } } ================================================ FILE: src/angular/src/app/services/utils/rest.service.ts ================================================ import {Injectable} from "@angular/core"; import {HttpClient, HttpErrorResponse} from "@angular/common/http"; import {Observable} from "rxjs/Observable"; import {LoggerService} from "./logger.service"; /** * WebReaction encapsulates the response for an action * executed on a BaseWebService */ export class WebReaction { readonly success: boolean; readonly data: string; readonly errorMessage: string; constructor(success: boolean, data: string, errorMessage: string) { this.success = success; this.data = data; this.errorMessage = errorMessage; } } /** * RestService exposes the HTTP REST API to clients */ @Injectable() export class RestService { constructor(private _logger: LoggerService, private _http: HttpClient) { } /** * Send backend a request and generate a WebReaction response * @param {string} url * @returns {Observable} */ public sendRequest(url: string): Observable { return Observable.create(observer => { this._http.get(url, {responseType: "text"}) .subscribe( data => { this._logger.debug("%s http response: %s", url, data); observer.next(new WebReaction(true, data, null)); }, (err: HttpErrorResponse) => { let errorMessage = null; this._logger.debug("%s error: %O", url, err); if (err.error instanceof Event) { errorMessage = err.error.type; } else { errorMessage = err.error; } observer.next(new WebReaction(false, null, errorMessage)); } ); }).shareReplay(1); // shareReplay is needed to: // prevent duplicate http requests // share result with those that subscribe after the value was published // More info: https://blog.thoughtram.io/angular/2016/06/16/cold-vs-hot-observables.html } } ================================================ FILE: src/angular/src/app/services/utils/version-check.service.ts ================================================ import {Injectable} from "@angular/core"; import * as compareVersions from "compare-versions"; import {RestService} from "./rest.service"; import {LoggerService} from "./logger.service"; import {NotificationService} from "./notification.service"; import {Notification} from "./notification"; import {Localization} from "../../common/localization"; declare function require(moduleName: string): any; const { version: appVersion } = require("../../../../package.json"); /** * VersionCheckService checks for the latest version and * triggers a notification */ @Injectable() export class VersionCheckService { private readonly GITHUB_LATEST_RELEASE_URL = "https://api.github.com/repos/ipsingh06/seedsync/releases/latest"; constructor(private _restService: RestService, private _notifService: NotificationService, private _logger: LoggerService) { this.checkVersion(); } private checkVersion() { this._restService.sendRequest(this.GITHUB_LATEST_RELEASE_URL).subscribe({ next: reaction => { if (reaction.success) { let jsonResponse; let latestVersion; let url; try { jsonResponse = JSON.parse(reaction.data); latestVersion = jsonResponse.tag_name; url = jsonResponse.html_url; } catch (e) { this._logger.error("Unable to parse github response: %O", e); return; } const message = Localization.Notification.NEW_VERSION_AVAILABLE(url); this._logger.debug("Latest version: ", message); if (VersionCheckService.isVersionNewer(latestVersion)) { const notif = new Notification({ level: Notification.Level.INFO, dismissible: true, text: message }); this._notifService.show(notif); } } else { this._logger.warn("Unable to fetch latest version info: %O", reaction); } } }); } private static isVersionNewer(version: string): boolean { // Remove the 'v' at the beginning, if any version = version.replace(/^v/, ""); // Replace - with . version = version.replace(/-/g, "."); return compareVersions(version, appVersion) > 0; } } ================================================ FILE: src/angular/src/app/tests/mocks/mock-event-source.ts ================================================ declare let spyOn: any; export class MockEventSource { url: string; onopen: (event: Event) => any; onerror: (event: Event) => any; eventListeners: Map = new Map(); constructor(url: string) { this.url = url; } addEventListener(type: string, listener: EventListener) { this.eventListeners.set(type, listener); } close() {} } export function createMockEventSource(url: string): MockEventSource { let mockEventSource = new MockEventSource(url); spyOn(mockEventSource, 'addEventListener').and.callThrough(); spyOn(mockEventSource, 'close').and.callThrough(); return mockEventSource; } ================================================ FILE: src/angular/src/app/tests/mocks/mock-model-file.service.ts ================================================ import {Subject} from "rxjs/Subject"; import {Observable} from "rxjs/Observable"; import * as Immutable from "immutable"; import {ModelFile} from "../../services/files/model-file"; export class MockModelFileService { _files = new Subject>(); get files(): Observable> { return this._files.asObservable(); } } ================================================ FILE: src/angular/src/app/tests/mocks/mock-rest.service.ts ================================================ import {Observable} from "rxjs/Observable"; import {WebReaction} from "../../services/utils/rest.service"; export class MockRestService { public sendRequest(url: string): Observable { return null; } } ================================================ FILE: src/angular/src/app/tests/mocks/mock-storage.service.ts ================================================ export class MockStorageService { // noinspection JSUnusedLocalSymbols public get(key: string): any {} // noinspection JSUnusedLocalSymbols set(key: string, value: any): void {} // noinspection JSUnusedLocalSymbols remove(key: string): void {} } ================================================ FILE: src/angular/src/app/tests/mocks/mock-stream-service.registry.ts ================================================ import {TestBed} from "@angular/core/testing"; import {ConnectedService} from "../../services/utils/connected.service"; import {MockModelFileService} from "./mock-model-file.service"; export class MockStreamServiceRegistry { // Real connected service connectedService = TestBed.get(ConnectedService); // Fake model file service modelFileService = new MockModelFileService(); connect() { this.connectedService.notifyConnected(); } disconnect() { this.connectedService.notifyDisconnected(); } } ================================================ FILE: src/angular/src/app/tests/mocks/mock-view-file-options.service.ts ================================================ import {Subject} from "rxjs/Subject"; import {Observable} from "rxjs/Observable"; import {ViewFileOptions} from "../../services/files/view-file-options"; export class MockViewFileOptionsService { _options = new Subject(); get options(): Observable { return this._options.asObservable(); } } ================================================ FILE: src/angular/src/app/tests/mocks/mock-view-file.service.ts ================================================ import {Subject} from "rxjs/Subject"; import {Observable} from "rxjs/Observable"; import * as Immutable from "immutable"; import {ViewFile} from "../../services/files/view-file"; import {ViewFileComparator, ViewFileFilterCriteria} from "../../services/files/view-file.service"; export class MockViewFileService { _files = new Subject>(); _filteredFiles = new Subject>(); get files(): Observable> { return this._files.asObservable(); } get filteredFiles(): Observable> { return this._filteredFiles.asObservable(); } // noinspection JSUnusedLocalSymbols public setFilterCriteria(criteria: ViewFileFilterCriteria) {} // noinspection JSUnusedLocalSymbols public setComparator(comparator: ViewFileComparator) {} } ================================================ FILE: src/angular/src/app/tests/unittests/services/autoqueue/autoqueue.service.spec.ts ================================================ import {fakeAsync, TestBed, tick} from "@angular/core/testing"; import {HttpClientTestingModule, HttpTestingController} from "@angular/common/http/testing"; import * as Immutable from "immutable"; import {LoggerService} from "../../../../services/utils/logger.service"; import {AutoQueueService} from "../../../../services/autoqueue/autoqueue.service"; import {AutoQueuePattern} from "../../../../services/autoqueue/autoqueue-pattern"; import {StreamServiceRegistry} from "../../../../services/base/stream-service.registry"; import {MockStreamServiceRegistry} from "../../../mocks/mock-stream-service.registry"; import {RestService} from "../../../../services/utils/rest.service"; import {ConnectedService} from "../../../../services/utils/connected.service"; // noinspection JSUnusedLocalSymbols const DoNothing = {next: reaction => {}}; describe("Testing autoqueue service", () => { let mockRegistry: MockStreamServiceRegistry; let httpMock: HttpTestingController; let aqService: AutoQueueService; beforeEach(fakeAsync(() => { TestBed.configureTestingModule({ imports: [ HttpClientTestingModule ], providers: [ AutoQueueService, LoggerService, RestService, ConnectedService, {provide: StreamServiceRegistry, useClass: MockStreamServiceRegistry} ] }); mockRegistry = TestBed.get(StreamServiceRegistry); httpMock = TestBed.get(HttpTestingController); aqService = TestBed.get(AutoQueueService); // Connect the services mockRegistry.connect(); // Finish the init aqService.onInit(); })); it("should create an instance", () => { expect(aqService).toBeDefined(); }); it("should parse patterns json correctly", fakeAsync(() => { const patternsJson = [ {"pattern": "one"}, {"pattern": "tw o"}, {"pattern": "th'ree"}, {"pattern": "fo\"ur"}, {"pattern": "fi%ve"}, ]; httpMock.expectOne("/server/autoqueue/get").flush(patternsJson); const expectedPatterns = [ new AutoQueuePattern({pattern: "one"}), new AutoQueuePattern({pattern: "tw o"}), new AutoQueuePattern({pattern: "th'ree"}), new AutoQueuePattern({pattern: "fo\"ur"}), new AutoQueuePattern({pattern: "fi%ve"}) ]; let actualCount = 0; aqService.patterns.subscribe({ next: patterns => { expect(patterns.size).toBe(5); for (let i = 0; i < patterns.size; i++) { expect(Immutable.is(patterns.get(i), expectedPatterns[i])).toBe(true); } actualCount++; } }); tick(); expect(actualCount).toBe(1); httpMock.verify(); })); it("should get empty list on get error 404", fakeAsync(() => { httpMock.expectOne("/server/autoqueue/get").flush( "Not found", {status: 404, statusText: "Bad Request"} ); let actualCount = 0; aqService.patterns.subscribe({ next: patterns => { expect(patterns.size).toBe(0); actualCount++; } }); tick(); expect(actualCount).toBe(1); httpMock.verify(); })); it("should get empty list on get network error", fakeAsync(() => { httpMock.expectOne("/server/autoqueue/get").error(new ErrorEvent("mock error")); let actualCount = 0; aqService.patterns.subscribe({ next: patterns => { expect(patterns.size).toBe(0); actualCount++; } }); tick(); expect(actualCount).toBe(1); httpMock.verify(); })); it("should get empty list on disconnect", fakeAsync(() => { const patternsJson = [ {"pattern": "one"} ]; httpMock.expectOne("/server/autoqueue/get").flush(patternsJson); const expectedPatterns = [ Immutable.List([new AutoQueuePattern({pattern: "one"})]), Immutable.List([]) ]; let actualCount = 0; aqService.patterns.subscribe({ next: patterns => { expect(Immutable.is(patterns, expectedPatterns[actualCount++])).toBe(true); } }); tick(); // status disconnect mockRegistry.disconnect(); tick(); expect(actualCount).toBe(2); httpMock.verify(); })); it("should retry GET on disconnect", fakeAsync(() => { // first connect httpMock.expectOne("/server/autoqueue/get").flush("[]"); tick(); // status disconnect mockRegistry.disconnect(); tick(); // status reconnect mockRegistry.connect(); tick(); httpMock.expectOne("/server/autoqueue/get").flush("[]"); tick(); httpMock.verify(); })); it("should send a GET on add pattern", fakeAsync(() => { httpMock.expectOne("/server/autoqueue/get").flush([]); let actualCount = 0; aqService.add("one").subscribe({ next: reaction => { expect(reaction.success).toBe(true); actualCount++; } }); httpMock.expectOne("/server/autoqueue/add/one").flush("{}"); tick(); expect(actualCount).toBe(1); httpMock.verify(); })); it("should send correct GET requests on add pattern", fakeAsync(() => { httpMock.expectOne("/server/autoqueue/get").flush([]); aqService.add("test").subscribe(DoNothing); httpMock.expectOne("/server/autoqueue/add/test").flush("{}"); aqService.add("test space").subscribe(DoNothing); httpMock.expectOne("/server/autoqueue/add/test%2520space").flush("{}"); aqService.add("test/slash").subscribe(DoNothing); httpMock.expectOne("/server/autoqueue/add/test%252Fslash").flush("{}"); aqService.add("test\"doublequote").subscribe(DoNothing); httpMock.expectOne("/server/autoqueue/add/test%2522doublequote").flush("{}"); aqService.add("/test/leadingslash").subscribe(DoNothing); httpMock.expectOne("/server/autoqueue/add/%252Ftest%252Fleadingslash").flush("{}"); httpMock.verify(); })); it("should return error on adding existing pattern", fakeAsync(() => { httpMock.expectOne("/server/autoqueue/get").flush([ {"pattern": "one"} ]); let actualCount = 0; aqService.add("one").subscribe({ next: reaction => { expect(reaction.success).toBe(false); expect(reaction.errorMessage).toBe("Pattern 'one' already exists."); actualCount++; } }); httpMock.expectNone("/server/autoqueue/add/one"); tick(); expect(actualCount).toBe(1); httpMock.verify(); })); it("should return error on adding empty pattern", fakeAsync(() => { httpMock.expectOne("/server/autoqueue/get").flush([]); let actualCount = 0; aqService.add("").subscribe({ next: reaction => { expect(reaction.success).toBe(false); expect(reaction.errorMessage).toBe("Cannot add an empty autoqueue pattern."); actualCount++; } }); aqService.add(" ").subscribe({ next: reaction => { expect(reaction.success).toBe(false); expect(reaction.errorMessage).toBe("Cannot add an empty autoqueue pattern."); actualCount++; } }); aqService.add(null).subscribe({ next: reaction => { expect(reaction.success).toBe(false); expect(reaction.errorMessage).toBe("Cannot add an empty autoqueue pattern."); actualCount++; } }); httpMock.expectNone("/server/autoqueue/add/"); tick(); expect(actualCount).toBe(3); httpMock.verify(); })); it("should send updated patterns after an add pattern", fakeAsync(() => { httpMock.expectOne("/server/autoqueue/get").flush([]); const expectedPatterns = [ Immutable.List([]), Immutable.List([new AutoQueuePattern({pattern: "one"})]) ]; let actualCount = 0; aqService.patterns.subscribe({ next: patterns => { expect(Immutable.is(patterns, expectedPatterns[actualCount++])).toBe(true); } }); aqService.add("one").subscribe(DoNothing); httpMock.expectOne("/server/autoqueue/add/one").flush("{}"); tick(); expect(actualCount).toBe(2); httpMock.verify(); })); it("should NOT send updated patterns after a failed add", fakeAsync(() => { httpMock.expectOne("/server/autoqueue/get").flush([ new AutoQueuePattern({pattern: "one"}) ]); const expectedPatterns = [ Immutable.List([new AutoQueuePattern({pattern: "one"})]) ]; let actualCount = 0; aqService.patterns.subscribe({ next: patterns => { expect(Immutable.is(patterns, expectedPatterns[actualCount++])).toBe(true); } }); aqService.add("one").subscribe(DoNothing); httpMock.expectNone("/server/autoqueue/add/one"); tick(); expect(actualCount).toBe(1); httpMock.verify(); })); it("should send a GET on remove pattern", fakeAsync(() => { httpMock.expectOne("/server/autoqueue/get").flush([ new AutoQueuePattern({pattern: "one"}) ]); let actualCount = 0; aqService.remove("one").subscribe({ next: reaction => { expect(reaction.success).toBe(true); actualCount++; } }); httpMock.expectOne("/server/autoqueue/remove/one").flush("{}"); tick(); expect(actualCount).toBe(1); httpMock.verify(); })); it("should send correct GET requests on remove pattern", fakeAsync(() => { httpMock.expectOne("/server/autoqueue/get").flush([ new AutoQueuePattern({pattern: "test"}), new AutoQueuePattern({pattern: "test space"}), new AutoQueuePattern({pattern: "test/slash"}), new AutoQueuePattern({pattern: "test\"doublequote"}), new AutoQueuePattern({pattern: "/test/leadingslash"}) ]); aqService.remove("test").subscribe(DoNothing); httpMock.expectOne("/server/autoqueue/remove/test").flush("{}"); aqService.remove("test space").subscribe(DoNothing); httpMock.expectOne("/server/autoqueue/remove/test%2520space").flush("{}"); aqService.remove("test/slash").subscribe(DoNothing); httpMock.expectOne("/server/autoqueue/remove/test%252Fslash").flush("{}"); aqService.remove("test\"doublequote").subscribe(DoNothing); httpMock.expectOne("/server/autoqueue/remove/test%2522doublequote").flush("{}"); aqService.remove("/test/leadingslash").subscribe(DoNothing); httpMock.expectOne("/server/autoqueue/remove/%252Ftest%252Fleadingslash").flush("{}"); httpMock.verify(); })); it("should return error on removing non-existing pattern", fakeAsync(() => { httpMock.expectOne("/server/autoqueue/get").flush([ ]); let actualCount = 0; aqService.remove("one").subscribe({ next: reaction => { expect(reaction.success).toBe(false); expect(reaction.errorMessage).toBe("Pattern 'one' not found."); actualCount++; } }); httpMock.expectNone("/server/autoqueue/remove/one"); tick(); expect(actualCount).toBe(1); httpMock.verify(); })); it("should return error on removing empty pattern", fakeAsync(() => { httpMock.expectOne("/server/autoqueue/get").flush([ ]); let actualCount = 0; aqService.remove("").subscribe({ next: reaction => { expect(reaction.success).toBe(false); expect(reaction.errorMessage).toBe("Pattern '' not found."); actualCount++; } }); aqService.remove(" ").subscribe({ next: reaction => { expect(reaction.success).toBe(false); expect(reaction.errorMessage).toBe("Pattern ' ' not found."); actualCount++; } }); aqService.remove(null).subscribe({ next: reaction => { expect(reaction.success).toBe(false); expect(reaction.errorMessage).toBe("Pattern 'null' not found."); actualCount++; } }); httpMock.expectNone("/server/autoqueue/remove/"); tick(); expect(actualCount).toBe(3); httpMock.verify(); })); it("should send updated patterns after a remove pattern", fakeAsync(() => { httpMock.expectOne("/server/autoqueue/get").flush([ new AutoQueuePattern({pattern: "one"}), new AutoQueuePattern({pattern: "two"}) ]); const expectedPatterns = [ Immutable.List([ new AutoQueuePattern({pattern: "one"}), new AutoQueuePattern({pattern: "two"}) ]), Immutable.List([ new AutoQueuePattern({pattern: "two"}) ]) ]; let actualCount = 0; aqService.patterns.subscribe({ next: patterns => { expect(Immutable.is(patterns, expectedPatterns[actualCount++])).toBe(true); } }); aqService.remove("one").subscribe(DoNothing); httpMock.expectOne("/server/autoqueue/remove/one").flush("{}"); tick(); expect(actualCount).toBe(2); httpMock.verify(); })); it("should NOT send updated patterns after a failed remove", fakeAsync(() => { httpMock.expectOne("/server/autoqueue/get").flush([ new AutoQueuePattern({pattern: "one"}) ]); const expectedPatterns = [ Immutable.List([new AutoQueuePattern({pattern: "one"})]) ]; let actualCount = 0; aqService.patterns.subscribe({ next: patterns => { expect(Immutable.is(patterns, expectedPatterns[actualCount++])).toBe(true); } }); aqService.remove("two").subscribe(DoNothing); httpMock.expectNone("/server/autoqueue/remove/two"); tick(); expect(actualCount).toBe(1); httpMock.verify(); })); }); ================================================ FILE: src/angular/src/app/tests/unittests/services/base/base-stream.service.spec.ts ================================================ import {TestBed} from "@angular/core/testing"; import {BaseStreamService} from "../../../../services/base/base-stream.service"; import {createMockEventSource, MockEventSource} from "../../../mocks/mock-event-source"; import {EventSourceFactory} from "../../../../services/base/stream-service.registry"; // noinspection JSUnusedLocalSymbols const DoNothing = {next: reaction => {}}; class TestBaseStreamService extends BaseStreamService { eventList = []; public registerEventName(eventName: string) { super.registerEventName(eventName); } protected onEvent(eventName: string, data: string) { console.log(eventName, data); this.eventList.push([eventName, data]); } public onConnected() {} public onDisconnected() {} } describe("Testing base stream service", () => { let baseStreamService: TestBaseStreamService; let mockEventSource: MockEventSource; beforeEach(() => { TestBed.configureTestingModule({ providers: [ TestBaseStreamService, ] }); spyOn(EventSourceFactory, 'createEventSource').and.callFake( (url: string) => { mockEventSource = createMockEventSource(url); return mockEventSource; } ); baseStreamService = TestBed.get(TestBaseStreamService); spyOn(baseStreamService, "onConnected"); spyOn(baseStreamService, "onDisconnected"); }); it("should create an instance", () => { expect(baseStreamService).toBeDefined(); }); it("should return all registered event names", () => { baseStreamService.registerEventName("event1"); baseStreamService.registerEventName("event2"); baseStreamService.registerEventName("event3"); expect(baseStreamService.getEventNames()).toEqual(["event1", "event2", "event3"]); }); it("should forward the event notifications", () => { baseStreamService.notifyEvent("event1", "data1"); expect(baseStreamService.eventList).toEqual([ ["event1", "data1"] ]); baseStreamService.notifyEvent("event2", "data2"); expect(baseStreamService.eventList).toEqual([ ["event1", "data1"], ["event2", "data2"] ]); baseStreamService.notifyEvent("event3", "data3"); expect(baseStreamService.eventList).toEqual([ ["event1", "data1"], ["event2", "data2"], ["event3", "data3"] ]); }); it("should forward the connected notifications", () => { baseStreamService.notifyConnected(); expect(baseStreamService.onConnected).toHaveBeenCalledTimes(1); baseStreamService.notifyConnected(); expect(baseStreamService.onConnected).toHaveBeenCalledTimes(2); }); it("should forward the disconnected notifications", () => { baseStreamService.notifyDisconnected(); expect(baseStreamService.onDisconnected).toHaveBeenCalledTimes(1); baseStreamService.notifyDisconnected(); expect(baseStreamService.onDisconnected).toHaveBeenCalledTimes(2); }); }); ================================================ FILE: src/angular/src/app/tests/unittests/services/base/base-web.service.spec.ts ================================================ import {TestBed} from "@angular/core/testing"; import {BaseWebService} from "../../../../services/base/base-web.service"; import {StreamServiceRegistry} from "../../../../services/base/stream-service.registry"; import {MockStreamServiceRegistry} from "../../../mocks/mock-stream-service.registry"; import {LoggerService} from "../../../../services/utils/logger.service"; import {ConnectedService} from "../../../../services/utils/connected.service"; // noinspection JSUnusedLocalSymbols const DoNothing = {next: reaction => {}}; class TestBaseWebService extends BaseWebService { public onConnected(): void {} public onDisconnected(): void {} } describe("Testing base web service", () => { let baseWebService: TestBaseWebService; let mockRegistry: MockStreamServiceRegistry; beforeEach(() => { TestBed.configureTestingModule({ providers: [ TestBaseWebService, LoggerService, ConnectedService, {provide: StreamServiceRegistry, useClass: MockStreamServiceRegistry} ] }); mockRegistry = TestBed.get(StreamServiceRegistry); baseWebService = TestBed.get(TestBaseWebService); spyOn(baseWebService, "onConnected"); spyOn(baseWebService, "onDisconnected"); // Initialize base web service baseWebService.onInit(); }); it("should create an instance", () => { expect(baseWebService).toBeDefined(); }); it("should forward the connected notification", () => { mockRegistry.connectedService.notifyConnected(); expect(baseWebService.onConnected).toHaveBeenCalledTimes(1); }); it("should forward the disconnected notification", () => { mockRegistry.connectedService.notifyDisconnected(); expect(baseWebService.onDisconnected).toHaveBeenCalledTimes(1); }); }); ================================================ FILE: src/angular/src/app/tests/unittests/services/base/stream-service.registry.spec.ts ================================================ import {fakeAsync, TestBed, tick} from "@angular/core/testing"; import {createMockEventSource, MockEventSource} from "../../../mocks/mock-event-source"; import {LoggerService} from "../../../../services/utils/logger.service"; import {EventSourceFactory, IStreamService, StreamDispatchService, StreamServiceRegistry} from "../../../../services/base/stream-service.registry"; import {ModelFileService} from "../../../../services/files/model-file.service"; import {ServerStatusService} from "../../../../services/server/server-status.service"; import {ConnectedService} from "../../../../services/utils/connected.service"; import {LogService} from "../../../../services/logs/log.service"; class MockStreamService implements IStreamService { eventList = []; connectedSeq = []; getEventNames(): string[] { throw new Error("Method not implemented."); } notifyConnected() { this.connectedSeq.push(true); } notifyDisconnected() { this.connectedSeq.push(false); } notifyEvent(eventName: string, data: string) { this.eventList.push([eventName, data]); } } describe("Testing stream dispatch service", () => { let dispatchService: StreamDispatchService; let mockEventSource: MockEventSource; let mockService1: MockStreamService; let mockService2: MockStreamService; beforeEach(fakeAsync(() => { TestBed.configureTestingModule({ providers: [ LoggerService, StreamDispatchService ] }); spyOn(EventSourceFactory, "createEventSource").and.callFake( (url: string) => { mockEventSource = createMockEventSource(url); return mockEventSource; } ); mockService1 = new MockStreamService(); mockService2 = new MockStreamService(); spyOn(mockService1, "getEventNames").and.returnValue(['event1a', 'event1b']); spyOn(mockService2, "getEventNames").and.returnValue(['event2a', 'event2b']); dispatchService = TestBed.get(StreamDispatchService); dispatchService.registerService(mockService1); dispatchService.registerService(mockService2); dispatchService.onInit(); tick(); })); it("should create an instance", () => { expect(dispatchService).toBeDefined(); }); it("should construct an event source with correct url", fakeAsync(() => { expect(mockEventSource.url).toBe("/server/stream"); })); it("should register all events with the event source", fakeAsync(() => { expect(mockEventSource.addEventListener).toHaveBeenCalledTimes(4); expect(mockEventSource.eventListeners.size).toBe(4); expect(mockEventSource.eventListeners.has("event1a")).toBe(true); expect(mockEventSource.eventListeners.has("event1b")).toBe(true); expect(mockEventSource.eventListeners.has("event2a")).toBe(true); expect(mockEventSource.eventListeners.has("event2b")).toBe(true); })); it("should set an error handler on the event source", fakeAsync(() => { expect(mockEventSource.onerror).toBeDefined(); })); it("should forward name and data correctly", fakeAsync(() => { mockEventSource.eventListeners.get("event1a")({data: "data1a"}); tick(); expect(mockService1.eventList).toEqual([ ["event1a", "data1a"] ]); expect(mockService2.eventList).toEqual([]); mockEventSource.eventListeners.get("event1b")({data: "data1b"}); tick(); expect(mockService1.eventList).toEqual([ ["event1a", "data1a"], ["event1b", "data1b"] ]); expect(mockService2.eventList).toEqual([]); mockEventSource.eventListeners.get("event2a")({data: "data2a"}); tick(); expect(mockService1.eventList).toEqual([ ["event1a", "data1a"], ["event1b", "data1b"] ]); expect(mockService2.eventList).toEqual([ ["event2a", "data2a"] ]); mockEventSource.eventListeners.get("event2b")({data: "data2b"}); tick(); expect(mockService1.eventList).toEqual([ ["event1a", "data1a"], ["event1b", "data1b"] ]); expect(mockService2.eventList).toEqual([ ["event2a", "data2a"], ["event2b", "data2b"] ]); mockEventSource.eventListeners.get("event1b")({data: "data1bbb"}); tick(); expect(mockService1.eventList).toEqual([ ["event1a", "data1a"], ["event1b", "data1b"], ["event1b", "data1bbb"] ]); expect(mockService2.eventList).toEqual([ ["event2a", "data2a"], ["event2b", "data2b"] ]); })); it("should call connect on open", fakeAsync(() => { mockEventSource.onopen(new Event("connected")); tick(); expect(mockService1.connectedSeq).toEqual([true]); expect(mockService2.connectedSeq).toEqual([true]); })); it("should call disconnect on error", fakeAsync(() => { mockEventSource.onerror(new Event("bad event")); tick(); expect(mockService1.connectedSeq).toEqual([false]); expect(mockService2.connectedSeq).toEqual([false]); tick(4000); })); it("should send events after reconnect", fakeAsync(() => { mockEventSource.onopen(new Event("connected")); tick(); mockEventSource.onerror(new Event("bad event")); tick(4000); mockEventSource.onopen(new Event("connected")); tick(); mockEventSource.eventListeners.get("event1a")({data: "data1a"}); tick(); expect(mockService1.eventList).toEqual([ ["event1a", "data1a"] ]); expect(mockService2.eventList).toEqual([]); mockEventSource.eventListeners.get("event2b")({data: "data2b"}); tick(); expect(mockService1.eventList).toEqual([["event1a", "data1a"]]); expect(mockService2.eventList).toEqual([["event2b", "data2b"]]); })); }); describe("Testing stream service registry", () => { let registry: StreamServiceRegistry; let mockDispatch; let mockModelFileService; let mockServerStatusService; let mockConnectedService; let mockLogService; let registered; beforeEach(() => { registered = []; mockDispatch = jasmine.createSpyObj("mockDispatch", ["registerService"]); mockDispatch.registerService.and.callFake(value => registered.push(value)); mockModelFileService = jasmine.createSpy("mockModelFileService"); mockServerStatusService = jasmine.createSpy("mockServerStatusService"); mockConnectedService = jasmine.createSpy("mockConnectedService"); mockLogService = jasmine.createSpy("mockLogService"); TestBed.configureTestingModule({ providers: [ StreamServiceRegistry, LoggerService, {provide: StreamDispatchService, useValue: mockDispatch}, {provide: ModelFileService, useValue: mockModelFileService}, {provide: ServerStatusService, useValue: mockServerStatusService}, {provide: ConnectedService, useValue: mockConnectedService}, {provide: LogService, useValue: mockLogService} ] }); registry = TestBed.get(StreamServiceRegistry); }); it("should create an instance", () => { expect(registry).toBeDefined(); }); it("should register model file service", () => { expect(registered.includes(mockModelFileService)).toBe(true); }); it("should register server status service", () => { expect(registered.includes(mockServerStatusService)).toBe(true); }); it("should register connected service", () => { expect(registered.includes(mockConnectedService)).toBe(true); }); it("should register log service", () => { expect(registered.includes(mockLogService)).toBe(true); }); }); ================================================ FILE: src/angular/src/app/tests/unittests/services/files/model-file.service.spec.ts ================================================ import {fakeAsync, TestBed, tick} from "@angular/core/testing"; import {HttpClientTestingModule, HttpTestingController} from "@angular/common/http/testing"; import * as Immutable from "immutable"; import {ModelFileService} from "../../../../services/files/model-file.service"; import {LoggerService} from "../../../../services/utils/logger.service"; import {ModelFile} from "../../../../services/files/model-file"; import {RestService} from "../../../../services/utils/rest.service"; // noinspection JSUnusedLocalSymbols const DoNothing = {next: reaction => {}}; describe("Testing model file service", () => { let modelFileService: ModelFileService; let httpMock: HttpTestingController; beforeEach(() => { TestBed.configureTestingModule({ imports: [ HttpClientTestingModule ], providers: [ LoggerService, RestService, ModelFileService ] }); httpMock = TestBed.get(HttpTestingController); modelFileService = TestBed.get(ModelFileService); }); it("should create an instance", () => { expect(modelFileService).toBeDefined(); }); it("should register all events with the event source", () => { expect(modelFileService.getEventNames()).toEqual( ["model-init", "model-added", "model-updated", "model-removed"] ); }); it("should send correct model on an init event", fakeAsync(() => { let count = 0; let latestModel: Immutable.Map = null; modelFileService.files.subscribe({ next: modelFiles => { count++; latestModel = modelFiles; } }); tick(); expect(count).toBe(1); expect(latestModel.size).toBe(0); let actualModelFiles = [ { name: "File.One", is_dir: false, local_size: 1234, remote_size: 4567, state: "default", downloading_speed: 99, eta: 54, full_path: "/full/path/to/file.one", children: [] } ]; let expectedModelFiles = [ new ModelFile({ name: "File.One", is_dir: false, local_size: 1234, remote_size: 4567, state: ModelFile.State.DEFAULT, downloading_speed: 99, eta: 54, full_path: "/full/path/to/file.one", children: Immutable.Set() }) ]; modelFileService.notifyEvent("model-init", JSON.stringify(actualModelFiles)); tick(); expect(count).toBe(2); expect(latestModel.size).toBe(1); expect(Immutable.is(latestModel.get("File.One"), expectedModelFiles[0])).toBe(true); })); it("should send correct model on an added event", fakeAsync(() => { let initialModelFiles = [ { name: "File.One", is_dir: false, local_size: 1234, remote_size: 4567, state: "default", downloading_speed: 99, eta: 54, full_path: "/full/path/to/file.one", children: [] } ]; modelFileService.notifyEvent("model-init", JSON.stringify(initialModelFiles)); let count = 0; let latestModel: Immutable.Map = null; modelFileService.files.subscribe({ next: modelFiles => { count++; latestModel = modelFiles; } }); tick(); expect(count).toBe(1); expect(latestModel.size).toBe(1); let addedModelFile = { new_file: { name: "File.Two", is_dir: false, local_size: 1234, remote_size: 4567, state: "default", downloading_speed: 99, eta: 54, full_path: "/full/path/to/file.two", children: [] }, old_file: {} }; let expectedModelFiles = [ new ModelFile({ name: "File.One", is_dir: false, local_size: 1234, remote_size: 4567, state: ModelFile.State.DEFAULT, downloading_speed: 99, eta: 54, full_path: "/full/path/to/file.one", children: Immutable.Set() }), new ModelFile({ name: "File.Two", is_dir: false, local_size: 1234, remote_size: 4567, state: ModelFile.State.DEFAULT, downloading_speed: 99, eta: 54, full_path: "/full/path/to/file.two", children: Immutable.Set() }) ]; modelFileService.notifyEvent("model-added", JSON.stringify(addedModelFile)); tick(); expect(count).toBe(2); expect(latestModel.size).toBe(2); expect(Immutable.is(latestModel.get("File.One"), expectedModelFiles[0])).toBe(true); expect(Immutable.is(latestModel.get("File.Two"), expectedModelFiles[1])).toBe(true); })); it("should send correct model on a removed event", fakeAsync(() => { let initialModelFiles = [ { name: "File.One", is_dir: false, local_size: 1234, remote_size: 4567, state: "default", downloading_speed: 99, eta: 54, full_path: "/full/path/to/file.one", children: [] } ]; modelFileService.notifyEvent("model-init", JSON.stringify(initialModelFiles)); let count = 0; let latestModel: Immutable.Map = null; modelFileService.files.subscribe({ next: modelFiles => { count++; latestModel = modelFiles; } }); tick(); expect(count).toBe(1); expect(latestModel.size).toBe(1); let removedModelFile = { new_file: {}, old_file: { name: "File.One", is_dir: false, local_size: 1234, remote_size: 4567, state: "default", downloading_speed: 99, eta: 54, full_path: "/full/path/to/file.one", children: [] } }; modelFileService.notifyEvent("model-removed", JSON.stringify(removedModelFile)); tick(); expect(count).toBe(2); expect(latestModel.size).toBe(0); })); it("should send correct model on an updated event", fakeAsync(() => { let initialModelFiles = [ { name: "File.One", is_dir: false, local_size: 1234, remote_size: 4567, state: "default", downloading_speed: 99, eta: 54, full_path: "/full/path/to/file.one", children: [] } ]; modelFileService.notifyEvent("model-init", JSON.stringify(initialModelFiles)); let count = 0; let latestModel: Immutable.Map = null; modelFileService.files.subscribe({ next: modelFiles => { count++; latestModel = modelFiles; } }); tick(); expect(count).toBe(1); expect(latestModel.size).toBe(1); let updatedModelFile = { new_file: { name: "File.One", is_dir: false, local_size: 4567, remote_size: 9012, state: "downloading", downloading_speed: 55, eta: 1, full_path: "/new/path/to/file.one", children: [] }, old_file: { name: "File.One", is_dir: false, local_size: 1234, remote_size: 4567, state: "default", downloading_speed: 99, eta: 54, full_path: "/full/path/to/file.one", children: [] } }; let expectedModelFiles = [ new ModelFile({ name: "File.One", is_dir: false, local_size: 4567, remote_size: 9012, state: ModelFile.State.DOWNLOADING, downloading_speed: 55, eta: 1, full_path: "/new/path/to/file.one", children: Immutable.Set() }) ]; modelFileService.notifyEvent("model-updated", JSON.stringify(updatedModelFile)); tick(); expect(count).toBe(2); expect(latestModel.size).toBe(1); expect(Immutable.is(latestModel.get("File.One"), expectedModelFiles[0])).toBe(true); })); it("should send empty model on disconnect", fakeAsync(() => { let count = 0; let latestModel: Immutable.Map = null; modelFileService.files.subscribe({ next: modelFiles => { count++; latestModel = modelFiles; } }); tick(); expect(count).toBe(1); expect(latestModel.size).toBe(0); modelFileService.notifyDisconnected(); tick(); expect(count).toBe(2); expect(latestModel.size).toBe(0); tick(4000); })); it("should send a GET on queue command", fakeAsync(() => { // Connect the service modelFileService.notifyConnected(); let modelFile = new ModelFile({ name: "File.One", is_dir: false, local_size: 4567, remote_size: 9012, state: ModelFile.State.DOWNLOADING, downloading_speed: 55, eta: 1, full_path: "/new/path/to/file.one", children: Immutable.Set() }); let count = 0; modelFileService.queue(modelFile).subscribe({ next: reaction => { expect(reaction.success).toBe(true); count++; } }); httpMock.expectOne("/server/command/queue/File.One").flush("done"); tick(); expect(count).toBe(1); httpMock.verify(); })); it("should send correct GET requests on queue command", fakeAsync(() => { // Connect the service modelFileService.notifyConnected(); let modelFile; modelFile = new ModelFile({ name: "test", state: ModelFile.State.DEFAULT, children: Immutable.Set() }); modelFileService.queue(modelFile).subscribe(DoNothing); httpMock.expectOne("/server/command/queue/test").flush("done"); modelFile = new ModelFile({ name: "test space", state: ModelFile.State.DEFAULT, children: Immutable.Set() }); modelFileService.queue(modelFile).subscribe(DoNothing); httpMock.expectOne("/server/command/queue/test%2520space").flush("done"); modelFile = new ModelFile({ name: "test/slash", state: ModelFile.State.DEFAULT, children: Immutable.Set() }); modelFileService.queue(modelFile).subscribe(DoNothing); httpMock.expectOne("/server/command/queue/test%252Fslash").flush("done"); modelFile = new ModelFile({ name: "test\"doublequote", state: ModelFile.State.DEFAULT, children: Immutable.Set() }); modelFileService.queue(modelFile).subscribe(DoNothing); httpMock.expectOne("/server/command/queue/test%2522doublequote").flush("done"); modelFile = new ModelFile({ name: "/test/leadingslash", state: ModelFile.State.DEFAULT, children: Immutable.Set() }); modelFileService.queue(modelFile).subscribe(DoNothing); httpMock.expectOne("/server/command/queue/%252Ftest%252Fleadingslash").flush("done"); })); it("should send a GET on stop command", fakeAsync(() => { // Connect the service modelFileService.notifyConnected(); let modelFile = new ModelFile({ name: "File.One", is_dir: false, local_size: 4567, remote_size: 9012, state: ModelFile.State.DOWNLOADING, downloading_speed: 55, eta: 1, full_path: "/new/path/to/file.one", children: Immutable.Set() }); let count = 0; modelFileService.stop(modelFile).subscribe({ next: reaction => { expect(reaction.success).toBe(true); count++; } }); httpMock.expectOne("/server/command/stop/File.One").flush("done"); tick(); expect(count).toBe(1); httpMock.verify(); })); it("should send correct GET requests on stop command", fakeAsync(() => { // Connect the service modelFileService.notifyConnected(); let modelFile; modelFile = new ModelFile({ name: "test", state: ModelFile.State.DEFAULT, children: Immutable.Set() }); modelFileService.stop(modelFile).subscribe(DoNothing); httpMock.expectOne("/server/command/stop/test").flush("done"); modelFile = new ModelFile({ name: "test space", state: ModelFile.State.DEFAULT, children: Immutable.Set() }); modelFileService.stop(modelFile).subscribe(DoNothing); httpMock.expectOne("/server/command/stop/test%2520space").flush("done"); modelFile = new ModelFile({ name: "test/slash", state: ModelFile.State.DEFAULT, children: Immutable.Set() }); modelFileService.stop(modelFile).subscribe(DoNothing); httpMock.expectOne("/server/command/stop/test%252Fslash").flush("done"); modelFile = new ModelFile({ name: "test\"doublequote", state: ModelFile.State.DEFAULT, children: Immutable.Set() }); modelFileService.stop(modelFile).subscribe(DoNothing); httpMock.expectOne("/server/command/stop/test%2522doublequote").flush("done"); modelFile = new ModelFile({ name: "/test/leadingslash", state: ModelFile.State.DEFAULT, children: Immutable.Set() }); modelFileService.stop(modelFile).subscribe(DoNothing); httpMock.expectOne("/server/command/stop/%252Ftest%252Fleadingslash").flush("done"); })); it("should send a GET on extract command", fakeAsync(() => { // Connect the service modelFileService.notifyConnected(); let modelFile = new ModelFile({ name: "File.One", is_dir: false, local_size: 4567, remote_size: 9012, state: ModelFile.State.DOWNLOADING, downloading_speed: 55, eta: 1, full_path: "/new/path/to/file.one", children: Immutable.Set() }); let count = 0; modelFileService.extract(modelFile).subscribe({ next: reaction => { expect(reaction.success).toBe(true); count++; } }); httpMock.expectOne("/server/command/extract/File.One").flush("done"); tick(); expect(count).toBe(1); httpMock.verify(); })); it("should send correct GET requests on extract command", fakeAsync(() => { // Connect the service modelFileService.notifyConnected(); let modelFile; modelFile = new ModelFile({ name: "test", state: ModelFile.State.DEFAULT, children: Immutable.Set() }); modelFileService.extract(modelFile).subscribe(DoNothing); httpMock.expectOne("/server/command/extract/test").flush("done"); modelFile = new ModelFile({ name: "test space", state: ModelFile.State.DEFAULT, children: Immutable.Set() }); modelFileService.extract(modelFile).subscribe(DoNothing); httpMock.expectOne("/server/command/extract/test%2520space").flush("done"); modelFile = new ModelFile({ name: "test/slash", state: ModelFile.State.DEFAULT, children: Immutable.Set() }); modelFileService.extract(modelFile).subscribe(DoNothing); httpMock.expectOne("/server/command/extract/test%252Fslash").flush("done"); modelFile = new ModelFile({ name: "test\"doublequote", state: ModelFile.State.DEFAULT, children: Immutable.Set() }); modelFileService.extract(modelFile).subscribe(DoNothing); httpMock.expectOne("/server/command/extract/test%2522doublequote").flush("done"); modelFile = new ModelFile({ name: "/test/leadingslash", state: ModelFile.State.DEFAULT, children: Immutable.Set() }); modelFileService.extract(modelFile).subscribe(DoNothing); httpMock.expectOne("/server/command/extract/%252Ftest%252Fleadingslash").flush("done"); })); it("should send a GET on delete local command", fakeAsync(() => { // Connect the service modelFileService.notifyConnected(); let modelFile = new ModelFile({ name: "File.One", is_dir: false, local_size: 4567, remote_size: 9012, state: ModelFile.State.DOWNLOADING, downloading_speed: 55, eta: 1, full_path: "/new/path/to/file.one", children: Immutable.Set() }); let count = 0; modelFileService.deleteLocal(modelFile).subscribe({ next: reaction => { expect(reaction.success).toBe(true); count++; } }); httpMock.expectOne("/server/command/delete_local/File.One").flush("done"); tick(); expect(count).toBe(1); httpMock.verify(); })); it("should send correct GET requests on delete local command", fakeAsync(() => { // Connect the service modelFileService.notifyConnected(); let modelFile; modelFile = new ModelFile({ name: "test", state: ModelFile.State.DEFAULT, children: Immutable.Set() }); modelFileService.deleteLocal(modelFile).subscribe(DoNothing); httpMock.expectOne("/server/command/delete_local/test").flush("done"); modelFile = new ModelFile({ name: "test space", state: ModelFile.State.DEFAULT, children: Immutable.Set() }); modelFileService.deleteLocal(modelFile).subscribe(DoNothing); httpMock.expectOne("/server/command/delete_local/test%2520space").flush("done"); modelFile = new ModelFile({ name: "test/slash", state: ModelFile.State.DEFAULT, children: Immutable.Set() }); modelFileService.deleteLocal(modelFile).subscribe(DoNothing); httpMock.expectOne("/server/command/delete_local/test%252Fslash").flush("done"); modelFile = new ModelFile({ name: "test\"doublequote", state: ModelFile.State.DEFAULT, children: Immutable.Set() }); modelFileService.deleteLocal(modelFile).subscribe(DoNothing); httpMock.expectOne("/server/command/delete_local/test%2522doublequote").flush("done"); modelFile = new ModelFile({ name: "/test/leadingslash", state: ModelFile.State.DEFAULT, children: Immutable.Set() }); modelFileService.deleteLocal(modelFile).subscribe(DoNothing); httpMock.expectOne("/server/command/delete_local/%252Ftest%252Fleadingslash").flush("done"); })); it("should send a GET on delete remote command", fakeAsync(() => { // Connect the service modelFileService.notifyConnected(); let modelFile = new ModelFile({ name: "File.One", is_dir: false, local_size: 4567, remote_size: 9012, state: ModelFile.State.DOWNLOADING, downloading_speed: 55, eta: 1, full_path: "/new/path/to/file.one", children: Immutable.Set() }); let count = 0; modelFileService.deleteRemote(modelFile).subscribe({ next: reaction => { expect(reaction.success).toBe(true); count++; } }); httpMock.expectOne("/server/command/delete_remote/File.One").flush("done"); tick(); expect(count).toBe(1); httpMock.verify(); })); it("should send correct GET requests on delete remote command", fakeAsync(() => { // Connect the service modelFileService.notifyConnected(); let modelFile; modelFile = new ModelFile({ name: "test", state: ModelFile.State.DEFAULT, children: Immutable.Set() }); modelFileService.deleteRemote(modelFile).subscribe(DoNothing); httpMock.expectOne("/server/command/delete_remote/test").flush("done"); modelFile = new ModelFile({ name: "test space", state: ModelFile.State.DEFAULT, children: Immutable.Set() }); modelFileService.deleteRemote(modelFile).subscribe(DoNothing); httpMock.expectOne("/server/command/delete_remote/test%2520space").flush("done"); modelFile = new ModelFile({ name: "test/slash", state: ModelFile.State.DEFAULT, children: Immutable.Set() }); modelFileService.deleteRemote(modelFile).subscribe(DoNothing); httpMock.expectOne("/server/command/delete_remote/test%252Fslash").flush("done"); modelFile = new ModelFile({ name: "test\"doublequote", state: ModelFile.State.DEFAULT, children: Immutable.Set() }); modelFileService.deleteRemote(modelFile).subscribe(DoNothing); httpMock.expectOne("/server/command/delete_remote/test%2522doublequote").flush("done"); modelFile = new ModelFile({ name: "/test/leadingslash", state: ModelFile.State.DEFAULT, children: Immutable.Set() }); modelFileService.deleteRemote(modelFile).subscribe(DoNothing); httpMock.expectOne("/server/command/delete_remote/%252Ftest%252Fleadingslash").flush("done"); })); }); ================================================ FILE: src/angular/src/app/tests/unittests/services/files/model-file.spec.ts ================================================ import * as Immutable from "immutable"; import {ModelFile} from "../../../../services/files/model-file"; describe("Testing model file initialization", () => { let baseJson; let baseModelFile; beforeEach(() => { baseJson = { name: "File.One", is_dir: false, local_size: 1234, remote_size: 4567, state: "default", downloading_speed: 99, eta: 54, full_path: "/full/path/to/file.one", is_extractable: true, local_created_timestamp: "1541828418.0", local_modified_timestamp: "1541828418.9439101", remote_created_timestamp: "1541828418.0", remote_modified_timestamp: "1541828418.9439101", children: [] }; baseModelFile = ModelFile.fromJson(baseJson); }); it("should be immutable", () => { expect(baseModelFile instanceof Immutable.Record).toBe(true); }); it("should have an immutable container of children", () => { expect(baseModelFile.children instanceof Immutable.Set).toBe(true); }); it("should correctly initialize all states", () => { baseJson.state = "default"; baseModelFile = ModelFile.fromJson(baseJson); expect(baseModelFile.state).toBe(ModelFile.State.DEFAULT); baseJson.state = "queued"; baseModelFile = ModelFile.fromJson(baseJson); expect(baseModelFile.state).toBe(ModelFile.State.QUEUED); baseJson.state = "downloading"; baseModelFile = ModelFile.fromJson(baseJson); expect(baseModelFile.state).toBe(ModelFile.State.DOWNLOADING); baseJson.state = "downloaded"; baseModelFile = ModelFile.fromJson(baseJson); expect(baseModelFile.state).toBe(ModelFile.State.DOWNLOADED); baseJson.state = "deleted"; baseModelFile = ModelFile.fromJson(baseJson); expect(baseModelFile.state).toBe(ModelFile.State.DELETED); baseJson.state = "extracting"; baseModelFile = ModelFile.fromJson(baseJson); expect(baseModelFile.state).toBe(ModelFile.State.EXTRACTING); baseJson.state = "extracted"; baseModelFile = ModelFile.fromJson(baseJson); expect(baseModelFile.state).toBe(ModelFile.State.EXTRACTED); }); it("should initialize with correct values", () => { expect(baseModelFile.name).toBe("File.One"); expect(baseModelFile.is_dir).toBe(false); expect(baseModelFile.local_size).toBe(1234); expect(baseModelFile.remote_size).toBe(4567); expect(baseModelFile.state).toBe(ModelFile.State.DEFAULT); expect(baseModelFile.downloading_speed).toBe(99); expect(baseModelFile.eta).toBe(54); expect(baseModelFile.full_path).toBe("/full/path/to/file.one"); expect(baseModelFile.is_extractable).toBe(true); expect(baseModelFile.local_created_timestamp).toEqual(new Date("November 9, 2018 21:40:18 PST")); expect(baseModelFile.local_modified_timestamp).toEqual(new Date(1541828418943)); expect(baseModelFile.remote_created_timestamp).toEqual(new Date("November 9, 2018 21:40:18 PST")); expect(baseModelFile.remote_modified_timestamp).toEqual(new Date(1541828418943)); expect(baseModelFile.children.size).toBe(0); }); it("should initialize null timestamps correctly", () => { baseJson.local_created_timestamp = null; baseModelFile = ModelFile.fromJson(baseJson); expect(baseModelFile.local_created_timestamp).toBeNull(); baseJson.local_modified_timestamp = null; baseModelFile = ModelFile.fromJson(baseJson); expect(baseModelFile.local_modified_timestamp).toBeNull(); baseJson.remote_created_timestamp = null; baseModelFile = ModelFile.fromJson(baseJson); expect(baseModelFile.remote_created_timestamp).toBeNull(); baseJson.remote_modified_timestamp = null; baseModelFile = ModelFile.fromJson(baseJson); expect(baseModelFile.remote_modified_timestamp).toBeNull(); }); it("should correctly initialize children", () => { baseJson.children = [ { name: "a", is_dir: true, local_size: 1, remote_size: 11, state: "default", downloading_speed: 111, eta: 1111, full_path: "root/a", is_extractable: true, children: [ { name: "aa", is_dir: false, local_size: 1, remote_size: 11, state: "default", downloading_speed: 111, eta: 1111, full_path: "root/a/aa", is_extractable: true, children: [] }, ] }, { name: "b", is_dir: false, local_size: 2, remote_size: 22, state: "default", downloading_speed: 222, eta: 2222, full_path: "root/b", is_extractable: false, children: [] } ]; baseModelFile = ModelFile.fromJson(baseJson); expect(baseModelFile.children.size).toBe(2); let a = baseModelFile.children.find(value => {return value.name === "a"}); expect(a.name).toBe("a"); expect(a.is_dir).toBe(true); expect(a.local_size).toBe(1); expect(a.remote_size).toBe(11); expect(a.state).toBe(ModelFile.State.DEFAULT); expect(a.downloading_speed).toBe(111); expect(a.eta).toBe(1111); expect(a.full_path).toBe("root/a"); expect(a.is_extractable).toBe(true); expect(a.children.size).toBe(1); let aa = a.children.find(value => {return value.name === "aa"}); expect(aa.name).toBe("aa"); expect(aa.is_dir).toBe(false); expect(aa.local_size).toBe(1); expect(aa.remote_size).toBe(11); expect(aa.state).toBe(ModelFile.State.DEFAULT); expect(aa.downloading_speed).toBe(111); expect(aa.eta).toBe(1111); expect(aa.full_path).toBe("root/a/aa"); expect(aa.is_extractable).toBe(true); expect(aa.children.size).toBe(0); let b = baseModelFile.children.find(value => {return value.name === "b"}); expect(b.name).toBe("b"); expect(b.is_dir).toBe(false); expect(b.local_size).toBe(2); expect(b.remote_size).toBe(22); expect(b.state).toBe(ModelFile.State.DEFAULT); expect(b.downloading_speed).toBe(222); expect(b.eta).toBe(2222); expect(b.full_path).toBe("root/b"); expect(b.is_extractable).toBe(false); expect(b.children.size).toBe(0); }); }); ================================================ FILE: src/angular/src/app/tests/unittests/services/files/view-file-filter.service.spec.ts ================================================ import {fakeAsync, TestBed, tick} from "@angular/core/testing"; import {ViewFileFilterService} from "../../../../services/files/view-file-filter.service"; import {LoggerService} from "../../../../services/utils/logger.service"; import {ViewFileFilterCriteria, ViewFileService} from "../../../../services/files/view-file.service"; import {MockViewFileService} from "../../../mocks/mock-view-file.service"; import {ViewFile} from "../../../../services/files/view-file"; import {ViewFileOptionsService} from "../../../../services/files/view-file-options.service"; import {MockViewFileOptionsService} from "../../../mocks/mock-view-file-options.service"; import {ViewFileOptions} from "../../../../services/files/view-file-options"; describe("Testing view file filter service", () => { let viewFilterService: ViewFileFilterService; let viewFileService: MockViewFileService; let viewFileOptionsService: MockViewFileOptionsService; let filterCriteria: ViewFileFilterCriteria; beforeEach(() => { TestBed.configureTestingModule({ providers: [ ViewFileFilterService, LoggerService, {provide: ViewFileService, useClass: MockViewFileService}, {provide: ViewFileOptionsService, useClass: MockViewFileOptionsService} ] }); viewFileService = TestBed.get(ViewFileService); spyOn(viewFileService, "setFilterCriteria").and.callFake( value => filterCriteria = value ); viewFileOptionsService = TestBed.get(ViewFileOptionsService); viewFilterService = TestBed.get(ViewFileFilterService); }); it("should create an instance", () => { expect(viewFilterService).toBeDefined(); }); it("does not set a filter criteria by default", () => { expect(viewFileService.setFilterCriteria).toHaveBeenCalledTimes(0); expect(filterCriteria).toBeUndefined(); }); it("calls setFilterCriteria on filter name set", fakeAsync(() => { expect(viewFileService.setFilterCriteria).toHaveBeenCalledTimes(0); viewFileOptionsService._options.next(new ViewFileOptions({ nameFilter: "something", })); tick(); expect(viewFileService.setFilterCriteria).toHaveBeenCalledTimes(1); })); it("does not call setFilterCriteria on duplicate filter names", fakeAsync(() => { expect(viewFileService.setFilterCriteria).toHaveBeenCalledTimes(0); viewFileOptionsService._options.next(new ViewFileOptions({ nameFilter: "something", })); tick(); expect(viewFileService.setFilterCriteria).toHaveBeenCalledTimes(1); viewFileOptionsService._options.next(new ViewFileOptions({ nameFilter: "something", })); tick(); expect(viewFileService.setFilterCriteria).toHaveBeenCalledTimes(1); })); it("does not call setFilterCriteria for filter name " + "when a different option is changed", fakeAsync(() => { expect(viewFileService.setFilterCriteria).toHaveBeenCalledTimes(0); viewFileOptionsService._options.next(new ViewFileOptions({ nameFilter: "something", showDetails: true, })); tick(); expect(viewFileService.setFilterCriteria).toHaveBeenCalledTimes(1); viewFileOptionsService._options.next(new ViewFileOptions({ nameFilter: "something", showDetails: false, })); tick(); expect(viewFileService.setFilterCriteria).toHaveBeenCalledTimes(1); })); it("correctly filters by name", fakeAsync(() => { viewFileOptionsService._options.next(new ViewFileOptions({ nameFilter: "tofu", })); tick(); // exact match expect(filterCriteria.meetsCriteria(new ViewFile({name: "tofu"}))).toBe(true); // no match expect(filterCriteria.meetsCriteria(new ViewFile({name: "flower"}))).toBe(false); // partial matches expect(filterCriteria.meetsCriteria(new ViewFile({name: "tofuflower"}))).toBe(true); expect(filterCriteria.meetsCriteria(new ViewFile({name: "flowertofu"}))).toBe(true); expect(filterCriteria.meetsCriteria(new ViewFile({name: "aaatofubbb"}))).toBe(true); // Another filter viewFileOptionsService._options.next(new ViewFileOptions({ nameFilter: "flower", })); tick(); // exact match expect(filterCriteria.meetsCriteria(new ViewFile({name: "flower"}))).toBe(true); // no match expect(filterCriteria.meetsCriteria(new ViewFile({name: "tofu"}))).toBe(false); // partial matches expect(filterCriteria.meetsCriteria(new ViewFile({name: "flowertofu"}))).toBe(true); expect(filterCriteria.meetsCriteria(new ViewFile({name: "tofuflower"}))).toBe(true); expect(filterCriteria.meetsCriteria(new ViewFile({name: "aaaflowerbbb"}))).toBe(true); })); it("ignores cases when filtering by name", fakeAsync(() => { viewFileOptionsService._options.next(new ViewFileOptions({ nameFilter: "tofu", })); tick(); expect(filterCriteria.meetsCriteria(new ViewFile({name: "TOFU"}))).toBe(true); expect(filterCriteria.meetsCriteria(new ViewFile({name: "TofU"}))).toBe(true); expect(filterCriteria.meetsCriteria(new ViewFile({name: "aaaToFubbb"}))).toBe(true); // Another filter viewFileOptionsService._options.next(new ViewFileOptions({ nameFilter: "flOweR", })); tick(); expect(filterCriteria.meetsCriteria(new ViewFile({name: "FLowEr"}))).toBe(true); expect(filterCriteria.meetsCriteria(new ViewFile({name: "tofuflowertofu"}))).toBe(true); expect(filterCriteria.meetsCriteria(new ViewFile({name: "floWer"}))).toBe(true); })); it("treats dots as spaces when filtering by name", fakeAsync(() => { viewFileOptionsService._options.next(new ViewFileOptions({ nameFilter: "to.fu", })); tick(); expect(filterCriteria.meetsCriteria(new ViewFile({name: "to.fu"}))).toBe(true); expect(filterCriteria.meetsCriteria(new ViewFile({name: "to fu"}))).toBe(true); // Another filter viewFileOptionsService._options.next(new ViewFileOptions({ nameFilter: "flo wer", })); tick(); expect(filterCriteria.meetsCriteria(new ViewFile({name: "flo wer"}))).toBe(true); expect(filterCriteria.meetsCriteria(new ViewFile({name: "flo.wer"}))).toBe(true); })); it("calls setFilterCriteria on filter status set", fakeAsync(() => { expect(viewFileService.setFilterCriteria).toHaveBeenCalledTimes(0); viewFileOptionsService._options.next(new ViewFileOptions({ selectedStatusFilter: ViewFile.Status.QUEUED })); tick(); expect(viewFileService.setFilterCriteria).toHaveBeenCalledTimes(1); })); it("does not call setFilterCriteria on duplicate filter status", fakeAsync(() => { expect(viewFileService.setFilterCriteria).toHaveBeenCalledTimes(0); viewFileOptionsService._options.next(new ViewFileOptions({ selectedStatusFilter: ViewFile.Status.QUEUED })); tick(); expect(viewFileService.setFilterCriteria).toHaveBeenCalledTimes(1); viewFileOptionsService._options.next(new ViewFileOptions({ selectedStatusFilter: ViewFile.Status.QUEUED })); tick(); expect(viewFileService.setFilterCriteria).toHaveBeenCalledTimes(1); })); it("does not call setFilterCriteria for filter status " + "when a different option is changed", fakeAsync(() => { expect(viewFileService.setFilterCriteria).toHaveBeenCalledTimes(0); viewFileOptionsService._options.next(new ViewFileOptions({ selectedStatusFilter: ViewFile.Status.QUEUED, showDetails: true, })); tick(); expect(viewFileService.setFilterCriteria).toHaveBeenCalledTimes(1); viewFileOptionsService._options.next(new ViewFileOptions({ selectedStatusFilter: ViewFile.Status.QUEUED, showDetails: false, })); tick(); expect(viewFileService.setFilterCriteria).toHaveBeenCalledTimes(1); })); it("correctly filters by status", fakeAsync(() => { viewFileOptionsService._options.next(new ViewFileOptions({ selectedStatusFilter: ViewFile.Status.DEFAULT, })); tick(); // exact match expect(filterCriteria.meetsCriteria( new ViewFile({status: ViewFile.Status.DEFAULT}))).toBe(true); // no match expect(filterCriteria.meetsCriteria( new ViewFile({status: ViewFile.Status.QUEUED}))).toBe(false); expect(filterCriteria.meetsCriteria( new ViewFile({status: ViewFile.Status.DOWNLOADING}))).toBe(false); expect(filterCriteria.meetsCriteria( new ViewFile({status: ViewFile.Status.DELETED}))).toBe(false); // Another filter viewFileOptionsService._options.next(new ViewFileOptions({ selectedStatusFilter: ViewFile.Status.EXTRACTING, })); tick(); // exact match expect(filterCriteria.meetsCriteria( new ViewFile({status: ViewFile.Status.EXTRACTING}))).toBe(true); // no match expect(filterCriteria.meetsCriteria( new ViewFile({status: ViewFile.Status.QUEUED}))).toBe(false); expect(filterCriteria.meetsCriteria( new ViewFile({status: ViewFile.Status.DOWNLOADING}))).toBe(false); expect(filterCriteria.meetsCriteria( new ViewFile({status: ViewFile.Status.DELETED}))).toBe(false); // Disable status filter viewFileOptionsService._options.next(new ViewFileOptions({ selectedStatusFilter: null, })); tick(); // all matches expect(filterCriteria.meetsCriteria( new ViewFile({status: ViewFile.Status.DEFAULT}))).toBe(true); expect(filterCriteria.meetsCriteria( new ViewFile({status: ViewFile.Status.QUEUED}))).toBe(true); expect(filterCriteria.meetsCriteria( new ViewFile({status: ViewFile.Status.DOWNLOADING}))).toBe(true); expect(filterCriteria.meetsCriteria( new ViewFile({status: ViewFile.Status.DOWNLOADED}))).toBe(true); expect(filterCriteria.meetsCriteria( new ViewFile({status: ViewFile.Status.STOPPED}))).toBe(true); expect(filterCriteria.meetsCriteria( new ViewFile({status: ViewFile.Status.DELETED}))).toBe(true); expect(filterCriteria.meetsCriteria( new ViewFile({status: ViewFile.Status.EXTRACTING}))).toBe(true); expect(filterCriteria.meetsCriteria( new ViewFile({status: ViewFile.Status.EXTRACTED}))).toBe(true); })); it("correctly filters by name AND status", fakeAsync(() => { viewFileOptionsService._options.next(new ViewFileOptions({ selectedStatusFilter: ViewFile.Status.DEFAULT, nameFilter: "tofu", })); tick(); // both match expect(filterCriteria.meetsCriteria(new ViewFile({ name: "tofu", status: ViewFile.Status.DEFAULT }))).toBe(true); // only one matches expect(filterCriteria.meetsCriteria(new ViewFile({ name: "flower", status: ViewFile.Status.DEFAULT }))).toBe(false); expect(filterCriteria.meetsCriteria(new ViewFile({ name: "tofu", status: ViewFile.Status.QUEUED }))).toBe(false); // neither matches expect(filterCriteria.meetsCriteria(new ViewFile({ name: "flower", status: ViewFile.Status.QUEUED }))).toBe(false); })); }); ================================================ FILE: src/angular/src/app/tests/unittests/services/files/view-file-options.service.spec.ts ================================================ import {fakeAsync, TestBed, tick} from "@angular/core/testing"; import {ViewFileOptionsService} from "../../../../services/files/view-file-options.service"; import {ViewFileOptions} from "../../../../services/files/view-file-options"; import {ViewFile} from "../../../../services/files/view-file"; import {LoggerService} from "../../../../services/utils/logger.service"; import {MockStorageService} from "../../../mocks/mock-storage.service"; import {LOCAL_STORAGE, StorageService} from "angular-webstorage-service"; import {StorageKeys} from "../../../../common/storage-keys"; function createViewOptionsService(): ViewFileOptionsService { return new ViewFileOptionsService( TestBed.get(LoggerService), TestBed.get(LOCAL_STORAGE) ); } describe("Testing view file options service", () => { let viewOptionsService: ViewFileOptionsService; let storageService: StorageService; beforeEach(() => { TestBed.configureTestingModule({ providers: [ ViewFileOptionsService, LoggerService, {provide: LOCAL_STORAGE, useClass: MockStorageService}, ] }); viewOptionsService = TestBed.get(ViewFileOptionsService); storageService = TestBed.get(LOCAL_STORAGE); }); it("should create an instance", () => { expect(viewOptionsService).toBeDefined(); }); it("should forward default options", fakeAsync(() => { let count = 0; viewOptionsService.options.subscribe({ next: options => { expect(options.showDetails).toBe(false); expect(options.sortMethod).toBe(ViewFileOptions.SortMethod.STATUS); expect(options.selectedStatusFilter).toBeNull(); expect(options.nameFilter).toBeNull(); expect(options.pinFilter).toBe(false); count++; } }); tick(); expect(count).toBe(1); })); it("should forward updates to showDetails", fakeAsync(() => { let count = 0; let showDetails = null; viewOptionsService.options.subscribe({ next: options => { showDetails = options.showDetails; count++; } }); tick(); expect(count).toBe(1); viewOptionsService.setShowDetails(true); tick(); expect(showDetails).toBe(true); expect(count).toBe(2); viewOptionsService.setShowDetails(false); tick(); expect(showDetails).toBe(false); expect(count).toBe(3); // Setting same value shouldn't trigger an update viewOptionsService.setShowDetails(false); tick(); expect(showDetails).toBe(false); expect(count).toBe(3); })); it("should load showDetails from storage", fakeAsync(() => { spyOn(storageService, "get").and.callFake(key => { if (key === StorageKeys.VIEW_OPTION_SHOW_DETAILS) { return true; } }); // Recreate the service viewOptionsService = createViewOptionsService(); expect(storageService.get).toHaveBeenCalledWith(StorageKeys.VIEW_OPTION_SHOW_DETAILS); let count = 0; let showDetails = null; viewOptionsService.options.subscribe({ next: options => { showDetails = options.showDetails; count++; } }); tick(); expect(count).toBe(1); expect(showDetails).toBe(true); })); it("should save showDetails to storage", fakeAsync(() => { spyOn(storageService, "set"); viewOptionsService.setShowDetails(true); expect(storageService.set).toHaveBeenCalledWith( StorageKeys.VIEW_OPTION_SHOW_DETAILS, true ); viewOptionsService.setShowDetails(false); expect(storageService.set).toHaveBeenCalledWith( StorageKeys.VIEW_OPTION_SHOW_DETAILS, false ); })); it("should forward updates to sortMethod", fakeAsync(() => { let count = 0; let sortMethod = null; viewOptionsService.options.subscribe({ next: options => { sortMethod = options.sortMethod; count++; } }); tick(); expect(count).toBe(1); viewOptionsService.setSortMethod(ViewFileOptions.SortMethod.NAME_ASC); tick(); expect(sortMethod).toBe(ViewFileOptions.SortMethod.NAME_ASC); expect(count).toBe(2); viewOptionsService.setSortMethod(ViewFileOptions.SortMethod.NAME_DESC); tick(); expect(sortMethod).toBe(ViewFileOptions.SortMethod.NAME_DESC); expect(count).toBe(3); // Setting same value shouldn't trigger an update viewOptionsService.setSortMethod(ViewFileOptions.SortMethod.NAME_DESC); tick(); expect(sortMethod).toBe(ViewFileOptions.SortMethod.NAME_DESC); expect(count).toBe(3); })); it("should load sortMethod from storage", fakeAsync(() => { spyOn(storageService, "get").and.callFake(key => { if (key === StorageKeys.VIEW_OPTION_SORT_METHOD) { return ViewFileOptions.SortMethod.NAME_ASC; } }); // Recreate the service viewOptionsService = createViewOptionsService(); expect(storageService.get).toHaveBeenCalledWith(StorageKeys.VIEW_OPTION_SHOW_DETAILS); let count = 0; let sortMethod = null; viewOptionsService.options.subscribe({ next: options => { sortMethod = options.sortMethod; count++; } }); tick(); expect(count).toBe(1); expect(sortMethod).toBe(ViewFileOptions.SortMethod.NAME_ASC); })); it("should save sortMethod to storage", fakeAsync(() => { spyOn(storageService, "set"); viewOptionsService.setSortMethod(ViewFileOptions.SortMethod.NAME_ASC); expect(storageService.set).toHaveBeenCalledWith( StorageKeys.VIEW_OPTION_SORT_METHOD, ViewFileOptions.SortMethod.NAME_ASC ); viewOptionsService.setSortMethod(ViewFileOptions.SortMethod.NAME_DESC); expect(storageService.set).toHaveBeenCalledWith( StorageKeys.VIEW_OPTION_SORT_METHOD, ViewFileOptions.SortMethod.NAME_DESC ); })); it("should forward updates to selectedStatusFilter", fakeAsync(() => { let count = 0; let selectedStatusFilter = null; viewOptionsService.options.subscribe({ next: options => { selectedStatusFilter = options.selectedStatusFilter; count++; } }); tick(); expect(count).toBe(1); viewOptionsService.setSelectedStatusFilter(ViewFile.Status.EXTRACTED); tick(); expect(selectedStatusFilter).toBe(ViewFile.Status.EXTRACTED); expect(count).toBe(2); viewOptionsService.setSelectedStatusFilter(ViewFile.Status.QUEUED); tick(); expect(selectedStatusFilter).toBe(ViewFile.Status.QUEUED); expect(count).toBe(3); // Setting same value shouldn't trigger an update viewOptionsService.setSelectedStatusFilter(ViewFile.Status.QUEUED); tick(); expect(selectedStatusFilter).toBe(ViewFile.Status.QUEUED); expect(count).toBe(3); // Null should be allowed viewOptionsService.setSelectedStatusFilter(null); tick(); expect(selectedStatusFilter).toBeNull(); expect(count).toBe(4); })); it("should forward updates to nameFilter", fakeAsync(() => { let count = 0; let nameFilter = null; viewOptionsService.options.subscribe({ next: options => { nameFilter = options.nameFilter; count++; } }); tick(); expect(count).toBe(1); viewOptionsService.setNameFilter("tofu"); tick(); expect(nameFilter).toBe("tofu"); expect(count).toBe(2); viewOptionsService.setNameFilter("flower"); tick(); expect(nameFilter).toBe("flower"); expect(count).toBe(3); // Setting same value shouldn't trigger an update viewOptionsService.setNameFilter("flower"); tick(); expect(nameFilter).toBe("flower"); expect(count).toBe(3); // Null should be allowed viewOptionsService.setNameFilter(null); tick(); expect(nameFilter).toBeNull(); expect(count).toBe(4); })); it("should forward updates to pinFilter", fakeAsync(() => { let count = 0; let pinFilter = null; viewOptionsService.options.subscribe({ next: options => { pinFilter = options.pinFilter; count++; } }); tick(); expect(count).toBe(1); viewOptionsService.setPinFilter(true); tick(); expect(pinFilter).toBe(true); expect(count).toBe(2); viewOptionsService.setPinFilter(false); tick(); expect(pinFilter).toBe(false); expect(count).toBe(3); // Setting same value shouldn't trigger an update viewOptionsService.setPinFilter(false); tick(); expect(pinFilter).toBe(false); expect(count).toBe(3); })); it("should load pinFilter from storage", fakeAsync(() => { spyOn(storageService, "get").and.callFake(key => { if (key === StorageKeys.VIEW_OPTION_PIN) { return true; } }); // Recreate the service viewOptionsService = createViewOptionsService(); expect(storageService.get).toHaveBeenCalledWith(StorageKeys.VIEW_OPTION_PIN); let count = 0; let pinFilter = null; viewOptionsService.options.subscribe({ next: options => { pinFilter = options.pinFilter; count++; } }); tick(); expect(count).toBe(1); expect(pinFilter).toBe(true); })); it("should save pinFilter to storage", fakeAsync(() => { spyOn(storageService, "set"); viewOptionsService.setPinFilter(true); expect(storageService.set).toHaveBeenCalledWith( StorageKeys.VIEW_OPTION_PIN, true ); viewOptionsService.setPinFilter(false); expect(storageService.set).toHaveBeenCalledWith( StorageKeys.VIEW_OPTION_PIN, false ); })); }); ================================================ FILE: src/angular/src/app/tests/unittests/services/files/view-file-sort.service.spec.ts ================================================ import {fakeAsync, TestBed, tick} from "@angular/core/testing"; import {LoggerService} from "../../../../services/utils/logger.service"; import {ViewFileSortService} from "../../../../services/files/view-file-sort.service"; import {MockViewFileService} from "../../../mocks/mock-view-file.service"; import {MockViewFileOptionsService} from "../../../mocks/mock-view-file-options.service"; import {ViewFileComparator, ViewFileService} from "../../../../services/files/view-file.service"; import {ViewFileOptionsService} from "../../../../services/files/view-file-options.service"; import {ViewFileOptions} from "../../../../services/files/view-file-options"; import {ViewFile} from "../../../../services/files/view-file"; describe("Testing view file sort service", () => { let viewSortService: ViewFileSortService; let viewFileService: MockViewFileService; let viewFileOptionsService: MockViewFileOptionsService; let sortComparator: ViewFileComparator; beforeEach(() => { TestBed.configureTestingModule({ providers: [ ViewFileSortService, LoggerService, {provide: ViewFileService, useClass: MockViewFileService}, {provide: ViewFileOptionsService, useClass: MockViewFileOptionsService} ] }); viewFileService = TestBed.get(ViewFileService); spyOn(viewFileService, "setComparator").and.callFake( value => sortComparator = value ); viewFileOptionsService = TestBed.get(ViewFileOptionsService); viewSortService = TestBed.get(ViewFileSortService); }); it("should create an instance", () => { expect(viewSortService).toBeDefined(); }); it("does not set a sort comparator by default", () => { expect(viewFileService.setComparator).toHaveBeenCalledTimes(0); expect(sortComparator).toBeUndefined(); }); it("calls setComparator when sort method is changed", fakeAsync(() => { expect(viewFileService.setComparator).toHaveBeenCalledTimes(0); viewFileOptionsService._options.next(new ViewFileOptions({ sortMethod: ViewFileOptions.SortMethod.STATUS })); tick(); expect(viewFileService.setComparator).toHaveBeenCalledTimes(1); expect(sortComparator).not.toBeNull(); viewFileOptionsService._options.next(new ViewFileOptions({ sortMethod: ViewFileOptions.SortMethod.NAME_ASC })); tick(); expect(viewFileService.setComparator).toHaveBeenCalledTimes(2); expect(sortComparator).not.toBeNull(); viewFileOptionsService._options.next(new ViewFileOptions({ sortMethod: ViewFileOptions.SortMethod.NAME_DESC })); tick(); expect(viewFileService.setComparator).toHaveBeenCalledTimes(3); expect(sortComparator).not.toBeNull(); })); it("does not call setComparator on duplicate sort methods", fakeAsync(() => { expect(viewFileService.setComparator).toHaveBeenCalledTimes(0); viewFileOptionsService._options.next(new ViewFileOptions({ sortMethod: ViewFileOptions.SortMethod.STATUS })); tick(); expect(viewFileService.setComparator).toHaveBeenCalledTimes(1); viewFileOptionsService._options.next(new ViewFileOptions({ sortMethod: ViewFileOptions.SortMethod.STATUS })); tick(); expect(viewFileService.setComparator).toHaveBeenCalledTimes(1); })); it("does not call setComparator when a different option is changed", fakeAsync(() => { expect(viewFileService.setComparator).toHaveBeenCalledTimes(0); viewFileOptionsService._options.next(new ViewFileOptions({ sortMethod: ViewFileOptions.SortMethod.STATUS, showDetails: false, })); tick(); expect(viewFileService.setComparator).toHaveBeenCalledTimes(1); viewFileOptionsService._options.next(new ViewFileOptions({ sortMethod: ViewFileOptions.SortMethod.STATUS, showDetails: true, })); tick(); expect(viewFileService.setComparator).toHaveBeenCalledTimes(1); })); it("correctly sorts by status", fakeAsync(() => { expect(viewFileService.setComparator).toHaveBeenCalledTimes(0); viewFileOptionsService._options.next(new ViewFileOptions({ sortMethod: ViewFileOptions.SortMethod.STATUS })); tick(); expect(viewFileService.setComparator).toHaveBeenCalledTimes(1); expect(sortComparator).not.toBeNull(); // Check the order based on status expect(sortComparator( new ViewFile({status: ViewFile.Status.EXTRACTING}), new ViewFile({status: ViewFile.Status.DOWNLOADING}) )).toBeLessThan(0); expect(sortComparator( new ViewFile({status: ViewFile.Status.DOWNLOADING}), new ViewFile({status: ViewFile.Status.QUEUED}) )).toBeLessThan(0); expect(sortComparator( new ViewFile({status: ViewFile.Status.QUEUED}), new ViewFile({status: ViewFile.Status.EXTRACTED}) )).toBeLessThan(0); expect(sortComparator( new ViewFile({status: ViewFile.Status.EXTRACTED}), new ViewFile({status: ViewFile.Status.DOWNLOADED}) )).toBeLessThan(0); expect(sortComparator( new ViewFile({status: ViewFile.Status.DOWNLOADED}), new ViewFile({status: ViewFile.Status.STOPPED}) )).toBeLessThan(0); expect(sortComparator( new ViewFile({status: ViewFile.Status.STOPPED}), new ViewFile({status: ViewFile.Status.DEFAULT}) )).toBeLessThan(0); expect(sortComparator( new ViewFile({status: ViewFile.Status.STOPPED}), new ViewFile({status: ViewFile.Status.DELETED}) )).toBeLessThan(0); // Default and Deleted should be intermixed expect(sortComparator( new ViewFile({status: ViewFile.Status.DEFAULT, name: ""}), new ViewFile({status: ViewFile.Status.DELETED, name: ""}) )).toBe(0); // Given same status, order should be determined by ascending name expect(sortComparator( new ViewFile({status: ViewFile.Status.EXTRACTED, name: "flower"}), new ViewFile({status: ViewFile.Status.EXTRACTED, name: "tofu"}) )).toBeLessThan(0); })); it("correctly sorts by ascending name", fakeAsync(() => { expect(viewFileService.setComparator).toHaveBeenCalledTimes(0); viewFileOptionsService._options.next(new ViewFileOptions({ sortMethod: ViewFileOptions.SortMethod.NAME_ASC })); tick(); expect(viewFileService.setComparator).toHaveBeenCalledTimes(1); expect(sortComparator).not.toBeNull(); expect(sortComparator( new ViewFile({status: ViewFile.Status.EXTRACTED, name: "flower"}), new ViewFile({status: ViewFile.Status.EXTRACTED, name: "tofu"}) )).toBeLessThan(0); expect(sortComparator( new ViewFile({status: ViewFile.Status.EXTRACTED, name: "cat"}), new ViewFile({status: ViewFile.Status.EXTRACTED, name: "dog"}) )).toBeLessThan(0); expect(sortComparator( new ViewFile({status: ViewFile.Status.EXTRACTED, name: "fff"}), new ViewFile({status: ViewFile.Status.EXTRACTED, name: "ffff"}) )).toBeLessThan(0); expect(sortComparator( new ViewFile({status: ViewFile.Status.EXTRACTED, name: "aaaa"}), new ViewFile({status: ViewFile.Status.EXTRACTED, name: "aaaa"}) )).toBe(0); })); it("correctly sorts by descending name", fakeAsync(() => { expect(viewFileService.setComparator).toHaveBeenCalledTimes(0); viewFileOptionsService._options.next(new ViewFileOptions({ sortMethod: ViewFileOptions.SortMethod.NAME_DESC })); tick(); expect(viewFileService.setComparator).toHaveBeenCalledTimes(1); expect(sortComparator).not.toBeNull(); expect(sortComparator( new ViewFile({status: ViewFile.Status.EXTRACTED, name: "flower"}), new ViewFile({status: ViewFile.Status.EXTRACTED, name: "tofu"}) )).toBeGreaterThan(0); expect(sortComparator( new ViewFile({status: ViewFile.Status.EXTRACTED, name: "cat"}), new ViewFile({status: ViewFile.Status.EXTRACTED, name: "dog"}) )).toBeGreaterThan(0); expect(sortComparator( new ViewFile({status: ViewFile.Status.EXTRACTED, name: "fff"}), new ViewFile({status: ViewFile.Status.EXTRACTED, name: "ffff"}) )).toBeGreaterThan(0); expect(sortComparator( new ViewFile({status: ViewFile.Status.EXTRACTED, name: "aaaa"}), new ViewFile({status: ViewFile.Status.EXTRACTED, name: "aaaa"}) )).toBe(0); })); }); ================================================ FILE: src/angular/src/app/tests/unittests/services/files/view-file.service.spec.ts ================================================ import {fakeAsync, TestBed, tick} from "@angular/core/testing"; import * as Immutable from "immutable"; import {ViewFileComparator, ViewFileService} from "../../../../services/files/view-file.service"; import {LoggerService} from "../../../../services/utils/logger.service"; import {StreamServiceRegistry} from "../../../../services/base/stream-service.registry"; import {MockStreamServiceRegistry} from "../../../mocks/mock-stream-service.registry"; import {ConnectedService} from "../../../../services/utils/connected.service"; import {MockModelFileService} from "../../../mocks/mock-model-file.service"; import {ModelFile} from "../../../../services/files/model-file"; import {ViewFile} from "../../../../services/files/view-file"; import {ViewFileFilterCriteria} from "../../../../services/files/view-file.service"; describe("Testing view file service", () => { let viewService: ViewFileService; let mockModelService: MockModelFileService; beforeEach(() => { TestBed.configureTestingModule({ providers: [ ViewFileService, LoggerService, ConnectedService, {provide: StreamServiceRegistry, useClass: MockStreamServiceRegistry} ] }); viewService = TestBed.get(ViewFileService); let mockRegistry: MockStreamServiceRegistry = TestBed.get(StreamServiceRegistry); mockModelService = mockRegistry.modelFileService; }); it("should create an instance", () => { expect(viewService).toBeDefined(); }); it("should forward an empty model by default", fakeAsync(() => { let count = 0; viewService.files.subscribe({ next: list => { expect(list.size).toBe(0); count++; } }); tick(); expect(count).toBe(1); })); it("should forward an empty model", fakeAsync(() => { let model = Immutable.Map(); mockModelService._files.next(model); tick(); let count = 0; viewService.files.subscribe({ next: list => { expect(list.size).toBe(0); count++; } }); tick(); expect(count).toBe(1); })); it("should correctly populate ViewFile props from a ModelFile", fakeAsync(() => { let model = Immutable.Map(); model = model.set("a", new ModelFile({ name: "a", is_dir: true, local_size: 0, remote_size: 11, state: ModelFile.State.DEFAULT, downloading_speed: 111, eta: 1111, full_path: "root/a", is_extractable: true, local_created_timestamp: new Date("November 9, 2018 21:40:18"), local_modified_timestamp: new Date(1541828418943), remote_created_timestamp: null, remote_modified_timestamp: new Date(1541828418943), })); mockModelService._files.next(model); tick(); let count = 0; viewService.files.subscribe({ next: list => { expect(list.size).toBe(1); let file = list.get(0); expect(file.name).toBe("a"); expect(file.isDir).toBe(true); expect(file.localSize).toBe(0); expect(file.remoteSize).toBe(11); expect(file.status).toBe(ViewFile.Status.DEFAULT); expect(file.downloadingSpeed).toBe(111); expect(file.eta).toBe(1111); expect(file.fullPath).toBe("root/a"); expect(file.isArchive).toBe(true); expect(file.localCreatedTimestamp).toEqual(new Date("November 9, 2018 21:40:18")); expect(file.localModifiedTimestamp).toEqual(new Date(1541828418943)); expect(file.remoteCreatedTimestamp).toBeNull(); expect(file.remoteModifiedTimestamp).toEqual(new Date(1541828418943)); count++; } }); tick(); expect(count).toBe(1); })); it("should correctly set the ViewFile status", fakeAsync(() => { let modelFile = new ModelFile({ name: "a", state: ModelFile.State.DEFAULT, }); let model = Immutable.Map(); model = model.set(modelFile.name, modelFile); let expectedStates = [ ViewFile.Status.DEFAULT, ViewFile.Status.QUEUED, ViewFile.Status.DOWNLOADING, ViewFile.Status.DOWNLOADED, ViewFile.Status.STOPPED, ViewFile.Status.DELETED, ViewFile.Status.EXTRACTING, ViewFile.Status.EXTRACTED ]; // First state - DEFAULT mockModelService._files.next(model); tick(); let count = 0; viewService.files.subscribe({ next: list => { expect(list.size).toBe(1); let file = list.get(0); expect(file.status).toBe(expectedStates[count++]); } }); tick(); expect(count).toBe(1); // Next state - QUEUED modelFile = new ModelFile(modelFile.set("state", ModelFile.State.QUEUED)); model = model.set(modelFile.name, modelFile); mockModelService._files.next(model); tick(); expect(count).toBe(2); // Next state - DOWNLOADING modelFile = new ModelFile(modelFile.set("state", ModelFile.State.DOWNLOADING)); model = model.set(modelFile.name, modelFile); mockModelService._files.next(model); tick(); expect(count).toBe(3); // Next state - DOWNLOADED modelFile = new ModelFile(modelFile.set("state", ModelFile.State.DOWNLOADED)); model = model.set(modelFile.name, modelFile); mockModelService._files.next(model); tick(); expect(count).toBe(4); // Next state - STOPPED // local size and remote size > 0 modelFile = new ModelFile(modelFile.set("state", ModelFile.State.DEFAULT)); modelFile = new ModelFile(modelFile.set("local_size", 50)); modelFile = new ModelFile(modelFile.set("remote_size", 50)); model = model.set(modelFile.name, modelFile); mockModelService._files.next(model); tick(); expect(count).toBe(5); // Next state - DELETED modelFile = new ModelFile(modelFile.set("state", ModelFile.State.DELETED)); model = model.set(modelFile.name, modelFile); mockModelService._files.next(model); tick(); expect(count).toBe(6); // Next state - DELETED modelFile = new ModelFile(modelFile.set("state", ModelFile.State.EXTRACTING)); model = model.set(modelFile.name, modelFile); mockModelService._files.next(model); tick(); expect(count).toBe(7); // Next state - DELETED modelFile = new ModelFile(modelFile.set("state", ModelFile.State.EXTRACTED)); model = model.set(modelFile.name, modelFile); mockModelService._files.next(model); tick(); expect(count).toBe(8); })); it("should always set a non-null file sizes in ViewFile", fakeAsync(() => { let model = Immutable.Map(); model = model.set("a", new ModelFile({ name: "a", local_size: null, remote_size: null, })); mockModelService._files.next(model); tick(); let count = 0; viewService.files.subscribe({ next: list => { expect(list.size).toBe(1); let file = list.get(0); expect(file.localSize).toBe(0); expect(file.remoteSize).toBe(0); count++; } }); tick(); expect(count).toBe(1); })); it("should correctly set ViewFile percent downloaded", fakeAsync(() => { // Test vectors of local size, remote size, percentage let testVectors = [ [0, 10, 0], [5, 10, 50], [10, 10, 100], [null, 10, 0], [10, null, 100] ]; let count = -1; viewService.files.subscribe({ next: list => { // Ignore first if(count >= 0) { expect(list.size).toBe(1); let file = list.get(0); expect(file.percentDownloaded).toBe(testVectors[count][2]); } count++; } }); tick(); expect(count).toBe(0); // Send over the test vectors for(let vector of testVectors) { let model = Immutable.Map(); model = model.set("a", new ModelFile({ name: "a", local_size: vector[0], remote_size: vector[1], })); mockModelService._files.next(model); tick(); } expect(count).toBe(testVectors.length); })); it("should should correctly set ViewFile isQueueable", fakeAsync(() => { // Test and expected result vectors // test - [ModelFile.State, local size, remote size] // result - [isQueueable, ViewFile.Status] let testVectors: any[][][] = [ // Default remote file is queueable [[ModelFile.State.DEFAULT, null, 100], [true, ViewFile.Status.DEFAULT]], // Default local file is NOT queueable [[ModelFile.State.DEFAULT, 100, null], [false, ViewFile.Status.DEFAULT]], // Stopped file is queueable [[ModelFile.State.DEFAULT, 50, 100], [true, ViewFile.Status.STOPPED]], // Deleted file is queueable [[ModelFile.State.DELETED, null, 100], [true, ViewFile.Status.DELETED]], // Queued file is NOT queueable [[ModelFile.State.QUEUED, null, 100], [false, ViewFile.Status.QUEUED]], // Downloading file is NOT queueable [[ModelFile.State.DOWNLOADING, 10, 100], [false, ViewFile.Status.DOWNLOADING]], // Downloaded file is NOT queueable [[ModelFile.State.DOWNLOADED, 100, 100], [false, ViewFile.Status.DOWNLOADED]], // Extracting file is NOT queueable [[ModelFile.State.EXTRACTING, 100, 100], [false, ViewFile.Status.EXTRACTING]], // Extracting local-only file is NOT queueable [[ModelFile.State.EXTRACTING, 100, null], [false, ViewFile.Status.EXTRACTING]], // Extracted file is NOT queueable [[ModelFile.State.EXTRACTED, 100, 100], [false, ViewFile.Status.EXTRACTED]], ]; let count = -1; viewService.files.subscribe({ next: list => { // Ignore first if(count >= 0) { expect(list.size).toBe(1); let file = list.get(0); let resultVector = testVectors[count][1]; expect(file.isQueueable).toBe(resultVector[0]); expect(file.status).toBe(resultVector[1]); } count++; } }); tick(); expect(count).toBe(0); // Send over the test vectors for(let vector of testVectors) { let testVector = vector[0]; let model = Immutable.Map(); model = model.set("a", new ModelFile({ name: "a", state: testVector[0], local_size: testVector[1], remote_size: testVector[2], })); mockModelService._files.next(model); tick(); } expect(count).toBe(testVectors.length); })); it("should should correctly set ViewFile isStoppable", fakeAsync(() => { // Test and expected result vectors // test - [ModelFile.State, local size, remote size] // result - [isStoppable, ViewFile.Status] let testVectors: any[][][] = [ // Default remote file is NOT stoppable [[ModelFile.State.DEFAULT, null, 100], [false, ViewFile.Status.DEFAULT]], // Default local file is NOT stoppable [[ModelFile.State.DEFAULT, 100, null], [false, ViewFile.Status.DEFAULT]], // Stopped file is NOT stoppable [[ModelFile.State.DEFAULT, 50, 100], [false, ViewFile.Status.STOPPED]], // Deleted file is NOT stoppable [[ModelFile.State.DELETED, null, 100], [false, ViewFile.Status.DELETED]], // Queued file is stoppable [[ModelFile.State.QUEUED, null, 100], [true, ViewFile.Status.QUEUED]], // Downloading file is stoppable [[ModelFile.State.DOWNLOADING, 10, 100], [true, ViewFile.Status.DOWNLOADING]], // Downloaded file is NOT stoppable [[ModelFile.State.DOWNLOADED, 100, 100], [false, ViewFile.Status.DOWNLOADED]], // Extracting file is NOT stoppable [[ModelFile.State.EXTRACTING, 100, 100], [false, ViewFile.Status.EXTRACTING]], // Extracted file is NOT stoppable [[ModelFile.State.EXTRACTED, 100, 100], [false, ViewFile.Status.EXTRACTED]], ]; let count = -1; viewService.files.subscribe({ next: list => { // Ignore first if(count >= 0) { expect(list.size).toBe(1); let file = list.get(0); let resultVector = testVectors[count][1]; expect(file.isStoppable).toBe(resultVector[0]); expect(file.status).toBe(resultVector[1]); } count++; } }); tick(); expect(count).toBe(0); // Send over the test vectors for(let vector of testVectors) { let testVector = vector[0]; let model = Immutable.Map(); model = model.set("a", new ModelFile({ name: "a", state: testVector[0], local_size: testVector[1], remote_size: testVector[2], })); mockModelService._files.next(model); tick(); } expect(count).toBe(testVectors.length); })); it("should should correctly set ViewFile isExtractable", fakeAsync(() => { // Test and expected result vectors // test - [ModelFile.State, local size, remote size] // result - [isExtractable, ViewFile.Status] let testVectors: any[][][] = [ // Default remote file is NOT extractable [[ModelFile.State.DEFAULT, null, 100], [false, ViewFile.Status.DEFAULT]], // Default local file is extractable [[ModelFile.State.DEFAULT, 100, null], [true, ViewFile.Status.DEFAULT]], // Stopped file is extractable [[ModelFile.State.DEFAULT, 50, 100], [true, ViewFile.Status.STOPPED]], // Deleted file is NOT extractable [[ModelFile.State.DELETED, null, 100], [false, ViewFile.Status.DELETED]], // Queued file is NOT extractable [[ModelFile.State.QUEUED, null, 100], [false, ViewFile.Status.QUEUED]], // Downloading file is NOT extractable [[ModelFile.State.DOWNLOADING, 10, 100], [false, ViewFile.Status.DOWNLOADING]], // Downloaded file is extractable [[ModelFile.State.DOWNLOADED, 100, 100], [true, ViewFile.Status.DOWNLOADED]], // Extracting file is NOT extractable [[ModelFile.State.EXTRACTING, 100, 100], [false, ViewFile.Status.EXTRACTING]], // Extracted file is extractable [[ModelFile.State.EXTRACTED, 100, 100], [true, ViewFile.Status.EXTRACTED]], ]; let count = -1; viewService.files.subscribe({ next: list => { // Ignore first if(count >= 0) { expect(list.size).toBe(1); let file = list.get(0); let resultVector = testVectors[count][1]; expect(file.isExtractable).toBe(resultVector[0]); expect(file.status).toBe(resultVector[1]); } count++; } }); tick(); expect(count).toBe(0); // Send over the test vectors for(let vector of testVectors) { let testVector = vector[0]; let model = Immutable.Map(); model = model.set("a", new ModelFile({ name: "a", state: testVector[0], local_size: testVector[1], remote_size: testVector[2], })); mockModelService._files.next(model); tick(); } expect(count).toBe(testVectors.length); })); // it("should sort view files by status then name", fakeAsync(() => { // // Test vectors to create model file // // name, ModelFile.State, local size, remote size // let testVector: any[][] = [ // ["a", ModelFile.State.DEFAULT, null, 100], // ["b", ModelFile.State.DEFAULT, 100, null], // ["c", ModelFile.State.DEFAULT, 50, 100], // ["d", ModelFile.State.DELETED, null, 100], // ["e", ModelFile.State.QUEUED, null, 100], // ["f", ModelFile.State.DOWNLOADING, 50, 100], // ["g", ModelFile.State.DOWNLOADED, 50, 100], // ["h", ModelFile.State.EXTRACTING, 50, 100], // ["i", ModelFile.State.EXTRACTED, 50, 100] // ]; // // // Except result vector in order of view file name and state // let resultVector: any[][] = [ // ["h", ViewFile.Status.EXTRACTING], // ["f", ViewFile.Status.DOWNLOADING], // ["e", ViewFile.Status.QUEUED], // ["i", ViewFile.Status.EXTRACTED], // ["g", ViewFile.Status.DOWNLOADED], // ["c", ViewFile.Status.STOPPED], // ["a", ViewFile.Status.DEFAULT], // ["b", ViewFile.Status.DEFAULT], // ["d", ViewFile.Status.DELETED] // ]; // // let model = Immutable.Map(); // for(let vector of testVector) { // model = model.set(vector[0], new ModelFile({ // name: vector[0], // state: vector[1], // local_size: vector[2], // remote_size: vector[3], // })); // } // mockModelService._files.next(model); // tick(); // // let count = 0; // viewService.files.subscribe({ // next: list => { // expect(list.size).toBe(resultVector.length); // resultVector.forEach((item, index) => { // let file = list.get(index); // expect(file.name).toBe(item[0]); // expect(file.status).toBe(item[1]); // }); // count++; // } // }); // tick(); // expect(count).toBe(1); // })); it("should correctly set and unset the selected file", fakeAsync(() => { let model = Immutable.Map(); model = model.set("a", new ModelFile({name: "a"})); model = model.set("b", new ModelFile({name: "b"})); model = model.set("c", new ModelFile({name: "c"})); let expectedSelectedFileIndex = -1; mockModelService._files.next(model); tick(); let viewFileList; let count = 0; viewService.files.subscribe({ next: list => { viewFileList = list; expect(list.size).toBe(3); expect(list.get(0).name).toBe("a"); expect(list.get(1).name).toBe("b"); expect(list.get(2).name).toBe("c"); list.forEach((item, index) => { // Only 1 file is selected at a time if(index == expectedSelectedFileIndex) { expect(item.isSelected).toBe(true); } else { expect(item.isSelected).toBe(false); } }); count++; } }); tick(); expect(count).toBe(1); // select "a" expectedSelectedFileIndex = 0; viewService.setSelected(viewFileList.get(0)); tick(); expect(count).toBe(2); // unselect "a" expectedSelectedFileIndex = -1; viewService.unsetSelected(); tick(); expect(count).toBe(3); // select "b" expectedSelectedFileIndex = 1; viewService.setSelected(viewFileList.get(1)); tick(); expect(count).toBe(4); // select "c" expectedSelectedFileIndex = 2; viewService.setSelected(viewFileList.get(2)); tick(); expect(count).toBe(5); // select "b" again expectedSelectedFileIndex = 1; viewService.setSelected(viewFileList.get(1)); tick(); expect(count).toBe(6); // unselect "b" expectedSelectedFileIndex = -1; viewService.unsetSelected(); tick(); expect(count).toBe(7); })); it("should should correctly set ViewFile isLocallyDeletable", fakeAsync(() => { // Test and expected result vectors // test - [ModelFile.State, local size, remote size] // result - [isLocallyDeletable, ViewFile.Status] let testVectors: any[][][] = [ // Default remote file is NOT locally deletable [[ModelFile.State.DEFAULT, null, 100], [false, ViewFile.Status.DEFAULT]], // Default local file is locally deletable [[ModelFile.State.DEFAULT, 100, null], [true, ViewFile.Status.DEFAULT]], // Stopped file is locally deletable [[ModelFile.State.DEFAULT, 50, 100], [true, ViewFile.Status.STOPPED]], // Deleted file is NOT locally deletable [[ModelFile.State.DELETED, null, 100], [false, ViewFile.Status.DELETED]], // Queued file is NOT locally deletable [[ModelFile.State.QUEUED, null, 100], [false, ViewFile.Status.QUEUED]], // Downloading file is NOT locally deletable [[ModelFile.State.DOWNLOADING, 10, 100], [false, ViewFile.Status.DOWNLOADING]], // Downloaded file is locally deletable [[ModelFile.State.DOWNLOADED, 100, 100], [true, ViewFile.Status.DOWNLOADED]], // Extracting file is NOT locally deletable [[ModelFile.State.EXTRACTING, 100, 100], [false, ViewFile.Status.EXTRACTING]], // Extracted file is locally deletable [[ModelFile.State.EXTRACTED, 100, 100], [true, ViewFile.Status.EXTRACTED]], ]; let count = -1; viewService.files.subscribe({ next: list => { // Ignore first if(count >= 0) { expect(list.size).toBe(1); let file = list.get(0); let resultVector = testVectors[count][1]; expect(file.isLocallyDeletable).toBe(resultVector[0]); expect(file.status).toBe(resultVector[1]); } count++; } }); tick(); expect(count).toBe(0); // Send over the test vectors for(let vector of testVectors) { let testVector = vector[0]; let model = Immutable.Map(); model = model.set("a", new ModelFile({ name: "a", state: testVector[0], local_size: testVector[1], remote_size: testVector[2], })); mockModelService._files.next(model); tick(); } expect(count).toBe(testVectors.length); })); it("should should correctly set ViewFile isRemotelyDeletable", fakeAsync(() => { // Test and expected result vectors // test - [ModelFile.State, local size, remote size] // result - [isRemotelyDeletable, ViewFile.Status] let testVectors: any[][][] = [ // Default remote file is remotely deletable [[ModelFile.State.DEFAULT, null, 100], [true, ViewFile.Status.DEFAULT]], // Default local file is NOT remotely deletable [[ModelFile.State.DEFAULT, 100, null], [false, ViewFile.Status.DEFAULT]], // Stopped file is remotely deletable [[ModelFile.State.DEFAULT, 50, 100], [true, ViewFile.Status.STOPPED]], // Deleted file is remotely deletable [[ModelFile.State.DELETED, null, 100], [true, ViewFile.Status.DELETED]], // Queued file is NOT remotely deletable [[ModelFile.State.QUEUED, null, 100], [false, ViewFile.Status.QUEUED]], // Downloading file is NOT remotely deletable [[ModelFile.State.DOWNLOADING, 10, 100], [false, ViewFile.Status.DOWNLOADING]], // Downloaded file is remotely deletable [[ModelFile.State.DOWNLOADED, 100, 100], [true, ViewFile.Status.DOWNLOADED]], // Extracting file is NOT remotely deletable [[ModelFile.State.EXTRACTING, 100, 100], [false, ViewFile.Status.EXTRACTING]], // Extracted file is remotely deletable [[ModelFile.State.EXTRACTED, 100, 100], [true, ViewFile.Status.EXTRACTED]], ]; let count = -1; viewService.files.subscribe({ next: list => { // Ignore first if(count >= 0) { expect(list.size).toBe(1); let file = list.get(0); let resultVector = testVectors[count][1]; expect(file.isRemotelyDeletable).toBe(resultVector[0]); expect(file.status).toBe(resultVector[1]); } count++; } }); tick(); expect(count).toBe(0); // Send over the test vectors for(let vector of testVectors) { let testVector = vector[0]; let model = Immutable.Map(); model = model.set("a", new ModelFile({ name: "a", state: testVector[0], local_size: testVector[1], remote_size: testVector[2], })); mockModelService._files.next(model); tick(); } expect(count).toBe(testVectors.length); })); it("should not filter any files by default", fakeAsync(() => { const model = Immutable.Map({ "aaaa": new ModelFile({name: "aaaa", state: ModelFile.State.DEFAULT}), "tofu": new ModelFile({name: "tofu", state: ModelFile.State.QUEUED}), "flower": new ModelFile({name: "flower", state: ModelFile.State.QUEUED}), "power": new ModelFile({name: "power", state: ModelFile.State.DOWNLOADING}), "max": new ModelFile({name: "max", state: ModelFile.State.DOWNLOADED}), "mrx": new ModelFile({name: "mrx", state: ModelFile.State.EXTRACTING}), "blueman": new ModelFile({name: "blueman", state: ModelFile.State.EXTRACTED}), "spicy": new ModelFile({name: "spicy", state: ModelFile.State.DELETED}), }); mockModelService._files.next(model); let count = 0; let viewFiles: Immutable.List = null; viewService.filteredFiles.subscribe({ next: list => { viewFiles = list; count++; } }); tick(); expect(count).toBe(1); expect(viewFiles.size).toBe(8); })); it("should apply filter criteria correctly", fakeAsync(() => { class TestCriteria implements ViewFileFilterCriteria { meetsCriteria(viewFile: ViewFile): boolean { return viewFile.status === ViewFile.Status.QUEUED || viewFile.status === ViewFile.Status.EXTRACTED; } } viewService.setFilterCriteria(new TestCriteria()); const model = Immutable.Map({ "aaaa": new ModelFile({name: "aaaa", state: ModelFile.State.DEFAULT}), "tofu": new ModelFile({name: "tofu", state: ModelFile.State.QUEUED}), "flower": new ModelFile({name: "flower", state: ModelFile.State.QUEUED}), "power": new ModelFile({name: "power", state: ModelFile.State.DOWNLOADING}), "max": new ModelFile({name: "max", state: ModelFile.State.DOWNLOADED}), "mrx": new ModelFile({name: "mrx", state: ModelFile.State.EXTRACTING}), "blueman": new ModelFile({name: "blueman", state: ModelFile.State.EXTRACTED}), "spicy": new ModelFile({name: "spicy", state: ModelFile.State.DELETED}), }); mockModelService._files.next(model); tick(); let count = 0; let viewFiles: Immutable.List = null; let viewFilesMap: Map = null; viewService.filteredFiles.subscribe({ next: list => { viewFiles = list; viewFilesMap = new Map(); list.forEach(value => viewFilesMap.set(value.name, value)); count++; } }); tick(); expect(count).toBe(1); expect(viewFiles.size).toBe(3); expect(viewFilesMap.has("tofu")).toBe(true); expect(viewFilesMap.has("flower")).toBe(true); expect(viewFilesMap.has("blueman")).toBe(true); })); it("should resend filtered files on criteria change", fakeAsync(() => { class TestCriteria implements ViewFileFilterCriteria { constructor(public flag: boolean) {} meetsCriteria(viewFile: ViewFile): boolean { if (this.flag) { return viewFile.status === ViewFile.Status.QUEUED; } else { return viewFile.status === ViewFile.Status.EXTRACTED; } } } viewService.setFilterCriteria(new TestCriteria(true)); let count = 0; let viewFiles: Immutable.List = null; let viewFilesMap: Map = null; viewService.filteredFiles.subscribe({ next: list => { viewFiles = list; viewFilesMap = new Map(); list.forEach(value => viewFilesMap.set(value.name, value)); count++; } }); expect(count).toBe(1); const model = Immutable.Map({ "aaaa": new ModelFile({name: "aaaa", state: ModelFile.State.DEFAULT}), "tofu": new ModelFile({name: "tofu", state: ModelFile.State.QUEUED}), "flower": new ModelFile({name: "flower", state: ModelFile.State.QUEUED}), "power": new ModelFile({name: "power", state: ModelFile.State.DOWNLOADING}), "max": new ModelFile({name: "max", state: ModelFile.State.DOWNLOADED}), "mrx": new ModelFile({name: "mrx", state: ModelFile.State.EXTRACTING}), "blueman": new ModelFile({name: "blueman", state: ModelFile.State.EXTRACTED}), "spicy": new ModelFile({name: "spicy", state: ModelFile.State.DELETED}), }); mockModelService._files.next(model); tick(); expect(count).toBe(2); expect(viewFiles.size).toBe(2); expect(viewFilesMap.has("tofu")).toBe(true); expect(viewFilesMap.has("flower")).toBe(true); // Update the filter criteria viewService.setFilterCriteria(new TestCriteria(false)); expect(count).toBe(3); expect(viewFiles.size).toBe(1); expect(viewFilesMap.has("blueman")).toBe(true); })); it("should not sort files by default", fakeAsync(() => { const model = Immutable.Map({ "aaaa": new ModelFile({name: "aaaa", state: ModelFile.State.DEFAULT}), "tofu": new ModelFile({name: "tofu", state: ModelFile.State.QUEUED}), "flower": new ModelFile({name: "flower", state: ModelFile.State.QUEUED}), "power": new ModelFile({name: "power", state: ModelFile.State.DOWNLOADING}), "max": new ModelFile({name: "max", state: ModelFile.State.DOWNLOADED}), "mrx": new ModelFile({name: "mrx", state: ModelFile.State.EXTRACTING}), "blueman": new ModelFile({name: "blueman", state: ModelFile.State.EXTRACTED}), "spicy": new ModelFile({name: "spicy", state: ModelFile.State.DELETED}), }); mockModelService._files.next(model); let count = 0; let viewFiles: Immutable.List = null; viewService.files.subscribe({ next: list => { viewFiles = list; count++; } }); tick(); expect(count).toBe(1); expect(viewFiles.size).toBe(8); expect(viewFiles.get(0).name).toBe("aaaa"); expect(viewFiles.get(1).name).toBe("tofu"); expect(viewFiles.get(2).name).toBe("flower"); expect(viewFiles.get(3).name).toBe("power"); expect(viewFiles.get(4).name).toBe("max"); expect(viewFiles.get(5).name).toBe("mrx"); expect(viewFiles.get(6).name).toBe("blueman"); expect(viewFiles.get(7).name).toBe("spicy"); })); it("should sort new model correctly", fakeAsync(() => { const comparator: ViewFileComparator = function(a: ViewFile, b: ViewFile) { // alphabetical order return a.name.localeCompare(b.name); }; viewService.setComparator(comparator); const model = Immutable.Map({ "aaaa": new ModelFile({name: "aaaa", state: ModelFile.State.DEFAULT}), "tofu": new ModelFile({name: "tofu", state: ModelFile.State.QUEUED}), "flower": new ModelFile({name: "flower", state: ModelFile.State.QUEUED}), "power": new ModelFile({name: "power", state: ModelFile.State.DOWNLOADING}), "max": new ModelFile({name: "max", state: ModelFile.State.DOWNLOADED}), "mrx": new ModelFile({name: "mrx", state: ModelFile.State.EXTRACTING}), "blueman": new ModelFile({name: "blueman", state: ModelFile.State.EXTRACTED}), "spicy": new ModelFile({name: "spicy", state: ModelFile.State.DELETED}), }); mockModelService._files.next(model); let count = 0; let viewFiles: Immutable.List = null; viewService.files.subscribe({ next: list => { viewFiles = list; count++; } }); tick(); expect(count).toBe(1); expect(viewFiles.size).toBe(8); expect(viewFiles.get(0).name).toBe("aaaa"); expect(viewFiles.get(1).name).toBe("blueman"); expect(viewFiles.get(2).name).toBe("flower"); expect(viewFiles.get(3).name).toBe("max"); expect(viewFiles.get(4).name).toBe("mrx"); expect(viewFiles.get(5).name).toBe("power"); expect(viewFiles.get(6).name).toBe("spicy"); expect(viewFiles.get(7).name).toBe("tofu"); })); it("should sort existing model on setComparator", fakeAsync(() => { const model = Immutable.Map({ "aaaa": new ModelFile({name: "aaaa", state: ModelFile.State.DEFAULT}), "tofu": new ModelFile({name: "tofu", state: ModelFile.State.QUEUED}), "flower": new ModelFile({name: "flower", state: ModelFile.State.QUEUED}), "power": new ModelFile({name: "power", state: ModelFile.State.DOWNLOADING}), "max": new ModelFile({name: "max", state: ModelFile.State.DOWNLOADED}), "mrx": new ModelFile({name: "mrx", state: ModelFile.State.EXTRACTING}), "blueman": new ModelFile({name: "blueman", state: ModelFile.State.EXTRACTED}), "spicy": new ModelFile({name: "spicy", state: ModelFile.State.DELETED}), }); mockModelService._files.next(model); let count = 0; let viewFiles: Immutable.List = null; viewService.files.subscribe({ next: list => { viewFiles = list; count++; } }); tick(); expect(count).toBe(1); const comparator: ViewFileComparator = function(a: ViewFile, b: ViewFile) { // reverse alphabetical order return -1 * a.name.localeCompare(b.name); }; viewService.setComparator(comparator); tick(); expect(count).toBe(2); expect(viewFiles.size).toBe(8); expect(viewFiles.get(0).name).toBe("tofu"); expect(viewFiles.get(1).name).toBe("spicy"); expect(viewFiles.get(2).name).toBe("power"); expect(viewFiles.get(3).name).toBe("mrx"); expect(viewFiles.get(4).name).toBe("max"); expect(viewFiles.get(5).name).toBe("flower"); expect(viewFiles.get(6).name).toBe("blueman"); expect(viewFiles.get(7).name).toBe("aaaa"); })); }); ================================================ FILE: src/angular/src/app/tests/unittests/services/logs/log-record.spec.ts ================================================ import * as Immutable from "immutable"; import {LogRecord} from "../../../../services/logs/log-record"; describe("Testing log record initialization", () => { let baseJson; let baseLogRecord: LogRecord; beforeEach(() => { baseJson = { level_name: "DEBUG", time: "1514776875.9439101", logger_name: "seedsync.Controller.Model", message: "LftpModel: Adding a listener", exc_tb: "Exception Traceback" }; baseLogRecord = LogRecord.fromJson(baseJson); }); it("should be immutable", () => { expect(baseLogRecord instanceof Immutable.Record).toBe(true); }); it("should correctly initialize logger name", () => { expect(baseLogRecord.loggerName).toBe("seedsync.Controller.Model"); }); it("should correctly initialize message", () => { expect(baseLogRecord.message).toBe("LftpModel: Adding a listener"); }); it("should correctly initialize level names", () => { baseJson.level_name = "DEBUG"; baseLogRecord = LogRecord.fromJson(baseJson); expect(baseLogRecord.level).toBe(LogRecord.Level.DEBUG); baseJson.level_name = "INFO"; baseLogRecord = LogRecord.fromJson(baseJson); expect(baseLogRecord.level).toBe(LogRecord.Level.INFO); baseJson.level_name = "WARNING"; baseLogRecord = LogRecord.fromJson(baseJson); expect(baseLogRecord.level).toBe(LogRecord.Level.WARNING); baseJson.level_name = "ERROR"; baseLogRecord = LogRecord.fromJson(baseJson); expect(baseLogRecord.level).toBe(LogRecord.Level.ERROR); baseJson.level_name = "CRITICAL"; baseLogRecord = LogRecord.fromJson(baseJson); expect(baseLogRecord.level).toBe(LogRecord.Level.CRITICAL); }); it("should correctly initialize time", () => { expect(baseLogRecord.time).toEqual(new Date(1514776875943)); }); it("should correctly initialize exception traceback", () => { expect(baseLogRecord.exceptionTraceback).toEqual("Exception Traceback"); baseJson.exc_tb = null; baseLogRecord = LogRecord.fromJson(baseJson); expect(baseLogRecord.exceptionTraceback).toBeNull(); }); }); ================================================ FILE: src/angular/src/app/tests/unittests/services/logs/log.service.spec.ts ================================================ import {fakeAsync, TestBed, tick} from "@angular/core/testing"; import * as Immutable from "immutable"; import {LoggerService} from "../../../../services/utils/logger.service"; import {LogService} from "../../../../services/logs/log.service"; import {LogRecord} from "../../../../services/logs/log-record"; describe("Testing log service", () => { let logService: LogService; beforeEach(() => { TestBed.configureTestingModule({ providers: [ LoggerService, LogService ] }); logService = TestBed.get(LogService); }); it("should create an instance", () => { expect(logService).toBeDefined(); }); it("should register all events with the event source", () => { expect(logService.getEventNames()).toEqual( ["log-record"] ); }); it("should send correct record on event", fakeAsync(() => { let count = 0; let latestRecord: LogRecord = null; // noinspection JSUnusedAssignment let json = null; logService.logs.subscribe({ next: record => { count++; latestRecord = record; } }); json = { level_name: "DEBUG", time: "1514776875.9439101", logger_name: "seedsync.Controller.Model", message: "LftpModel: Adding a listener" }; logService.notifyEvent("log-record", JSON.stringify(json)); tick(); expect(count).toBe(1); expect(Immutable.is(latestRecord, LogRecord.fromJson(json))).toBe(true); json = { level_name: "WARNING", time: "1514771875.9746701", logger_name: "another name", message: "another message" }; logService.notifyEvent("log-record", JSON.stringify(json)); tick(); expect(count).toBe(2); expect(Immutable.is(latestRecord, LogRecord.fromJson(json))).toBe(true); })); it("should cache records", fakeAsync(() => { let count = 0; let latestRecord: LogRecord = null; // noinspection JSUnusedAssignment let data1 = null; // noinspection JSUnusedAssignment let data2 = null; data1 = { level_name: "WARNING", time: "1514771875.9746701", logger_name: "another name", message: "another message" }; data2 = { level_name: "DEBUG", time: "1514776875.9439101", logger_name: "seedsync.Controller.Model", message: "LftpModel: Adding a listener" }; // Send out some data before subscription logService.notifyEvent("log-record", JSON.stringify(data1)); logService.notifyEvent("log-record", JSON.stringify(data2)); tick(); logService.logs.subscribe({ next: record => { count++; latestRecord = record; } }); tick(); // Expect some data here expect(count).toBe(2); logService.notifyEvent("log-record", JSON.stringify(data2)); tick(); expect(count).toBe(3); expect(Immutable.is(latestRecord, LogRecord.fromJson(data2))).toBe(true); logService.notifyEvent("log-record", JSON.stringify(data1)); tick(); expect(count).toBe(4); expect(Immutable.is(latestRecord, LogRecord.fromJson(data1))).toBe(true); })); }); ================================================ FILE: src/angular/src/app/tests/unittests/services/server/server-command.service.spec.ts ================================================ import {TestBed} from "@angular/core/testing"; import {HttpClientTestingModule, HttpTestingController} from "@angular/common/http/testing"; import {LoggerService} from "../../../../services/utils/logger.service"; import {ServerCommandService} from "../../../../services/server/server-command.service"; import {MockStreamServiceRegistry} from "../../../mocks/mock-stream-service.registry"; import {RestService} from "../../../../services/utils/rest.service"; import {ConnectedService} from "../../../../services/utils/connected.service"; import {StreamServiceRegistry} from "../../../../services/base/stream-service.registry"; describe("Testing server command service", () => { let mockRegistry: MockStreamServiceRegistry; let httpMock: HttpTestingController; let commandService: ServerCommandService; beforeEach(() => { TestBed.configureTestingModule({ imports: [ HttpClientTestingModule ], providers: [ ServerCommandService, LoggerService, RestService, ConnectedService, {provide: StreamServiceRegistry, useClass: MockStreamServiceRegistry} ] }); mockRegistry = TestBed.get(StreamServiceRegistry); httpMock = TestBed.get(HttpTestingController); commandService = TestBed.get(ServerCommandService); // Connect the services mockRegistry.connect(); // Finish test config init commandService.onInit(); }); it("should create an instance", () => { expect(commandService).toBeDefined(); }); it("should send a GET restart command", () => { let count = 0; commandService.restart().subscribe({ next: reaction => { count++; expect(reaction.success).toBe(true); } }); // set request httpMock.expectOne("/server/command/restart").flush("{}"); expect(count).toBe(1); httpMock.verify(); }); }); ================================================ FILE: src/angular/src/app/tests/unittests/services/server/server-status.service.spec.ts ================================================ import {fakeAsync, TestBed, tick} from "@angular/core/testing"; import {ServerStatusService} from "../../../../services/server/server-status.service"; import {LoggerService} from "../../../../services/utils/logger.service"; import {ServerStatus, ServerStatusJson} from "../../../../services/server/server-status"; describe("Testing server status service", () => { let serverStatusService: ServerStatusService; beforeEach(() => { TestBed.configureTestingModule({ providers: [ LoggerService, ServerStatusService ] }); serverStatusService = TestBed.get(ServerStatusService); }); it("should create an instance", () => { expect(serverStatusService).toBeDefined(); }); it("should register all events with the event source", () => { expect(serverStatusService.getEventNames()).toEqual( ["status"] ); }); it("should send correct status on event", fakeAsync(() => { let count = 0; let latestStatus: ServerStatus = null; serverStatusService.status.subscribe({ next: status => { count++; latestStatus = status; } }); // Initial status tick(); expect(count).toBe(1); expect(latestStatus.server.up).toBe(false); // New status const statusJson: ServerStatusJson = { server: { up: true, error_msg: null }, controller: { latest_local_scan_time: null, latest_remote_scan_time: null, latest_remote_scan_failed: null, latest_remote_scan_error: null } }; serverStatusService.notifyEvent("status", JSON.stringify(statusJson)); tick(); expect(count).toBe(2); expect(latestStatus.server.up).toBe(true); // Status update statusJson.server.up = false; statusJson.server.error_msg = "uh oh spaghettios"; serverStatusService.notifyEvent("status", JSON.stringify(statusJson)); tick(); expect(count).toBe(3); expect(latestStatus.server.up).toBe(false); expect(latestStatus.server.errorMessage).toBe("uh oh spaghettios"); })); it("should send correct status on disconnect", fakeAsync(() => { // Initial status const statusJson: ServerStatusJson = { server: { up: true, error_msg: null }, controller: { latest_local_scan_time: null, latest_remote_scan_time: null, latest_remote_scan_failed: null, latest_remote_scan_error: null } }; serverStatusService.notifyEvent("status", JSON.stringify(statusJson)); let count = 0; let latestStatus: ServerStatus = null; serverStatusService.status.subscribe({ next: status => { count++; latestStatus = status; } }); tick(); expect(count).toBe(1); // Error serverStatusService.notifyDisconnected(); tick(); expect(count).toBe(2); expect(latestStatus.server.up).toBe(false); })); }); ================================================ FILE: src/angular/src/app/tests/unittests/services/server/server-status.spec.ts ================================================ import * as Immutable from "immutable"; import {ServerStatus, ServerStatusJson} from "../../../../services/server/server-status"; describe("Testing log record initialization", () => { let baseJson: ServerStatusJson; let baseStatus: ServerStatus; beforeEach(() => { baseJson = { server: { up: true, error_msg: "An error message" }, controller: { latest_local_scan_time: "1514776875.9439101", latest_remote_scan_time: "1524743857.3456243", latest_remote_scan_failed: true, latest_remote_scan_error: "message failure reason" } }; baseStatus = ServerStatus.fromJson(baseJson); }); it("should be immutable", () => { expect(baseStatus instanceof Immutable.Record).toBe(true); }); it("should correctly initialize server up", () => { expect(baseStatus.server.up).toBe(true); }); it("should correctly initialize server error message", () => { expect(baseStatus.server.errorMessage).toBe("An error message"); }); it("should correctly initialize controller latest local scan time", () => { expect(baseStatus.controller.latestLocalScanTime).toEqual(new Date(1514776875943)); // Allow null baseJson.controller.latest_local_scan_time = null; const newStatus = ServerStatus.fromJson(baseJson); expect(newStatus.controller.latestLocalScanTime).toBeNull(); }); it("should correctly initialize controller latest remote scan time", () => { expect(baseStatus.controller.latestRemoteScanTime).toEqual(new Date(1524743857345)); // Allow null baseJson.controller.latest_remote_scan_time = null; const newStatus = ServerStatus.fromJson(baseJson); expect(newStatus.controller.latestRemoteScanTime).toBeNull(); }); it("should correctly initialize controller failure", () => { expect(baseStatus.controller.latestRemoteScanFailed).toBe(true); }); it("should correctly initialize controller error", () => { expect(baseStatus.controller.latestRemoteScanError).toBe("message failure reason"); }); }); ================================================ FILE: src/angular/src/app/tests/unittests/services/settings/config.service.spec.ts ================================================ import {fakeAsync, TestBed, tick} from "@angular/core/testing"; import {HttpClientTestingModule, HttpTestingController} from "@angular/common/http/testing"; import * as Immutable from "immutable"; import {ConfigService} from "../../../../services/settings/config.service"; import {LoggerService} from "../../../../services/utils/logger.service"; import {Config} from "../../../../services/settings/config"; import {MockStreamServiceRegistry} from "../../../mocks/mock-stream-service.registry"; import {ConnectedService} from "../../../../services/utils/connected.service"; import {RestService} from "../../../../services/utils/rest.service"; import {StreamServiceRegistry} from "../../../../services/base/stream-service.registry"; // noinspection JSUnusedLocalSymbols const DoNothing = {next: reaction => {}}; describe("Testing config service", () => { let mockRegistry: MockStreamServiceRegistry; let httpMock: HttpTestingController; let configService: ConfigService; beforeEach(() => { TestBed.configureTestingModule({ imports: [ HttpClientTestingModule ], providers: [ ConfigService, LoggerService, RestService, ConnectedService, {provide: StreamServiceRegistry, useClass: MockStreamServiceRegistry} ] }); mockRegistry = TestBed.get(StreamServiceRegistry); httpMock = TestBed.get(HttpTestingController); configService = TestBed.get(ConfigService); // Connect the services mockRegistry.connect(); // Finish test config init configService.onInit(); }); it("should create an instance", () => { expect(configService).toBeDefined(); }); it("should parse config json correctly", () => { const configJson = { general: { debug: true }, lftp: { remote_address: "remote.server.com", remote_username: "some.user", remote_password: "my.password", remote_path: "/some/remote/path", local_path: "/some/local/path", remote_path_to_scan_script: "/another/remote/path", use_ssh_key: true, num_max_parallel_downloads: 2, num_max_parallel_files_per_download: 8, num_max_connections_per_root_file: 32, num_max_connections_per_dir_file: 4, num_max_total_connections: 32, use_temp_file: true, }, controller: { interval_ms_remote_scan: 30000, interval_ms_local_scan: 10000, interval_ms_downloading_scan: 1000 }, web: { port: 8800 }, autoqueue: { enabled: true, patterns_only: false } }; httpMock.expectOne("/server/config/get").flush(configJson); configService.config.subscribe({ next: config => { expect(config.general.debug).toBe(true); expect(config.lftp.remote_address).toBe("remote.server.com"); expect(config.lftp.remote_username).toBe("some.user"); expect(config.lftp.remote_password).toBe("my.password"); expect(config.lftp.remote_path).toBe("/some/remote/path"); expect(config.lftp.local_path).toBe("/some/local/path"); expect(config.lftp.remote_path_to_scan_script).toBe("/another/remote/path"); expect(config.lftp.use_ssh_key).toBe(true); expect(config.lftp.num_max_parallel_downloads).toBe(2); expect(config.lftp.num_max_parallel_files_per_download).toBe(8); expect(config.lftp.num_max_connections_per_root_file).toBe(32); expect(config.lftp.num_max_connections_per_dir_file).toBe(4); expect(config.lftp.num_max_total_connections).toBe(32); expect(config.lftp.use_temp_file).toBe(true); expect(config.controller.interval_ms_remote_scan).toBe(30000); expect(config.controller.interval_ms_local_scan).toBe(10000); expect(config.controller.interval_ms_downloading_scan).toBe(1000); expect(config.web.port).toBe(8800); expect(config.autoqueue.enabled).toBe(true); expect(config.autoqueue.patterns_only).toBe(false); } }); httpMock.verify(); }); it("should get null on get error 404", () => { httpMock.expectOne("/server/config/get").flush( "Not found", {status: 404, statusText: "Bad Request"} ); configService.config.subscribe({ next: config => { expect(config).toBe(null); } }); httpMock.verify(); }); it("should get null on get network error", () => { httpMock.expectOne("/server/config/get").error(new ErrorEvent("mock error")); configService.config.subscribe({ next: config => { expect(config).toBe(null); } }); httpMock.verify(); }); it("should get null on disconnect", fakeAsync(() => { const configExpected = [ new Config({lftp: {remote_address: "first"}}), null ]; httpMock.expectOne("/server/config/get").flush({lftp: {remote_address: "first"}}); let configSubscriberIndex = 0; configService.config.subscribe({ next: config => { expect(Immutable.is(config, configExpected[configSubscriberIndex++])).toBe(true); } }); // status disconnect mockRegistry.disconnect(); tick(); httpMock.verify(); expect(configSubscriberIndex).toBe(2); })); it("should retry GET on disconnect", fakeAsync(() => { // first connect httpMock.expectOne("/server/config/get").flush("{}"); // status disconnect mockRegistry.disconnect(); tick(); // status reconnect mockRegistry.connect(); tick(); httpMock.expectOne("/server/config/get").flush("{}"); httpMock.verify(); })); it("should send a GET on a set config option", () => { // first connect httpMock.expectOne("/server/config/get").flush("{}"); let configSubscriberIndex = 0; configService.set("general", "debug", true).subscribe({ next: reaction => { configSubscriberIndex++; expect(reaction.success).toBe(true); } }); // set request httpMock.expectOne("/server/config/set/general/debug/true").flush("{}"); expect(configSubscriberIndex).toBe(1); httpMock.verify(); }); it("should send correct GET requests on setting config options", () => { // first connect httpMock.expectOne("/server/config/get").flush("{}"); // boolean configService.set("general", "debug", true).subscribe(DoNothing); httpMock.expectOne("/server/config/set/general/debug/true").flush("{}"); configService.set("general", "debug", false).subscribe(DoNothing); httpMock.expectOne("/server/config/set/general/debug/false").flush("{}"); // integer configService.set("general", "debug", 0).subscribe(DoNothing); httpMock.expectOne("/server/config/set/general/debug/0").flush("{}"); configService.set("general", "debug", 1000).subscribe(DoNothing); httpMock.expectOne("/server/config/set/general/debug/1000").flush("{}"); configService.set("general", "debug", -1000).subscribe(DoNothing); httpMock.expectOne("/server/config/set/general/debug/-1000").flush("{}"); // string configService.set("general", "debug", "test").subscribe(DoNothing); httpMock.expectOne("/server/config/set/general/debug/test").flush("{}"); configService.set("general", "debug", "test space").subscribe(DoNothing); httpMock.expectOne("/server/config/set/general/debug/test%2520space").flush("{}"); configService.set("general", "debug", "test/slash").subscribe(DoNothing); httpMock.expectOne("/server/config/set/general/debug/test%252Fslash").flush("{}"); configService.set("general", "debug", "test\"doublequote").subscribe( DoNothing ); httpMock.expectOne("/server/config/set/general/debug/test%2522doublequote").flush("{}"); configService.set("general", "debug", "/test/leadingslash").subscribe(DoNothing); httpMock.expectOne("/server/config/set/general/debug/%252Ftest%252Fleadingslash").flush("{}"); httpMock.verify(); }); it("should return error on setting non-existing section", () => { // first connect httpMock.expectOne("/server/config/get").flush("{}"); let configSubscriberIndex = 0; configService.set("bad_section", "debug", true).subscribe({ next: reaction => { configSubscriberIndex++; expect(reaction.success).toBe(false); expect(reaction.errorMessage).toBe("Config has no option named bad_section.debug"); } }); expect(configSubscriberIndex).toBe(1); httpMock.verify(); }); it("should return error on setting non-existing option", () => { // first connect httpMock.expectOne("/server/config/get").flush("{}"); let configSubscriberIndex = 0; configService.set("general", "bad_option", true).subscribe({ next: reaction => { configSubscriberIndex++; expect(reaction.success).toBe(false); expect(reaction.errorMessage).toBe("Config has no option named general.bad_option"); } }); expect(configSubscriberIndex).toBe(1); httpMock.verify(); }); it("should return error on empty value", () => { // first connect httpMock.expectOne("/server/config/get").flush("{}"); let configSubscriberIndex = 0; configService.set("general", "debug", "").subscribe({ next: reaction => { configSubscriberIndex++; expect(reaction.success).toBe(false); expect(reaction.errorMessage).toBe("Setting general.debug cannot be blank."); } }); expect(configSubscriberIndex).toBe(1); httpMock.verify(); }); it("should send updated config on a successful set", () => { const configJson = {general: {debug: false}}; // first connect httpMock.expectOne("/server/config/get").flush(configJson); const configExpected = [ new Config({general: {debug: false}}), new Config({general: {debug: true}}) ]; let configSubscriberIndex = 0; configService.config.subscribe({ next: config => { expect(Immutable.is(config, configExpected[configSubscriberIndex++])).toBe(true); } }); // issue the set configService.set("general", "debug", true).subscribe(DoNothing); // set request httpMock.expectOne("/server/config/set/general/debug/true").flush(""); expect(configSubscriberIndex).toBe(2); httpMock.verify(); }); it("should NOT send updated config on a failed set", () => { const configJson = {general: {debug: false}}; // first connect httpMock.expectOne("/server/config/get").flush(configJson); const configExpected = [ new Config({general: {debug: false}}) ]; let configSubscriberIndex = 0; configService.config.subscribe({ next: config => { expect(Immutable.is(config, configExpected[configSubscriberIndex++])).toBe(true); } }); // issue the set configService.set("general", "debug", true).subscribe(DoNothing); // set request httpMock.expectOne("/server/config/set/general/debug/true").flush( "Not found", {status: 404, statusText: "Bad Request"} ); expect(configSubscriberIndex).toBe(1); httpMock.verify(); }); }); ================================================ FILE: src/angular/src/app/tests/unittests/services/settings/config.spec.ts ================================================ import * as Immutable from "immutable"; import {Config} from "../../../../services/settings/config"; describe("Testing config record initialization", () => { let config: Config; beforeEach(() => { const configJson = { general: { debug: true }, lftp: { remote_address: "remote.server.com", remote_username: "some.user", remote_password: "my.password", remote_port: 3456, remote_path: "/some/remote/path", local_path: "/some/local/path", remote_path_to_scan_script: "/another/remote/path", use_ssh_key: true, num_max_parallel_downloads: 2, num_max_parallel_files_per_download: 8, num_max_connections_per_root_file: 32, num_max_connections_per_dir_file: 4, num_max_total_connections: 32, use_temp_file: true }, controller: { interval_ms_remote_scan: 30000, interval_ms_local_scan: 10000, interval_ms_downloading_scan: 1000, extract_path: "/path/to/extract", use_local_path_as_extract_path: true, }, web: { port: 8800 }, autoqueue: { enabled: true, patterns_only: false, auto_extract: true, } }; config = new Config(configJson); }); it("should initialize with correct values", () => { expect(config.general.debug).toBe(true); expect(config.lftp.remote_address).toBe("remote.server.com"); expect(config.lftp.remote_username).toBe("some.user"); expect(config.lftp.remote_password).toBe("my.password"); expect(config.lftp.remote_port).toBe(3456); expect(config.lftp.remote_path).toBe("/some/remote/path"); expect(config.lftp.local_path).toBe("/some/local/path"); expect(config.lftp.remote_path_to_scan_script).toBe("/another/remote/path"); expect(config.lftp.use_ssh_key).toBe(true); expect(config.lftp.num_max_parallel_downloads).toBe(2); expect(config.lftp.num_max_parallel_files_per_download).toBe(8); expect(config.lftp.num_max_connections_per_root_file).toBe(32); expect(config.lftp.num_max_connections_per_dir_file).toBe(4); expect(config.lftp.num_max_total_connections).toBe(32); expect(config.lftp.use_temp_file).toBe(true); expect(config.controller.interval_ms_remote_scan).toBe(30000); expect(config.controller.interval_ms_local_scan).toBe(10000); expect(config.controller.interval_ms_downloading_scan).toBe(1000); expect(config.controller.extract_path).toBe("/path/to/extract"); expect(config.controller.use_local_path_as_extract_path).toBe(true); expect(config.web.port).toBe(8800); expect(config.autoqueue.enabled).toBe(true); expect(config.autoqueue.patterns_only).toBe(false); expect(config.autoqueue.auto_extract).toBe(true); }); it("should be immutable", () => { expect(config instanceof Immutable.Record).toBe(true); }); it("should have immutable members", () => { expect(config.general instanceof Immutable.Record).toBe(true); expect(config.lftp instanceof Immutable.Record).toBe(true); expect(config.controller instanceof Immutable.Record).toBe(true); expect(config.web instanceof Immutable.Record).toBe(true); expect(config.autoqueue instanceof Immutable.Record).toBe(true); }); }); ================================================ FILE: src/angular/src/app/tests/unittests/services/utils/connected.service.spec.ts ================================================ import {fakeAsync, TestBed, tick} from "@angular/core/testing"; import {ConnectedService} from "../../../../services/utils/connected.service"; describe("Testing connected service", () => { let connectedService: ConnectedService; let connectedResults: boolean[]; beforeEach(fakeAsync(() => { TestBed.configureTestingModule({ providers: [ ConnectedService ] }); connectedService = TestBed.get(ConnectedService); connectedResults = []; connectedService.connected.subscribe({ next: connected => { connectedResults.push(connected); } }); tick(); })); it("should create an instance", () => { expect(connectedService).toBeDefined(); }); it("should start off unconnected", () => { expect(connectedResults).toEqual([false]); }); it("should notify on first connection success", fakeAsync(() => { connectedService.notifyConnected(); tick(); expect(connectedResults).toEqual([false, true]); })); it("should NOT notify on first connection failure", fakeAsync(() => { connectedService.notifyDisconnected(); tick(); expect(connectedResults).toEqual([false]); })); it("should notify on disconnection", fakeAsync(() => { connectedService.notifyConnected(); tick(); connectedService.notifyDisconnected(); tick(); expect(connectedResults).toEqual([false, true, false]); tick(); })); it("should notify on re-connection", fakeAsync(() => { connectedService.notifyConnected(); tick(); connectedService.notifyDisconnected(); tick(); connectedService.notifyConnected(); tick(); expect(connectedResults).toEqual([false, true, false, true]); })); it("should NOT notify on repeated disconnection", fakeAsync(() => { connectedService.notifyConnected(); tick(); connectedService.notifyDisconnected(); tick(); connectedService.notifyDisconnected(); tick(); expect(connectedResults).toEqual([false, true, false]); })); it("should NOT notify on repeated re-connection", fakeAsync(() => { connectedService.notifyConnected(); tick(); connectedService.notifyConnected(); tick(); expect(connectedResults).toEqual([false, true]); })); }); ================================================ FILE: src/angular/src/app/tests/unittests/services/utils/dom.service.spec.ts ================================================ import {fakeAsync, TestBed, tick} from "@angular/core/testing"; import {DomService} from "../../../../services/utils/dom.service"; describe("Testing view file options service", () => { let domService: DomService; beforeEach(() => { TestBed.configureTestingModule({ providers: [ DomService, ] }); domService = TestBed.get(DomService); }); it("should create an instance", () => { expect(domService).toBeDefined(); }); it("should forward updates to headerHeight", fakeAsync(() => { let count = 0; let headerHeight = null; domService.headerHeight.subscribe({ next: height => { headerHeight = height; count++; } }); tick(); expect(count).toBe(1); domService.setHeaderHeight(10); tick(); expect(headerHeight).toBe(10); expect(count).toBe(2); domService.setHeaderHeight(20); tick(); expect(headerHeight).toBe(20); expect(count).toBe(3); // Setting same value shouldn't trigger an update domService.setHeaderHeight(20); tick(); expect(headerHeight).toBe(20); expect(count).toBe(3); })); }); ================================================ FILE: src/angular/src/app/tests/unittests/services/utils/notification.service.spec.ts ================================================ import {fakeAsync, TestBed, tick} from "@angular/core/testing"; import * as Immutable from "immutable"; import {NotificationService} from "../../../../services/utils/notification.service"; import {LoggerService} from "../../../../services/utils/logger.service"; import {Notification} from "../../../../services/utils/notification"; class TestNotificationService extends NotificationService { } describe("Testing notification service", () => { let notificationService: TestNotificationService; beforeEach(() => { TestBed.configureTestingModule({ providers: [ LoggerService, {provide: NotificationService, useClass: TestNotificationService}, ] }); notificationService = TestBed.get(NotificationService); }); it("should create an instance", () => { expect(notificationService).toBeDefined(); }); it("should show notification", fakeAsync(() => { const expectedNotification = new Notification({level: Notification.Level.DANGER, text: "danger"}); notificationService.show(expectedNotification); let actualCount = 0; notificationService.notifications.subscribe({ next: list => { expect(list.size).toBe(1); expect(Immutable.is(expectedNotification, list.get(0))).toBe(true); actualCount++; } }); tick(); expect(actualCount).toBe(1); })); it("should hide notification", fakeAsync(() => { const expectedNotification = new Notification({level: Notification.Level.DANGER, text: "danger"}); notificationService.show(expectedNotification); tick(); notificationService.hide(expectedNotification); let actualCount = 0; notificationService.notifications.subscribe({ next: list => { expect(list.size).toBe(0); actualCount++; } }); tick(); expect(actualCount).toBe(1); })); it("should only send one update if show is called twice", fakeAsync(() => { const expectedNotification = new Notification({level: Notification.Level.DANGER, text: "danger"}); notificationService.show(expectedNotification); let actualCount = 0; // noinspection JSUnusedLocalSymbols notificationService.notifications.subscribe({ next: list => { actualCount++; } }); tick(); notificationService.show(expectedNotification); tick(); expect(actualCount).toBe(1); })); it("should only send one update if hide is called twice", fakeAsync(() => { const expectedNotification = new Notification({level: Notification.Level.DANGER, text: "danger"}); notificationService.show(expectedNotification); tick(); notificationService.hide(expectedNotification); let actualCount = 0; // noinspection JSUnusedLocalSymbols notificationService.notifications.subscribe({ next: list => { actualCount++; } }); tick(); notificationService.hide(expectedNotification); tick(); expect(actualCount).toBe(1); })); it("should sort notifications by level", fakeAsync(() => { const noteDanger = new Notification({level: Notification.Level.DANGER, text: "danger"}); const noteInfo = new Notification({level: Notification.Level.INFO, text: "info"}); const noteWarning = new Notification({level: Notification.Level.WARNING, text: "warning"}); const noteSuccess = new Notification({level: Notification.Level.SUCCESS, text: "success"}); notificationService.show(noteDanger); notificationService.show(noteInfo); notificationService.show(noteWarning); notificationService.show(noteSuccess); let actualCount = 0; notificationService.notifications.subscribe({ next: list => { expect(list.size).toBe(4); expect(Immutable.is(noteDanger, list.get(0))).toBe(true); expect(Immutable.is(noteWarning, list.get(1))).toBe(true); expect(Immutable.is(noteInfo, list.get(2))).toBe(true); expect(Immutable.is(noteSuccess, list.get(3))).toBe(true); actualCount++; } }); tick(); expect(actualCount).toBe(1); })); it("should sort notifications by timestamp", fakeAsync(() => { function sleepFor( sleepDuration ) { const now = new Date().getTime(); while (new Date().getTime() < now + sleepDuration) { /* do nothing */ } } // Sleep a little between inits const noteOlder = new Notification({level: Notification.Level.DANGER, text: "older"}); sleepFor(10); const noteNewer = new Notification({level: Notification.Level.DANGER, text: "newer"}); sleepFor(10); const noteNewest = new Notification({level: Notification.Level.DANGER, text: "newest"}); notificationService.show(noteNewer); notificationService.show(noteNewest); notificationService.show(noteOlder); let actualCount = 0; notificationService.notifications.subscribe({ next: list => { expect(list.size).toBe(3); expect(Immutable.is(noteNewest, list.get(0))).toBe(true); expect(Immutable.is(noteNewer, list.get(1))).toBe(true); expect(Immutable.is(noteOlder, list.get(2))).toBe(true); actualCount++; } }); tick(); expect(actualCount).toBe(1); })); it("should sort notifications by level first, then timestamp", fakeAsync(() => { function sleepFor( sleepDuration ) { const now = new Date().getTime(); while (new Date().getTime() < now + sleepDuration) { /* do nothing */ } } // Sleep a little between inits const noteOlder = new Notification({level: Notification.Level.DANGER, text: "older"}); sleepFor(10); const noteNewer = new Notification({level: Notification.Level.INFO, text: "newer"}); sleepFor(10); const noteNewest = new Notification({level: Notification.Level.INFO, text: "newest"}); notificationService.show(noteNewer); notificationService.show(noteNewest); notificationService.show(noteOlder); let actualCount = 0; notificationService.notifications.subscribe({ next: list => { expect(list.size).toBe(3); expect(Immutable.is(noteOlder, list.get(0))).toBe(true); expect(Immutable.is(noteNewest, list.get(1))).toBe(true); expect(Immutable.is(noteNewer, list.get(2))).toBe(true); actualCount++; } }); tick(); expect(actualCount).toBe(1); })); }); ================================================ FILE: src/angular/src/app/tests/unittests/services/utils/rest.service.spec.ts ================================================ import {fakeAsync, TestBed} from "@angular/core/testing"; import {HttpClientTestingModule, HttpTestingController} from "@angular/common/http/testing"; import {LoggerService} from "../../../../services/utils/logger.service"; import {RestService} from "../../../../services/utils/rest.service"; describe("Testing rest service", () => { let restService: RestService; let httpMock: HttpTestingController; beforeEach(() => { TestBed.configureTestingModule({ imports: [ HttpClientTestingModule ], providers: [ RestService, LoggerService, ] }); httpMock = TestBed.get(HttpTestingController); restService = TestBed.get(RestService); }); it("should create an instance", () => { expect(restService).toBeDefined(); }); it("should send http GET on sendRequest", fakeAsync(() => { let subscriberIndex = 0; restService.sendRequest("/server/request").subscribe({ next: reaction => { subscriberIndex++; expect(reaction.success).toBe(true); } }); httpMock.expectOne("/server/request").flush("success"); expect(subscriberIndex).toBe(1); httpMock.verify(); })); it("should return correct data on sendRequest", fakeAsync(() => { let subscriberIndex = 0; restService.sendRequest("/server/request").subscribe({ next: reaction => { subscriberIndex++; expect(reaction.success).toBe(true); expect(reaction.data).toBe("this is some data"); } }); httpMock.expectOne("/server/request").flush("this is some data"); expect(subscriberIndex).toBe(1); httpMock.verify(); })); it("should get error message on sendRequest error 404", fakeAsync(() => { let subscriberIndex = 0; restService.sendRequest("/server/request").subscribe({ next: reaction => { subscriberIndex++; expect(reaction.success).toBe(false); expect(reaction.errorMessage).toBe("Not found"); } }); httpMock.expectOne("/server/request").flush( "Not found", {status: 404, statusText: "Bad Request"} ); expect(subscriberIndex).toBe(1); httpMock.verify(); })); it("should get error message on sendRequest network error", fakeAsync(() => { let subscriberIndex = 0; restService.sendRequest("/server/request").subscribe({ next: reaction => { subscriberIndex++; expect(reaction.success).toBe(false); expect(reaction.errorMessage).toBe("mock error"); } }); httpMock.expectOne("/server/request").error(new ErrorEvent("mock error")); expect(subscriberIndex).toBe(1); httpMock.verify(); })); }); ================================================ FILE: src/angular/src/app/tests/unittests/services/utils/version-check.service.spec.ts ================================================ import {fakeAsync, TestBed, tick} from "@angular/core/testing"; import * as compareVersions from "compare-versions"; import {VersionCheckService} from "../../../../services/utils/version-check.service"; import {RestService, WebReaction} from "../../../../services/utils/rest.service"; import {NotificationService} from "../../../../services/utils/notification.service"; import {LoggerService} from "../../../../services/utils/logger.service"; import {MockRestService} from "../../../mocks/mock-rest.service"; import {Subject} from "rxjs/Subject"; describe("Testing version check service", () => { let versionCheckService: VersionCheckService; let notifService: NotificationService; let restService: RestService; let sendRequestSpy = null; beforeEach(() => { TestBed.configureTestingModule({ providers: [ VersionCheckService, LoggerService, NotificationService, {provide: RestService, useClass: MockRestService}, ] }); notifService = TestBed.get(NotificationService); restService = TestBed.get(RestService); spyOn(notifService, "show"); sendRequestSpy = spyOn(restService, "sendRequest").and.returnValue( new Subject()); versionCheckService = TestBed.get(VersionCheckService); }); function createVersionCheckService(): VersionCheckService { return new VersionCheckService( restService, notifService, TestBed.get(LoggerService) ); } it("should create an instance", () => { expect(versionCheckService).toBeDefined(); }); it("should request the correct github url", fakeAsync(() => { expect(restService.sendRequest).toHaveBeenCalledWith( "https://api.github.com/repos/ipsingh06/seedsync/releases/latest" ); })); it("should fail gracefully on failed request to github", fakeAsync(() => { const subject = new Subject(); sendRequestSpy.and.returnValue(subject); // Recreate the service versionCheckService = createVersionCheckService(); subject.next(new WebReaction(false, null, "some error")); tick(); expect(notifService.show).not.toHaveBeenCalled(); })); it("should fail gracefully on garbage data from github", fakeAsync(() => { const subject = new Subject(); sendRequestSpy.and.returnValue(subject); // Recreate the service versionCheckService = createVersionCheckService(); subject.next(new WebReaction(true, "garbage data", null)); tick(); expect(notifService.show).not.toHaveBeenCalled(); })); it("should fire a notification on new version", fakeAsync(() => { const subject = new Subject(); sendRequestSpy.and.returnValue(subject); // Note: can't spy on compareVersions, so just replace the private method instead spyOn(VersionCheckService, "isVersionNewer").and.returnValue(true); // Recreate the service versionCheckService = createVersionCheckService(); subject.next(new WebReaction(true, JSON.stringify({"tag_name": "v0.0-0"}), null)); tick(); expect(notifService.show).toHaveBeenCalled(); })); it("should not fire a notification on old version", fakeAsync(() => { const subject = new Subject(); sendRequestSpy.and.returnValue(subject); // Note: can't spy on compareVersions, so just replace the private method instead spyOn(VersionCheckService, "isVersionNewer").and.returnValue(false); // Recreate the service versionCheckService = createVersionCheckService(); subject.next(new WebReaction(true, JSON.stringify({"tag_name": "v0.0-0"}), null)); tick(); expect(notifService.show).not.toHaveBeenCalled(); })); }); ================================================ FILE: src/angular/src/assets/.gitkeep ================================================ ================================================ FILE: src/angular/src/environments/environment.prod.ts ================================================ import {LoggerService} from "../app/services/utils/logger.service" export const environment = { production: true, logger: { level: LoggerService.Level.WARN } }; ================================================ FILE: src/angular/src/environments/environment.ts ================================================ // The file contents for the current environment will overwrite these during build. // The build system defaults to the dev environment which uses `environment.ts`, but if you do // `ng build --env=prod` then `environment.prod.ts` will be used instead. // The list of which env maps to which file can be found in `.angular-cli.json`. import {LoggerService} from "../app/services/utils/logger.service" export const environment = { production: false, logger: { level: LoggerService.Level.DEBUG } }; ================================================ FILE: src/angular/src/index.html ================================================ SeedSync ================================================ FILE: src/angular/src/main.ts ================================================ import {enableProdMode} from '@angular/core'; import {platformBrowserDynamic} from '@angular/platform-browser-dynamic'; import {AppModule} from './app/app.module'; import {environment} from './environments/environment'; if (environment.production) { enableProdMode(); } platformBrowserDynamic().bootstrapModule(AppModule); ================================================ FILE: src/angular/src/polyfills.ts ================================================ /** * This file includes polyfills needed by Angular and is loaded before the app. * You can add your own extra polyfills to this file. * * This file is divided into 2 sections: * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. * 2. Application imports. Files imported after ZoneJS that should be loaded before your main * file. * * The current setup is for so-called "evergreen" browsers; the last versions of browsers that * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. * * Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html */ /*************************************************************************************************** * BROWSER POLYFILLS */ /** IE9, IE10 and IE11 requires all of the following polyfills. **/ // import 'core-js/es6/symbol'; // import 'core-js/es6/object'; // import 'core-js/es6/function'; // import 'core-js/es6/parse-int'; // import 'core-js/es6/parse-float'; // import 'core-js/es6/number'; // import 'core-js/es6/math'; // import 'core-js/es6/string'; // import 'core-js/es6/date'; // import 'core-js/es6/array'; // import 'core-js/es6/regexp'; // import 'core-js/es6/map'; // import 'core-js/es6/weak-map'; // import 'core-js/es6/set'; /** IE10 and IE11 requires the following for NgClass support on SVG elements */ // import 'classlist.js'; // Run `npm install --save classlist.js`. /** Evergreen browsers require these. **/ import 'core-js/es6/reflect'; import 'core-js/es7/reflect'; /** * Required to support Web Animations `@angular/animation`. * Needed for: All but Chrome, Firefox and Opera. http://caniuse.com/#feat=web-animation **/ // import 'web-animations-js'; // Run `npm install --save web-animations-js`. /*************************************************************************************************** * Zone JS is required by Angular itself. */ import 'zone.js/dist/zone'; // Included with Angular CLI. /*************************************************************************************************** * APPLICATION IMPORTS */ /** * Date, currency, decimal and percent pipes. * Needed for: All but Chrome, Firefox, Edge, IE11 and Safari 10 */ // import 'intl'; // Run `npm install --save intl`. /** * Need to import at least one locale-data with intl. */ // import 'intl/locale-data/jsonp/en'; ================================================ FILE: src/angular/src/styles.scss ================================================ @import 'app/common/common'; html { -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; -moz-text-size-adjust: 100%; } html, body { font-family: Verdana,sans-serif; font-size: 15px; line-height: 1.5; margin: 0; } /* show the input search cancel button */ input[type=search]::-webkit-search-cancel-button { -webkit-appearance: searchfield-cancel-button; } div { /*border: 1px solid black;*/ /* make padding and border inside box */ box-sizing: border-box; } // Bootstrap modifications .modal-body { /* break up long text */ overflow-wrap: normal; hyphens: auto; word-break: break-word; } ================================================ FILE: src/angular/src/test.ts ================================================ // This file is required by karma.conf.js and loads recursively all the .spec and framework files import 'zone.js/dist/long-stack-trace-zone'; import 'zone.js/dist/proxy.js'; import 'zone.js/dist/sync-test'; import 'zone.js/dist/jasmine-patch'; import 'zone.js/dist/async-test'; import 'zone.js/dist/fake-async-test'; import {getTestBed} from '@angular/core/testing'; import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; // Unfortunately there's no typing for the `__karma__` variable. Just declare it as any. declare const __karma__: any; declare const require: any; // Prevent Karma from running prematurely. __karma__.loaded = function () {}; // First, initialize the Angular testing environment. getTestBed().initTestEnvironment( BrowserDynamicTestingModule, platformBrowserDynamicTesting() ); // Then we find all the tests. const context = require.context('./', true, /\.spec\.ts$/); // And load the modules. context.keys().map(context); // Finally, start Karma to run the tests. __karma__.start(); ================================================ FILE: src/angular/src/tsconfig.app.json ================================================ { "extends": "../tsconfig.json", "compilerOptions": { "outDir": "../out-tsc/app", "baseUrl": "./", "module": "commonjs", "types": [] }, "exclude": [ "test.ts", "**/*.spec.ts" ] } ================================================ FILE: src/angular/src/tsconfig.spec.json ================================================ { "extends": "../tsconfig.json", "compilerOptions": { "outDir": "../out-tsc/spec", "baseUrl": "./", "module": "commonjs", "target": "es5", "types": [ "jasmine", "node" ] }, "files": [ "test.ts" ], "include": [ "**/*.spec.ts", "**/*.d.ts" ] } ================================================ FILE: src/angular/src/typings.d.ts ================================================ /* SystemJS module definition */ declare var module: NodeModule; interface NodeModule { id: string; } ================================================ FILE: src/angular/tsconfig.json ================================================ { "compileOnSave": false, "compilerOptions": { "outDir": "./dist/out-tsc", "sourceMap": true, "declaration": false, "moduleResolution": "node", "emitDecoratorMetadata": true, "experimentalDecorators": true, "target": "es5", "typeRoots": [ "node_modules/@types" ], "lib": [ "es2017", "dom" ] } } ================================================ FILE: src/angular/tslint.json ================================================ { "rulesDirectory": [ "node_modules/codelyzer" ], "rules": { "arrow-return-shorthand": true, "callable-types": true, "class-name": true, "comment-format": [ true, "check-space" ], "curly": true, "eofline": true, "forin": true, "import-blacklist": [ true, "rxjs" ], "import-spacing": true, "indent": [ true, "spaces" ], "interface-over-type-literal": true, "label-position": true, "max-line-length": [ true, 140 ], "member-access": false, "no-arg": true, "no-bitwise": true, "no-console": [ true, "debug", "info", "time", "timeEnd", "trace" ], "no-construct": true, "no-debugger": true, "no-duplicate-super": true, "no-empty": false, "no-empty-interface": true, "no-eval": true, "no-inferrable-types": [ true, "ignore-params" ], "no-misused-new": true, "no-non-null-assertion": true, "no-shadowed-variable": true, "no-string-literal": false, "no-string-throw": true, "no-switch-case-fall-through": true, "no-trailing-whitespace": true, "no-unnecessary-initializer": true, "no-unused-expression": true, "no-use-before-declare": true, "no-var-keyword": true, "object-literal-sort-keys": false, "one-line": [ true, "check-open-brace", "check-catch", "check-else", "check-whitespace" ], "prefer-const": true, "quotemark": [ true, "double" ], "radix": true, "semicolon": [ true, "always" ], "triple-equals": [ true, "allow-null-check" ], "typedef-whitespace": [ true, { "call-signature": "nospace", "index-signature": "nospace", "parameter": "nospace", "property-declaration": "nospace", "variable-declaration": "nospace" } ], "typeof-compare": true, "unified-signatures": true, "variable-name": false, "whitespace": [ true, "check-branch", "check-decl", "check-operator", "check-separator", "check-type" ], "directive-selector": [ true, "attribute", "app", "camelCase" ], "component-selector": [ true, "element", "app", "kebab-case" ], "use-input-property-decorator": true, "use-output-property-decorator": true, "use-host-property-decorator": true, "no-input-rename": true, "no-output-rename": true, "use-life-cycle-interface": true, "use-pipe-transform-interface": true, "component-class-suffix": true, "directive-class-suffix": true, "no-access-missing-member": true, "templates-use-public": true, "invoke-injectable": true } } ================================================ FILE: src/debian/changelog ================================================ seedsync (0.8.6) stable; urgency=low * Fixed broken rar extraction in previous release -- Inderpreet Singh Wed, 30 Dec 2020 18:10:14 -0800 seedsync (0.8.5) stable; urgency=low * Fixed errors caused by non-utf8 characters in file names * Reduced docker image size by switching to a slim base -- Inderpreet Singh Wed, 30 Dec 2020 17:20:54 -0800 seedsync (0.8.4) stable; urgency=low * Support for Raspberry Pi (armv7 and armv8 architectures) * Fixed more lftp parser errors -- Inderpreet Singh Wed, 19 Aug 2020 15:34:11 -0700 seedsync (0.8.3) stable; urgency=low * Ignore non-consecutive lftp parser errors -- Inderpreet Singh Mon, 29 Jun 2020 21:57:23 -0700 seedsync (0.8.2) stable; urgency=low * Fixed more lftp parser errors -- Inderpreet Singh Fri, 26 Jun 2020 15:27:43 -0700 seedsync (0.8.1) stable; urgency=low * Fixed another lftp parser error -- Inderpreet Singh Wed, 17 Jun 2020 23:40:16 -0700 seedsync (0.8.0) stable; urgency=low * Remote server disconnections no longer stop the app * Fixed an lftp parser error * Remote server connection error messages are more descriptive -- Inderpreet Singh Tue, 09 Jun 2020 23:00:53 -0700 seedsync (0.7.3) stable; urgency=low * Fixed docker image scp error with unknown user -- Inderpreet Singh Sun, 31 May 2020 18:50:38 -0700 seedsync (0.7.2) stable; urgency=low * Fixed docker image ssh directory path -- Inderpreet Singh Sun, 31 May 2020 18:06:32 -0700 seedsync (0.7.1) stable; urgency=low * Support for Ubuntu 20.04 (debian package) * Support for arbitrary host uid in Docker image * Fixed some lftp parsing errors -- Inderpreet Singh Sat, 30 May 2020 23:26:59 -0700 seedsync (0.7.0) stable; urgency=low * Fixed 100% CPU utilization when idle * Fixed file permission issues with Docker application * Docker application is now architecture independent * Faster startup by skipping remote scanfs installation when possible * Show file created and modified timestamps in UI * Added UI option to sort list of files by name * Improved filtering UI * Improved settings UI on mobile devices * Improved logging UI * Remember some UI preferences * Support for wildcards in auto-queue patterns * Added a notification for newer releases of SeedSync * Various other bugfixes -- Inderpreet Singh Fri, 01 Feb 2019 01:11:07 -0800 seedsync (0.6.0) stable; urgency=low * Added support for password-based SSH login * Fixed some SSH errors -- Inderpreet Singh Wed, 21 Mar 2018 19:38:14 -0700 seedsync (0.5.1) stable; urgency=low * Added (hidden) setting to enable verbose logging * Fixed LFTP timeout errors crashing the app -- Inderpreet Singh Wed, 14 Mar 2018 00:37:42 -0700 seedsync (0.5.0) stable; urgency=low * Added LFTP option to rename downloading/unfinished files -- Inderpreet Singh Fri, 09 Mar 2018 15:21:29 -0800 seedsync (0.4.0) stable; urgency=low * Docker image released * Fixed auto-queue not working when patterns list empty * Fixed zombie lftp process after exit -- Inderpreet Singh Thu, 08 Mar 2018 02:31:30 -0800 seedsync (0.3.0) stable; urgency=low * Added ability to extract archive files * Added ability to delete files on local and remote server * Added ability to automatically extract auto-queued files * Shiny new icons * Added UI notification when waiting for remote server to respond * Fixed service exiting on 'text file is busy' error * Various UI improvements -- Inderpreet Singh Tue, 06 Mar 2018 02:28:12 -0800 seedsync (0.2.0) stable; urgency=low * Added option for remote SSH port * Added ability to view log in web GUI * Added option to enable/disable pattern-restricted AutoQueue * Added option to enable/disable AutoQueue entirely * Fixed AutoQueue not re-queuing file when it changes on remote server * Minor UI improvements -- Inderpreet Singh Thu, 04 Jan 2018 02:00:54 -0800 seedsync (0.1.0) stable; urgency=low * Initial Release -- Inderpreet Singh Sat, 23 Dec 2017 14:36:35 -0800 ================================================ FILE: src/debian/compat ================================================ 10 ================================================ FILE: src/debian/config ================================================ #!/bin/sh -e # Source debconf library. . /usr/share/debconf/confmodule # Ask for username db_input high seedsync/username || true db_go || true ================================================ FILE: src/debian/control ================================================ Source: seedsync Section: utils Priority: extra Maintainer: Inderpreet Singh Build-Depends: debhelper (>= 8.0.0), dh-systemd (>= 1.5) Standards-Version: 4.0.0 Package: seedsync Architecture: amd64 Depends: ${shlibs:Depends}, ${misc:Depends}, lftp, openssh-client Pre-Depends: debconf (>= 0.2.17) Description: fully GUI-configurable, lftp-based file transfer and management program seedsync is a lftp-based file transfer program to keep your local server synchronized with your remote seedbox. It features a web-based GUI to fully configure lftp settings as well as view the transfer status. It additionally allows you to extract archives and delete files on both the local and remote server, all from the web-based GUI. ================================================ FILE: src/debian/postinst ================================================ #!/bin/sh OVERRIDE_DIR=/etc/systemd/system/seedsync.service.d OVERRIDE_FILE=override.conf #!/bin/sh -e # Source debconf library. . /usr/share/debconf/confmodule db_get seedsync/username USER=$RET if [ -z "${USER}" ]; then echo "Skipping user override" else echo "Setting user to ${USER}" if [ "$RET" = "root" ]; then rm -rf ${OVERRIDE_DIR}/${OVERRIDE_FILE} else mkdir -p ${OVERRIDE_DIR} echo [Service]\\nUser=${USER}\\nEnvironment=\"HOME=/home/${USER}\" > ${OVERRIDE_DIR}/${OVERRIDE_FILE} fi fi #DEBHELPER# ================================================ FILE: src/debian/postrm ================================================ #!/bin/sh if [ "$1" = "purge" ]; then rm -rf /etc/systemd/system/seedsync.service.d fi #DEBHELPER# ================================================ FILE: src/debian/rules ================================================ #!/usr/bin/make -f export DESTROOT=$(CURDIR)/debian/seedsync %: dh $@ --with=systemd override_dh_auto_build: dh_auto_build override_dh_auto_install: dh_auto_install mkdir -p $(DESTROOT)/usr/lib cp -rf seedsync $(DESTROOT)/usr/lib/ override_dh_shlibdeps: dh_shlibdeps -l$(CURDIR)/seedsync ================================================ FILE: src/debian/seedsync.service ================================================ # Service unit for seedsync # Note: User should be overridden in # /etc/systemd/system/seedsync.service.d/override.conf [Unit] Description=Job that runs the SeedSync daemon Requires=local-fs.target network-online.target After=local-fs.target network-online.target [Service] Type=simple User=root Environment="HOME=/root" ExecStartPre=/bin/mkdir -p ${HOME}/.seedsync ExecStartPre=/bin/mkdir -p ${HOME}/.seedsync/log ExecStart=/usr/lib/seedsync/seedsync --logdir ${HOME}/.seedsync/log -c ${HOME}/.seedsync [Install] WantedBy=multi-user.target ================================================ FILE: src/debian/source/format ================================================ 3.0 (native) ================================================ FILE: src/debian/templates ================================================ Template: seedsync/username Type: string Default: root Description: User for SeedSync service The service will run under this user. All transferred files will be owned by this user. ================================================ FILE: src/docker/build/deb/Dockerfile ================================================ # Creates environment to build python binaries FROM ubuntu:16.04 as seedsync_build_pyinstaller_env RUN apt-get update && apt-get install -y software-properties-common RUN add-apt-repository ppa:deadsnakes/ppa && \ apt-get update && apt-get install -y \ python3.8 \ python3.8-dev \ python3.8-distutils \ curl \ binutils # Switch to Python 3.8 RUN update-alternatives --install /usr/bin/python python /usr/bin/python3.8 1 RUN update-alternatives --set python /usr/bin/python3.8 RUN update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.8 1 RUN update-alternatives --set python3 /usr/bin/python3.8 # Install Poetry RUN curl -s https://bootstrap.pypa.io/get-pip.py -o get-pip.py && \ python get-pip.py --force-reinstall && \ rm get-pip.py RUN pip3 install poetry RUN poetry config virtualenvs.create false COPY src/python/pyproject.toml /app/python/ COPY src/python/poetry.lock /app/python/ WORKDIR /python ENV LC_ALL=C.UTF-8 ENV LANG=C.UTF-8 RUN cd /app/python && poetry install # Builds seedsync with pyinstaller # Output is in /build/dist/seedsync/ FROM seedsync_build_pyinstaller_env as seedsync_build_pyinstaller COPY src/python /python COPY src/pyinstaller_hooks /pyinstaller_hooks RUN mkdir -p /build RUN pyinstaller /python/seedsync.py \ -y \ -p /python \ --distpath /build/dist \ --workpath /build/work \ --specpath /build \ --additional-hooks-dir /pyinstaller_hooks/ \ --hidden-import="pkg_resources.py2_warn" \ --name seedsync # Builds scanfs with pyinstaller # Output is in /build/dist/ FROM seedsync_build_pyinstaller_env as seedsync_build_scanfs COPY src/python /python RUN mkdir -p /build RUN pyinstaller /python/scan_fs.py \ -y \ --onefile \ -p /python \ --distpath /build/dist \ --workpath /build/work \ --specpath /build \ --name scanfs # Creates environment for angular FROM node:12.16 as seedsync_build_angular_env COPY src/angular/package*.json /app/ WORKDIR /app RUN npm install # Builds angular app into html # Output is in /build/dist/ FROM seedsync_build_angular_env as seedsync_build_angular COPY src/angular /app WORKDIR /app RUN node_modules/@angular/cli/bin/ng build -prod --output-path /build/dist/ # Creates environment to build deb packages FROM ubuntu:16.04 as seedsync_build_deb_env RUN apt-get update RUN apt-get install -y build-essential dh-systemd debhelper RUN apt-get install -y devscripts # Builds debian package # Output is in /build/dist/ FROM seedsync_build_deb_env as seedsync_build_deb RUN mkdir -p /build/work COPY src/debian /build/work/debian COPY --from=seedsync_build_pyinstaller /build/dist/seedsync /build/work/seedsync COPY --from=seedsync_build_scanfs /build/dist/scanfs /build/work/seedsync/ COPY --from=seedsync_build_angular /build/dist /build/work/seedsync/html WORKDIR /build/work RUN dpkg-buildpackage -B -uc -us RUN ls /build/*.deb && echo "----" && ls /build/work # Exports deb package to host FROM scratch AS seedsync_build_deb_export COPY --from=seedsync_build_deb /build/*.deb . # Exports scanfs to host FROM scratch AS seedsync_build_scanfs_export COPY --from=seedsync_build_scanfs /build/dist/scanfs . # Exports html to host FROM scratch AS seedsync_build_angular_export COPY --from=seedsync_build_angular /build/dist ./html ================================================ FILE: src/docker/build/deb/Dockerfile.dockerignore ================================================ **/*.pyc **/__pycache__ **/node_modules **/.venv .git .idea build src/angular/dist src/python/build ================================================ FILE: src/docker/build/docker-image/Dockerfile ================================================ ARG STAGING_REGISTRY=localhost:5000 ARG STAGING_VERSION=latest # Creates environment to run Seedsync python code # Installs all python dependencies FROM python:3.8-slim as seedsync_run_python_env # Install dependencies RUN sed -i -e's/ main/ main contrib non-free/g' /etc/apt/sources.list && \ apt-get update && \ apt-get install -y \ gcc \ libssl-dev \ lftp \ openssh-client \ p7zip \ p7zip-full \ p7zip-rar \ bzip2 \ curl \ libnss-wrapper \ libxml2-dev libxslt-dev libffi-dev \ && apt-get clean # Fix for patoolib # See: https://github.com/wummel/patool/issues/90 RUN ln -s /usr/lib/p7zip/Codecs/Rar.so /usr/lib/p7zip/Codecs/Rar29.so # Install Poetry RUN curl -s https://bootstrap.pypa.io/get-pip.py -o get-pip.py && \ python get-pip.py --force-reinstall && \ rm get-pip.py RUN pip3 install poetry RUN poetry config virtualenvs.create false ENV LC_ALL=C.UTF-8 ENV LANG=C.UTF-8 # Install Python dependencies RUN mkdir -p /app COPY src/python/pyproject.toml /app/python/ COPY src/python/poetry.lock /app/python/ RUN cd /app/python && poetry install --no-dev # Creates environment for Python dev FROM seedsync_run_python_env as seedsync_run_python_devenv RUN cd /app/python && poetry install # Installs Seedsync python code FROM seedsync_run_python_env as seedsync_run_python RUN mkdir -p /app COPY src/python /app/python # Full Seedsync docker image FROM ${STAGING_REGISTRY}/seedsync/build/angular/export:${STAGING_VERSION} as seedsync_build_angular_export FROM ${STAGING_REGISTRY}/seedsync/build/scanfs/export:${STAGING_VERSION} as seedsync_build_scanfs_export FROM seedsync_run_python as seedsync_run COPY --from=seedsync_build_angular_export /html /app/html COPY --from=seedsync_build_scanfs_export /scanfs /app/scanfs COPY src/docker/build/docker-image/setup_default_config.sh /scripts/ # Disable the known hosts prompt RUN mkdir -p /root/.ssh && echo "StrictHostKeyChecking no\nUserKnownHostsFile /dev/null" > /root/.ssh/config # SSH as any user fix # https://stackoverflow.com/a/57531352 COPY src/docker/build/docker-image/run_as_user /usr/local/bin/ RUN chmod a+x /usr/local/bin/run_as_user COPY src/docker/build/docker-image/ssh /usr/local/sbin RUN chmod a+x /usr/local/sbin/ssh COPY src/docker/build/docker-image/scp /usr/local/sbin RUN chmod a+x /usr/local/sbin/scp # Create non-root user and directories under that user RUN groupadd -g 1000 seedsync && \ useradd -r -u 1000 -g seedsync seedsync RUN mkdir /config && \ mkdir /downloads && \ chown seedsync:seedsync /config && \ chown seedsync:seedsync /downloads # Switch to non-root user USER seedsync # First time config setup and replacement RUN /scripts/setup_default_config.sh # Must run app inside shell # Otherwise the container crashes as soon as a child process exits CMD [ \ "python", \ "/app/python/seedsync.py", \ "-c", "/config", \ "--html", "/app/html", \ "--scanfs", "/app/scanfs" \ ] EXPOSE 8800 ================================================ FILE: src/docker/build/docker-image/Dockerfile.dockerignore ================================================ **/*.pyc **/__pycache__ **/node_modules **/.venv .git .idea build src/angular/dist src/python/tests src/python/build ================================================ FILE: src/docker/build/docker-image/run_as_user ================================================ #!/bin/sh # run a command as (non-existent) user, using libnss-wrapper U=`id -u` G=`id -g` HOME_DIR=/home/seedsync PASSWD=/var/tmp/passwd GROUP=/var/tmp/group if [ ! -d "$HOME_DIR" ]; then mkdir "$HOME_DIR" fi if [ ! -f "$PASSWD" ]; then echo "user::$U:$G::$HOME_DIR:" > "$PASSWD" fi if [ ! -f "$GROUP" ]; then echo "user::$G:" > "$GROUP" fi LD_PRELOAD=libnss_wrapper.so NSS_WRAPPER_PASSWD="$PASSWD" NSS_WRAPPER_GROUP="$GROUP" "$@" ================================================ FILE: src/docker/build/docker-image/scp ================================================ #!/bin/sh SCP=/usr/bin/scp /usr/local/bin/run_as_user "$SCP" "$@" ================================================ FILE: src/docker/build/docker-image/setup_default_config.sh ================================================ #!/bin/bash # exit on first error set -e CONFIG_DIR="/config" SETTINGS_FILE="${CONFIG_DIR}/settings.cfg" SCRIPT_PATH="/app/python/seedsync.py" replace_setting() { NAME=$1 OLD_VALUE=$2 NEW_VALUE=$3 echo "Replacing ${NAME} from ${OLD_VALUE} to ${NEW_VALUE}" sed -i "s/${NAME} = ${OLD_VALUE}/${NAME} = ${NEW_VALUE}/" ${SETTINGS_FILE} && \ grep -q "${NAME} = ${NEW_VALUE}" ${SETTINGS_FILE} } # Generate default config python ${SCRIPT_PATH} \ -c ${CONFIG_DIR} \ --html / \ --scanfs / \ --exit > /dev/null 2>&1 > /dev/null || true cat ${SETTINGS_FILE} # Replace default values replace_setting 'local_path' '' '\/downloads\/' echo echo echo "Done configuring seedsync" cat ${SETTINGS_FILE} ================================================ FILE: src/docker/build/docker-image/ssh ================================================ #!/bin/sh SSH=/usr/bin/ssh /usr/local/bin/run_as_user "$SSH" "$@" ================================================ FILE: src/docker/stage/deb/Dockerfile ================================================ ARG BASE_IMAGE=ubuntu:16.04 FROM $BASE_IMAGE # Install dependencies RUN apt-get update && apt-get install -y \ sudo \ libssl-dev \ libexpat1 \ expect \ lftp \ openssh-client # Create non-root user RUN useradd --create-home -s /bin/bash user && \ echo "user:user" | chpasswd && adduser user sudo # Create directory for downloaded files RUN mkdir /downloads && \ chown user:user /downloads USER user # Add ssh keys for user, as user ADD --chown=user:user src/docker/stage/deb/id_rsa.pub /home/user/.ssh/ ADD --chown=user:user src/docker/stage/deb/id_rsa /home/user/.ssh/ RUN chmod 600 /home/user/.ssh/id_rsa USER root # Let user run sudo without password RUN echo "user ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers WORKDIR /scripts ADD src/docker/stage/deb/install_seedsync.sh /scripts/ ADD src/docker/stage/deb/expect_seedsync.exp /scripts/ ADD src/docker/stage/deb/entrypoint.sh /scripts/ ENTRYPOINT ["/scripts/entrypoint.sh"] CMD ["/lib/systemd/systemd --log-target=journal 3>&1"] EXPOSE 8800 ================================================ FILE: src/docker/stage/deb/compose-ubu1604.yml ================================================ version: "3.4" services: myapp: image: seedsync/stage/deb/ubu1604 container_name: seedsync_stage_deb_ubu1604 build: args: - BASE_IMAGE=ubuntu-systemd:16.04 ================================================ FILE: src/docker/stage/deb/compose-ubu1804.yml ================================================ version: "3.4" services: myapp: image: seedsync/stage/deb/ubu1804 container_name: seedsync_stage_deb_ubu1804 build: args: - BASE_IMAGE=ubuntu-systemd:18.04 ================================================ FILE: src/docker/stage/deb/compose-ubu2004.yml ================================================ version: "3.4" services: myapp: image: seedsync/stage/deb/ubu2004 container_name: seedsync_stage_deb_ubu2004 build: args: - BASE_IMAGE=ubuntu-systemd:20.04 ================================================ FILE: src/docker/stage/deb/compose.yml ================================================ version: "3.4" services: myapp: image: seedsync/stage/deb container_name: seedsync_stage_deb build: context: ../../../.. dockerfile: src/docker/stage/deb/Dockerfile tty: true tmpfs: - /run - /run/lock security_opt: - seccomp:unconfined volumes: - type: bind source: ${SEEDSYNC_DEB} target: /install/seedsync.deb read_only: true - type: bind source: /sys/fs/cgroup target: /sys/fs/cgroup read_only: true ================================================ FILE: src/docker/stage/deb/entrypoint.sh ================================================ #!/bin/bash # exit on first error set -e echo "Running entrypoint" echo "Installing SeedSync" ./expect_seedsync.exp echo "Continuing docker CMD" echo "$@" exec $@ ================================================ FILE: src/docker/stage/deb/expect_seedsync.exp ================================================ #!/usr/bin/expect set timeout 10 spawn "./install_seedsync.sh" expect { timeout {error "ERROR: missing user prompt"; exit 1} "User for SeedSync service" } send "user\n" expect { timeout {error "ERROR: timeout"; exit 1} eof } ================================================ FILE: src/docker/stage/deb/id_rsa ================================================ -----BEGIN RSA PRIVATE KEY----- MIIEowIBAAKCAQEA4Pn1J1LJKi9/xE1XaZZuSzdfwPQykqbnevCx1iSq8cWA8rdu ZcR9sY7dyMc6xQ8YQ08nZnRyvMfxdmvIDiymy7/ugxS7CwCoX9/CMLw11y9RyT6t hrtvdqSFoQDPydWlgmYyq28FYRFWpKgmWgo9xgE1LywVxOGvI7nxr2UPq/bM2T8C y4DIEcVe6jU1NR5okVg4UJeBl6ToAOQ5qjOSPgHtLQi1vqHtTi5BDkC1AkBv2FI1 H007Z1O4kgQUhBd3WoeDFTbvQ+3FBfjRZRLgFXR7/QrCa6RenI9qnnWnp+oRzaqR NRk7VG1tC9/En3Kh91t3pVj3wXU9lygXhTs66wIDAQABAoIBABcoY53Knb5j6Ujx lR/fRjcj2g1olZQW7hjvkb6zQ41jgSR60ThUg4O1awrxxxDlvt+e1DVtoynfgvFn os4itoCenxSLG73EMZC83aZamUgvLMIEW6RUwuJ5iO/Lv5fNEB5eGrUe1nTpbfvA +0GlcDpjgW/7n7oGaRrKVyBwzK4spfYMLIuP8j6sLlUOyXi6gLjsOHA+W6GeDp7L GbK2cq80L2xA/wduaxh7QLacPlnpabJaIGCF+Wq7O6DddBg02bi7AZCtFRBs6eLI XCB4SAA7eT/qwoukX/apSXYwWGy5xKQPbZr8V4BkhCXvJDzE62YAV8cLezdyPdTL DOE3NxECgYEA+EexLMo0b+tNsBJJCk55qXZv1MSreda1JqT9aTLdaIhNVlw1ErBq ngfsytvgmUYEN/g//NStUKTVtRkvzR6sluNVTLdSJskbKPtWBP0ipaM1AvlW4l7R PdAzvMFQswMjHYElxassEmj7iM+NFvpdZdW0z2rlw04cOvhs2k/fVF0CgYEA5/jE w0TAeUsvd4sl+xq1sTzfr1mSeg4irE2kw/NFQsTaDvGZJc8+HtFv8O1ZUSkn9/qk SypHf97G78+dN59d5rR+P/Eb5OJFWo3aKytu4drt+qtbvc4sxvkW/ORwEfmfXDVK q7jolmk0i9/oVyA4N4ruAjB1sxZRmYohQcCM1+cCgYAAllLR80x6c0kEwJZRouvg vbn3+9sX960IAV3kEM27QI9GRAOQHsCxzPz/YdO/KQ47f6fPFkWuqiUjP4MAbjEk TjdWbhyQoOsihq2mZ17cm201q5dMA8Nk7QgiSybAtaIwoKyRMh1xkbP+l9cSldcA taeu0ebnNlkUvp+rSIMTtQKBgFal9dl6tOqZywE8WNOTBotN0cAOFUjCPvFdj04i cJygK1OpqysUXn/ke4vjHJnUZbmbRgNNp6d775NkWbWNMeYbRY1c4q58VquckQHP F3wF6x7XI02i1db89DlCmxobxAsNXPcH+tk0MwyMdp0Uy+rzWjQ3Jb/fdluD3ShS ZEnBAoGBAMmwVdUc2r+93TpJUYtD+NKZ8uAF7BiPdEQzCS8VDk73xGT5hnE5GLcW vtBqwnLOCaRYEi0GvWk5LizfxClrtzhRsiD2n9dJYWVHxc7YDuD9Soe8w3/VELLf ABAiTRuLbr5dweLwjY8KDsVmZ8yWOz8ejrAlGtT8Gnqf/2Uo6UNN -----END RSA PRIVATE KEY----- ================================================ FILE: src/docker/stage/deb/id_rsa.pub ================================================ ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDg+fUnUskqL3/ETVdplm5LN1/A9DKSpud68LHWJKrxxYDyt25lxH2xjt3IxzrFDxhDTydmdHK8x/F2a8gOLKbLv+6DFLsLAKhf38IwvDXXL1HJPq2Gu292pIWhAM/J1aWCZjKrbwVhEVakqCZaCj3GATUvLBXE4a8jufGvZQ+r9szZPwLLgMgRxV7qNTU1HmiRWDhQl4GXpOgA5DmqM5I+Ae0tCLW+oe1OLkEOQLUCQG/YUjUfTTtnU7iSBBSEF3dah4MVNu9D7cUF+NFlEuAVdHv9CsJrpF6cj2qedaen6hHNqpE1GTtUbW0L38SfcqH3W3elWPfBdT2XKBeFOzrr user@572e13b2bdf3 ================================================ FILE: src/docker/stage/deb/install_seedsync.sh ================================================ #!/bin/bash dpkg -i /install/seedsync.deb ================================================ FILE: src/docker/stage/deb/ubuntu-systemd/ubuntu-16.04-systemd/Dockerfile ================================================ # Copied from: # https://github.com/solita/docker-systemd/blob/master/Dockerfile # https://hub.docker.com/r/solita/ubuntu-systemd/ FROM ubuntu:16.04 ENV container docker # Don't start any optional services except for the few we need. RUN find /etc/systemd/system \ /lib/systemd/system \ -path '*.wants/*' \ -not -name '*journald*' \ -not -name '*systemd-tmpfiles*' \ -not -name '*systemd-user-sessions*' \ -exec rm \{} \; RUN systemctl set-default multi-user.target COPY setup /sbin/ STOPSIGNAL SIGRTMIN+3 # Workaround for docker/docker#27202, technique based on comments from docker/docker#9212 CMD ["/bin/bash", "-c", "exec /sbin/init --log-target=journal 3>&1"] ================================================ FILE: src/docker/stage/deb/ubuntu-systemd/ubuntu-16.04-systemd/setup ================================================ #!/bin/sh set -eu if nsenter --mount=/host/proc/1/ns/mnt -- mount | grep /sys/fs/cgroup/systemd >/dev/null 2>&1; then echo 'The systemd cgroup hierarchy is already mounted at /sys/fs/cgroup/systemd.' else if [ -d /host/sys/fs/cgroup/systemd ]; then echo 'The mount point for the systemd cgroup hierarchy already exists at /sys/fs/cgroup/systemd.' else echo 'Creating the mount point for the systemd cgroup hierarchy at /sys/fs/cgroup/systemd.' mkdir -p /host/sys/fs/cgroup/systemd fi echo 'Mounting the systemd cgroup hierarchy.' nsenter --mount=/host/proc/1/ns/mnt -- mount -t cgroup cgroup -o none,name=systemd /sys/fs/cgroup/systemd fi echo 'Your Docker host is now configured for running systemd containers!' ================================================ FILE: src/docker/stage/deb/ubuntu-systemd/ubuntu-18.04-systemd/Dockerfile ================================================ # Copied from: # https://github.com/solita/docker-systemd/blob/master/Dockerfile # https://hub.docker.com/r/solita/ubuntu-systemd/ FROM ubuntu:18.04 RUN apt-get update && apt-get install -y systemd ENV container docker # Don't start any optional services except for the few we need. RUN find /etc/systemd/system \ /lib/systemd/system \ -path '*.wants/*' \ -not -name '*journald*' \ -not -name '*systemd-tmpfiles*' \ -not -name '*systemd-user-sessions*' \ -exec rm \{} \; RUN systemctl set-default multi-user.target COPY setup /sbin/ STOPSIGNAL SIGRTMIN+3 # Workaround for docker/docker#27202, technique based on comments from docker/docker#9212 CMD ["/bin/bash", "-c", "exec /sbin/init --log-target=journal 3>&1"] ================================================ FILE: src/docker/stage/deb/ubuntu-systemd/ubuntu-18.04-systemd/setup ================================================ #!/bin/sh set -eu if nsenter --mount=/host/proc/1/ns/mnt -- mount | grep /sys/fs/cgroup/systemd >/dev/null 2>&1; then echo 'The systemd cgroup hierarchy is already mounted at /sys/fs/cgroup/systemd.' else if [ -d /host/sys/fs/cgroup/systemd ]; then echo 'The mount point for the systemd cgroup hierarchy already exists at /sys/fs/cgroup/systemd.' else echo 'Creating the mount point for the systemd cgroup hierarchy at /sys/fs/cgroup/systemd.' mkdir -p /host/sys/fs/cgroup/systemd fi echo 'Mounting the systemd cgroup hierarchy.' nsenter --mount=/host/proc/1/ns/mnt -- mount -t cgroup cgroup -o none,name=systemd /sys/fs/cgroup/systemd fi echo 'Your Docker host is now configured for running systemd containers!' ================================================ FILE: src/docker/stage/deb/ubuntu-systemd/ubuntu-20.04-systemd/Dockerfile ================================================ # Copied from: # https://github.com/solita/docker-systemd/blob/master/Dockerfile # https://hub.docker.com/r/solita/ubuntu-systemd/ FROM ubuntu:20.04 RUN apt-get update && apt-get install -y systemd ENV container docker # Don't start any optional services except for the few we need. RUN find /etc/systemd/system \ /lib/systemd/system \ -path '*.wants/*' \ -not -name '*journald*' \ -not -name '*systemd-tmpfiles*' \ -not -name '*systemd-user-sessions*' \ -exec rm \{} \; RUN systemctl set-default multi-user.target COPY setup /sbin/ STOPSIGNAL SIGRTMIN+3 # Workaround for docker/docker#27202, technique based on comments from docker/docker#9212 CMD ["/bin/bash", "-c", "exec /lib/systemd/systemd --log-target=journal 3>&1"] ================================================ FILE: src/docker/stage/deb/ubuntu-systemd/ubuntu-20.04-systemd/setup ================================================ #!/bin/sh set -eu if nsenter --mount=/host/proc/1/ns/mnt -- mount | grep /sys/fs/cgroup/systemd >/dev/null 2>&1; then echo 'The systemd cgroup hierarchy is already mounted at /sys/fs/cgroup/systemd.' else if [ -d /host/sys/fs/cgroup/systemd ]; then echo 'The mount point for the systemd cgroup hierarchy already exists at /sys/fs/cgroup/systemd.' else echo 'Creating the mount point for the systemd cgroup hierarchy at /sys/fs/cgroup/systemd.' mkdir -p /host/sys/fs/cgroup/systemd fi echo 'Mounting the systemd cgroup hierarchy.' nsenter --mount=/host/proc/1/ns/mnt -- mount -t cgroup cgroup -o none,name=systemd /sys/fs/cgroup/systemd fi echo 'Your Docker host is now configured for running systemd containers!' ================================================ FILE: src/docker/stage/docker-image/compose.yml ================================================ version: "3.4" services: myapp: image: ${STAGING_REGISTRY}/seedsync:${STAGING_VERSION} container_name: seedsync_test_e2e_myapp ================================================ FILE: src/docker/test/angular/Dockerfile ================================================ FROM seedsync/build/angular/env as seedsync_test_angular RUN apt-get update RUN wget -nv -O /tmp/chrome.deb https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb && \ dpkg -i /tmp/chrome.deb; apt-get -fy install > /dev/null COPY \ src/angular/tsconfig.json \ src/angular/tslint.json \ src/angular/karma.conf.js \ src/angular/.angular-cli.json \ /app/ RUN ls -l /app/ # ng src needs to be mounted on /app/src WORKDIR /app/src CMD ["/app/node_modules/@angular/cli/bin/ng", "test", \ "--browsers", "ChromeHeadless", \ "--single-run"] ================================================ FILE: src/docker/test/angular/compose.yml ================================================ version: "3.4" services: tests: image: seedsync/test/angular container_name: seedsync_test_angular tty: true build: context: ../../../../ dockerfile: src/docker/test/angular/Dockerfile target: seedsync_test_angular volumes: - type: bind source: ../../../angular/src target: /app/src read_only: true ================================================ FILE: src/docker/test/e2e/Dockerfile ================================================ # Creates environment for e2e tests FROM node:12.16 as seedsync_test_e2e_env COPY src/e2e/package*.json /app/ WORKDIR /app RUN npm install # Builds and runs e2e tests FROM seedsync_test_e2e_env as seedsync_test_e2e COPY \ src/e2e/conf.ts \ src/e2e/tsconfig.json \ /app/ COPY src/e2e/tests /app/tests COPY \ src/docker/test/e2e/urls.ts \ src/docker/test/e2e/run_tests.sh \ src/docker/test/e2e/parse_seedsync_status.py \ /app/ WORKDIR /app RUN node_modules/typescript/bin/tsc --outDir ./tmp CMD ["/app/run_tests.sh"] ================================================ FILE: src/docker/test/e2e/chrome/Dockerfile ================================================ FROM yukinying/chrome-headless-browser-selenium:latest USER root RUN apt-get update && apt-get install -y libxi6 libgconf-2-4 USER headless ================================================ FILE: src/docker/test/e2e/compose-dev.yml ================================================ version: "3.4" services: tests: command: /bin/true myapp: ports: - target: 8800 published: 8800 protocol: tcp mode: host chrome: ports: - target: 4444 published: 4444 protocol: tcp mode: host ================================================ FILE: src/docker/test/e2e/compose.yml ================================================ version: "3.4" services: tests: image: seedsync/test/e2e container_name: seedsync_test_e2e build: context: ../../../../ dockerfile: src/docker/test/e2e/Dockerfile target: seedsync_test_e2e depends_on: - chrome - remote chrome: image: seedsync/test/e2e/chrome container_name: seedsync_test_e2e_chrome build: context: ../../../../ dockerfile: src/docker/test/e2e/chrome/Dockerfile shm_size: 1024M cap_add: - SYS_ADMIN remote: image: seedsync/test/e2e/remote container_name: seedsync_test_e2e_remote build: context: ../../../../ dockerfile: src/docker/test/e2e/remote/Dockerfile configure: image: seedsync/test/e2e/configure container_name: seedsync_test_e2e_configure build: context: ../../../../ dockerfile: src/docker/test/e2e/configure/Dockerfile depends_on: - myapp ================================================ FILE: src/docker/test/e2e/configure/Dockerfile ================================================ FROM alpine:3.11.6 RUN apk add --no-cache curl bash WORKDIR / ADD src/docker/wait-for-it.sh / ADD src/docker/test/e2e/configure/setup_seedsync.sh / CMD ["/setup_seedsync.sh"] ================================================ FILE: src/docker/test/e2e/configure/setup_seedsync.sh ================================================ #!/bin/bash ./wait-for-it.sh myapp:8800 -- echo "Seedsync app is up (before configuring)" curl -sS "http://myapp:8800/server/config/set/general/debug/true"; echo curl -sS "http://myapp:8800/server/config/set/general/verbose/true"; echo curl -sS "http://myapp:8800/server/config/set/lftp/local_path/%252Fdownloads"; echo curl -sS "http://myapp:8800/server/config/set/lftp/remote_address/remote"; echo curl -sS "http://myapp:8800/server/config/set/lftp/remote_username/remoteuser"; echo curl -sS "http://myapp:8800/server/config/set/lftp/remote_password/remotepass"; echo curl -sS "http://myapp:8800/server/config/set/lftp/remote_port/1234"; echo curl -sS "http://myapp:8800/server/config/set/lftp/remote_path/%252Fhome%252Fremoteuser%252Ffiles"; echo curl -sS "http://myapp:8800/server/config/set/autoqueue/patterns_only/true"; echo curl -sS "http://myapp:8800/server/command/restart"; echo ./wait-for-it.sh myapp:8800 -- echo "Seedsync app is up (after configuring)" echo echo "Done configuring SeedSync app" ================================================ FILE: src/docker/test/e2e/parse_seedsync_status.py ================================================ import sys import json try: print(json.load(sys.stdin)['server']['up']) except: print('False') ================================================ FILE: src/docker/test/e2e/remote/Dockerfile ================================================ FROM ubuntu:18.04 # Install dependencies RUN apt-get update && apt-get install -y \ python3.7 # Switch to Python 3.7 RUN update-alternatives --install /usr/bin/python python /usr/bin/python3.7 1 RUN update-alternatives --set python /usr/bin/python3.7 # Create non-root user RUN useradd --create-home -s /bin/bash remoteuser && \ echo "remoteuser:remotepass" | chpasswd # Add install image's user's key to authorized USER remoteuser ADD --chown=remoteuser:remoteuser src/docker/test/e2e/remote/id_rsa.pub /home/remoteuser/user_id_rsa.pub RUN mkdir -p /home/remoteuser/.ssh && \ cat /home/remoteuser/user_id_rsa.pub >> /home/remoteuser/.ssh/authorized_keys USER root # Copy over data ADD --chown=remoteuser:remoteuser src/docker/test/e2e/remote/files /home/remoteuser/files # Install and run ssh server RUN apt-get update && apt-get install -y openssh-server # Change port RUN sed -i '/Port 22/c\Port 1234' /etc/ssh/sshd_config EXPOSE 1234 RUN mkdir /var/run/sshd CMD ["/usr/sbin/sshd", "-D"] ================================================ FILE: src/docker/test/e2e/remote/id_rsa.pub ================================================ ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDg+fUnUskqL3/ETVdplm5LN1/A9DKSpud68LHWJKrxxYDyt25lxH2xjt3IxzrFDxhDTydmdHK8x/F2a8gOLKbLv+6DFLsLAKhf38IwvDXXL1HJPq2Gu292pIWhAM/J1aWCZjKrbwVhEVakqCZaCj3GATUvLBXE4a8jufGvZQ+r9szZPwLLgMgRxV7qNTU1HmiRWDhQl4GXpOgA5DmqM5I+Ae0tCLW+oe1OLkEOQLUCQG/YUjUfTTtnU7iSBBSEF3dah4MVNu9D7cUF+NFlEuAVdHv9CsJrpF6cj2qedaen6hHNqpE1GTtUbW0L38SfcqH3W3elWPfBdT2XKBeFOzrr user@572e13b2bdf3 ================================================ FILE: src/docker/test/e2e/run_tests.sh ================================================ #!/bin/bash red=`tput setaf 1` green=`tput setaf 2` reset=`tput sgr0` END=$((SECONDS+10)) while [ ${SECONDS} -lt ${END} ]; do SERVER_UP=$( curl -s myapp:8800/server/status | \ python ./parse_seedsync_status.py ) if [[ "${SERVER_UP}" == 'True' ]]; then break fi echo "E2E Test is waiting for Seedsync server to come up..." sleep 1 done if [[ "${SERVER_UP}" == 'True' ]]; then echo "${green}E2E Test detected that Seedsync server is UP${reset}" node_modules/protractor/bin/protractor tmp/conf.js else echo "${red}E2E Test failed to detect Seedsync server${reset}" exit 1 fi ================================================ FILE: src/docker/test/e2e/urls.ts ================================================ export class Urls { static readonly APP_BASE_URL = "http://myapp:8800/"; static readonly SELENIUM_ADDRESS = "http://chrome:4444/wd/hub"; } ================================================ FILE: src/docker/test/python/Dockerfile ================================================ FROM seedsync/run/python/devenv as seedsync_test_python RUN ls -l /app/python # Install dependencies RUN apt-get install -y software-properties-common && \ apt-add-repository non-free && \ apt-get update && \ apt-get install -y \ openssh-server \ rar ADD src/docker/test/python/entrypoint.sh /app/ # setup sshd RUN mkdir /var/run/sshd RUN ssh-keygen -t rsa -N "" -f /root/.ssh/id_rsa && \ cat /root/.ssh/id_rsa.pub >> /root/.ssh/authorized_keys # Disable the known hosts prompt RUN echo "StrictHostKeyChecking no\nUserKnownHostsFile /dev/null\nLogLevel=quiet" > /root/.ssh/config # create the seedsynctest user, add root's public key to seedsynctest RUN useradd --create-home -s /bin/bash seedsynctest && \ echo "seedsynctest:seedsyncpass" | chpasswd RUN usermod -a -G root seedsynctest RUN cp /root/.ssh/id_rsa.pub /home/seedsynctest/ && \ chown seedsynctest:seedsynctest /home/seedsynctest/id_rsa.pub USER seedsynctest RUN mkdir -p /home/seedsynctest/.ssh && \ cat /home/seedsynctest/id_rsa.pub >> /home/seedsynctest/.ssh/authorized_keys USER root EXPOSE 22 # src needs to be mounted on /src/ WORKDIR /src/ ENV PYTHONPATH=/src ENTRYPOINT ["/app/entrypoint.sh"] CMD ["pytest", "-v"] ================================================ FILE: src/docker/test/python/compose.yml ================================================ version: "3.4" services: tests: image: seedsync/test/python container_name: seedsync_test_python build: context: ../../../../ dockerfile: src/docker/test/python/Dockerfile target: seedsync_test_python volumes: - type: bind source: ../../../python target: /src read_only: true ================================================ FILE: src/docker/test/python/entrypoint.sh ================================================ #!/bin/bash # exit on first error set -e echo "Running sshd" /usr/sbin/sshd -D & echo "Continuing entrypoint" echo "$@" exec $@ ================================================ FILE: src/docker/wait-for-it.sh ================================================ #!/usr/bin/env bash # Use this script to test if a given TCP host/port are available WAITFORIT_cmdname=${0##*/} echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } usage() { cat << USAGE >&2 Usage: $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] -h HOST | --host=HOST Host or IP under test -p PORT | --port=PORT TCP port under test Alternatively, you specify the host and port as host:port -s | --strict Only execute subcommand if the test succeeds -q | --quiet Don't output any status messages -t TIMEOUT | --timeout=TIMEOUT Timeout in seconds, zero for no timeout -- COMMAND ARGS Execute command with args after the test finishes USAGE exit 1 } wait_for() { if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" else echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" fi WAITFORIT_start_ts=$(date +%s) while : do if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then nc -z $WAITFORIT_HOST $WAITFORIT_PORT WAITFORIT_result=$? else (echo > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 WAITFORIT_result=$? fi if [[ $WAITFORIT_result -eq 0 ]]; then WAITFORIT_end_ts=$(date +%s) echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" break fi sleep 1 done return $WAITFORIT_result } wait_for_wrapper() { # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 if [[ $WAITFORIT_QUIET -eq 1 ]]; then timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & else timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & fi WAITFORIT_PID=$! trap "kill -INT -$WAITFORIT_PID" INT wait $WAITFORIT_PID WAITFORIT_RESULT=$? if [[ $WAITFORIT_RESULT -ne 0 ]]; then echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" fi return $WAITFORIT_RESULT } # process arguments while [[ $# -gt 0 ]] do case "$1" in *:* ) WAITFORIT_hostport=(${1//:/ }) WAITFORIT_HOST=${WAITFORIT_hostport[0]} WAITFORIT_PORT=${WAITFORIT_hostport[1]} shift 1 ;; --child) WAITFORIT_CHILD=1 shift 1 ;; -q | --quiet) WAITFORIT_QUIET=1 shift 1 ;; -s | --strict) WAITFORIT_STRICT=1 shift 1 ;; -h) WAITFORIT_HOST="$2" if [[ $WAITFORIT_HOST == "" ]]; then break; fi shift 2 ;; --host=*) WAITFORIT_HOST="${1#*=}" shift 1 ;; -p) WAITFORIT_PORT="$2" if [[ $WAITFORIT_PORT == "" ]]; then break; fi shift 2 ;; --port=*) WAITFORIT_PORT="${1#*=}" shift 1 ;; -t) WAITFORIT_TIMEOUT="$2" if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi shift 2 ;; --timeout=*) WAITFORIT_TIMEOUT="${1#*=}" shift 1 ;; --) shift WAITFORIT_CLI=("$@") break ;; --help) usage ;; *) echoerr "Unknown argument: $1" usage ;; esac done if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then echoerr "Error: you need to provide a host and port to test." usage fi WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} # Check to see if timeout is from busybox? WAITFORIT_TIMEOUT_PATH=$(type -p timeout) WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) WAITFORIT_BUSYTIMEFLAG="" if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then WAITFORIT_ISBUSY=1 # Check if busybox timeout uses -t flag # (recent Alpine versions don't support -t anymore) if timeout &>/dev/stdout | grep -q -e '-t '; then WAITFORIT_BUSYTIMEFLAG="-t" fi else WAITFORIT_ISBUSY=0 fi if [[ $WAITFORIT_CHILD -gt 0 ]]; then wait_for WAITFORIT_RESULT=$? exit $WAITFORIT_RESULT else if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then wait_for_wrapper WAITFORIT_RESULT=$? else wait_for WAITFORIT_RESULT=$? fi fi if [[ $WAITFORIT_CLI != "" ]]; then if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" exit $WAITFORIT_RESULT fi exec "${WAITFORIT_CLI[@]}" else exit $WAITFORIT_RESULT fi ================================================ FILE: src/e2e/.gitignore ================================================ *.js node_modules tmp/ ================================================ FILE: src/e2e/README.md ================================================ ### To run e2e tests in dev mode: 1. Install dependencies ```bash cd src/e2e npm install ``` 2. Choose which dev image to run: deb install or docker image - deb install ```bash make run-tests-e2e SEEDSYNC_VERSION=latest SEEDSYNC_ARCH= DEV=1 ``` - docker image ```bash make run-tests-e2e SEEDSYNC_DEB=`readlink -f build/*.deb` SEEDSYNC_OS= DEV=1 ``` 3. Compile and run the tests ```bash cd src/e2e/ rm -rf tmp && \ ./node_modules/typescript/bin/tsc && \ ./node_modules/protractor/bin/protractor tmp/conf.js ``` ### About The dev end-to-end tests use the following docker images: 1. myapp: Installs and runs the seedsync deb package 2. chrome: Runs the selenium server 3. remote: Runs a remote SSH server The automated e2e tests additionally have: 4. tests: Runs the e2e tests Notes: 1. In dev mode, the app is visible at [http://localhost:8800](http://localhost:8800) However the url used in test is still [http://myapp:8800](http://myapp:8800) as that's how the selenium server accesses it. 2. The app requires a fully configured settings.cfg. This is done automatically in during the start of the docker image that runs the app. ================================================ FILE: src/e2e/conf.ts ================================================ // Because this file imports from protractor, you'll need to have it as a // project dependency. Please see the reference config: lib/config.ts for more // information. // // Why you might want to create your config with typescript: // Editors like Microsoft Visual Studio Code will have autocomplete and // description hints. // // To run this example, first transpile it to javascript with `npm run tsc`, // then run `protractor conf.js`. import {Config} from 'protractor'; import {Urls} from "./urls"; let SpecReporter = require('jasmine-spec-reporter').SpecReporter; export let config: Config = { framework: 'jasmine', capabilities: { browserName: 'chrome', chromeOptions: { args: [ '--headless', '--disable-gpu', '--no-sandbox', '--disable-extensions', '--disable-dev-shm-usage' ] }, }, specs: ['tests/**/*.spec.js'], seleniumAddress: Urls.SELENIUM_ADDRESS, // You could set no globals to true to avoid jQuery '$' and protractor '$' // collisions on the global namespace. noGlobals: true, allScriptsTimeout: 5000, // Options to be passed to Jasmine-node. jasmineNodeOpts: { showColors: true, defaultTimeoutInterval: 3000, print: function() {} }, onPrepare: function () { jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); } }; ================================================ FILE: src/e2e/package.json ================================================ { "name": "e2e", "version": "1.0.0", "description": "end to end tests", "author": "", "dependencies": { "@types/jasminewd2": "^2.0.6", "@types/node": "^14.0.1", "jasmine": "^3.3.1", "jasmine-spec-reporter": "^5.0.2", "protractor": "^7.0.0", "typescript": "^3.9.2" }, "devDependencies": { "@types/jasmine": "^3.5.10", "@types/jasminewd2": "^2.0.6", "ts-node": "^8.10.1" } } ================================================ FILE: src/e2e/tests/about.page.spec.ts ================================================ import {AboutPage} from "./about.page"; describe('Testing about page', () => { let page: AboutPage; beforeEach(() => { page = new AboutPage(); page.navigateTo(); }); it('should have right top title', () => { expect(page.getTopTitle()).toEqual("About"); }); it('should have the right version', () => { expect(page.getVersion()).toEqual("v0.8.6"); }); }); ================================================ FILE: src/e2e/tests/about.page.ts ================================================ import {browser, by, element} from 'protractor'; import {promise} from "selenium-webdriver"; import Promise = promise.Promise; import {Urls} from "../urls"; import {App} from "./app"; export class AboutPage extends App { navigateTo() { return browser.get(Urls.APP_BASE_URL + "about"); } getVersion(): Promise { return element(by.css("#version")).getText(); } } ================================================ FILE: src/e2e/tests/app.spec.ts ================================================ import {App} from "./app"; describe('Testing top-level app', () => { let app: App; beforeEach(() => { app = new App(); app.navigateTo(); }); it('should have right title', () => { expect(app.getTitle()).toEqual("SeedSync"); }); it('should have all the sidebar items', () => { expect(app.getSidebarItems()).toEqual( [ "Dashboard", "Settings", "AutoQueue", "Logs", "About", "Restart" ] ); }); it('should default to the dashboard page', () => { expect(app.getTopTitle()).toEqual("Dashboard"); }); }); ================================================ FILE: src/e2e/tests/app.ts ================================================ import {browser, by, element} from 'protractor'; import {promise} from "selenium-webdriver"; import Promise = promise.Promise; import {Urls} from "../urls"; export class App { navigateTo() { return browser.get(Urls.APP_BASE_URL); } getTitle(): Promise { return browser.getTitle(); } getSidebarItems(): Promise> { return element.all(by.css("#sidebar a span.text")).map(function (elm) { return browser.executeScript("return arguments[0].innerHTML;", elm); }); } getTopTitle(): Promise { return browser.executeScript("return arguments[0].innerHTML;", element(by.css("#title"))); } } ================================================ FILE: src/e2e/tests/autoqueue.page.spec.ts ================================================ import {AutoQueuePage} from "./autoqueue.page"; describe('Testing autoqueue page', () => { let page: AutoQueuePage; beforeEach(() => { page = new AutoQueuePage(); page.navigateTo(); }); it('should have right top title', () => { expect(page.getTopTitle()).toEqual("AutoQueue"); }); it('should add and remove patterns', () => { // start with an empty list expect(page.getPatterns()).toEqual([]); // add some patterns, and expect them in added order page.addPattern("APattern"); page.addPattern("CPattern"); page.addPattern("DPattern"); page.addPattern("BPattern"); expect(page.getPatterns()).toEqual([ "APattern", "CPattern", "DPattern", "BPattern" ]); // remove patterns one by one page.removePattern(2); expect(page.getPatterns()).toEqual([ "APattern", "CPattern", "BPattern" ]); page.removePattern(0); expect(page.getPatterns()).toEqual([ "CPattern", "BPattern" ]); page.removePattern(1); expect(page.getPatterns()).toEqual([ "CPattern" ]); page.removePattern(0); expect(page.getPatterns()).toEqual([]); }); it('should list existing patterns in alphabetical order', () => { // start with an empty list expect(page.getPatterns()).toEqual([]); // add some patterns, and expect them in added order page.addPattern("APattern"); page.addPattern("CPattern"); page.addPattern("DPattern"); page.addPattern("BPattern"); // reload the page page.navigateTo(); // patterns should be in alphabetical order expect(page.getPatterns()).toEqual([ "APattern", "BPattern", "CPattern", "DPattern" ]); // remove all patterns page.removePattern(0); page.removePattern(0); page.removePattern(0); page.removePattern(0); expect(page.getPatterns()).toEqual([]); }); }); ================================================ FILE: src/e2e/tests/autoqueue.page.ts ================================================ import {browser, by, element} from 'protractor'; import {promise} from "selenium-webdriver"; import Promise = promise.Promise; import {Urls} from "../urls"; import {App} from "./app"; export class AutoQueuePage extends App { navigateTo() { return browser.get(Urls.APP_BASE_URL + "autoqueue"); } getPatterns(): Promise> { return element.all(by.css("#autoqueue .pattern span.text")).map(function (elm) { return browser.executeScript("return arguments[0].innerHTML;", elm); }); } addPattern(pattern: string) { let input = element(by.css("#add-pattern input")); input.sendKeys(pattern); let button = element(by.css("#add-pattern .button")); button.click(); } removePattern(index: number) { let button = element.all(by.css("#autoqueue .pattern")).get(index).element(by.css(".button")); button.click(); } } ================================================ FILE: src/e2e/tests/dashboard.page.spec.ts ================================================ import {DashboardPage, File, FileActionButtonState} from "./dashboard.page"; describe('Testing dashboard page', () => { let page: DashboardPage; beforeEach(async () => { page = new DashboardPage(); await page.navigateTo(); }); it('should have right top title', () => { expect(page.getTopTitle()).toEqual("Dashboard"); }); it('should have a list of files', () => { let golden = [ new File("áßç déÀ.mp4", '', "0 B of 840 KB"), new File("clients.jpg", '', "0 B of 36.5 KB"), new File("crispycat", '', "0 B of 1.53 MB"), new File("documentation.png", '', "0 B of 8.88 KB"), new File("goose", '', "0 B of 2.78 MB"), new File("illusion.jpg", '', "0 B of 81.5 KB"), new File("joke", '', "0 B of 168 KB"), new File("testing.gif", '', "0 B of 8.95 MB"), new File("üæÒ", '', "0 B of 70.8 KB"), ]; expect(page.getFiles()).toEqual(golden); }); it('should show and hide action buttons on select', () => { expect(page.isFileActionsVisible(1)).toBe(false); page.selectFile(1); expect(page.isFileActionsVisible(1)).toBe(true); page.selectFile(1); expect(page.isFileActionsVisible(1)).toBe(false); }); it('should show action buttons for most recent file selected', () => { expect(page.isFileActionsVisible(1)).toBe(false); expect(page.isFileActionsVisible(2)).toBe(false); page.selectFile(1); expect(page.isFileActionsVisible(1)).toBe(true); expect(page.isFileActionsVisible(2)).toBe(false); page.selectFile(2); expect(page.isFileActionsVisible(1)).toBe(false); expect(page.isFileActionsVisible(2)).toBe(true); page.selectFile(2); expect(page.isFileActionsVisible(1)).toBe(false); expect(page.isFileActionsVisible(2)).toBe(false); }); it('should have all the action buttons', async () => { await page.getFileActions(1).then(states => { expect(states.length).toBe(5); expect(states[0].title).toBe("Queue"); expect(states[1].title).toBe("Stop"); expect(states[2].title).toBe("Extract"); expect(states[3].title).toBe("Delete Local"); expect(states[4].title).toBe("Delete Remote"); }); }); it('should have Queue action enabled for Default state', async () => { await page.getFiles().then(files => { expect(files[1].status).toEqual(""); }); await page.getFileActions(1).then(states => { expect(states[0].title).toBe("Queue"); expect(states[0].isEnabled).toBe(true); }); }); it('should have Stop action disabled for Default state', async () => { await page.getFiles().then(files => { expect(files[1].status).toEqual(""); }); await page.getFileActions(1).then(states => { expect(states[1].title).toBe("Stop"); expect(states[1].isEnabled).toBe(false); }); }); }); ================================================ FILE: src/e2e/tests/dashboard.page.ts ================================================ import {browser, by, element, ExpectedConditions} from 'protractor'; import {promise} from "selenium-webdriver"; import Promise = promise.Promise; import {Urls} from "../urls"; import {App} from "./app"; export class File { constructor(public name, public status, public size) { } } export class FileActionButtonState { constructor(public title, public isEnabled) { } } export class DashboardPage extends App { navigateTo() { return browser.get(Urls.APP_BASE_URL + "dashboard").then(value => { // Wait for the files list to show up return browser.wait(ExpectedConditions.presenceOf( element.all(by.css("#file-list .file")).first() )); }) } getFiles(): Promise> { return element.all(by.css("#file-list .file")).map(function (elm) { let name = elm.element(by.css(".name .text")).getText(); let statusElm = elm.element(by.css(".content .status")); let status = statusElm.isElementPresent(by.css("span.text")).then(value => { if(value) { return browser.executeScript( "return arguments[0].innerHTML;", statusElm.element(by.css("span.text")) ); } else { return ""; } }); // let status = browser.executeScript("return arguments[0].innerHTML;", subelm); let size = elm.element(by.css(".size .size_info")).getText(); return new File(name, status, size); }); } selectFile(index: number) { element.all(by.css("#file-list .file")).get(index).click(); } isFileActionsVisible(index: number) { return element.all(by.css("#file-list .file")).get(index) .element(by.css(".actions")).isDisplayed(); } getFileActions(index: number): Promise> { return element.all(by.css("#file-list .file")).get(index) .element(by.css(".actions")) .all(by.css(".button")) .map(buttonElm => { let title = browser.executeScript( "return arguments[0].innerHTML;", buttonElm.element(by.css("div.text span")) ); let isEnabled = buttonElm.getAttribute("disabled").then(value => { return value == null; }); return new FileActionButtonState(title, isEnabled); }); } } ================================================ FILE: src/e2e/tests/settings.page.spec.ts ================================================ import {SettingsPage} from "./settings.page"; describe('Testing settings page', () => { let page: SettingsPage; beforeEach(() => { page = new SettingsPage(); page.navigateTo(); }); it('should have right top title', () => { expect(page.getTopTitle()).toEqual("Settings"); }); }); ================================================ FILE: src/e2e/tests/settings.page.ts ================================================ import {browser, by, element} from 'protractor'; import {promise} from "selenium-webdriver"; import Promise = promise.Promise; import {Urls} from "../urls"; import {App} from "./app"; export class SettingsPage extends App { navigateTo() { return browser.get(Urls.APP_BASE_URL + "settings"); } } ================================================ FILE: src/e2e/tsconfig.json ================================================ // Source: https://github.com/angular/protractor/tree/master/exampleTypescript { "compilerOptions": { "target": "es6", "module": "commonjs", "moduleResolution": "node", "inlineSourceMap": true, "declaration": false, "noImplicitAny": false, "outDir": "tmp" }, "exclude": [ "node_modules" ] } ================================================ FILE: src/e2e/urls.ts ================================================ export class Urls { static readonly APP_BASE_URL = "http://myapp:8800/"; static readonly SELENIUM_ADDRESS = "http://localhost:4444/wd/hub"; } ================================================ FILE: src/pyinstaller_hooks/hook-patoolib.py ================================================ #----------------------------------------------------------------------------- # Copyright (c) 2013-2017, PyInstaller Development Team. # # Distributed under the terms of the GNU General Public License with exception # for distributing bootloader. # # The full license is in the file COPYING.txt, distributed with this software. #----------------------------------------------------------------------------- """ patoolib uses importlib and pyinstaller doesn't find it and add it to the list of needed modules """ hiddenimports = [ 'patoolib.programs', 'patoolib.programs.ar', 'patoolib.programs.arc', 'patoolib.programs.archmage', 'patoolib.programs.bsdcpio', 'patoolib.programs.bsdtar', 'patoolib.programs.bzip2', 'patoolib.programs.cabextract', 'patoolib.programs.chmlib', 'patoolib.programs.clzip', 'patoolib.programs.compress', 'patoolib.programs.cpio', 'patoolib.programs.dpkg', 'patoolib.programs.flac', 'patoolib.programs.genisoimage', 'patoolib.programs.gzip', 'patoolib.programs.isoinfo', 'patoolib.programs.lbzip2', 'patoolib.programs.lcab', 'patoolib.programs.lha', 'patoolib.programs.lhasa', 'patoolib.programs.lrzip', 'patoolib.programs.lzip', 'patoolib.programs.lzma', 'patoolib.programs.lzop', 'patoolib.programs.mac', 'patoolib.programs.nomarch', 'patoolib.programs.p7azip', 'patoolib.programs.p7rzip', 'patoolib.programs.p7zip', 'patoolib.programs.pbzip2', 'patoolib.programs.pdlzip', 'patoolib.programs.pigz', 'patoolib.programs.plzip', 'patoolib.programs.py_bz2', 'patoolib.programs.py_echo', 'patoolib.programs.py_gzip', 'patoolib.programs.py_lzma', 'patoolib.programs.py_tarfile', 'patoolib.programs.py_zipfile', 'patoolib.programs.rar', 'patoolib.programs.rpm', 'patoolib.programs.rpm2cpio', 'patoolib.programs.rzip', 'patoolib.programs.shar', 'patoolib.programs.shorten', 'patoolib.programs.star', 'patoolib.programs.tar', 'patoolib.programs.unace', 'patoolib.programs.unadf', 'patoolib.programs.unalz', 'patoolib.programs.uncompress', 'patoolib.programs.unrar', 'patoolib.programs.unshar', 'patoolib.programs.unzip', 'patoolib.programs.xdms', 'patoolib.programs.xz', 'patoolib.programs.zip', 'patoolib.programs.zoo', 'patoolib.programs.zopfli', 'patoolib.programs.zpaq', ] ================================================ FILE: src/python/__init__.py ================================================ ================================================ FILE: src/python/common/__init__.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from .types import overrides from .job import Job from .context import Context, Args from .error import AppError, ServiceExit, ServiceRestart from .constants import Constants from .config import Config, ConfigError from .persist import Persist, PersistError, Serializable from .localization import Localization from .multiprocessing_logger import MultiprocessingLogger from .status import Status, IStatusListener, StatusComponent, IStatusComponentListener from .app_process import AppProcess, AppOneShotProcess ================================================ FILE: src/python/common/app_process.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import logging import sys from abc import abstractmethod from multiprocessing import Process, Queue, Event import queue import signal import threading from datetime import datetime import tblib.pickling_support from common import overrides, ServiceExit, MultiprocessingLogger tblib.pickling_support.install() class ExceptionWrapper: """ An exception wrapper that works across processes Source: https://stackoverflow.com/a/26096355/8571324 """ def __init__(self, ee): self.ee = ee __, __, self.tb = sys.exc_info() def re_raise(self): raise self.ee.with_traceback(self.tb) class AppProcess(Process): """ Process with some additional functionality and fixes * Support for a multiprocessing logger * Removes signals to prevent join problems * Propagates exceptions to owner process * Safe terminate with timeout, followed by force terminate """ # Timeout before process is force terminated __DEFAULT_TERMINATE_TIMEOUT_MS = 1000 def __init__(self, name: str): self.__name = name super().__init__(name=self.__name) self.mp_logger = None self.logger = logging.getLogger(self.__name) self.__exception_queue = Queue() self._terminate = Event() def set_multiprocessing_logger(self, mp_logger: MultiprocessingLogger): self.mp_logger = mp_logger @overrides(Process) def run(self): # Replace the signal handlers that may have been set by main process to # default handlers. Having non-default handlers in subprocesses causes # a deadlock when attempting to join the process # Info: https://stackoverflow.com/a/631605 # NOTE: There is a minuscule chance of deadlock if a signal is received # between start of the method and these resets. # The ideal solution is to remove the signal before the process is # started. Unfortunately that's difficult to do here because the # subprocess is started from a job thread, and python doesn't # allow setting signals from outside the main thread. # So we accept this risk for the quick and easy solution here signal.signal(signal.SIGTERM, signal.SIG_DFL) signal.signal(signal.SIGINT, signal.SIG_DFL) # Set the thread name for convenience threading.current_thread().name = self.__name # Configure the logger for this process if self.mp_logger: self.logger = self.mp_logger.get_process_safe_logger().getChild(self.__name) self.logger.debug("Started process") self.run_init() try: while not self._terminate.is_set(): self.run_loop() self.logger.debug("Process received terminate flag") except ServiceExit: self.logger.debug("Process received a ServiceExit") except Exception as e: self.logger.debug("Process caught an exception") self.__exception_queue.put(ExceptionWrapper(e)) raise finally: self.run_cleanup() self.logger.debug("Exiting process") @overrides(Process) def terminate(self): # Send a terminate signal, and force terminate after a timeout self._terminate.set() def elapsed_ms(start): delta_in_s = (datetime.now() - start).total_seconds() delta_in_ms = int(delta_in_s * 1000) return delta_in_ms timestamp_start = datetime.now() while self.is_alive() and \ elapsed_ms(timestamp_start) < AppProcess.__DEFAULT_TERMINATE_TIMEOUT_MS: pass super().terminate() def propagate_exception(self): """ Raises any exception that was caught by the process :return: """ try: exc = self.__exception_queue.get(block=False) raise exc.re_raise() except queue.Empty: pass @abstractmethod def run_init(self): """ Called once before the run loop :return: """ pass @abstractmethod def run_cleanup(self): """ Called once before cleanup :return: """ pass @abstractmethod def run_loop(self): """ Process behaviour should be implemented here. This function is repeatedly called until process exits. The check for graceful shutdown is performed between the loop iterations, so try to limit the run time for this method. :return: """ pass class AppOneShotProcess(AppProcess): """ App process that runs only once and then exits """ def run_loop(self): self.run_once() self._terminate.set() def run_cleanup(self): pass def run_init(self): pass @abstractmethod def run_once(self): """ Process behaviour should be implemented here :return: """ pass ================================================ FILE: src/python/common/config.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import configparser from typing import Dict from io import StringIO import collections from distutils.util import strtobool from abc import ABC from typing import Type, TypeVar, Callable, Any from .error import AppError from .persist import Persist, PersistError from .types import overrides class ConfigError(AppError): """ Exception indicating a bad config value """ pass InnerConfigType = Dict[str, str] OuterConfigType = Dict[str, InnerConfigType] # Source: https://stackoverflow.com/a/39205612/8571324 T = TypeVar('T', bound='InnerConfig') class Converters: @staticmethod def null(_: T, __: str, value: str) -> str: return value @staticmethod def int(cls: T, name: str, value: str) -> int: if not value: raise ConfigError("Bad config: {}.{} is empty".format( cls.__name__, name )) try: val = int(value) except ValueError: raise ConfigError("Bad config: {}.{} ({}) must be an integer value".format( cls.__name__, name, value )) return val @staticmethod def bool(cls: T, name: str, value: str) -> bool: if not value: raise ConfigError("Bad config: {}.{} is empty".format( cls.__name__, name )) try: val = bool(strtobool(value)) except ValueError: raise ConfigError("Bad config: {}.{} ({}) must be a boolean value".format( cls.__name__, name, value )) return val class Checkers: @staticmethod def null(_: T, __: str, value: Any) -> Any: return value @staticmethod def string_nonempty(cls: T, name: str, value: str) -> str: if not value or not value.strip(): raise ConfigError("Bad config: {}.{} is empty".format( cls.__name__, name )) return value @staticmethod def int_non_negative(cls: T, name: str, value: int) -> int: if value < 0: raise ConfigError("Bad config: {}.{} ({}) must be zero or greater".format( cls.__name__, name, value )) return value @staticmethod def int_positive(cls: T, name: str, value: int) -> int: if value < 1: raise ConfigError("Bad config: {}.{} ({}) must be greater than 0".format( cls.__name__, name, value )) return value class InnerConfig(ABC): """ Abstract base class for a config section Config values are exposed as properties. They must be set using their native type. Internal utility methods are provided to convert strings to native types. These are only used when creating config from a dict. Implementation details: Each property has associated with is a checker and a converter function. The checker function performs boundary check on the native type value. The converter function converts the string representation into the native type. """ class PropMetadata: """Tracks property metadata""" def __init__(self, checker: Callable, converter: Callable): self.checker = checker self.converter = converter # Global map to map a property to its metadata # Is there a way for each concrete class to do this separately? __prop_addon_map = collections.OrderedDict() @classmethod def _create_property(cls, name: str, checker: Callable, converter: Callable) -> property: # noinspection PyProtectedMember prop = property(fget=lambda s: s._get_property(name), fset=lambda s, v: s._set_property(name, v, checker)) prop_addon = InnerConfig.PropMetadata(checker=checker, converter=converter) InnerConfig.__prop_addon_map[prop] = prop_addon return prop def _get_property(self, name: str) -> Any: return getattr(self, "__" + name, None) def _set_property(self, name: str, value: Any, checker: Callable): # Allow setting to None for the first time if value is None and self._get_property(name) is None: setattr(self, "__" + name, None) else: setattr(self, "__" + name, checker(self.__class__, name, value)) @classmethod def from_dict(cls: Type[T], config_dict: InnerConfigType) -> T: """ Construct and return inner config from a dict Dict values can be either native types, or str representations :param config_dict: :return: """ config_dict = dict(config_dict) # copy that we can modify # Loop over all the property name, and set them to the value given in config_dict # Raise error if a matching key is not found in config_dict # noinspection PyCallingNonCallable inner_config = cls() property_map = {p: getattr(cls, p) for p in dir(cls) if isinstance(getattr(cls, p), property)} for name, prop in property_map.items(): if name not in config_dict: raise ConfigError("Missing config: {}.{}".format(cls.__name__, name)) inner_config.set_property(name, config_dict[name]) del config_dict[name] # Raise error if a key in config_dict did not match a property extra_keys = config_dict.keys() if extra_keys: raise ConfigError("Unknown config: {}.{}".format(cls.__name__, next(iter(extra_keys)))) return inner_config def as_dict(self) -> InnerConfigType: """ Return the dict representation of the inner config :return: """ config_dict = collections.OrderedDict() cls = self.__class__ my_property_to_name_map = {getattr(cls, p): p for p in dir(cls) if isinstance(getattr(cls, p), property)} # Arrange prop names in order of creation. Use the prop map to get the order # Prop map contains all properties of all config classes, so filtering is required all_properties = InnerConfig.__prop_addon_map.keys() for prop in all_properties: if prop in my_property_to_name_map.keys(): name = my_property_to_name_map[prop] config_dict[name] = getattr(self, name) return config_dict def has_property(self, name: str) -> bool: """ Returns true if the given property exists, false otherwise :param name: :return: """ try: return isinstance(getattr(self.__class__, name), property) except AttributeError: return False def set_property(self, name: str, value: Any): """ Set a property dynamically Do a str conversion of the value, if necessary :param name: :param value: :return: """ cls = self.__class__ prop_addon = InnerConfig.__prop_addon_map[getattr(cls, name)] # Do the conversion if value is of type str native_value = prop_addon.converter(cls, name, value) if type(value) is str else value # Set the property, which will invoke the checker # noinspection PyProtectedMember self._set_property(name, native_value, prop_addon.checker) # Useful aliases IC = InnerConfig # noinspection PyProtectedMember PROP = InnerConfig._create_property class Config(Persist): """ Configuration registry """ class General(IC): debug = PROP("debug", Checkers.null, Converters.bool) verbose = PROP("verbose", Checkers.null, Converters.bool) def __init__(self): super().__init__() self.debug = None self.verbose = None class Lftp(IC): remote_address = PROP("remote_address", Checkers.string_nonempty, Converters.null) remote_username = PROP("remote_username", Checkers.string_nonempty, Converters.null) remote_password = PROP("remote_password", Checkers.string_nonempty, Converters.null) remote_port = PROP("remote_port", Checkers.int_positive, Converters.int) remote_path = PROP("remote_path", Checkers.string_nonempty, Converters.null) local_path = PROP("local_path", Checkers.string_nonempty, Converters.null) remote_path_to_scan_script = PROP("remote_path_to_scan_script", Checkers.string_nonempty, Converters.null) use_ssh_key = PROP("use_ssh_key", Checkers.null, Converters.bool) num_max_parallel_downloads = PROP("num_max_parallel_downloads", Checkers.int_positive, Converters.int) num_max_parallel_files_per_download = PROP("num_max_parallel_files_per_download", Checkers.int_positive, Converters.int) num_max_connections_per_root_file = PROP("num_max_connections_per_root_file", Checkers.int_positive, Converters.int) num_max_connections_per_dir_file = PROP("num_max_connections_per_dir_file", Checkers.int_positive, Converters.int) num_max_total_connections = PROP("num_max_total_connections", Checkers.int_non_negative, Converters.int) use_temp_file = PROP("use_temp_file", Checkers.null, Converters.bool) def __init__(self): super().__init__() self.remote_address = None self.remote_username = None self.remote_password = None self.remote_port = None self.remote_path = None self.local_path = None self.remote_path_to_scan_script = None self.use_ssh_key = None self.num_max_parallel_downloads = None self.num_max_parallel_files_per_download = None self.num_max_connections_per_root_file = None self.num_max_connections_per_dir_file = None self.num_max_total_connections = None self.use_temp_file = None class Controller(IC): interval_ms_remote_scan = PROP("interval_ms_remote_scan", Checkers.int_positive, Converters.int) interval_ms_local_scan = PROP("interval_ms_local_scan", Checkers.int_positive, Converters.int) interval_ms_downloading_scan = PROP("interval_ms_downloading_scan", Checkers.int_positive, Converters.int) extract_path = PROP("extract_path", Checkers.string_nonempty, Converters.null) use_local_path_as_extract_path = PROP("use_local_path_as_extract_path", Checkers.null, Converters.bool) def __init__(self): super().__init__() self.interval_ms_remote_scan = None self.interval_ms_local_scan = None self.interval_ms_downloading_scan = None self.extract_path = None self.use_local_path_as_extract_path = None class Web(InnerConfig): port = PROP("port", Checkers.int_positive, Converters.int) def __init__(self): super().__init__() self.port = None class AutoQueue(InnerConfig): enabled = PROP("enabled", Checkers.null, Converters.bool) patterns_only = PROP("patterns_only", Checkers.null, Converters.bool) auto_extract = PROP("auto_extract", Checkers.null, Converters.bool) def __init__(self): super().__init__() self.enabled = None self.patterns_only = None self.auto_extract = None def __init__(self): self.general = Config.General() self.lftp = Config.Lftp() self.controller = Config.Controller() self.web = Config.Web() self.autoqueue = Config.AutoQueue() @staticmethod def _check_section(dct: OuterConfigType, name: str) -> InnerConfigType: if name not in dct: raise ConfigError("Missing config section: {}".format(name)) val = dct[name] del dct[name] return val @staticmethod def _check_empty_outer_dict(dct: OuterConfigType): extra_keys = dct.keys() if extra_keys: raise ConfigError("Unknown section: {}".format(next(iter(extra_keys)))) @classmethod @overrides(Persist) def from_str(cls: "Config", content: str) -> "Config": config_parser = configparser.ConfigParser() try: config_parser.read_string(content) except ( configparser.MissingSectionHeaderError, configparser.ParsingError ) as e: raise PersistError("Error parsing Config - {}: {}".format( type(e).__name__, str(e)) ) config_dict = {} for section in config_parser.sections(): config_dict[section] = {} for option in config_parser.options(section): config_dict[section][option] = config_parser.get(section, option) return Config.from_dict(config_dict) @overrides(Persist) def to_str(self) -> str: config_parser = configparser.ConfigParser() config_dict = self.as_dict() for section in config_dict: config_parser.add_section(section) section_dict = config_dict[section] for key in section_dict: config_parser.set(section, key, str(section_dict[key])) str_io = StringIO() config_parser.write(str_io) return str_io.getvalue() @staticmethod def from_dict(config_dict: OuterConfigType) -> "Config": config_dict = dict(config_dict) # copy that we can modify config = Config() config.general = Config.General.from_dict(Config._check_section(config_dict, "General")) config.lftp = Config.Lftp.from_dict(Config._check_section(config_dict, "Lftp")) config.controller = Config.Controller.from_dict(Config._check_section(config_dict, "Controller")) config.web = Config.Web.from_dict(Config._check_section(config_dict, "Web")) config.autoqueue = Config.AutoQueue.from_dict(Config._check_section(config_dict, "AutoQueue")) Config._check_empty_outer_dict(config_dict) return config def as_dict(self) -> OuterConfigType: # We convert all values back to strings # Use an ordered dict to main section order config_dict = collections.OrderedDict() config_dict["General"] = self.general.as_dict() config_dict["Lftp"] = self.lftp.as_dict() config_dict["Controller"] = self.controller.as_dict() config_dict["Web"] = self.web.as_dict() config_dict["AutoQueue"] = self.autoqueue.as_dict() return config_dict def has_section(self, name: str) -> bool: """ Returns true if the given section exists, false otherwise :param name: :return: """ try: return isinstance(getattr(self, name), InnerConfig) except AttributeError: return False ================================================ FILE: src/python/common/constants.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. class Constants: """ POD class to hold shared constants :return: """ SERVICE_NAME = "seedsync" MAIN_THREAD_SLEEP_INTERVAL_IN_SECS = 0.5 MAX_LOG_SIZE_IN_BYTES = 10*1024*1024 # 10 MB LOG_BACKUP_COUNT = 10 WEB_ACCESS_LOG_NAME = 'web_access' MIN_PERSIST_TO_FILE_INTERVAL_IN_SECS = 30 JSON_PRETTY_PRINT_INDENT = 4 LFTP_TEMP_FILE_SUFFIX = ".lftp" ================================================ FILE: src/python/common/context.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import logging import copy import collections # my libs from .config import Config from .status import Status class Args: """ Container for args These are settings that aren't part of config but still needed by sub-components """ def __init__(self): self.local_path_to_scanfs = None self.html_path = None self.debug = None self.exit = None def as_dict(self) -> dict: dct = collections.OrderedDict() dct["local_path_to_scanfs"] = str(self.local_path_to_scanfs) dct["html_path"] = str(self.html_path) dct["debug"] = str(self.debug) dct["exit"] = str(self.exit) return dct class Context: """ Stores contextual information for the entire application """ def __init__(self, logger: logging.Logger, web_access_logger: logging.Logger, config: Config, args: Args, status: Status): """ Primary constructor to construct the top-level context """ # Config self.logger = logger self.web_access_logger = web_access_logger self.config = config self.args = args self.status = status def create_child_context(self, context_name: str) -> "Context": child_context = copy.copy(self) child_context.logger = self.logger.getChild(context_name) return child_context def print_to_log(self): # Print the config self.logger.debug("Config:") config_dict = self.config.as_dict() for section in config_dict.keys(): for option in config_dict[section].keys(): value = config_dict[section][option] self.logger.debug(" {}.{}: {}".format(section, option, value)) self.logger.debug("Args:") for name, value in self.args.as_dict().items(): self.logger.debug(" {}: {}".format(name, value)) ================================================ FILE: src/python/common/error.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. class AppError(Exception): """ Exception indicating an error """ pass class ServiceExit(AppError): """ Custom exception which is used to trigger the clean exit of all running threads and the main program. """ pass class ServiceRestart(AppError): """ Exception indicating a restart is requested """ pass ================================================ FILE: src/python/common/job.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import sys import threading import time from abc import ABC, abstractmethod # my libs from .context import Context from .types import overrides class Job(threading.Thread, ABC): """ Job thread that handles graceful shutdown """ _DEFAULT_SLEEP_INTERVAL_IN_SECS = 0.5 def __init__(self, name: str, context: Context): super().__init__() self.name = name self.logger = context.logger # The shutdown_flag is a threading.Event object that # indicates whether the thread should be terminated. self.shutdown_flag = threading.Event() # For exception propagation self.exc_info = None @overrides(threading.Thread) def run(self): self.logger.debug("Thread {} started".format(self.name)) # ... Setup code here ... self.logger.debug("Calling setup for {}".format(self.name)) self.setup() self.logger.debug("Finished setup for {}".format(self.name)) while not self.shutdown_flag.is_set(): # ... Job code here ... # noinspection PyBroadException try: self.execute() except Exception: self.exc_info = sys.exc_info() self.logger.exception("Caught exception in job {}".format(self.name)) # break out of run loop and proceed to cleanup self.shutdown_flag.set() break time.sleep(Job._DEFAULT_SLEEP_INTERVAL_IN_SECS) # ... Clean shutdown code here ... self.logger.debug("Calling cleanup for {}".format(self.name)) self.cleanup() self.logger.debug("Finished cleanup for {}".format(self.name)) self.logger.debug("Thread {} stopped".format(self.name)) def terminate(self): """ Mark job for termination :return: """ self.shutdown_flag.set() def propagate_exception(self): """ Raises any exception captured by this job in whatever thread calls this method Source: https://stackoverflow.com/a/1854263/8571324 :return: """ if self.exc_info: exc_info = self.exc_info self.exc_info = None raise exc_info[1].with_traceback(exc_info[2]) @abstractmethod def setup(self): """ Setup is run once when the job starts :return: """ pass @abstractmethod def execute(self): """ Execute is run repeatedly, separated by a sleep interval, while the job is running This method must return relatively quickly, otherwise the job won't be able to safely terminate :return: """ pass @abstractmethod def cleanup(self): """ Cleanup is run one when the job is about to terminate :return: """ pass ================================================ FILE: src/python/common/localization.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. class Localization: class Error: MISSING_FILE = "The file '{}' doesn't exist." REMOTE_SERVER_SCAN = "An error occurred while scanning the remote server: '{}'." REMOTE_SERVER_INSTALL = "An error occurred while installing scanner script to remote server: '{}'." LOCAL_SERVER_SCAN = "An error occurred while scanning the local system." SETTINGS_INCOMPLETE = "The settings are not fully configured." ================================================ FILE: src/python/common/multiprocessing_logger.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import multiprocessing import threading import queue import logging import time import sys from logging.handlers import QueueHandler class MultiprocessingLogger: """ A helper class to enable logging across processes It starts a listener thread on the main process. The listener thread receives records on a queue from other processes and sends them to the main logger (effectively serializing the logging). Other processes use a QueueHandler to send logging records to the listener thread on the main process. Source: https://gist.github.com/vsajip/820132 """ __LISTENER_SLEEP_INTERVAL_IN_SECS = 0.1 def __init__(self, base_logger: logging.Logger): self.logger = base_logger.getChild("MPLogger") self.__queue = multiprocessing.Queue(-1) self.__logger_level = base_logger.getEffectiveLevel() self.__listener = threading.Thread(name="MPLoggerListener", target=self.__listener) self.__listener_shutdown = threading.Event() self.__listener_exc_info = None def start(self): self.__listener.start() def stop(self): self.__listener_shutdown.set() self.__listener.join() def propagate_exception(self): """ Raises any exception captured by the listener thread Source: https://stackoverflow.com/a/1854263/8571324 :return: """ if self.__listener_exc_info: exc_info = self.__listener_exc_info self.__listener_exc_info = None raise exc_info[1].with_traceback(exc_info[2]) def get_process_safe_logger(self) -> logging.Logger: """ Returns a process-safe logger This logger sends all records to the main process :return: """ queue_handler = QueueHandler(self.__queue) root_logger = logging.getLogger() # The fork may have happened after the root logger was setup by the main process # Remove all handlers from the root logger for this process handlers = root_logger.handlers[:] for handler in handlers: handler.close() root_logger.removeHandler(handler) root_logger.addHandler(queue_handler) root_logger.setLevel(self.__logger_level) return root_logger def __listener(self): self.logger.debug("Started listener thread") while not self.__listener_shutdown.is_set(): # noinspection PyBroadException try: while True: try: record = self.__queue.get(block=False) self.logger.getChild(record.name).handle(record) except queue.Empty: break except Exception: self.__listener_exc_info = sys.exc_info() self.logger.exception("Caught exception in listener thread") # break out of run loop self.__listener_shutdown.set() break time.sleep(MultiprocessingLogger.__LISTENER_SLEEP_INTERVAL_IN_SECS) self.logger.debug("Stopped listener thread") ================================================ FILE: src/python/common/persist.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import os from abc import ABC, abstractmethod from typing import Type, TypeVar from .error import AppError from .localization import Localization # Source: https://stackoverflow.com/a/39205612/8571324 T_Persist = TypeVar('T_Persist', bound='Persist') T_Serializable = TypeVar('T_Serializable', bound='Serializable') class Serializable(ABC): """ Defines a class that is serializable to string. The string representation must be human readable (i.e. not pickle) """ @classmethod @abstractmethod def from_str(cls: Type[T_Serializable], content: str) -> T_Serializable: pass @abstractmethod def to_str(self) -> str: pass class PersistError(AppError): """ Exception indicating persist loading/saving error """ pass class Persist(Serializable): """ Defines state that should be persisted between runs Provides utility methods to persist/load content to/from file Concrete implementations need to implement the from_str() and to_str() functionality """ @classmethod def from_file(cls: Type[T_Persist], file_path: str) -> T_Persist: if not os.path.isfile(file_path): raise AppError(Localization.Error.MISSING_FILE.format(file_path)) with open(file_path, "r") as f: return cls.from_str(f.read()) def to_file(self, file_path: str): with open(file_path, "w") as f: f.write(self.to_str()) @classmethod @abstractmethod def from_str(cls: Type[T_Persist], content: str) -> T_Persist: pass @abstractmethod def to_str(self) -> str: pass ================================================ FILE: src/python/common/status.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from abc import ABC, abstractmethod from typing import Any, TypeVar, Type from threading import Lock from common import overrides T = TypeVar('T', bound='StatusComponent') class IStatusComponentListener(ABC): @abstractmethod def notify(self, name: str): """ Called when a property is changed :param name: :return: """ pass class BaseStatus: """ Provides functionality to dynamically create properties """ # noinspection PyProtectedMember @classmethod def _create_property(cls, name: str) -> property: return property(fget=lambda s: s._get_property(name), fset=lambda s, v: s._set_property(name, v)) def _get_property(self, name: str) -> Any: return getattr(self, "__" + name, None) def _set_property(self, name: str, value: Any): setattr(self, "__" + name, value) class StatusComponent(BaseStatus): """ Base class for status of a single component """ def __init__(self): self.__listeners = [] self.__properties = [] # names of properties created def add_listener(self, listener: IStatusComponentListener): if listener not in self.__listeners: self.__listeners.append(listener) def remove_listener(self, listener: IStatusComponentListener): if listener in self.__listeners: self.__listeners.remove(listener) @classmethod def copy(cls: Type[T], src: T, dst: T) -> None: property_names = [p for p in dir(cls) if isinstance(getattr(cls, p), property)] for prop in property_names: setattr(dst, "__" + prop, getattr(src, "__" + prop)) @overrides(BaseStatus) def _set_property(self, name: str, value: Any): super()._set_property(name, value) # Notify listeners for listener in self.__listeners: listener.notify(name) class IStatusListener(ABC): @abstractmethod def notify(self): """ Called when any property of a component is changed :return: """ pass class Status(BaseStatus): """ This class tracks the status of all components across the server This is meant to be one-way communication - i.e. only one component should set the status Clients can use listeners to be notified when values are updated. Listeners can be added to the overall status for notification on any change, or to each component for component-specific changes. """ class CompListener(IStatusComponentListener): """Propagates notifications from component to status listeners""" def __init__(self, status: "Status"): self.status = status def notify(self, name: str): self.status._listeners_lock.acquire() for listener in self.status._listeners: listener.notify() self.status._listeners_lock.release() # ----- Start of component definition ----- class ServerStatus(StatusComponent): up = StatusComponent._create_property("up") error_msg = StatusComponent._create_property("error_msg") def __init__(self): super().__init__() self.up = True self.error_msg = None class ControllerStatus(StatusComponent): latest_local_scan_time = StatusComponent._create_property("latest_local_scan_time") latest_remote_scan_time = StatusComponent._create_property("latest_remote_scan_time") latest_remote_scan_failed = StatusComponent._create_property("latest_remote_scan_failed") latest_remote_scan_error = StatusComponent._create_property("latest_remote_scan_error") def __init__(self): super().__init__() self.latest_local_scan_time = None self.latest_remote_scan_time = None self.latest_remote_scan_failed = None self.latest_remote_scan_error = None # ----- End of component definition ----- # Component registration server = BaseStatus._create_property("server") controller = BaseStatus._create_property("controller") def __init__(self): self._listeners = [] self._listeners_lock = Lock() self.__comp_listener = Status.CompListener(self) # Component initialization self.server = self.__create_component(Status.ServerStatus) self.controller = self.__create_component(Status.ControllerStatus) def copy(self) -> "Status": copy = Status() cls = Status property_names = [p for p in dir(cls) if isinstance(getattr(cls, p), property)] for prop in property_names: src_comp = self._get_property(prop) dst_comp = copy._get_property(prop) src_comp.__class__.copy(src_comp, dst_comp) return copy def add_listener(self, listener: IStatusListener): self._listeners_lock.acquire() if listener not in self._listeners: self._listeners.append(listener) self._listeners_lock.release() def remove_listener(self, listener: IStatusListener): self._listeners_lock.acquire() if listener in self._listeners: self._listeners.remove(listener) self._listeners_lock.release() def __create_component(self, comp_cls: Type[T]) -> T: """Create a component and register our listener with it""" # PyCharm is confused and complains about the ctor # noinspection PyCallingNonCallable comp = comp_cls() comp.add_listener(self.__comp_listener) return comp @overrides(BaseStatus) def _set_property(self, name: str, value: Any): """Override set property so that it can only be set once""" if self._get_property(name) is not None: raise ValueError("Cannot reassign component") super()._set_property(name, value) ================================================ FILE: src/python/common/types.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import inspect def overrides(interface_class): """ Decorator to check that decorated method is a valid override Source: https://stackoverflow.com/a/8313042 :param interface_class: The super class :return: """ assert(inspect.isclass(interface_class)), "Overrides parameter must be a class type" def overrider(method): assert(method.__name__ in dir(interface_class)), "Method does not override super class" return method return overrider ================================================ FILE: src/python/controller/__init__.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from .controller import Controller from .controller_job import ControllerJob from .controller_persist import ControllerPersist from .model_builder import ModelBuilder from .auto_queue import AutoQueue, AutoQueuePersist, IAutoQueuePersistListener, AutoQueuePattern from .scan import IScanner, ScannerResult, ScannerProcess, ScannerError ================================================ FILE: src/python/controller/auto_queue.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import json from abc import ABC, abstractmethod from typing import Set, List, Callable, Tuple import fnmatch from common import overrides, Constants, Context, Persist, PersistError, Serializable from model import IModelListener, ModelFile from .controller import Controller class AutoQueuePattern(Serializable): # Keys __KEY_PATTERN = "pattern" def __init__(self, pattern: str): self.__pattern = pattern @property def pattern(self) -> str: return self.__pattern def __eq__(self, other: "AutoQueuePattern") -> bool: return self.__pattern == other.__pattern def __hash__(self) -> int: return hash(self.__pattern) def to_str(self) -> str: dct = dict() dct[AutoQueuePattern.__KEY_PATTERN] = self.__pattern return json.dumps(dct) @classmethod def from_str(cls, content: str) -> "AutoQueuePattern": dct = json.loads(content) return AutoQueuePattern(pattern=dct[AutoQueuePattern.__KEY_PATTERN]) class IAutoQueuePersistListener(ABC): """Listener for receiving AutoQueuePersist events""" @abstractmethod def pattern_added(self, pattern: AutoQueuePattern): pass @abstractmethod def pattern_removed(self, pattern: AutoQueuePattern): pass class AutoQueuePersist(Persist): """ Persisting state for auto-queue """ # Keys __KEY_PATTERNS = "patterns" def __init__(self): self.__patterns = [] self.__listeners = [] @property def patterns(self) -> Set[AutoQueuePattern]: return set(self.__patterns) def add_pattern(self, pattern: AutoQueuePattern): # Check values if not pattern.pattern.strip(): raise ValueError("Cannot add blank pattern") if pattern not in self.__patterns: self.__patterns.append(pattern) for listener in self.__listeners: listener.pattern_added(pattern) def remove_pattern(self, pattern: AutoQueuePattern): if pattern in self.__patterns: self.__patterns.remove(pattern) for listener in self.__listeners: listener.pattern_removed(pattern) def add_listener(self, listener: IAutoQueuePersistListener): self.__listeners.append(listener) @classmethod @overrides(Persist) def from_str(cls: "AutoQueuePersist", content: str) -> "AutoQueuePersist": persist = AutoQueuePersist() try: dct = json.loads(content) pattern_list = dct[AutoQueuePersist.__KEY_PATTERNS] for pattern in pattern_list: persist.add_pattern(AutoQueuePattern.from_str(pattern)) return persist except (json.decoder.JSONDecodeError, KeyError) as e: raise PersistError("Error parsing AutoQueuePersist - {}: {}".format( type(e).__name__, str(e)) ) @overrides(Persist) def to_str(self) -> str: dct = dict() dct[AutoQueuePersist.__KEY_PATTERNS] = list(p.to_str() for p in self.__patterns) return json.dumps(dct, indent=Constants.JSON_PRETTY_PRINT_INDENT) class AutoQueueModelListener(IModelListener): """Keeps track of added and modified files""" def __init__(self): self.new_files = [] # list of new files self.modified_files = [] # list of pairs (old_file, new_file) @overrides(IModelListener) def file_added(self, file: ModelFile): self.new_files.append(file) @overrides(IModelListener) def file_updated(self, old_file: ModelFile, new_file: ModelFile): self.modified_files.append((old_file, new_file)) @overrides(IModelListener) def file_removed(self, file: ModelFile): pass class AutoQueuePersistListener(IAutoQueuePersistListener): """Keeps track of newly added patterns""" def __init__(self): self.new_patterns = set() @overrides(IAutoQueuePersistListener) def pattern_added(self, pattern: AutoQueuePattern): self.new_patterns.add(pattern) @overrides(IAutoQueuePersistListener) def pattern_removed(self, pattern: AutoQueuePattern): if pattern in self.new_patterns: self.new_patterns.remove(pattern) class AutoQueue: """ Implements auto-queue functionality by sending commands to controller as matching files are discovered AutoQueue is in the same thread as Controller, so no synchronization is needed for now """ def __init__(self, context: Context, persist: AutoQueuePersist, controller: Controller): self.logger = context.logger.getChild("AutoQueue") self.__persist = persist self.__controller = controller self.__model_listener = AutoQueueModelListener() self.__persist_listener = AutoQueuePersistListener() self.__enabled = context.config.autoqueue.enabled self.__patterns_only = context.config.autoqueue.patterns_only self.__auto_extract_enabled = context.config.autoqueue.auto_extract if self.__enabled: persist.add_listener(self.__persist_listener) initial_model_files = self.__controller.get_model_files_and_add_listener(self.__model_listener) # pass the initial model files through to our listener for file in initial_model_files: self.__model_listener.file_added(file) # Print the initial persist state self.logger.debug("Auto-Queue Patterns:") for pattern in self.__persist.patterns: self.logger.debug(" {}".format(pattern.pattern)) def process(self): """ Advance the auto queue state :return: """ if not self.__enabled: return ### # Queue ### queue_candidate_files = [] # Candidate all new files queue_candidate_files += self.__model_listener.new_files # Candidate modified files where the remote size changed for old_file, new_file in self.__model_listener.modified_files: if old_file.remote_size != new_file.remote_size: queue_candidate_files.append(new_file) files_to_queue = self.__filter_candidates( candidates=queue_candidate_files, accept=lambda f: f.remote_size is not None and f.state == ModelFile.State.DEFAULT ) ### # Extract ### files_to_extract = [] if self.__auto_extract_enabled: extract_candidate_files = [] # Candidate all new files extract_candidate_files += self.__model_listener.new_files # Candidate modified files that just became DOWNLOADED # But not files that went EXTRACTING -> DOWNLOADED (failed extraction) for old_file, new_file in self.__model_listener.modified_files: if old_file.state != ModelFile.State.DOWNLOADED and \ old_file.state != ModelFile.State.EXTRACTING and \ new_file.state == ModelFile.State.DOWNLOADED: extract_candidate_files.append(new_file) files_to_extract = self.__filter_candidates( candidates=extract_candidate_files, accept=lambda f: f.state == ModelFile.State.DOWNLOADED and f.local_size is not None and f.local_size > 0 and f.is_extractable ) ### # Send commands ### # Send the queue commands for filename, pattern in files_to_queue: self.logger.info( "Auto queueing '{}'".format(filename) + (" for pattern '{}'".format(pattern.pattern) if pattern else "") ) command = Controller.Command(Controller.Command.Action.QUEUE, filename) self.__controller.queue_command(command) # Send the extract commands for filename, pattern in files_to_extract: self.logger.info( "Auto extracting '{}'".format(filename) + (" for pattern '{}'".format(pattern.pattern) if pattern else "") ) command = Controller.Command(Controller.Command.Action.EXTRACT, filename) self.__controller.queue_command(command) # Clear the processed files self.__model_listener.new_files.clear() self.__model_listener.modified_files.clear() # Clear the new patterns self.__persist_listener.new_patterns.clear() def __filter_candidates(self, candidates: List[ModelFile], accept: Callable[[ModelFile], bool]) -> List[Tuple[str, AutoQueuePattern]]: """ Given a list of candidate files, filter out those that match the accept criteria Also takes into consideration new patterns that were added The accept criteria is applied to candidates AND all existing files in case of new patterns :param candidates: :param accept: :return: list of (filename, pattern) pairs """ # Files accepted and matched, filename -> pattern map # Filename key prevents a file from being accepted twice files_matched = dict() # Step 1: run candidates through all the patterns if they are enabled # otherwise accept all files for file in candidates: if self.__patterns_only: for pattern in self.__persist.patterns: if accept(file) and self.__match(pattern, file): files_matched[file.name] = pattern break elif accept(file): files_matched[file.name] = None # Step 2: run new pattern through all the files if self.__persist_listener.new_patterns: model_files = self.__controller.get_model_files() for new_pattern in self.__persist_listener.new_patterns: for file in model_files: if accept(file) and self.__match(new_pattern, file): files_matched[file.name] = new_pattern return list(zip(files_matched.keys(), files_matched.values())) @staticmethod def __match(pattern: AutoQueuePattern, file: ModelFile) -> bool: """ Returns true is file matches the pattern :param pattern: :param file: :return: """ # make the search case insensitive pattern = pattern.pattern.lower() filename = file.name.lower() # 1. pattern match # 2. wildcard match return pattern in filename or \ fnmatch.fnmatch(filename, pattern) ================================================ FILE: src/python/controller/controller.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from abc import ABC, abstractmethod from typing import List, Callable from threading import Lock from queue import Queue from enum import Enum import copy # my libs from .scan import ScannerProcess, ActiveScanner, LocalScanner, RemoteScanner from .extract import ExtractProcess, ExtractStatus from .model_builder import ModelBuilder from common import Context, AppError, MultiprocessingLogger, AppOneShotProcess, Constants from model import ModelError, ModelFile, Model, ModelDiff, ModelDiffUtil, IModelListener from lftp import Lftp, LftpError, LftpJobStatus from .controller_persist import ControllerPersist from .delete import DeleteLocalProcess, DeleteRemoteProcess class ControllerError(AppError): """ Exception indicating a controller error """ pass class Controller: """ Top-level class that controls the behaviour of the app """ class Command: """ Class by which clients of Controller can request Actions to be executed Supports callbacks by which clients can be notified of action success/failure Note: callbacks will be executed in Controller thread, so any heavy computation should be moved out of the callback """ class Action(Enum): QUEUE = 0 STOP = 1 EXTRACT = 2 DELETE_LOCAL = 3 DELETE_REMOTE = 4 class ICallback(ABC): """Command callback interface""" @abstractmethod def on_success(self): """Called on successful completion of action""" pass @abstractmethod def on_failure(self, error: str): """Called on action failure""" pass def __init__(self, action: Action, filename: str): self.action = action self.filename = filename self.callbacks = [] def add_callback(self, callback: ICallback): self.callbacks.append(callback) class CommandProcessWrapper: """ Wraps any one-shot command processes launched by the controller """ def __init__(self, process: AppOneShotProcess, post_callback: Callable): self.process = process self.post_callback = post_callback def __init__(self, context: Context, persist: ControllerPersist): self.__context = context self.__persist = persist self.logger = context.logger.getChild("Controller") # Decide the password here self.__password = context.config.lftp.remote_password if not context.config.lftp.use_ssh_key else None # The command queue self.__command_queue = Queue() # The model self.__model = Model() self.__model.set_base_logger(self.logger) # Lock for the model # Note: While the scanners are in a separate process, the rest of the application # is threaded in a single process. (The webserver is bottle+paste which is # multi-threaded). Therefore it is safe to use a threading Lock for the model # (the scanner processes never try to access the model) self.__model_lock = Lock() # Model builder self.__model_builder = ModelBuilder() self.__model_builder.set_base_logger(self.logger) self.__model_builder.set_downloaded_files(self.__persist.downloaded_file_names) self.__model_builder.set_extracted_files(self.__persist.extracted_file_names) # Lftp self.__lftp = Lftp(address=self.__context.config.lftp.remote_address, port=self.__context.config.lftp.remote_port, user=self.__context.config.lftp.remote_username, password=self.__password) self.__lftp.set_base_logger(self.logger) self.__lftp.set_base_remote_dir_path(self.__context.config.lftp.remote_path) self.__lftp.set_base_local_dir_path(self.__context.config.lftp.local_path) # Configure Lftp self.__lftp.num_parallel_jobs = self.__context.config.lftp.num_max_parallel_downloads self.__lftp.num_parallel_files = self.__context.config.lftp.num_max_parallel_files_per_download self.__lftp.num_connections_per_root_file = self.__context.config.lftp.num_max_connections_per_root_file self.__lftp.num_connections_per_dir_file = self.__context.config.lftp.num_max_connections_per_dir_file self.__lftp.num_max_total_connections = self.__context.config.lftp.num_max_total_connections self.__lftp.use_temp_file = self.__context.config.lftp.use_temp_file self.__lftp.temp_file_name = "*" + Constants.LFTP_TEMP_FILE_SUFFIX self.__lftp.set_verbose_logging(self.__context.config.general.verbose) # Setup the scanners and scanner processes self.__active_scanner = ActiveScanner(self.__context.config.lftp.local_path) self.__local_scanner = LocalScanner( local_path=self.__context.config.lftp.local_path, use_temp_file=self.__context.config.lftp.use_temp_file ) self.__remote_scanner = RemoteScanner( remote_address=self.__context.config.lftp.remote_address, remote_username=self.__context.config.lftp.remote_username, remote_password=self.__password, remote_port=self.__context.config.lftp.remote_port, remote_path_to_scan=self.__context.config.lftp.remote_path, local_path_to_scan_script=self.__context.args.local_path_to_scanfs, remote_path_to_scan_script=self.__context.config.lftp.remote_path_to_scan_script ) self.__active_scan_process = ScannerProcess( scanner=self.__active_scanner, interval_in_ms=self.__context.config.controller.interval_ms_downloading_scan, verbose=False ) self.__local_scan_process = ScannerProcess( scanner=self.__local_scanner, interval_in_ms=self.__context.config.controller.interval_ms_local_scan, ) self.__remote_scan_process = ScannerProcess( scanner=self.__remote_scanner, interval_in_ms=self.__context.config.controller.interval_ms_remote_scan, ) # Setup extract process if self.__context.config.controller.use_local_path_as_extract_path: out_dir_path = self.__context.config.lftp.local_path else: out_dir_path = self.__context.config.controller.extract_path self.__extract_process = ExtractProcess( out_dir_path=out_dir_path, local_path=self.__context.config.lftp.local_path ) # Setup multiprocess logging self.__mp_logger = MultiprocessingLogger(self.logger) self.__active_scan_process.set_multiprocessing_logger(self.__mp_logger) self.__local_scan_process.set_multiprocessing_logger(self.__mp_logger) self.__remote_scan_process.set_multiprocessing_logger(self.__mp_logger) self.__extract_process.set_multiprocessing_logger(self.__mp_logger) # Keep track of active files self.__active_downloading_file_names = [] self.__active_extracting_file_names = [] # Keep track of active command processes self.__active_command_processes = [] self.__started = False def start(self): """ Start the controller Must be called after ctor and before process() :return: """ self.logger.debug("Starting controller") self.__active_scan_process.start() self.__local_scan_process.start() self.__remote_scan_process.start() self.__extract_process.start() self.__mp_logger.start() self.__started = True def process(self): """ Advance the controller state This method should return relatively quickly as the heavy lifting is done by concurrent tasks :return: """ if not self.__started: raise ControllerError("Cannot process, controller is not started") self.__propagate_exceptions() self.__cleanup_commands() self.__process_commands() self.__update_model() def exit(self): self.logger.debug("Exiting controller") if self.__started: self.__lftp.exit() self.__active_scan_process.terminate() self.__local_scan_process.terminate() self.__remote_scan_process.terminate() self.__extract_process.terminate() self.__active_scan_process.join() self.__local_scan_process.join() self.__remote_scan_process.join() self.__extract_process.join() self.__mp_logger.stop() self.__started = False self.logger.info("Exited controller") def get_model_files(self) -> List[ModelFile]: """ Returns a copy of all the model files :return: """ # Lock the model self.__model_lock.acquire() model_files = self.__get_model_files() # Release the model self.__model_lock.release() return model_files def add_model_listener(self, listener: IModelListener): """ Adds a listener to the controller's model :param listener: :return: """ # Lock the model self.__model_lock.acquire() self.__model.add_listener(listener) # Release the model self.__model_lock.release() def remove_model_listener(self, listener: IModelListener): """ Removes a listener from the controller's model :param listener: :return: """ # Lock the model self.__model_lock.acquire() self.__model.remove_listener(listener) # Release the model self.__model_lock.release() def get_model_files_and_add_listener(self, listener: IModelListener): """ Adds a listener and returns the current state of model files in one atomic operation This guarantees that model update events are not missed or duplicated for the clients Without an atomic operation, the following scenarios can happen: 1. get_model() -> model updated -> add_listener() The model update never propagates to client 2. add_listener() -> model updated -> get_model() The model update is duplicated on client side (once through listener, and once through the model). :param listener: :return: """ # Lock the model self.__model_lock.acquire() self.__model.add_listener(listener) model_files = self.__get_model_files() # Release the model self.__model_lock.release() return model_files def queue_command(self, command: Command): self.__command_queue.put(command) def __get_model_files(self) -> List[ModelFile]: model_files = [] for filename in self.__model.get_file_names(): model_files.append(copy.deepcopy(self.__model.get_file(filename))) return model_files def __update_model(self): # Grab the latest scan results latest_remote_scan = self.__remote_scan_process.pop_latest_result() latest_local_scan = self.__local_scan_process.pop_latest_result() latest_active_scan = self.__active_scan_process.pop_latest_result() # Grab the Lftp status lftp_statuses = None try: lftp_statuses = self.__lftp.status() except LftpError as e: self.logger.warning("Caught lftp error: {}".format(str(e))) # Grab the latest extract results latest_extract_statuses = self.__extract_process.pop_latest_statuses() # Grab the latest extracted file names latest_extracted_results = self.__extract_process.pop_completed() # Update list of active file names if lftp_statuses is not None: self.__active_downloading_file_names = [ s.name for s in lftp_statuses if s.state == LftpJobStatus.State.RUNNING ] if latest_extract_statuses is not None: self.__active_extracting_file_names = [ s.name for s in latest_extract_statuses.statuses if s.state == ExtractStatus.State.EXTRACTING ] # Update the active scanner's state self.__active_scanner.set_active_files( self.__active_downloading_file_names + self.__active_extracting_file_names ) # Update model builder state if latest_remote_scan is not None: self.__model_builder.set_remote_files(latest_remote_scan.files) if latest_local_scan is not None: self.__model_builder.set_local_files(latest_local_scan.files) if latest_active_scan is not None: self.__model_builder.set_active_files(latest_active_scan.files) if lftp_statuses is not None: self.__model_builder.set_lftp_statuses(lftp_statuses) if latest_extract_statuses is not None: self.__model_builder.set_extract_statuses(latest_extract_statuses.statuses) if latest_extracted_results: for result in latest_extracted_results: self.__persist.extracted_file_names.add(result.name) self.__model_builder.set_extracted_files(self.__persist.extracted_file_names) # Build the new model, if needed if self.__model_builder.has_changes(): new_model = self.__model_builder.build_model() # Lock the model self.__model_lock.acquire() # Diff the new model with old model model_diff = ModelDiffUtil.diff_models(self.__model, new_model) # Apply changes to the new model for diff in model_diff: if diff.change == ModelDiff.Change.ADDED: self.__model.add_file(diff.new_file) elif diff.change == ModelDiff.Change.REMOVED: self.__model.remove_file(diff.old_file.name) elif diff.change == ModelDiff.Change.UPDATED: self.__model.update_file(diff.new_file) # Detect if a file was just Downloaded # an Added file in Downloaded state # an Updated file transitioning to Downloaded state # If so, update the persist state # Note: This step is done after the new model is build because # model_builder is the one that discovers when a file is Downloaded downloaded = False if diff.change == ModelDiff.Change.ADDED and \ diff.new_file.state == ModelFile.State.DOWNLOADED: downloaded = True elif diff.change == ModelDiff.Change.UPDATED and \ diff.new_file.state == ModelFile.State.DOWNLOADED and \ diff.old_file.state != ModelFile.State.DOWNLOADED: downloaded = True if downloaded: self.__persist.downloaded_file_names.add(diff.new_file.name) self.__model_builder.set_downloaded_files(self.__persist.downloaded_file_names) # Prune the extracted files list of any files that were deleted locally # This prevents these files from going to EXTRACTED state if they are re-downloaded remove_extracted_file_names = set() existing_file_names = self.__model.get_file_names() for extracted_file_name in self.__persist.extracted_file_names: if extracted_file_name in existing_file_names: file = self.__model.get_file(extracted_file_name) if file.state == ModelFile.State.DELETED: # Deleted locally, remove remove_extracted_file_names.add(extracted_file_name) else: # Not in the model at all # This could be because local and remote scans are not yet available pass if remove_extracted_file_names: self.logger.info("Removing from extracted list: {}".format(remove_extracted_file_names)) self.__persist.extracted_file_names.difference_update(remove_extracted_file_names) self.__model_builder.set_extracted_files(self.__persist.extracted_file_names) # Release the model self.__model_lock.release() # Update the controller status if latest_remote_scan is not None: self.__context.status.controller.latest_remote_scan_time = latest_remote_scan.timestamp self.__context.status.controller.latest_remote_scan_failed = latest_remote_scan.failed self.__context.status.controller.latest_remote_scan_error = latest_remote_scan.error_message if latest_local_scan is not None: self.__context.status.controller.latest_local_scan_time = latest_local_scan.timestamp def __process_commands(self): def _notify_failure(_command: Controller.Command, _msg: str): self.logger.warning("Command failed. {}".format(_msg)) for _callback in _command.callbacks: _callback.on_failure(_msg) while not self.__command_queue.empty(): command = self.__command_queue.get() self.logger.info("Received command {} for file {}".format(str(command.action), command.filename)) try: file = self.__model.get_file(command.filename) except ModelError: _notify_failure(command, "File '{}' not found".format(command.filename)) continue if command.action == Controller.Command.Action.QUEUE: if file.remote_size is None: _notify_failure(command, "File '{}' does not exist remotely".format(command.filename)) continue try: self.__lftp.queue(file.name, file.is_dir) except LftpError as e: _notify_failure(command, "Lftp error: ".format(str(e))) continue elif command.action == Controller.Command.Action.STOP: if file.state not in (ModelFile.State.DOWNLOADING, ModelFile.State.QUEUED): _notify_failure(command, "File '{}' is not Queued or Downloading".format(command.filename)) continue try: self.__lftp.kill(file.name) except LftpError as e: _notify_failure(command, "Lftp error: ".format(str(e))) continue elif command.action == Controller.Command.Action.EXTRACT: # Note: We don't check the is_extractable flag because it's just a guess if file.state not in ( ModelFile.State.DEFAULT, ModelFile.State.DOWNLOADED, ModelFile.State.EXTRACTED ): _notify_failure(command, "File '{}' in state {} cannot be extracted".format( command.filename, str(file.state) )) continue elif file.local_size is None: _notify_failure(command, "File '{}' does not exist locally".format(command.filename)) continue else: self.__extract_process.extract(file) elif command.action == Controller.Command.Action.DELETE_LOCAL: if file.state not in ( ModelFile.State.DEFAULT, ModelFile.State.DOWNLOADED, ModelFile.State.EXTRACTED ): _notify_failure(command, "Local file '{}' cannot be deleted in state {}".format( command.filename, str(file.state) )) continue elif file.local_size is None: _notify_failure(command, "File '{}' does not exist locally".format(command.filename)) continue else: process = DeleteLocalProcess( local_path=self.__context.config.lftp.local_path, file_name=file.name ) process.set_multiprocessing_logger(self.__mp_logger) post_callback = self.__local_scan_process.force_scan command_wrapper = Controller.CommandProcessWrapper( process=process, post_callback=post_callback ) self.__active_command_processes.append(command_wrapper) command_wrapper.process.start() elif command.action == Controller.Command.Action.DELETE_REMOTE: if file.state not in ( ModelFile.State.DEFAULT, ModelFile.State.DOWNLOADED, ModelFile.State.EXTRACTED, ModelFile.State.DELETED ): _notify_failure(command, "Remote file '{}' cannot be deleted in state {}".format( command.filename, str(file.state) )) continue elif file.remote_size is None: _notify_failure(command, "File '{}' does not exist remotely".format(command.filename)) continue else: process = DeleteRemoteProcess( remote_address=self.__context.config.lftp.remote_address, remote_username=self.__context.config.lftp.remote_username, remote_password=self.__password, remote_port=self.__context.config.lftp.remote_port, remote_path=self.__context.config.lftp.remote_path, file_name=file.name ) process.set_multiprocessing_logger(self.__mp_logger) post_callback = self.__remote_scan_process.force_scan command_wrapper = Controller.CommandProcessWrapper( process=process, post_callback=post_callback ) self.__active_command_processes.append(command_wrapper) command_wrapper.process.start() # If we get here, it was a success for callback in command.callbacks: callback.on_success() def __propagate_exceptions(self): """ Propagate any exceptions from child processes/threads to this thread :return: """ self.__lftp.raise_pending_error() self.__active_scan_process.propagate_exception() self.__local_scan_process.propagate_exception() self.__remote_scan_process.propagate_exception() self.__mp_logger.propagate_exception() self.__extract_process.propagate_exception() def __cleanup_commands(self): """ Cleanup the list of active commands and do any callbacks :return: """ still_active_processes = [] for command_process in self.__active_command_processes: if command_process.process.is_alive(): still_active_processes.append(command_process) else: # Do the post callback command_process.post_callback() # Propagate the exception command_process.process.propagate_exception() self.__active_command_processes = still_active_processes ================================================ FILE: src/python/controller/controller_job.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. # my libs from common import overrides, Job, Context from .controller import Controller from .auto_queue import AutoQueue class ControllerJob(Job): """ The controller service Handles querying and downloading of files """ def __init__(self, context: Context, controller: Controller, auto_queue: AutoQueue): super().__init__(name=self.__class__.__name__, context=context) self.__controller = controller self.__auto_queue = auto_queue @overrides(Job) def setup(self): self.__controller.start() @overrides(Job) def execute(self): self.__controller.process() self.__auto_queue.process() @overrides(Job) def cleanup(self): self.__controller.exit() ================================================ FILE: src/python/controller/controller_persist.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import json from common import overrides, Constants, Persist, PersistError class ControllerPersist(Persist): """ Persisting state for controller """ # Keys __KEY_DOWNLOADED_FILE_NAMES = "downloaded" __KEY_EXTRACTED_FILE_NAMES = "extracted" def __init__(self): self.downloaded_file_names = set() self.extracted_file_names = set() @classmethod @overrides(Persist) def from_str(cls: "ControllerPersist", content: str) -> "ControllerPersist": persist = ControllerPersist() try: dct = json.loads(content) persist.downloaded_file_names = set(dct[ControllerPersist.__KEY_DOWNLOADED_FILE_NAMES]) persist.extracted_file_names = set(dct[ControllerPersist.__KEY_EXTRACTED_FILE_NAMES]) return persist except (json.decoder.JSONDecodeError, KeyError) as e: raise PersistError("Error parsing AutoQueuePersist - {}: {}".format( type(e).__name__, str(e)) ) @overrides(Persist) def to_str(self) -> str: dct = dict() dct[ControllerPersist.__KEY_DOWNLOADED_FILE_NAMES] = list(self.downloaded_file_names) dct[ControllerPersist.__KEY_EXTRACTED_FILE_NAMES] = list(self.extracted_file_names) return json.dumps(dct, indent=Constants.JSON_PRETTY_PRINT_INDENT) ================================================ FILE: src/python/controller/delete/__init__.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from .delete_process import DeleteLocalProcess, DeleteRemoteProcess ================================================ FILE: src/python/controller/delete/delete_process.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import os import shutil from typing import Optional from common import AppOneShotProcess from ssh import Sshcp, SshcpError class DeleteLocalProcess(AppOneShotProcess): def __init__(self, local_path: str, file_name: str): super().__init__(name=self.__class__.__name__) self.__local_path = local_path self.__file_name = file_name def run_once(self): file_path = os.path.join(self.__local_path, self.__file_name) self.logger.debug("Deleting local file {}".format(self.__file_name)) if not os.path.exists(file_path): self.logger.error("Failed to delete non-existing file: {}".format(file_path)) else: if os.path.isfile(file_path): os.remove(file_path) else: shutil.rmtree(file_path, ignore_errors=True) class DeleteRemoteProcess(AppOneShotProcess): def __init__(self, remote_address: str, remote_username: str, remote_password: Optional[str], remote_port: int, remote_path: str, file_name: str): super().__init__(name=self.__class__.__name__) self.__remote_path = remote_path self.__file_name = file_name self.__ssh = Sshcp(host=remote_address, port=remote_port, user=remote_username, password=remote_password) def run_once(self): self.__ssh.set_base_logger(self.logger) file_path = os.path.join(self.__remote_path, self.__file_name) self.logger.debug("Deleting remote file {}".format(self.__file_name)) try: out = self.__ssh.shell("rm -rf '{}'".format(file_path)) self.logger.debug("Remote delete output: {}".format(out.decode())) except SshcpError: self.logger.exception("Exception while deleting remote file") ================================================ FILE: src/python/controller/extract/__init__.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from .extract import Extract, ExtractError from .dispatch import ExtractDispatch, ExtractDispatchError, ExtractListener, ExtractStatus from .extract_process import ExtractProcess, ExtractStatusResult, ExtractCompletedResult ================================================ FILE: src/python/controller/extract/dispatch.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from enum import Enum from typing import List import queue import logging import os import threading import time from abc import ABC, abstractmethod import re from .extract import Extract, ExtractError from model import ModelFile from common import AppError class ExtractDispatchError(AppError): pass class ExtractListener(ABC): @abstractmethod def extract_completed(self, name: str, is_dir: bool): pass @abstractmethod def extract_failed(self, name: str, is_dir: bool): pass class ExtractStatus: """ Represents the status of a single extraction request """ class State(Enum): EXTRACTING = 0 def __init__(self, name: str, is_dir: bool, state: State): self.__name = name self.__is_dir = is_dir self.__state = state @property def name(self) -> str: return self.__name @property def is_dir(self) -> bool: return self.__is_dir @property def state(self) -> State: return self.__state def __eq__(self, other): return self.__dict__ == other.__dict__ class ExtractDispatch: __WORKER_SLEEP_INTERVAL_IN_SECS = 0.5 class _Task: def __init__(self, root_name: str, root_is_dir: bool): self.root_name = root_name self.root_is_dir = root_is_dir self.archive_paths = [] # list of (archive path, out path) pairs def add_archive(self, archive_path: str, out_dir_path: str): self.archive_paths.append((archive_path, out_dir_path)) def __init__(self, out_dir_path: str, local_path: str): self.__out_dir_path = out_dir_path self.__local_path = local_path self.__task_queue = queue.Queue() self.__worker = threading.Thread(name="ExtractWorker", target=self.__worker) self.__worker_shutdown = threading.Event() self.__listeners = [] self.__listeners_lock = threading.Lock() self.logger = logging.getLogger(self.__class__.__name__) def set_base_logger(self, base_logger: logging.Logger): self.logger = base_logger.getChild(self.__class__.__name__) def start(self): self.__worker.start() def stop(self): self.__worker_shutdown.set() self.__worker.join() def add_listener(self, listener: ExtractListener): self.__listeners_lock.acquire() self.__listeners.append(listener) self.__listeners_lock.release() def status(self) -> List[ExtractStatus]: tasks = list(self.__task_queue.queue) statuses = [] for task in tasks: status = ExtractStatus(name=task.root_name, is_dir=task.root_is_dir, state=ExtractStatus.State.EXTRACTING) statuses.append(status) return statuses def extract(self, model_file: ModelFile): self.logger.debug("Received extract for {}".format(model_file.name)) for task in self.__task_queue.queue: if task.root_name == model_file.name: self.logger.info("Ignoring extract for {}, already exists".format(model_file.name)) return # noinspection PyProtectedMember task = ExtractDispatch._Task(model_file.name, model_file.is_dir) if model_file.is_dir: # For a directory, try and find all archives # Loop through all directories using BFS frontier = [model_file] while frontier: curr_file = frontier.pop(0) if curr_file.is_dir: frontier += curr_file.get_children() else: archive_full_path = os.path.join(self.__local_path, curr_file.full_path) out_dir_path = os.path.join(self.__out_dir_path, os.path.dirname(curr_file.full_path)) if curr_file.local_size is not None \ and curr_file.local_size > 0 \ and Extract.is_archive(archive_full_path): task.add_archive(archive_path=archive_full_path, out_dir_path=out_dir_path) # Coalesce extractions ExtractDispatch.__coalesce_extractions(task) # Verify that there was at least one archive file if len(task.archive_paths) > 0: self.__task_queue.put(task) else: raise ExtractDispatchError( "Directory does not contain any archives: {}".format(model_file.name) ) else: # For a single file, it must exist locally and must be an archive if model_file.local_size in (None, 0): raise ExtractDispatchError("File does not exist locally: {}".format(model_file.name)) archive_full_path = os.path.join(self.__local_path, model_file.name) if not Extract.is_archive(archive_full_path): raise ExtractDispatchError("File is not an archive: {}".format(model_file.name)) task.add_archive(archive_path=archive_full_path, out_dir_path=self.__out_dir_path) self.__task_queue.put(task) def __worker(self): self.logger.debug("Started worker thread") while not self.__worker_shutdown.is_set(): # Try to grab next task # Do another check for shutdown while len(self.__task_queue.queue) > 0 and not self.__worker_shutdown.is_set(): # peek the task task = self.__task_queue.queue[0] # We have a task, extract archives one by one completed = True try: for archive_path, out_dir_path in task.archive_paths: if self.__worker_shutdown.is_set(): # exit early self.logger.warning("Extraction failed, shutdown requested") completed = False break self.logger.debug("Extracting {}".format(archive_path)) Extract.extract_archive( archive_path=archive_path, out_dir_path=out_dir_path ) except ExtractError: self.logger.exception("Caught an extraction error") completed = False finally: # pop the task self.__task_queue.get(block=False) # Send notification to listeners self.__listeners_lock.acquire() for listener in self.__listeners: if completed: listener.extract_completed(task.root_name, task.root_is_dir) else: listener.extract_failed(task.root_name, task.root_is_dir) self.__listeners_lock.release() time.sleep(ExtractDispatch.__WORKER_SLEEP_INTERVAL_IN_SECS) self.logger.debug("Stopped worker thread") @staticmethod def __coalesce_extractions(task: _Task): """ Remove duplicate extractions due to split files :param task: :return: """ # Filter out any rxx files for a split rar filtered_paths = [] for archive_path, out_path in task.archive_paths: file_ext = os.path.splitext(os.path.basename(archive_path))[1] if not re.match("^\.r\d{2,}$", file_ext): filtered_paths.append((archive_path, out_path)) task.archive_paths = filtered_paths ================================================ FILE: src/python/controller/extract/extract.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import os import patoolib import patoolib.util from common import AppError class ExtractError(AppError): """ Indicates an extraction error """ pass class Extract: """ Utility to extract archive files """ @staticmethod def is_archive(archive_path: str) -> bool: if not os.path.isfile(archive_path): return False try: # noinspection PyUnusedLocal,PyShadowingBuiltins format, compression = patoolib.get_archive_format(archive_path) return True except patoolib.util.PatoolError: return False @staticmethod def is_archive_fast(archive_path: str) -> bool: """ Fast version of is_archive that only looks at file extension May return false negatives :param archive_path: :return: """ file_ext = os.path.splitext(os.path.basename(archive_path))[1] if file_ext: file_ext = file_ext[1:] # remove the dot # noinspection SpellCheckingInspection return file_ext in [ "7z", "bz2", "gz", "lz", "rar", "tar", "tgz", "tbz2", "zip", "zipx" ] else: return False @staticmethod def extract_archive(archive_path: str, out_dir_path: str): if not Extract.is_archive(archive_path): raise ExtractError("Path is not a valid archive: {}".format(archive_path)) try: # Try to create the outdir path if not os.path.exists(out_dir_path): os.makedirs(out_dir_path) patoolib.extract_archive(archive_path, outdir=out_dir_path, interactive=False) except FileNotFoundError as e: raise ExtractError(str(e)) except patoolib.util.PatoolError as e: raise ExtractError(str(e)) ================================================ FILE: src/python/controller/extract/extract_process.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import multiprocessing import datetime import time import queue from typing import Optional, List import logging from .dispatch import ExtractDispatch, ExtractStatus, ExtractListener, ExtractDispatchError from common import overrides, AppProcess from model import ModelFile class ExtractStatusResult: def __init__(self, timestamp: datetime, statuses: List[ExtractStatus]): self.timestamp = timestamp self.statuses = statuses class ExtractCompletedResult: def __init__(self, timestamp: datetime, name: str, is_dir: bool): self.timestamp = timestamp self.name = name self.is_dir = is_dir class ExtractProcess(AppProcess): __DEFAULT_SLEEP_INTERVAL_IN_SECS = 0.5 class __ExtractListener(ExtractListener): def __init__(self, logger: logging.Logger, completed_queue: multiprocessing.Queue): self.logger = logger self.completed_queue = completed_queue def extract_completed(self, name: str, is_dir: bool): self.logger.info("Extraction completed for {}".format(name)) completed_result = ExtractCompletedResult(timestamp=datetime.datetime.now(), name=name, is_dir=is_dir) self.completed_queue.put(completed_result) def extract_failed(self, name: str, is_dir: bool): self.logger.error("Extraction failed for {}".format(name)) def __init__(self, out_dir_path: str, local_path: str): super().__init__(name=self.__class__.__name__) self.__out_dir_path = out_dir_path self.__local_path = local_path self.__command_queue = multiprocessing.Queue() self.__status_result_queue = multiprocessing.Queue() self.__completed_result_queue = multiprocessing.Queue() self.__dispatch = None @overrides(AppProcess) def run_init(self): # Create dispatch inside the process self.__dispatch = ExtractDispatch(out_dir_path=self.__out_dir_path, local_path=self.__local_path) # Add extract listener listener = ExtractProcess.__ExtractListener( logger=self.logger, completed_queue=self.__completed_result_queue ) self.__dispatch.add_listener(listener) # Start dispatch self.__dispatch.start() @overrides(AppProcess) def run_cleanup(self): self.__dispatch.stop() @overrides(AppProcess) def run_loop(self): # Forward all the extract commands try: while True: file = self.__command_queue.get(block=False) try: self.__dispatch.extract(file) except ExtractDispatchError as e: self.logger.warning(str(e)) except queue.Empty: pass # Queue the latest status statuses = self.__dispatch.status() status_result = ExtractStatusResult(timestamp=datetime.datetime.now(), statuses=statuses) self.__status_result_queue.put(status_result) time.sleep(ExtractProcess.__DEFAULT_SLEEP_INTERVAL_IN_SECS) def extract(self, file: ModelFile): """ Process-safe method to queue an extraction :param file: :return: """ self.__command_queue.put(file) def pop_latest_statuses(self) -> Optional[ExtractStatusResult]: """ Process-safe method to retrieve latest extract status Returns none if no new status is available since the last time this method was called :return: """ latest_result = None try: while True: latest_result = self.__status_result_queue.get(block=False) except queue.Empty: pass return latest_result def pop_completed(self) -> List[ExtractCompletedResult]: """ Process-safe method to retrieve list of newly completed extractions Returns an empty list if no new extractions were completed since the last time this method was called. :return: """ completed = [] try: while True: result = self.__completed_result_queue.get(block=False) completed.append(result) except queue.Empty: pass return completed ================================================ FILE: src/python/controller/model_builder.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import os import logging from typing import List, Optional, Set import math # my libs from system import SystemFile from lftp import LftpJobStatus from model import ModelFile, Model, ModelError from .extract import ExtractStatus, Extract class ModelBuilder: """ ModelBuilder combines all the difference sources of file system info to build a model. These sources include: * downloading file system as a Dict[name, SystemFile] * local file system as a Dict[name, SystemFile] * remote file system as a Dict[name, SystemFile] * lftp status as Dict[name, LftpJobStatus] """ def __init__(self): self.logger = logging.getLogger("ModelBuilder") self.__local_files = dict() self.__remote_files = dict() self.__lftp_statuses = dict() self.__downloaded_files = set() self.__extract_statuses = dict() self.__extracted_files = set() self.__cached_model = None def set_base_logger(self, base_logger: logging.Logger): self.logger = base_logger.getChild("ModelBuilder") def set_active_files(self, active_files: List[SystemFile]): # Update the local file state with this latest information for file in active_files: self.__local_files[file.name] = file # Invalidate the cache if len(active_files) > 0: self.__cached_model = None def set_local_files(self, local_files: List[SystemFile]): prev_local_files = self.__local_files self.__local_files = {file.name: file for file in local_files} # Invalidate the cache if self.__local_files != prev_local_files: self.__cached_model = None def set_remote_files(self, remote_files: List[SystemFile]): prev_remote_files = self.__remote_files self.__remote_files = {file.name: file for file in remote_files} # Invalidate the cache if self.__remote_files != prev_remote_files: self.__cached_model = None def set_lftp_statuses(self, lftp_statuses: List[LftpJobStatus]): prev_lftp_statuses = self.__lftp_statuses self.__lftp_statuses = {file.name: file for file in lftp_statuses} # Invalidate the cache if self.__lftp_statuses != prev_lftp_statuses: self.__cached_model = None def set_downloaded_files(self, downloaded_files: Set[str]): prev_downloaded_files = self.__downloaded_files self.__downloaded_files = downloaded_files # Invalidate the cache if self.__downloaded_files != prev_downloaded_files: self.__cached_model = None def set_extract_statuses(self, extract_statuses: List[ExtractStatus]): prev_extract_statuses = self.__extract_statuses self.__extract_statuses = {status.name: status for status in extract_statuses} # Invalidate the cache if self.__extract_statuses != prev_extract_statuses: self.__cached_model = None def set_extracted_files(self, extracted_files: Set[str]): prev_extracted_files = self.__extracted_files self.__extracted_files = extracted_files # Invalidate the cache if self.__extracted_files != prev_extracted_files: self.__cached_model = None def clear(self): self.__local_files.clear() self.__remote_files.clear() self.__lftp_statuses.clear() self.__downloaded_files.clear() self.__extract_statuses.clear() self.__extracted_files.clear() self.__cached_model = None def has_changes(self) -> bool: """ Returns true is model has changes and requires rebuild :return: """ return self.__cached_model is None def build_model(self) -> Model: if self.__cached_model is not None: return self.__cached_model model = Model() model.set_base_logger(logging.getLogger("dummy")) # ignore the logs for this temp model all_file_names = set().union(self.__local_files.keys(), self.__remote_files.keys(), self.__lftp_statuses.keys()) for name in all_file_names: remote = self.__remote_files.get(name, None) local = self.__local_files.get(name, None) status = self.__lftp_statuses.get(name, None) if remote is None and local is None and status is None: # this should never happen, but just in case raise ModelError("Zero sources have a file object") # sanity check between the sources is_dir = remote.is_dir if remote else local.is_dir if local else status.type == LftpJobStatus.Type.MIRROR if (remote and is_dir != remote.is_dir) or \ (local and is_dir != local.is_dir) or \ (status and is_dir != (status.type == LftpJobStatus.Type.MIRROR)): raise ModelError("Mismatch in is_dir between sources") def __fill_model_file(_model_file: ModelFile, _remote: Optional[SystemFile], _local: Optional[SystemFile], _transfer_state: Optional[LftpJobStatus.TransferState]): # set local and remote sizes if _remote: _model_file.remote_size = _remote.size if _local: _model_file.local_size = _local.size # Note: no longer use lftp's file sizes # they represent remaining size for resumed downloads # set the downloading speed and eta if _transfer_state: _model_file.downloading_speed = _transfer_state.speed _model_file.eta = _transfer_state.eta # set the transferred size (only if file or dir exists on both ends) if _local and _remote: if _model_file.is_dir: # dir transferred size is updated by child files _model_file.transferred_size = 0 else: _model_file.transferred_size = min(_local.size, _remote.size) # also update all parent directories _parent_file = _model_file.parent while _parent_file is not None: _parent_file.transferred_size += _model_file.transferred_size _parent_file = _parent_file.parent # set the is_extractable flag if not _model_file.is_dir and Extract.is_archive_fast(_model_file.name): _model_file.is_extractable = True # Also set the flag for all of its parents _parent_file = _model_file.parent while _parent_file is not None: _parent_file.is_extractable = True _parent_file = _parent_file.parent # set the timestamps if _local: if _local.timestamp_created: _model_file.local_created_timestamp = _local.timestamp_created if _local.timestamp_modified: _model_file.local_modified_timestamp = _local.timestamp_modified if _remote: if _remote.timestamp_created: _model_file.remote_created_timestamp = _remote.timestamp_created if _remote.timestamp_modified: _model_file.remote_modified_timestamp = _remote.timestamp_modified model_file = ModelFile(name, is_dir) # set the file state # for now we only set to Queued or Downloading # later after all children are built, we can set to Downloaded after performing a check if status: model_file.state = ModelFile.State.QUEUED if status.state == LftpJobStatus.State.QUEUED \ else ModelFile.State.DOWNLOADING # fill the rest __fill_model_file(model_file, remote, local, status.total_transfer_state if status and status.state == LftpJobStatus.State.RUNNING else None) # Traverse SystemFile children tree in BFS order # Store (remote, local, status, model_file) tuple in traversal frontier where remote and local # correspond to the same node in both remote and local SystemFile trees, status corresponds # to the LFTP status for the entire tree, and model_file corresponds to the generated ModelFile # for the pair # Note: in this case the frontier contains nodes that have already been process, it is # merely used for traversing children frontier = [] if remote or local: frontier.append((remote, local, status, model_file)) while frontier: _remote, _local, _status, _model_file = frontier.pop(0) _remote_children = {sf.name: sf for sf in _remote.children} if _remote else {} _local_children = {sf.name: sf for sf in _local.children} if _local else {} _all_children_names = set().union(_remote_children.keys(), _local_children.keys()) for _child_name in _all_children_names: _remote_child = _remote_children.get(_child_name, None) _local_child = _local_children.get(_child_name, None) _is_dir = _remote_child.is_dir if _remote_child else _local_child.is_dir # sanity check is_dir if (_remote_child and _is_dir != _remote_child.is_dir) or \ (_local_child and _is_dir != _local_child.is_dir): raise ModelError("Mismatch in is_dir between child sources") _child_model_file = ModelFile(_child_name, _is_dir) # add it to the parent right away so we can access the full path _model_file.add_child(_child_model_file) # find the transfer state (if it exists) corresponding to this child # Note: transfer states are in full paths # Note2: transfer states don't include root path _child_status_path = os.path.join(*(_child_model_file.full_path.split(os.sep)[1:])) _child_transfer_state = None if _status: _child_transfer_state = next((ts for n, ts in _status.get_active_file_transfer_states() if n == _child_status_path), None) # Set the state, first matching criteria below decides state # child is a directory: Default # child is active: Downloading # child local_size >= remote_size: Downloaded # remote child exists and root is Queued or Downloading: Queued # Default # Result: # subdirectories are always Default # downloading files are Downloading # finished files are Downloaded # Queued and Downloading root's unfinished files are Queued # Local-only files are Default if _is_dir: _child_model_file.state = ModelFile.State.DEFAULT elif _child_transfer_state: _child_model_file.state = ModelFile.State.DOWNLOADING elif _remote_child and _local_child and _local_child.size >= _remote_child.size: _child_model_file.state = ModelFile.State.DOWNLOADED elif _remote_child and model_file.state in (ModelFile.State.QUEUED, ModelFile.State.DOWNLOADING): _child_model_file.state = ModelFile.State.QUEUED else: _child_model_file.state = ModelFile.State.DEFAULT # fill the rest __fill_model_file(_child_model_file, _remote_child, _local_child, _child_transfer_state) # add child to frontier frontier.append((_remote_child, _local_child, _status, _child_model_file)) # estimate the ETA for the root if it's not available if model_file.state == ModelFile.State.DOWNLOADING and \ model_file.eta is None and \ model_file.downloading_speed is not None and \ model_file.downloading_speed > 0 and \ model_file.transferred_size is not None: # First-order estimate remaining_size = max(model_file.remote_size - model_file.transferred_size, 0) model_file.eta = int(math.ceil(remaining_size / model_file.downloading_speed)) # now we can determine if root is Downloaded # root is Downloaded if all child remote files are Downloaded # again we use BFS to traverse if model_file.state == ModelFile.State.DEFAULT: if not model_file.is_dir and \ model_file.local_size is not None and \ model_file.remote_size is not None and \ model_file.local_size >= model_file.remote_size: # root is a finished single file model_file.state = ModelFile.State.DOWNLOADED elif model_file.is_dir and model_file.remote_size is not None: # root is a directory that also exists remotely # check all the children all_downloaded = True frontier = [] frontier += model_file.get_children() while frontier: _child_file = frontier.pop(0) if not _child_file.is_dir and \ _child_file.remote_size is not None and \ _child_file.state != ModelFile.State.DOWNLOADED: all_downloaded = False break frontier += _child_file.get_children() if all_downloaded: model_file.state = ModelFile.State.DOWNLOADED # next we determine if root was Deleted # root is Deleted if it does not exist locally, but was downloaded in the past if model_file.state == ModelFile.State.DEFAULT and \ model_file.local_size is None and \ model_file.name in self.__downloaded_files: model_file.state = ModelFile.State.DELETED # next we check if root is Extracting # root is Extracting if it's part of an extract status, in an expected state, # and exists locally # if root is NOT in an expected state, then ignore the extract status # and report a warning message, as this shouldn't be happening if model_file.name in self.__extract_statuses: extract_status = self.__extract_statuses[model_file.name] if model_file.is_dir != extract_status.is_dir: raise ModelError("Mismatch in is_dir between file and extract status") if model_file.state in ( ModelFile.State.DEFAULT, ModelFile.State.DOWNLOADED ) and model_file.local_size is not None: model_file.state = ModelFile.State.EXTRACTING else: if model_file.local_size is None: self.logger.warning("File {} has extract status but doesn't exist locally!".format( model_file.name )) else: self.logger.warning("File {} has extract status but is in state {}".format( model_file.name, str(model_file.state) )) # next we check if root is Extracted # root is Extracted if it is in Downloaded state and in extracted files list # Note: Default files aren't marked extracted because they can still be queued # for download, and it doesn't make sense to queue after extracting # If a Default file is extracted, it will return back to the Default state if model_file.name in self.__extracted_files and model_file.state == ModelFile.State.DOWNLOADED: model_file.state = ModelFile.State.EXTRACTED model.add_file(model_file) self.__cached_model = model return model ================================================ FILE: src/python/controller/scan/__init__.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from .scanner_process import IScanner, ScannerResult, ScannerProcess, ScannerError from .active_scanner import ActiveScanner from .local_scanner import LocalScanner from .remote_scanner import RemoteScanner ================================================ FILE: src/python/controller/scan/active_scanner.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import logging from typing import List import multiprocessing import queue from .scanner_process import IScanner from common import overrides from system import SystemScanner, SystemScannerError, SystemFile class ActiveScanner(IScanner): """ Scanner implementation to scan the active files only A caller sets the names of the active files that need to be scanned. A multiprocessing.Queue is used to store the names because the set and scan methods are called by different processes. """ def __init__(self, local_path: str): self.__scanner = SystemScanner(local_path) self.__active_files_queue = multiprocessing.Queue() self.__active_files = [] # latest state self.logger = logging.getLogger(self.__class__.__name__) @overrides(IScanner) def set_base_logger(self, base_logger: logging.Logger): self.logger = base_logger.getChild(self.__class__.__name__) def set_active_files(self, file_names: List[str]): """ Set the list of active file names. Only these files will be scanned. :param file_names: :return: """ self.__active_files_queue.put(file_names) @overrides(IScanner) def scan(self) -> List[SystemFile]: # Grab the latest list of active files, if any try: while True: self.__active_files = self.__active_files_queue.get(block=False) except queue.Empty: pass # Do the scan # self.logger.debug("Scanning files: {}".format(str(self.__active_files))) result = [] for file_name in self.__active_files: try: result.append(self.__scanner.scan_single(file_name)) except SystemScannerError as ex: # Ignore errors here, file may have been deleted self.logger.warning(str(ex)) return result ================================================ FILE: src/python/controller/scan/local_scanner.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import logging from typing import List from .scanner_process import IScanner, ScannerError from common import overrides, Localization, Constants from system import SystemScanner, SystemFile, SystemScannerError class LocalScanner(IScanner): """ Scanner implementation to scan the local filesystem """ def __init__(self, local_path: str, use_temp_file: bool): self.__scanner = SystemScanner(local_path) if use_temp_file: self.__scanner.set_lftp_temp_suffix(Constants.LFTP_TEMP_FILE_SUFFIX) self.logger = logging.getLogger("LocalScanner") @overrides(IScanner) def set_base_logger(self, base_logger: logging.Logger): self.logger = base_logger.getChild("LocalScanner") @overrides(IScanner) def scan(self) -> List[SystemFile]: try: result = self.__scanner.scan() except SystemScannerError: self.logger.exception("Caught SystemScannerError") raise ScannerError(Localization.Error.LOCAL_SERVER_SCAN, recoverable=False) return result ================================================ FILE: src/python/controller/scan/remote_scanner.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import logging import pickle from typing import List import os from typing import Optional import hashlib from .scanner_process import IScanner, ScannerError from common import overrides, Localization from ssh import Sshcp, SshcpError from system import SystemFile class RemoteScanner(IScanner): """ Scanner implementation to scan the remote filesystem """ def __init__(self, remote_address: str, remote_username: str, remote_password: Optional[str], remote_port: int, remote_path_to_scan: str, local_path_to_scan_script: str, remote_path_to_scan_script: str): self.logger = logging.getLogger("RemoteScanner") self.__remote_path_to_scan = remote_path_to_scan self.__local_path_to_scan_script = local_path_to_scan_script self.__remote_path_to_scan_script = remote_path_to_scan_script self.__ssh = Sshcp(host=remote_address, port=remote_port, user=remote_username, password=remote_password) self.__first_run = True # Append scan script name to remote path if not there already script_name = os.path.basename(self.__local_path_to_scan_script) if os.path.basename(self.__remote_path_to_scan_script) != script_name: self.__remote_path_to_scan_script = os.path.join(self.__remote_path_to_scan_script, script_name) @overrides(IScanner) def set_base_logger(self, base_logger: logging.Logger): self.logger = base_logger.getChild("RemoteScanner") self.__ssh.set_base_logger(self.logger) @overrides(IScanner) def scan(self) -> List[SystemFile]: if self.__first_run: self._install_scanfs() try: out = self.__ssh.shell("'{}' '{}'".format( self.__remote_path_to_scan_script, self.__remote_path_to_scan) ) except SshcpError as e: self.logger.warning("Caught an SshcpError: {}".format(str(e))) recoverable = True # Any scanner errors are fatal if "SystemScannerError" in str(e): recoverable = False # First time errors are fatal # User should be prompted to correct these if self.__first_run: recoverable = False raise ScannerError( Localization.Error.REMOTE_SERVER_SCAN.format(str(e).strip()), recoverable=recoverable ) try: remote_files = pickle.loads(out) except pickle.UnpicklingError as err: self.logger.error("Unpickling error: {}\n{}".format(str(err), out)) raise ScannerError( Localization.Error.REMOTE_SERVER_SCAN.format("Invalid pickled data"), recoverable=False ) self.__first_run = False return remote_files def _install_scanfs(self): # Check md5sum on remote to see if we can skip installation with open(self.__local_path_to_scan_script, "rb") as f: local_md5sum = hashlib.md5(f.read()).hexdigest() self.logger.debug("Local scanfs md5sum = {}".format(local_md5sum)) try: out = self.__ssh.shell("md5sum {} | awk '{{print $1}}' || echo".format(self.__remote_path_to_scan_script)) out = out.decode() if out == local_md5sum: self.logger.info("Skipping remote scanfs installation: already installed") return except SshcpError as e: self.logger.exception("Caught scp exception") raise ScannerError( Localization.Error.REMOTE_SERVER_INSTALL.format(str(e).strip()), recoverable=False ) # Go ahead and install self.logger.info("Installing local:{} to remote:{}".format( self.__local_path_to_scan_script, self.__remote_path_to_scan_script )) if not os.path.isfile(self.__local_path_to_scan_script): raise ScannerError( Localization.Error.REMOTE_SERVER_SCAN.format( "Failed to find scanfs executable at {}".format(self.__local_path_to_scan_script) ), recoverable=False ) try: self.__ssh.copy(local_path=self.__local_path_to_scan_script, remote_path=self.__remote_path_to_scan_script) except SshcpError as e: self.logger.exception("Caught scp exception") raise ScannerError( Localization.Error.REMOTE_SERVER_INSTALL.format(str(e).strip()), recoverable=False ) ================================================ FILE: src/python/controller/scan/scanner_process.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import logging from abc import ABC, abstractmethod import multiprocessing from datetime import datetime from typing import List, Optional import queue from common import overrides, AppProcess, AppError from system import SystemFile class ScannerError(AppError): """ Indicates a scanner error Args: recoverable: indicates scans can be retried """ def __init__(self, message: str, recoverable: bool = False): super().__init__(message) self.recoverable = recoverable class IScanner(ABC): """ Interface to scan the system. This hides the scanning implementation from the scanner process. """ @abstractmethod def scan(self) -> List[SystemFile]: """Scan system""" pass @abstractmethod def set_base_logger(self, base_logger: logging.Logger): pass class ScannerResult: """ Results of a system scan """ def __init__(self, timestamp: datetime, files: List[SystemFile], failed: bool = False, error_message: str = None): self.timestamp = timestamp self.files = files self.failed = failed self.error_message = error_message class ScannerProcess(AppProcess): """ Process to scan a file system and publish the result """ def __init__(self, scanner: IScanner, interval_in_ms: int, verbose: bool = True): """ Create a scanner process :param scanner: IScanner implementation :param interval_in_ms: Minimum interval (in ms) between results """ super().__init__(name=scanner.__class__.__name__) self.__queue = multiprocessing.Queue() self.__wake_event = multiprocessing.Event() self.__scanner = scanner self.__interval_in_ms = interval_in_ms self.verbose = verbose @overrides(AppProcess) def run_init(self): # Set the base logger for scanner self.__scanner.set_base_logger(self.logger) @overrides(AppProcess) def run_cleanup(self): pass @overrides(AppProcess) def run_loop(self): timestamp_start = datetime.now() if self.verbose: self.logger.debug("Running a scan") try: files = self.__scanner.scan() result = ScannerResult(timestamp=timestamp_start, files=files) except ScannerError as e: # Non-recoverable errors continue up as a fatal error if not e.recoverable: raise result = ScannerResult(timestamp=timestamp_start, files=[], failed=True, error_message=str(e)) self.__queue.put(result) delta_in_s = (datetime.now() - timestamp_start).total_seconds() delta_in_ms = int(delta_in_s * 1000) if self.verbose: self.logger.debug("Scan took {:.3f}s".format(delta_in_s)) # Wait until the next interval, or until a wake event is fired if delta_in_ms < self.__interval_in_ms: wait_time_in_s = float(self.__interval_in_ms - delta_in_ms) / 1000.0 self.__wake_event.wait(timeout=wait_time_in_s) self.__wake_event.clear() def pop_latest_result(self) -> Optional[ScannerResult]: """ Process-safe method to retrieve latest scan result Returns None if no new scan result was generated since the last time this method was called :return: """ latest_scan = None try: while True: latest_scan = self.__queue.get(block=False) except queue.Empty: pass return latest_scan def force_scan(self): """Force process to wake and do an immediate scan""" self.__wake_event.set() ================================================ FILE: src/python/docs/faq.md ================================================ # Frequently Asked Questions (FAQ) ## General ### How do I restart SeedSync Debian Service? SeedSync can be restarted from the web GUI. If that fails, you can restart the service from command-line: :::bash sudo service seedsync restart ### How can I save my settings across updates when using the Docker image? To maintain state across updates, you can store the settings in the host machine. Add the following option when starting the container. :::bash -v :/config where `` refers to the location on host machine where you wish to store the application state. ## Security ### Does SeedSync collect any data? No, SeedSync does not collect any data. ## Troubleshooting ### SeedSync can't seem to connect to my remote server? Make sure your remote server address was entered correctly. If using password-based login, make sure the password is correct. Check the logs for details about the exact failure. ### I am getting some errors about locale? On some servers you may see errors in the log like so: `Unpickling error: unpickling stack underflow b'bash: warning: setlocale: LC_ALL: cannot change locale` This means your remote server requires that the locale matches with the Seedsync app. We can fix this my changing the locale for Seedsync. For Seedsync docker, try adding the following options to the `docker run` command: ``` -e LC_ALL=en_US.UTF-8 -e LANG=en_US.UTF-8 ``` See [this issue](https://github.com/ipsingh06/seedsync/issues/66) for more details. ================================================ FILE: src/python/docs/index.md ================================================

SeedSync

# Documentation Welcome to SeedSync documentation! On the left navigation you will find useful links. External links: * Github: [ipsingh06/seedsync](https://github.com/ipsingh06/seedsync) * Docker Hub: [ipsingh06/seedsync](https://hub.docker.com/repository/docker/ipsingh06/seedsync) ================================================ FILE: src/python/docs/install.md ================================================ # Installation SeedSync requires installation on a local machine. Nothing needs to be installed on the remote server. ## Requirements ### Remote Server Requirements for the remote server are: * Linux-based system (64-bit) * SSH access ### Local Machine The following table describes the installation method supported for each platform. | | Docker Image | Deb Package | | ------------ | :-------------: | :------------: | | Linux/Ubuntu 64-bit | ✅️ | ✅ | | Raspberry Pi (v2, v3, v4) | ✅ | | | Windows | ✅ | | | MacOS | ✅ | | Select the section for your platform: * [Docker Image (Linux/Ubuntu, Raspberry Pi) ](#install-docker) * [Docker Image (Windows)](#install-windows) * [Deb Package (Linux/Ubuntu)](#install-ubuntu) ## Docker Image (Linux/Ubuntu, Raspberry Pi) 1. Run the docker image with the following command: :::bash docker run \ -p 8800:8800 \ -v :/downloads \ -v :/config \ ipsingh06/seedsync where * `` refers to the location on host machine where downloaded files will be placed * `` refers to the location on host machine where config files will be placed * both these directories must already exist By default the docker image is run under the default user (uid=1000). To run as a different user, include the option `--user :`. If you receive errors related to locale when connecting to the remote server, then also include the following options. ``` -e LC_ALL=en_US.UTF-8 -e LANG=en_US.UTF-8 ``` 2. Access application GUI by going to [http://localhost:8800](http://localhost:8800) in your browser. 3. Go to the Settings page and fill out the required information. Under the Local Directory setting, enter `/downloads`. 4. **While password-based login is supported, key-based authentication is highly recommended!** See the [Key-Based Authentication Setup](#key-auth) section for details. ## Docker Image (Windows) SeedSync supports Windows via the Docker container. 1. Install Docker on Windows. 1. [Docker for Windows](https://www.docker.com/docker-windows) if you have Windows 10 Pro or above 2. [Docker Toolbox](https://docs.docker.com/toolbox/toolbox_install_windows/) if you have Windows 10 Home or below 2. Make sure you can successfully run the [Hello World](https://docs.docker.com/get-started/#test-docker-installation) app in Docker. 3. Open the Docker terminal and run the SeedSync image with the following command: :::bash docker run \ -p 8800:8800 \ -v :/downloads \ -v :/config \ ipsingh06/seedsync where * `` refers to the location on host machine where downloaded files will be placed * `` refers to the location on host machine where config files will be placed * both these directories must already exist !!! note The Windows host machine path is specified as `/c/Users/...` 4. Access application GUI to verify SeedSync is running. Docker on Windows may not forward port to the local host. We need to find the IP address of the container. 1. Open a new Docker terminal and run the command: :::bash docker-machine ip 192.168.100.17 2. Open <ip address>:8800 in your browser. In this example that would be [http://192.168.100.17:8800](http://192.168.100.17:8800) 3. Verify that SeedSync dashboard loads. 5. Go to the Settings page and fill out the required information. Under the Local Directory setting, enter `/downloads`. 6. **While password-based login is supported, key-based authentication is highly recommended!** See the [Key-Based Authentication Setup](#key-auth) section for details. ## Post-Install Setup SeedSync's web-based GUI can be accessed at [http://localhost:8800](http://localhost:8800). Or in case of docker, whatever host port you specified in the `-p :8800` option. You may also access it from another device by replacing 'localhost' with the IP address or hostname of the machine where it is installed. ### Password-less/Key-based Authentication Setup Password-based access to your remote server is highly unsecure. It is strongly recommended that you set up key-based authentication. 1. You will need to generate a public-private key pair. Here is a [simple tutorial](https://www.tecmint.com/ssh-passwordless-login-using-ssh-keygen-in-5-easy-steps/) that walks you through this process. !!! note Make sure the access is set up for the user under which SeedSync is running. !!! note If you're using docker, also see the [Using SSH Keys with Docker](#keys-inside-docker) section. 2. Before continuing, verify the password-less access by SSH'ing into your remote server in a terminal: :::bash ssh @ You should be able to log in to the remote server without being prompted for a password 3. Update the settings 1. Access the web GUI and choose the Settings page from the menu. 2. Replace your password in the "Server Password" field with anything else (it can't be empty). 3. Select "Use password-less key-based authentication". 4. Restart SeedSync ### Using SSH Keys with Docker 1. Generate a SSH private/public key pair if you haven't already. Here is a [simple tutorial](https://www.tecmint.com/ssh-passwordless-login-using-ssh-keygen-in-5-easy-steps/) that walks you through this process. 2. Include the following option with your docker command: :::bash -v :/home/seedsync/.ssh Most commonly this should be: :::bash -v ~/.ssh:/home/seedsync/.ssh !!! note If you are running the docker guest with a non-standard user using the `--user` option, then you must make sure that your `.ssh` directory is also readable by that user. ## Deb Package (Linux/Ubuntu) 1. Download the deb package from the [latest](https://github.com/ipsingh06/seedsync/releases/latest) release 2. Install the deb package: :::bash sudo dpkg -i 3. During the first install, you will be prompted for a user name: ![Install prompt for username](https://raw.githubusercontent.com/ipsingh06/seedsync/master/doc/images/install_1.png) This is the user under which the SeedSync service will run. The transferred files will be owned by this user. It is recommended that you set this to your user (and NOT root). 4. After the installation is complete, verify that the application is running by going to [http://localhost:8800](http://localhost:8800) in your browser. 5. Go to the Settings page and fill out the required information. **While password-based login is supported, key-based authentication is highly recommended!** See the [Key-Based Authentication Setup](#key-auth) section for details. ================================================ FILE: src/python/docs/usage.md ================================================ # Usage ## Dashboard The Dashboard page shows all the files and directories on the remote server and the local machine. Here you can manually queue files to be transferred, extract archives and delete files. ## AutoQueue AutoQueue queues all newly discovered files on the remote server. You can also restrict AutoQueue to pattern-based matches (see this option in the Settings page). When pattern restriction is enabled, the AutoQueue page is where you can add or remove patterns. Any files or directories on the remote server that match a pattern will be automatically queued for transfer. ================================================ FILE: src/python/lftp/__init__.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from .lftp import Lftp, LftpError from .job_status import LftpJobStatus from .job_status_parser import LftpJobStatusParser, LftpJobStatusParserError ================================================ FILE: src/python/lftp/job_status.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from collections import namedtuple from enum import Enum from typing import List, Tuple class LftpJobStatus: """ Represents the status of a single Lftp jobs """ class Type(Enum): MIRROR = "mirror" PGET = "pget" class State(Enum): QUEUED = 0 RUNNING = 1 class TransferState(namedtuple("TransferState", ["size_local", "size_remote", "percent_local", "speed", "eta"])): """ State of transfer for a file or entire download size_local: size in bytes that have been downloaded size_remote: size in bytes on the remote server (may not be available) percent_local: percent of bytes that have been downloaded (0-100) speed: transfer speed in bytes per second eta: est. remaining transfer time in seconds """ pass def __init__(self, job_id: int, job_type: Type, state: State, name: str, flags: str): self.__id = job_id self.__type = job_type self.__state = state self.__name = name self.__flags = flags self.__total_transfer_state = LftpJobStatus.TransferState(None, None, None, None, None) # dict of active file transfer states, maps filename to their transfer state # there's no hierarchical info for now self.__active_files_state = {} @property def id(self) -> int: return self.__id @property def type(self) -> Type: return self.__type @property def state(self) -> "LftpJobStatus.State": return self.__state @property def name(self) -> str: return self.__name @property def total_transfer_state(self) -> TransferState: return self.__total_transfer_state @total_transfer_state.setter def total_transfer_state(self, total_transfer_state: TransferState): if self.__state == LftpJobStatus.State.QUEUED: raise TypeError("Cannot set transfer state on job of type queue") self.__total_transfer_state = total_transfer_state def add_active_file_transfer_state(self, filename: str, transfer_state: TransferState): if self.__state == LftpJobStatus.State.QUEUED: raise TypeError("Cannot set transfer state on job of type queue") self.__active_files_state[filename] = transfer_state def get_active_file_transfer_states(self) -> List[Tuple[str, TransferState]]: """ Returns list of pairs (filename, transfer state) :return: """ return list(zip(self.__active_files_state.keys(), self.__active_files_state.values())) def __eq__(self, other): return self.__dict__ == other.__dict__ def __str__(self): return str(self.__dict__) def __repr__(self): return str(self.__dict__) ================================================ FILE: src/python/lftp/job_status_parser.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import os import re from typing import List import logging from common import AppError from .job_status import LftpJobStatus class LftpJobStatusParserError(AppError): pass class LftpJobStatusParser: """ Parses the output of lftp's "jobs -v" command into a LftpJobStatus """ # python doesn't support partial inline-modified flags, so we need # to capture all case-sensitive cases here __SIZE_UNITS_REGEX = ("b|B|" "k|kb|kib|K|Kb|KB|KiB|Kib|" "m|mb|mib|M|Mb|MB|MiB|Mib|" "g|gb|gib|G|Gb|GB|GiB|Gib") __TIME_UNITS_REGEX = "(?P\d*d)?(?P\d*h)?(?P\d*m)?(?P\d*s)?" __QUOTED_FILE_NAME_REGEX = "`(?P.*)'" __QUEUE_DONE_REGEX = "^\[(?P\d+)\]\sDone\s\(queue\s\(.+\)\)" def __init__(self): self.logger = logging.getLogger("LftpJobStatusParser") def set_base_logger(self, base_logger: logging.Logger): self.logger = base_logger.getChild("LftpJobStatusParser") @staticmethod def _size_to_bytes(size: str) -> int: """ Parse the size string and return number of bytes :param size: :return: """ if size == "0": return 0 m = re.compile("(?P\d+\.?\d*)\s*(?P{})?".format(LftpJobStatusParser.__SIZE_UNITS_REGEX)) result = m.search(size) if not result: raise ValueError("String '{}' does not match the size pattern".format(size)) number = float(result.group("number")) unit = (result.group("units") or "b")[0].lower() multipliers = {'b': 1, 'k': 1024, 'm': 1024*1024, 'g': 1024*1024*1024} if unit not in multipliers.keys(): raise ValueError("Unrecognized unit {} in size string '{}'".format(unit, size)) return int(number*multipliers[unit]) @staticmethod def _eta_to_seconds(eta: str) -> int: """ Parse the time string and return number of seconds :param eta: :return: """ m = re.compile(LftpJobStatusParser.__TIME_UNITS_REGEX) result = m.search(eta) if not result: raise ValueError("String '{}' does not match the eta pattern".format(eta)) # the [:-1] below remove the last character eta_d = int((result.group("eta_d") or '0d')[:-1]) eta_h = int((result.group("eta_h") or '0h')[:-1]) eta_m = int((result.group("eta_m") or '0m')[:-1]) eta_s = int((result.group("eta_s") or '0s')[:-1]) return eta_d*24*3600 + eta_h*3600 + eta_m*60 + eta_s def parse(self, output: str) -> List[LftpJobStatus]: statuses = list() lines = [s.strip() for s in output.splitlines()] lines = list(filter(None, lines)) # remove blank lines # remove all lines before the first 'jobs -v' start = next((i+1 for i, l in enumerate(lines) if l == "jobs -v"), 0) lines = lines[start:] # remove any remaining 'jobs -v' lines lines = list(filter(lambda s: s != "jobs -v", lines)) # remove any remaining log line lines = filter(lambda s: not re.match(r"^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}.*\s->\s.*$", s), lines) lines = list(lines) try: statuses += self.__parse_queue(lines) statuses += self.__parse_jobs(lines) except ValueError as e: self.logger.error("LftpJobStateParser error: {}".format(str(e))) self.logger.error("Status:\n{}".format(output)) raise LftpJobStatusParserError("Error parsing lftp job status") return statuses @staticmethod def __parse_jobs(lines: List[str]) -> List[LftpJobStatus]: jobs = [] # Header patterns # pget header pget_header_pattern = ("^\[(?P\d+)\]\s+" "pget\s+" "(?P.*?)\s+" "(?P['\"]|)(?P.+)(?P=lq)\s+" # greedy on purpose "-o\s+" "(?P['\"]|)(?P.+)(?P=rq)$") # greedy on purpose pget_header_m = re.compile(pget_header_pattern) # mirror header (downloading) mirror_header_pattern = ("^\[(?P\d+)\]\s+" "mirror\s+" "(?P.*?)\s+" "(?P['\"]|)(?P.+)(?P=lq)\s+" # greedy on purpose "(?P['\"]|)(?P.+)(?P=rq)\s+" # greedy on purpose "--\s+" "(?P\d+\.?\d*\s?({sz})?)" # size=0 has no units "\/" "(?P\d+\.?\d*\s?({sz})?)\s+" # size=0 has no units "\((?P\d+)%\)" "(\s+(?P\d+\.?\d*\s?({sz}))\/s)?$")\ .format(sz=LftpJobStatusParser.__SIZE_UNITS_REGEX) mirror_header_m = re.compile(mirror_header_pattern) # mirror header (connecting or receiving file list) mirror_fl_header_pattern = ("^\[(?P\d+)\]\s+" "mirror\s+" "(?P.*?)\s+" "(?P['\"]|)(?P.+)(?P=lq)\s+" # greedy on purpose "(?P['\"]|)(?P.+)(?P=rq)$") # greedy on purpose mirror_fl_header_m = re.compile(mirror_fl_header_pattern) # Data patterns filename_pattern = "\\\\transfer\s" + LftpJobStatusParser.__QUOTED_FILE_NAME_REGEX filename_m = re.compile(filename_pattern) chunk_at_pattern = ("^" + LftpJobStatusParser.__QUOTED_FILE_NAME_REGEX + "\s+" "at\s+" "\d+\s+" # this is NOT the local size "(?:\(\d+%\)\s+)?" # this is NOT the local percent "((?P\d+\.?\d*\s?({sz}))\/s\s+)?" "(eta:(?P{eta})\s+)?" "\s*\[(?P.*)\]$")\ .format(sz=LftpJobStatusParser.__SIZE_UNITS_REGEX, eta=LftpJobStatusParser.__TIME_UNITS_REGEX) chunk_at_m = re.compile(chunk_at_pattern) chunk_at2_pattern = ("^" + LftpJobStatusParser.__QUOTED_FILE_NAME_REGEX + "\s+" "at\s+" "\d+\s+" # this is NOT the local size "(?:\(\d+%\))") # this is NOT the local percent chunk_at2_m = re.compile(chunk_at2_pattern) chunk_got_pattern = ("^" + LftpJobStatusParser.__QUOTED_FILE_NAME_REGEX + ",\s+" "got\s+" "(?P\d+)\s+" "of\s+" "(?P\d+)\s+" "\((?P\d+)%\)" "(\s+(?P\d+\.?\d*\s?({sz}))\/s)?" "(\seta:(?P{eta}))?")\ .format(sz=LftpJobStatusParser.__SIZE_UNITS_REGEX, eta=LftpJobStatusParser.__TIME_UNITS_REGEX) chunk_got_m = re.compile(chunk_got_pattern) chunk_header_pattern = ("\\\\chunk\s" "(?P\d+)" "-" "(?P\d+)") chunk_header_m = re.compile(chunk_header_pattern) chmod_header_pattern = ("chmod\s" "(?P.*)") chmod_header_m = re.compile(chmod_header_pattern) chmod_pattern = (LftpJobStatusParser.__QUOTED_FILE_NAME_REGEX + "" "\s\[\]") chmod_pattern_m = re.compile(chmod_pattern) mirror_pattern = ("\\\\mirror\s" "" + LftpJobStatusParser.__QUOTED_FILE_NAME_REGEX + "\s+" "--\s+" "(?P\d+\.?\d*\s?({sz})?)" # size=0 has no units "\/" "(?P\d+\.?\d*\s?({sz})?)\s+" # size=0 has no units "\((?P\d+)%\)" "(\s+(?P\d+\.?\d*\s?({sz}))\/s)?$")\ .format(sz=LftpJobStatusParser.__SIZE_UNITS_REGEX) mirror_m = re.compile(mirror_pattern) mirror_empty_pattern = ("\\\\mirror\s" "" + LftpJobStatusParser.__QUOTED_FILE_NAME_REGEX + "\s*$") mirror_empty_m = re.compile(mirror_empty_pattern) queue_done_m = re.compile(LftpJobStatusParser.__QUEUE_DONE_REGEX) prev_job = None while lines: line = lines.pop(0) # First line must be a valid job header if not ( prev_job or pget_header_m.match(line) or mirror_header_m.match(line) or mirror_fl_header_m.match(line) ): raise ValueError("First line is not a matching header '{}'".format(line)) # Search for pget header result = pget_header_m.search(line) if result: # Next line must be the sftp line if len(lines) < 1 or "sftp" not in lines[0]: raise ValueError("Missing the 'sftp' line for pget header '{}'".format(line)) lines.pop(0) # pop the 'sftp' line # Data line may not exist result_at = None result_at2 = None result_got = None if lines: line = lines.pop(0) # data line result_at = chunk_at_m.search(line) result_at2 = chunk_at2_m.search(line) result_got = chunk_got_m.search(line) id_ = int(result.group("id")) name = os.path.basename(os.path.normpath(result.group("remote"))) flags = result.group("flags") type_ = LftpJobStatus.Type.PGET status = LftpJobStatus(job_id=id_, job_type=type_, state=LftpJobStatus.State.RUNNING, name=name, flags=flags) if result_at: if result.group("remote") != result_at.group("name"): raise ValueError("Mismatch between pget names '{}' vs '{}'".format( result.group("remote"), result_at.group("name") )) size_local = None percent_local = None speed = None if result_at.group("speed"): speed = LftpJobStatusParser._size_to_bytes(result_at.group("speed")) eta = None if result_at.group("eta"): eta = LftpJobStatusParser._eta_to_seconds(result_at.group("eta")) transfer_state = LftpJobStatus.TransferState( size_local, None, # size remote percent_local, speed, eta ) elif result_at2: if result.group("remote") != result_at2.group("name"): raise ValueError("Mismatch between pget names '{}' vs '{}'".format( result.group("remote"), result_at2.group("name") )) transfer_state = LftpJobStatus.TransferState(None, None, None, None, None) elif result_got: got_group_basename = os.path.basename(os.path.normpath(result_got.group("name"))) if got_group_basename != name: raise ValueError("Mismatch: filename '{}' but chunk data for '{}'" .format(name, got_group_basename)) size_local = int(result_got.group("szlocal")) size_remote = int(result_got.group("szremote")) percent_local = int(result_got.group("pctlocal")) speed = None if result_got.group("speed"): speed = LftpJobStatusParser._size_to_bytes(result_got.group("speed")) eta = None if result_got.group("eta"): eta = LftpJobStatusParser._eta_to_seconds(result_got.group("eta")) transfer_state = LftpJobStatus.TransferState( size_local, size_remote, percent_local, speed, eta ) else: # No data line at all transfer_state = LftpJobStatus.TransferState(None, None, None, None, None) status.total_transfer_state = transfer_state jobs.append(status) prev_job = status continue # Search for mirror header result = mirror_header_m.search(line) if result: id_ = int(result.group("id")) name = os.path.basename(os.path.normpath(result.group("remote"))) flags = result.group("flags") type_ = LftpJobStatus.Type.MIRROR status = LftpJobStatus(job_id=id_, job_type=type_, state=LftpJobStatus.State.RUNNING, name=name, flags=flags) size_local = LftpJobStatusParser._size_to_bytes(result.group("szlocal")) size_remote = LftpJobStatusParser._size_to_bytes(result.group("szremote")) percent_local = int(result.group("pctlocal")) speed = None if result.group("speed"): speed = LftpJobStatusParser._size_to_bytes(result.group("speed")) transfer_state = LftpJobStatus.TransferState( size_local, size_remote, percent_local, speed, None # eta ) status.total_transfer_state = transfer_state jobs.append(status) prev_job = status # Continue the outer loop continue # Search for mirror connecting header # Note: this must be after the more restrictive mirror header above result = mirror_fl_header_m.search(line) if result: # There may be a 'Connecting' or 'cd' line ahead, but not always if lines and ( lines[0].startswith("Getting file list") or lines[0].startswith("cd ") ): lines.pop(0) # pop the connecting line id_ = int(result.group("id")) name = os.path.basename(os.path.normpath(result.group("remote"))) flags = result.group("flags") type_ = LftpJobStatus.Type.MIRROR status = LftpJobStatus(job_id=id_, job_type=type_, state=LftpJobStatus.State.RUNNING, name=name, flags=flags) jobs.append(status) prev_job = status # Continue the outer loop continue # Search for filename result = filename_m.search(line) if result: name = result.group("name") if not lines: raise ValueError("Missing chunk data for filename '{}'".format(name)) line = lines.pop(0) result_at = chunk_at_m.search(line) result_at2 = chunk_at2_m.search(line) result_got = chunk_got_m.search(line) if result_at: # filename is full path, but chunk name is only normpath if result_at.group("name") != os.path.basename(os.path.normpath(name)): raise ValueError("Mismatch: filename '{}' but chunk data for '{}'" .format(name, result_at.group("name"))) size_local = None percent_local = None speed = None if result_at.group("speed"): speed = LftpJobStatusParser._size_to_bytes(result_at.group("speed")) eta = None if result_at.group("eta"): eta = LftpJobStatusParser._eta_to_seconds(result_at.group("eta")) file_status = LftpJobStatus.TransferState( size_local, None, percent_local, speed, eta ) prev_job.add_active_file_transfer_state(name, file_status) elif result_at2: # filename is full path, but chunk name is only normpath if result_at2.group("name") != os.path.basename(os.path.normpath(name)): raise ValueError("Mismatch: filename '{}' but chunk data for '{}'" .format(name, result_at2.group("name"))) file_status = LftpJobStatus.TransferState(None, None, None, None, None) prev_job.add_active_file_transfer_state(name, file_status) elif result_got: if result_got.group("name") != os.path.basename(os.path.normpath(name)): raise ValueError("Mismatch: filename '{}' but chunk data for '{}'" .format(name, result_got.group("name"))) size_local = int(result_got.group("szlocal")) size_remote = int(result_got.group("szremote")) percent_local = int(result_got.group("pctlocal")) speed = None if result_got.group("speed"): speed = LftpJobStatusParser._size_to_bytes(result_got.group("speed")) eta = None if result_got.group("eta"): eta = LftpJobStatusParser._eta_to_seconds(result_got.group("eta")) file_status = LftpJobStatus.TransferState( size_local, size_remote, percent_local, speed, eta ) prev_job.add_active_file_transfer_state(name, file_status) else: raise ValueError("Missing chunk data for filename '{}'".format(name)) # Continue the outer loop continue # Search for but ignore "\mirror" line result = mirror_m.search(line) if result: # Continue the outer loop continue result = mirror_empty_m.search(line) if result: name = result.group("name") # One of these lines may follow, ignore it as well # "Getting files list" # "cd" # ": " # "mkdir" if lines: if "Getting file list" in lines[0] or \ lines[0].startswith("cd ") or \ lines[0] == "{}:".format(name) or \ lines[0].startswith("mkdir "): lines.pop(0) # Continue the outer loop continue # Search for but ignore "\chunk" line result = chunk_header_m.search(line) if result: # Also need to ignore the next line if not lines: raise ValueError("Missing data line for chunk '{}'".format(line)) lines.pop(0) # Continue the outer loop continue # Search for but ignore "chmod" line result = chmod_header_m.search(line) if result: name = result.group("name") # Also ignore the next one or two lines if not lines or not lines[0].startswith("file:"): raise ValueError("Missing 'file:' line for chmod '{}'".format(name)) lines.pop(0) if lines: result_chmod = chmod_pattern_m.search(lines[0]) if result_chmod: name_chmod = result_chmod.group("name") if name != name_chmod: raise ValueError("Mismatch in names chmod '{}'".format(name)) lines.pop(0) # Continue the outer loop continue # Search for the Done line, but it better be the last line result = queue_done_m.match(line) if result: if lines: raise ValueError("There are more lines after the 'Done' line") # Continue the outer loop continue # If we got here, then we don't know how to parse this line raise ValueError("Unable to parse line '{}'".format(line)) return jobs @staticmethod def __parse_queue(lines: List[str]) -> List[LftpJobStatus]: queue = [] queue_done_m = re.compile(LftpJobStatusParser.__QUEUE_DONE_REGEX) if len(lines) == 1: if not queue_done_m.match(lines[0]): raise ValueError("Unrecognized line '{}'".format(lines[0])) lines.pop(0) if lines: # Look for the header lines if len(lines) < 2: raise ValueError("Missing queue header") header1_pattern = "^\[\d+\] queue \(sftp://.*@.*\)(?:\s+--\s+(?:\d+\.\d+|\d+)\s(?:{})\/s)?$"\ .format(LftpJobStatusParser.__SIZE_UNITS_REGEX) header2_pattern = "^sftp://.*@.*$" line = lines.pop(0) if not re.match(header1_pattern, line): raise ValueError("Missing queue header line 1: {}".format(line)) line = lines.pop(0) if not re.match(header2_pattern, line): raise ValueError("Missing queue header line 2: {}".format(line)) if not lines: raise ValueError("Missing queue status") # Look for 'Now executing' lines line = lines.pop(0) if re.match("Queue is stopped.", line): # Nothing to do pass elif re.match("Now executing:", line): # Remove any more lines associated with 'now executing' while lines and re.match("^-\[\d+\]", lines[0]): lines.pop(0) # Look for the actual queue if lines and re.match("Commands queued:", lines[0]): lines.pop(0) if not lines: raise ValueError("Missing queued commands") # Parse the queued commands queue_pget_pattern = ("^(?P\d+)\.\s+" "pget\s+" "(?P.*?)\s+" "(?P[\'\"]|)(?P.+)(?P=lq)\s+" # greedy on purpose "(?:-o\s+)" "(?P[\'\"]|)(?P.+)(?P=rq)$") # greedy on purpose queue_pget_m = re.compile(queue_pget_pattern) queue_mirror_pattern = ("^(?P\d+)\.\s+" "mirror\s+" "(?P.*?)\s+" "(?P[\'\"]|)(?P.+)(?P=lq)\s+" # greedy on purpose "(?P[\'\"]|)(?P.+)(?P=rq)$") # greedy on purpose queue_mirror_m = re.compile(queue_mirror_pattern) while lines: line = lines[0] if re.match("^\d+\.", line): # header line lines.pop(0) result_pget = queue_pget_m.match(line) result_mirror = queue_mirror_m.match(line) if result_pget: type_ = LftpJobStatus.Type.PGET result = result_pget elif result_mirror: type_ = LftpJobStatus.Type.MIRROR result = result_mirror else: raise ValueError("Failed to parse queue line: {}".format(line)) id_ = int(result.group("id")) name = os.path.basename(os.path.normpath(result.group("remote"))) flags = result.group("flags") status = LftpJobStatus(job_id=id_, job_type=type_, state=LftpJobStatus.State.QUEUED, name=name, flags=flags) queue.append(status) elif re.match("^cd\s.*$", line): # 'cd' line after pget, ignore lines.pop(0) else: # no match, exit loop break # Look for the done line if lines and queue_done_m.match(lines[0]): lines.pop(0) return queue ================================================ FILE: src/python/lftp/lftp.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import logging import re from functools import wraps from typing import Callable, Union, List, Optional # 3rd party libs import pexpect # my libs from common import AppError from .job_status_parser import LftpJobStatus, LftpJobStatusParser, LftpJobStatusParserError # How many status errors are allowed before error propagates out MAX_CONSECUTIVE_STATUS_ERRORS = 2 class LftpError(AppError): """ Custom exception that describes the failure of the lftp command """ pass class Lftp: """ Lftp command utility """ __SET_NUM_PARALLEL_FILES = "mirror:parallel-transfer-count" __SET_NUM_CONNECTIONS_PGET = "pget:default-n" __SET_NUM_CONNECTIONS_MIRROR = "mirror:use-pget-n" __SET_NUM_MAX_TOTAL_CONNECTIONS = "net:connection-limit" __SET_RATE_LIMIT = "net:limit-rate" __SET_MIN_CHUNK_SIZE = "pget:min-chunk-size" __SET_NUM_PARALLEL_JOBS = "cmd:queue-parallel" __SET_MOVE_BACKGROUND_ON_EXIT = "cmd:move-background" __SET_COMMAND_AT_EXIT = "cmd:at-exit" __SET_USE_TEMP_FILE = "xfer:use-temp-file" __SET_TEMP_FILE_NAME = "xfer:temp-file-name" __SET_SFTP_AUTO_CONFIRM = "sftp:auto-confirm" __SET_SFTP_CONNECT_PROGRAM = "sftp:connect-program" def __init__(self, address: str, port: int, user: str, password: Optional[str]): self.__user = user self.__password = password self.__address = address self.__base_remote_dir_path = "" self.__base_local_dir_path = "" self.logger = logging.getLogger("Lftp") self.__expect_pattern = "lftp {}@{}:.*>".format(self.__user, self.__address) self.__job_status_parser = LftpJobStatusParser() self.__timeout = 3 # in seconds self.__consecutive_status_errors = 0 self.__log_command_output = False self.__pending_error = None args = [ "-p", str(port), "-u", "{},{}".format(self.__user, self.__password if self.__password else ""), "sftp://{}".format(self.__address) ] self.__process = pexpect.spawn("/usr/bin/lftp", args) self.__process.expect(self.__expect_pattern) self.__setup() def set_verbose_logging(self, verbose: bool): self.__log_command_output = verbose def __setup(self): """ Setup the lftp instance with default settings :return: """ # Set to kill on exit to prevent a zombie process self.__set(Lftp.__SET_COMMAND_AT_EXIT, "\"kill all\"") # Auto-add server to known host file self.sftp_auto_confirm = True def with_check_process(method: Callable): """ Decorator that checks for a valid process before executing the decorated method :param method: :return: """ @wraps(method) def wrapper(inst: "Lftp", *args, **kwargs): if inst.__process is None or not inst.__process.isalive(): raise LftpError("lftp process is not running") return method(inst, *args, **kwargs) return wrapper def set_base_logger(self, base_logger: logging.Logger): self.logger = base_logger.getChild("Lftp") self.__job_status_parser.set_base_logger(self.logger) def set_base_remote_dir_path(self, base_remote_dir_path: str): self.__base_remote_dir_path = base_remote_dir_path def set_base_local_dir_path(self, base_local_dir_path: str): self.__base_local_dir_path = base_local_dir_path def raise_pending_error(self): """ Raise any pending errors Errors show up late after a command is executed This method raises any errors that were detected while executing the next command :return: """ if self.__pending_error: error = self.__pending_error self.__pending_error = None raise LftpError(error) @with_check_process def __run_command(self, command: str): if self.__log_command_output: self.logger.debug("command: {}".format(command.encode('utf8', 'surrogateescape'))) self.__process.sendline(command) try: self.__process.expect(self.__expect_pattern, timeout=self.__timeout) except pexpect.exceptions.TIMEOUT: self.logger.exception("Lftp timeout exception") pass finally: out = self.__process.before.decode('utf8', 'replace') out = out.strip() # remove any CRs if self.__log_command_output: self.logger.debug("out ({} bytes):\n {}".format(len(out), out)) after = self.__process.after.decode('utf8', 'replace').strip() \ if self.__process.after != pexpect.TIMEOUT else "" self.logger.debug("after: {}".format(after)) # let's try and detect some errors if self.__detect_errors_from_output(out): # we need to consume the actual output so that # it doesn't get passed onto next command error_out = out try: self.__process.expect(self.__expect_pattern, timeout=self.__timeout) except pexpect.exceptions.TIMEOUT: self.logger.exception("Lftp timeout exception") pass finally: out = self.__process.before.decode('utf8', 'replace') out = out.strip() # remove any CRs if self.__log_command_output: self.logger.debug("retry out ({} bytes):\n {}".format(len(out), out)) after = self.__process.after.decode('utf8', 'replace').strip() \ if self.__process.after != pexpect.TIMEOUT else "" self.logger.debug("retry after: {}".format(after)) self.logger.error("Lftp detected error: {}".format(error_out)) # save pending error self.__pending_error = error_out return out @staticmethod def __detect_errors_from_output(out: str) -> bool: errors = [ "pget: Access failed", "pget-chunk: Access failed", "mirror: Access failed", "Login failed: Login incorrect" ] for error in errors: if error in out: return True return False def __set(self, setting: str, value: str): """ Set a setting in the lftp runtime :param setting: :param value: :return: """ self.__run_command("set {} {}".format(setting, value)) def __get(self, setting: str) -> str: """ Get a setting from the lftp runtime :param setting: :return: """ out = self.__run_command("set -a | grep {}".format(setting)) m = re.search("set {} (.*)".format(setting), out) if not m or not m.group or not m.group(1): raise LftpError("Failed to get setting '{}'. Output: '{}'".format(setting, out)) return m.group(1).strip() @staticmethod def __to_bool(value: str) -> bool: # sets are taken from LFTP manual if value.lower() in {"true", "on", "yes", "1", "+"}: return True elif value.lower() in {"false", "off", "no", "0", "-"}: return False else: raise LftpError("Cannot convert value '{}' to boolean".format(value)) @property def num_connections_per_dir_file(self) -> int: return int(self.__get(Lftp.__SET_NUM_CONNECTIONS_MIRROR)) @num_connections_per_dir_file.setter def num_connections_per_dir_file(self, num_connections: int): if num_connections < 1: raise ValueError("Number of connections must be positive") self.__set(Lftp.__SET_NUM_CONNECTIONS_MIRROR, str(num_connections)) @property def num_connections_per_root_file(self) -> int: return int(self.__get(Lftp.__SET_NUM_CONNECTIONS_PGET)) @num_connections_per_root_file.setter def num_connections_per_root_file(self, num_connections: int): if num_connections < 1: raise ValueError("Number of connections must be positive") self.__set(Lftp.__SET_NUM_CONNECTIONS_PGET, str(num_connections)) @property def num_max_total_connections(self) -> int: return int(self.__get(Lftp.__SET_NUM_MAX_TOTAL_CONNECTIONS)) @num_max_total_connections.setter def num_max_total_connections(self, num_connections: int): if num_connections < 0: raise ValueError("Number of connections must be zero or greater") self.__set(Lftp.__SET_NUM_MAX_TOTAL_CONNECTIONS, str(num_connections)) @property def num_parallel_files(self) -> int: return int(self.__get(Lftp.__SET_NUM_PARALLEL_FILES)) @num_parallel_files.setter def num_parallel_files(self, num_parallel_files: int): if num_parallel_files < 1: raise ValueError("Number of parallel files must be positive") self.__set(Lftp.__SET_NUM_PARALLEL_FILES, str(num_parallel_files)) @property def rate_limit(self) -> str: return self.__get(Lftp.__SET_RATE_LIMIT) @rate_limit.setter def rate_limit(self, rate_limit: Union[int, str]): self.__set(Lftp.__SET_RATE_LIMIT, str(rate_limit)) @property def min_chunk_size(self) -> str: return self.__get(Lftp.__SET_MIN_CHUNK_SIZE) @min_chunk_size.setter def min_chunk_size(self, min_chunk_size: Union[int, str]): self.__set(Lftp.__SET_MIN_CHUNK_SIZE, str(min_chunk_size)) @property def num_parallel_jobs(self) -> int: return int(self.__get(Lftp.__SET_NUM_PARALLEL_JOBS)) @num_parallel_jobs.setter def num_parallel_jobs(self, num_parallel_jobs: int): if num_parallel_jobs < 1: raise ValueError("Number of parallel jobs must be positive") self.__set(Lftp.__SET_NUM_PARALLEL_JOBS, str(num_parallel_jobs)) @property def move_background_on_exit(self) -> bool: return Lftp.__to_bool(self.__get(Lftp.__SET_MOVE_BACKGROUND_ON_EXIT)) @move_background_on_exit.setter def move_background_on_exit(self, move_background_on_exit: bool): self.__set(Lftp.__SET_MOVE_BACKGROUND_ON_EXIT, str(int(move_background_on_exit))) @property def use_temp_file(self) -> bool: return Lftp.__to_bool(self.__get(Lftp.__SET_USE_TEMP_FILE)) @use_temp_file.setter def use_temp_file(self, use_temp_file: bool): self.__set(Lftp.__SET_USE_TEMP_FILE, str(int(use_temp_file))) @property def temp_file_name(self) -> str: return self.__get(Lftp.__SET_TEMP_FILE_NAME) @temp_file_name.setter def temp_file_name(self, temp_file_name: str): self.__set(Lftp.__SET_TEMP_FILE_NAME, temp_file_name) @property def sftp_auto_confirm(self) -> bool: return Lftp.__to_bool(self.__get(Lftp.__SET_SFTP_AUTO_CONFIRM)) @sftp_auto_confirm.setter def sftp_auto_confirm(self, auto_confirm: bool): self.__set(Lftp.__SET_SFTP_AUTO_CONFIRM, str(int(auto_confirm))) @property def sftp_connect_program(self) -> str: return self.__get(Lftp.__SET_SFTP_CONNECT_PROGRAM) @sftp_connect_program.setter def sftp_connect_program(self, program: str): self.__set(Lftp.__SET_SFTP_CONNECT_PROGRAM, program) def status(self) -> List[LftpJobStatus]: """ Return a status list of queued and running jobs :return: """ out = self.__run_command("jobs -v") try: statuses = self.__job_status_parser.parse(out) self.__consecutive_status_errors = 0 except LftpJobStatusParserError: self.__consecutive_status_errors += 1 if self.__consecutive_status_errors <= MAX_CONSECUTIVE_STATUS_ERRORS: self.logger.warning(f"Ignoring status error (count={self.__consecutive_status_errors})") statuses = [] else: raise return statuses def queue(self, name: str, is_dir: bool): """ Queues a job for download This method may cause an exception to be generated in a later method call: * Wrong type (is_dir) is specified * File/folder does not exist :param name: name of file or folder to download :param is_dir: true if folder, false if file :return: """ # Escape single and double quotes in any string used in queue command def escape(s: str) -> str: return s.replace("'", "\\'").replace("\"", "\\\"") command = " ".join([ "queue", "'", "pget" if not is_dir else "mirror", "-c", "\"{remote_dir}/{filename}\"".format(remote_dir=escape(self.__base_remote_dir_path), filename=escape(name)), "-o" if not is_dir else "", "\"{local_dir}/\"".format(local_dir=escape(self.__base_local_dir_path)), "'" ]) self.__run_command(command) def kill(self, name: str) -> bool: """ Kill a queued or running job :param name: :return: True if job of given name was found, False otherwise """ # look for this name in the status list job_to_kill = None for status in self.status(): if status.name == name: job_to_kill = status break if job_to_kill is None: self.logger.debug("Kill failed to find job '{}'".format(name)) return False # Note: there's a chance that job ids change between when we called status # and when we execute the kill command # in this case the wrong job may be killed, there's nothing we can do about it if job_to_kill.state == LftpJobStatus.State.RUNNING: self.logger.debug("Killing running job '{}'...".format(name)) self.__run_command("kill {}".format(job_to_kill.id)) elif job_to_kill.state == LftpJobStatus.State.QUEUED: self.logger.debug("Killing queued job '{}'...".format(name)) self.__run_command("queue --delete {}".format(job_to_kill.id)) else: raise NotImplementedError("Unsupported state {}".format(str(job_to_kill.state))) return True def kill_all(self): """ Kills are jobs, queued or downloading :return: """ # empty the queue and kill running jobs self.__run_command("queue -d *") self.__run_command("kill all") def exit(self): """ Exit the lftp instance. It cannot be used after being killed :return: """ self.kill_all() self.__process.sendline("exit") self.__process.close(force=True) # Mark decorators as static (must be at end of class) # Source: https://stackoverflow.com/a/3422823 with_check_process = staticmethod(with_check_process) ================================================ FILE: src/python/mkdocs.yml ================================================ site_name: SeedSync theme: name: material language: en palette: scheme: preference primary: teal accent: teal font: false logo: images/logo.png favicon: images/favicon.png markdown_extensions: - admonition - codehilite: guess_lang: false - toc: permalink: true repo_name: ipsingh06/seedsync repo_url: https://github.com/ipsingh06/seedsync nav: - Home: index.md - Installation: install.md - Usage: usage.md - FAQ: faq.md ================================================ FILE: src/python/model/__init__.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from .model import Model, IModelListener, ModelError from .file import ModelFile from .diff import ModelDiff, ModelDiffUtil ================================================ FILE: src/python/model/diff.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from enum import Enum from typing import List, Optional import copy # my libs from .file import ModelFile from .model import Model class ModelDiff: """ Represents a single change in the model """ class Change(Enum): ADDED = 0 REMOVED = 1 UPDATED = 2 def __init__(self, change: Change, old_file: Optional[ModelFile], new_file: Optional[ModelFile]): self.__change = change self.__old_file = old_file self.__new_file = new_file def __eq__(self, other): return self.__dict__ == other.__dict__ def __repr__(self): return str(self.__dict__) @property def change(self) -> Change: return self.__change @property def old_file(self) -> Optional[ModelFile]: return self.__old_file @property def new_file(self) -> Optional[ModelFile]: return self.__new_file class ModelDiffUtil: @staticmethod def diff_models(model_before: Model, model_after: Model) -> List[ModelDiff]: """ Compare two models and generate their diff :param model_before: :param model_after: :return: """ diffs = [] file_names_before = model_before.get_file_names() file_names_after = model_after.get_file_names() # 'after minus before' gives added files file_names_added = file_names_after.difference(file_names_before) if file_names_added: diffs += [ ModelDiff( ModelDiff.Change.ADDED, None, model_after.get_file(name) ) for name in file_names_added ] # 'before minus after' gives removed files file_names_removed = file_names_before.difference(file_names_after) if file_names_removed: diffs += [ ModelDiff( ModelDiff.Change.REMOVED, model_before.get_file(name), None ) for name in file_names_removed ] # 'before intersect after' gives potentially updated files file_names_updated = file_names_before.intersection(file_names_after) for name in file_names_updated: file_before = model_before.get_file(name) file_after = model_after.get_file(name) if file_before != file_after: diffs.append(ModelDiff(ModelDiff.Change.UPDATED, file_before, file_after)) return diffs ================================================ FILE: src/python/model/file.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from datetime import datetime from enum import Enum from typing import Optional, List import copy import os class ModelFile: """ Represents a file or directory The information in this object may be inconsistent. E.g. the size of a directory may not match the sum of its children. This is allowed as a source may have updated only certain levels in the hierarchy. Specifically for this example, an Lftp status provides local sizes for a downloading directory but not its children. """ class State(Enum): DEFAULT = 0 DOWNLOADING = 1 QUEUED = 2 DOWNLOADED = 3 DELETED = 4 EXTRACTING = 5 EXTRACTED = 6 def __init__(self, name: str, is_dir: bool): self.__name = name # file or folder name self.__is_dir = is_dir # True if this is a dir, False if file self.__state = ModelFile.State.DEFAULT # status self.__remote_size = None # remote size in bytes, None if file does not exist self.__local_size = None # local size in bytes, None if file does not exist self.__transferred_size = None # transferred size in bytes, None if file does not exist self.__downloading_speed = None # in bytes / sec, None if not downloading self.__eta = None # est. time remaining in seconds, None if not available self.__is_extractable = False # whether file is an archive or dir contains archives self.__local_created_timestamp = None self.__local_modified_timestamp = None self.__remote_created_timestamp = None self.__remote_modified_timestamp = None # timestamp of the latest update # Note: timestamp is not part of equality operator self.__update_timestamp = datetime.now() self.__children = [] # children files self.__parent = None # direct predecessor def __eq__(self, other): # disregard in comparisons: # timestamp: we don't care about it # parent: semantics are to check self and children only # children: check these manually for easier debugging ka = set(self.__dict__).difference({ "_ModelFile__update_timestamp", "_ModelFile__parent", "_ModelFile__children" }) kb = set(other.__dict__).difference({ "_ModelFile__update_timestamp", "_ModelFile__parent", "_ModelFile__children" }) # Check self properties if ka != kb: return False if not all(self.__dict__[k] == other.__dict__[k] for k in ka): return False # Check children's properties if len(self.__children) != len(other.__children): return False my_children_dict = {f.name: f for f in self.__children} other_children_dict = {f.name: f for f in other.__children} if my_children_dict.keys() != other_children_dict.keys(): return False for name in my_children_dict.keys(): if my_children_dict[name] != other_children_dict[name]: return False return True def __repr__(self): return str(self.__dict__) @property def name(self) -> str: return self.__name @property def is_dir(self) -> bool: return self.__is_dir @property def state(self) -> State: return self.__state @state.setter def state(self, state: State): if type(state) != ModelFile.State: raise TypeError self.__state = state @property def remote_size(self) -> Optional[int]: return self.__remote_size @remote_size.setter def remote_size(self, remote_size: Optional[int]): if type(remote_size) == int: if remote_size < 0: raise ValueError self.__remote_size = remote_size elif remote_size is None: self.__remote_size = remote_size else: raise TypeError @property def local_size(self) -> Optional[int]: return self.__local_size @local_size.setter def local_size(self, local_size: Optional[int]): if type(local_size) == int: if local_size < 0: raise ValueError self.__local_size = local_size elif local_size is None: self.__local_size = local_size else: raise TypeError @property def transferred_size(self) -> Optional[int]: return self.__transferred_size @transferred_size.setter def transferred_size(self, transferred_size: Optional[int]): if type(transferred_size) == int: if transferred_size < 0: raise ValueError self.__transferred_size = transferred_size elif transferred_size is None: self.__transferred_size = transferred_size else: raise TypeError @property def downloading_speed(self) -> Optional[int]: return self.__downloading_speed @downloading_speed.setter def downloading_speed(self, downloading_speed: Optional[int]): if type(downloading_speed) == int: if downloading_speed < 0: raise ValueError self.__downloading_speed = downloading_speed elif downloading_speed is None: self.__downloading_speed = downloading_speed else: raise TypeError @property def update_timestamp(self) -> datetime: return self.__update_timestamp @update_timestamp.setter def update_timestamp(self, update_timestamp: datetime): if type(update_timestamp) != datetime: raise TypeError self.__update_timestamp = update_timestamp @property def eta(self) -> Optional[int]: return self.__eta @eta.setter def eta(self, eta: Optional[int]): if type(eta) == int: if eta < 0: raise ValueError self.__eta = eta elif eta is None: self.__eta = eta else: raise TypeError @property def is_extractable(self) -> bool: return self.__is_extractable @is_extractable.setter def is_extractable(self, is_extractable: bool): self.__is_extractable = is_extractable @property def local_created_timestamp(self) -> datetime: return self.__local_created_timestamp @local_created_timestamp.setter def local_created_timestamp(self, local_created_timestamp: datetime): if type(local_created_timestamp) != datetime: raise TypeError self.__local_created_timestamp = local_created_timestamp @property def local_modified_timestamp(self) -> datetime: return self.__local_modified_timestamp @local_modified_timestamp.setter def local_modified_timestamp(self, local_modified_timestamp: datetime): if type(local_modified_timestamp) != datetime: raise TypeError self.__local_modified_timestamp = local_modified_timestamp @property def remote_created_timestamp(self) -> datetime: return self.__remote_created_timestamp @remote_created_timestamp.setter def remote_created_timestamp(self, remote_created_timestamp: datetime): if type(remote_created_timestamp) != datetime: raise TypeError self.__remote_created_timestamp = remote_created_timestamp @property def remote_modified_timestamp(self) -> datetime: return self.__remote_modified_timestamp @remote_modified_timestamp.setter def remote_modified_timestamp(self, remote_modified_timestamp: datetime): if type(remote_modified_timestamp) != datetime: raise TypeError self.__remote_modified_timestamp = remote_modified_timestamp @property def full_path(self) -> str: """Full path including all predecessors""" if self.__parent: return os.path.join(self.__parent.full_path, self.name) return self.name def add_child(self, child_file: "ModelFile"): if not self.is_dir: raise TypeError("Cannot add child to a non-directory") if child_file is self: raise ValueError("Cannot add parent as a child") if child_file.name in (f.name for f in self.__children): raise ValueError("Cannot add child more than once") self.__children.append(child_file) child_file.__parent = self def get_children(self) -> List["ModelFile"]: return copy.copy(self.__children) @property def parent(self) -> Optional["ModelFile"]: return self.__parent ================================================ FILE: src/python/model/model.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import logging from abc import ABC, abstractmethod from typing import Set # my libs from common import AppError from .file import ModelFile class ModelError(AppError): """ Exception indicating a model error """ pass class IModelListener(ABC): """ Interface to listen to model events """ @abstractmethod def file_added(self, file: ModelFile): """ Event indicating a file was added to the model :param file: :return: """ pass @abstractmethod def file_removed(self, file: ModelFile): """ Event indicating that the given file was removed from the model :param file: :return: """ pass @abstractmethod def file_updated(self, old_file: ModelFile, new_file: ModelFile): """ Event indicating that the given file was updated :param old_file: :param new_file: :return: """ pass class Model: """ Represents the entire state of lftp """ def __init__(self): self.logger = logging.getLogger("Model") self.__files = {} # name->LftpFile self.__listeners = [] def set_base_logger(self, base_logger: logging.Logger): self.logger = base_logger.getChild("Model") def add_listener(self, listener: IModelListener): """ Add a model listener :param listener: :return: """ self.logger.debug("LftpModel: Adding a listener") if listener not in self.__listeners: self.__listeners.append(listener) def remove_listener(self, listener: IModelListener): """ Add a model listener :param listener: :return: """ self.logger.debug("LftpModel: Removing a listener") if listener not in self.__listeners: self.logger.error("LftpModel: listener does not exist!") else: self.__listeners.remove(listener) def add_file(self, file: ModelFile): """ Add a file to the model :param file: :return: """ self.logger.debug("LftpModel: Adding file '{}'".format(file.name)) if file.name in self.__files: raise ModelError("File already exists in the model") self.__files[file.name] = file for listener in self.__listeners: listener.file_added(self.__files[file.name]) def remove_file(self, filename: str): """ Remove the file from the model :param filename: :return: """ self.logger.debug("LftpModel: Removing file '{}'".format(filename)) if filename not in self.__files: raise ModelError("File does not exist in the model") file = self.__files[filename] del self.__files[filename] for listener in self.__listeners: listener.file_removed(file) def update_file(self, file: ModelFile): """ Update an already existing file :param file: :return: """ self.logger.debug("LftpModel: Updating file '{}'".format(file.name)) if file.name not in self.__files: raise ModelError("File does not exist in the model") old_file = self.__files[file.name] new_file = file self.__files[file.name] = new_file for listener in self.__listeners: listener.file_updated(old_file, new_file) def get_file(self, name: str) -> ModelFile: """ Returns a copy of the file of the given name :param name: :return: """ if name not in self.__files: raise ModelError("File does not exist in the model") return self.__files[name] def get_file_names(self) -> Set[str]: return set(self.__files.keys()) ================================================ FILE: src/python/pyproject.toml ================================================ [tool] [tool.poetry] name = "seedsync" version = "0.0.0" description = "" authors = [] [tool.poetry.dependencies] python = "~3.8" bottle = "*" mkdocs = "*" mkdocs-material = "*" parameterized = "*" paste = "*" patool = "*" pexpect = "*" pytz = "*" requests = "*" tblib = "*" timeout-decorator = "*" [tool.poetry.dev-dependencies] pyinstaller = "*" testfixtures = "*" webtest = "*" pytest = "^6.2.1" ================================================ FILE: src/python/scan_fs.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import pickle import sys import argparse # my libs from system import SystemScanner, SystemFile, SystemScannerError if __name__ == "__main__": if sys.hexversion < 0x03050000: sys.exit("Python 3.5 or newer is required to run this program.") parser = argparse.ArgumentParser(description="File size scanner") parser.add_argument("path", help="Path of the root directory to scan") parser.add_argument("-e", "--exclude-hidden", action="store_true", default=False, help="Exclude hidden files") parser.add_argument("-H", "--human-readable", action="store_true", default=False, help="Human readable output") args = parser.parse_args() scanner = SystemScanner(args.path) if args.exclude_hidden: scanner.add_exclude_prefix(".") try: root_files = scanner.scan() except SystemScannerError as e: sys.exit("SystemScannerError: {}".format(str(e))) if args.human_readable: def print_file(file: SystemFile, level: int): sys.stdout.write(" "*level) sys.stdout.write("{} {} {}\n".format( file.name, "d" if file.is_dir else "f", file.size )) for child in file.children: print_file(child, level+1) for root_file in root_files: print_file(root_file, 0) else: bytes_out = pickle.dumps(root_files) sys.stdout.buffer.write(bytes_out) ================================================ FILE: src/python/seedsync.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import signal import sys import time import argparse import os import logging from datetime import datetime from logging.handlers import RotatingFileHandler from typing import Optional, Type, TypeVar import shutil import platform # my libs from common import ServiceExit, Context, Constants, Config, Args, AppError from common import ServiceRestart from common import Localization, Status, ConfigError, Persist, PersistError from controller import Controller, ControllerJob, ControllerPersist, AutoQueue, AutoQueuePersist from web import WebAppJob, WebAppBuilder T_Persist = TypeVar('T_Persist', bound=Persist) class Seedsync: """ Implements the service for seedsync It is run in the main thread (no daemonization) """ __FILE_CONFIG = "settings.cfg" __FILE_AUTO_QUEUE_PERSIST = "autoqueue.persist" __FILE_CONTROLLER_PERSIST = "controller.persist" __CONFIG_DUMMY_VALUE = "" # This logger is used to print any exceptions caught at top module logger = None def __init__(self): # Parse the args args = self._parse_args(sys.argv[1:]) # Create/load config config = None self.config_path = os.path.join(args.config_dir, Seedsync.__FILE_CONFIG) create_default_config = False if os.path.isfile(self.config_path): try: config = Config.from_file(self.config_path) except (ConfigError, PersistError): Seedsync.__backup_file(self.config_path) # set config to default create_default_config = True else: create_default_config = True if create_default_config: # Create default config config = Seedsync._create_default_config() config.to_file(self.config_path) # Determine the true value of debug is_debug = args.debug or config.general.debug # Create context args ctx_args = Args() ctx_args.local_path_to_scanfs = args.scanfs ctx_args.html_path = args.html ctx_args.debug = is_debug ctx_args.exit = args.exit # Logger setup # We separate the main log from the web-access log logger = self._create_logger(name=Constants.SERVICE_NAME, debug=is_debug, logdir=args.logdir) Seedsync.logger = logger web_access_logger = self._create_logger(name=Constants.WEB_ACCESS_LOG_NAME, debug=is_debug, logdir=args.logdir) logger.info("Debug mode is {}.".format("enabled" if is_debug else "disabled")) # Create status status = Status() # Create context self.context = Context(logger=logger, web_access_logger=web_access_logger, config=config, args=ctx_args, status=status) # Register the signal handlers signal.signal(signal.SIGTERM, self.signal) signal.signal(signal.SIGINT, self.signal) # Print context to log self.context.print_to_log() # Load the persists self.controller_persist_path = os.path.join(args.config_dir, Seedsync.__FILE_CONTROLLER_PERSIST) self.controller_persist = self._load_persist(ControllerPersist, self.controller_persist_path) self.auto_queue_persist_path = os.path.join(args.config_dir, Seedsync.__FILE_AUTO_QUEUE_PERSIST) self.auto_queue_persist = self._load_persist(AutoQueuePersist, self.auto_queue_persist_path) def run(self): self.context.logger.info("Starting SeedSync") self.context.logger.info("Platform: {}".format(platform.machine())) # Create controller controller = Controller(self.context, self.controller_persist) # Create auto queue auto_queue = AutoQueue(self.context, self.auto_queue_persist, controller) # Create web app web_app_builder = WebAppBuilder(self.context, controller, self.auto_queue_persist) web_app = web_app_builder.build() # Define child threads controller_job = ControllerJob( context=self.context.create_child_context(ControllerJob.__name__), controller=controller, auto_queue=auto_queue ) webapp_job = WebAppJob( context=self.context.create_child_context(WebAppJob.__name__), web_app=web_app ) do_start_controller = True # Initial checks to see if we should bother starting the controller if Seedsync._detect_incomplete_config(self.context.config): if not self.context.args.exit: do_start_controller = False self.context.logger.error("Config is incomplete") self.context.status.server.up = False self.context.status.server.error_msg = Localization.Error.SETTINGS_INCOMPLETE else: raise AppError("Config is incomplete") # Start child threads here if do_start_controller: controller_job.start() webapp_job.start() try: prev_persist_timestamp = datetime.now() # Thread loop while True: # Persist to file occasionally now = datetime.now() if (now - prev_persist_timestamp).total_seconds() > Constants.MIN_PERSIST_TO_FILE_INTERVAL_IN_SECS: prev_persist_timestamp = now self.persist() # Propagate exceptions webapp_job.propagate_exception() # Catch controller exceptions and keep running, but notify the web server of the error try: controller_job.propagate_exception() except AppError as exc: if not self.context.args.exit: self.context.status.server.up = False self.context.status.server.error_msg = str(exc) Seedsync.logger.exception("Caught exception") else: raise # Check if a restart is requested if web_app_builder.server_handler.is_restart_requested(): raise ServiceRestart() # Nothing else to do time.sleep(Constants.MAIN_THREAD_SLEEP_INTERVAL_IN_SECS) except Exception: self.context.logger.info("Exiting Seedsync") # This sleep is important to allow the jobs to finish setup before we terminate them # If we kill too early, the jobs may leave lingering threads around # Note: There might be a better way to ensure that job setup has completed, but this # will do for now time.sleep(Constants.MAIN_THREAD_SLEEP_INTERVAL_IN_SECS) # Join all the threads here if do_start_controller: controller_job.terminate() webapp_job.terminate() # Wait for the threads to close if do_start_controller: controller_job.join() webapp_job.join() # Last persist self.persist() # Raise any exceptions so they can be logged properly # Note: ServiceRestart and ServiceExit will be caught and handled # by outer code raise def persist(self): # Save the persists self.context.logger.debug("Persisting states to file") self.controller_persist.to_file(self.controller_persist_path) self.auto_queue_persist.to_file(self.auto_queue_persist_path) self.context.config.to_file(self.config_path) def signal(self, signum: int, _): # noinspection PyUnresolvedReferences # Signals is a generated enum self.context.logger.info("Caught signal {}".format(signal.Signals(signum).name)) raise ServiceExit() @staticmethod def _parse_args(args): parser = argparse.ArgumentParser(description="Seedsync daemon") parser.add_argument("-c", "--config_dir", required=True, help="Path to config directory") parser.add_argument("--logdir", help="Directory for log files") parser.add_argument("-d", "--debug", action="store_true", help="Enable debug logs") parser.add_argument("--exit", action="store_true", help="Exit on error") # Whether package is frozen is_frozen = getattr(sys, 'frozen', False) # Html path is only required if not running a frozen package # For a frozen package, set default to root/html # noinspection PyUnresolvedReferences # noinspection PyProtectedMember default_html_path = os.path.join(sys._MEIPASS, "html") if is_frozen else None parser.add_argument("--html", required=not is_frozen, default=default_html_path, help="Path to directory containing html resources") # Scanfs path is only required if not running a frozen package # For a frozen package, set default to root/scanfs # noinspection PyUnresolvedReferences # noinspection PyProtectedMember default_scanfs_path = os.path.join(sys._MEIPASS, "scanfs") if is_frozen else None parser.add_argument("--scanfs", required=not is_frozen, default=default_scanfs_path, help="Path to scanfs executable") return parser.parse_args(args) @staticmethod def _create_logger(name: str, debug: bool, logdir: Optional[str]) -> logging.Logger: logger = logging.getLogger(name) # Remove any existing handlers (needed when restarting) handlers = logger.handlers[:] for handler in handlers: handler.close() logger.removeHandler(handler) logger.setLevel(logging.DEBUG if debug else logging.INFO) if logdir is not None: # Output logs to a file in the given directory handler = RotatingFileHandler( "{}/{}.log".format(logdir, name), maxBytes=Constants.MAX_LOG_SIZE_IN_BYTES, backupCount=Constants.LOG_BACKUP_COUNT ) else: handler = logging.StreamHandler(sys.stdout) formatter = logging.Formatter( "%(asctime)s - %(levelname)s - %(name)s (%(processName)s/%(threadName)s) - %(message)s" ) handler.setFormatter(formatter) logger.addHandler(handler) return logger @staticmethod def _create_default_config() -> Config: """ Create a config with default values :return: """ config = Config() config.general.debug = False config.general.verbose = False config.lftp.remote_address = Seedsync.__CONFIG_DUMMY_VALUE config.lftp.remote_username = Seedsync.__CONFIG_DUMMY_VALUE config.lftp.remote_password = Seedsync.__CONFIG_DUMMY_VALUE config.lftp.remote_port = 22 config.lftp.remote_path = Seedsync.__CONFIG_DUMMY_VALUE config.lftp.local_path = Seedsync.__CONFIG_DUMMY_VALUE config.lftp.remote_path_to_scan_script = "/tmp" config.lftp.use_ssh_key = False config.lftp.num_max_parallel_downloads = 2 config.lftp.num_max_parallel_files_per_download = 4 config.lftp.num_max_connections_per_root_file = 4 config.lftp.num_max_connections_per_dir_file = 4 config.lftp.num_max_total_connections = 16 config.lftp.use_temp_file = False config.controller.interval_ms_remote_scan = 30000 config.controller.interval_ms_local_scan = 10000 config.controller.interval_ms_downloading_scan = 1000 config.controller.extract_path = "/tmp" config.controller.use_local_path_as_extract_path = True config.web.port = 8800 config.autoqueue.enabled = True config.autoqueue.patterns_only = False config.autoqueue.auto_extract = True return config @staticmethod def _detect_incomplete_config(config: Config) -> bool: config_dict = config.as_dict() for sec_name in config_dict: for key in config_dict[sec_name]: if Seedsync.__CONFIG_DUMMY_VALUE == config_dict[sec_name][key]: return True return False @staticmethod def _load_persist(cls: Type[T_Persist], file_path: str) -> T_Persist: """ Loads a persist from file. Backs up existing persist if it's corrupted. Returns a new blank persist in its place. :param cls: :param file_path: :return: """ if os.path.isfile(file_path): try: return cls.from_file(file_path) except PersistError: if Seedsync.logger: Seedsync.logger.exception("Caught exception") # backup file Seedsync.__backup_file(file_path) # noinspection PyCallingNonCallable return cls() else: # noinspection PyCallingNonCallable return cls() @staticmethod def __backup_file(file_path: str): file_name = os.path.basename(file_path) file_dir = os.path.dirname(file_path) i = 1 while True: backup_path = os.path.join( file_dir, "{}.{}.bak".format(file_name, i) ) if not os.path.exists(backup_path): break i += 1 if Seedsync.logger: Seedsync.logger.info("Backing up {} to {}".format(file_path, backup_path)) shutil.copy(file_path, backup_path) if __name__ == "__main__": if sys.hexversion < 0x03050000: sys.exit("Python 3.5 or newer is required to run this program.") while True: try: seedsync = Seedsync() seedsync.run() except ServiceExit: break except ServiceRestart: Seedsync.logger.info("Restarting...") continue except Exception as e: Seedsync.logger.exception("Caught exception") raise Seedsync.logger.info("Exited successfully") ================================================ FILE: src/python/ssh/__init__.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from .sshcp import Sshcp, SshcpError ================================================ FILE: src/python/ssh/sshcp.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import logging import time import pexpect # my libs from common import AppError class SshcpError(AppError): """ Custom exception that describes the failure of the ssh command """ pass class Sshcp: """ Scp command utility """ __TIMEOUT_SECS = 180 def __init__(self, host: str, port: int, user: str = None, password: str = None): if host is None: raise ValueError("Hostname not specified.") self.__host = host self.__port = port self.__user = user self.__password = password self.logger = logging.getLogger(self.__class__.__name__) def set_base_logger(self, base_logger: logging.Logger): self.logger = base_logger.getChild(self.__class__.__name__) def __run_command(self, command: str, flags: str, args: str) -> bytes: command_args = [ command, flags ] # Common flags command_args += [ "-o", "StrictHostKeyChecking=no", # ignore host key changes "-o", "UserKnownHostsFile=/dev/null", # ignore known hosts file "-o", "LogLevel=error", # suppress warnings ] if self.__password is None: command_args += [ "-o", "PasswordAuthentication=no", # don't ask for password ] else: command_args += [ "-o", "PubkeyAuthentication=no" # don't use key authentication ] command_args.append(args) command = " ".join(command_args) self.logger.debug("Command: {}".format(command)) start_time = time.time() sp = pexpect.spawn(command) try: if self.__password is not None: i = sp.expect([ 'password: ', # i=0, all's good pexpect.EOF, # i=1, unknown error 'lost connection', # i=2, connection refused 'Could not resolve hostname', # i=3, bad hostname 'Connection refused', # i=4, connection refused ]) if i > 0: before = sp.before.decode().strip() if sp.before != pexpect.EOF else "" after = sp.after.decode().strip() if sp.after != pexpect.EOF else "" self.logger.warning("Command failed: '{} - {}'".format(before, after)) if i == 1: error_msg = "Unknown error" if sp.before.decode().strip(): error_msg += " - " + sp.before.decode().strip() raise SshcpError(error_msg) elif i == 3: raise SshcpError("Bad hostname: {}".format(self.__host)) elif i in {2, 4}: error_msg = "Connection refused by server" if sp.before.decode().strip(): error_msg += " - " + sp.before.decode().strip() raise SshcpError(error_msg) sp.sendline(self.__password) i = sp.expect( [ pexpect.EOF, # i=0, all's good 'password: ', # i=1, wrong password 'lost connection', # i=2, connection refused 'Could not resolve hostname', # i=3, bad hostname 'Connection refused', # i=4, connection refused ], timeout=self.__TIMEOUT_SECS ) if i > 0: before = sp.before.decode().strip() if sp.before != pexpect.EOF else "" after = sp.after.decode().strip() if sp.after != pexpect.EOF else "" self.logger.warning("Command failed: '{} - {}'".format(before, after)) if i == 1: raise SshcpError("Incorrect password") elif i == 3: raise SshcpError("Bad hostname: {}".format(self.__host)) elif i in {2, 4}: error_msg = "Connection refused by server" if sp.before.decode().strip(): error_msg += " - " + sp.before.decode().strip() raise SshcpError(error_msg) except pexpect.exceptions.TIMEOUT: self.logger.exception("Timed out") self.logger.error("Command output before:\n{}".format(sp.before)) raise SshcpError("Timed out") sp.close() end_time = time.time() self.logger.debug("Return code: {}".format(sp.exitstatus)) self.logger.debug("Command took {:.3f}s".format(end_time-start_time)) if sp.exitstatus != 0: before = sp.before.decode().strip() if sp.before != pexpect.EOF else "" after = sp.after.decode().strip() if sp.after != pexpect.EOF else "" self.logger.warning("Command failed: '{} - {}'".format(before, after)) raise SshcpError(sp.before.decode().strip()) return sp.before.replace(b'\r\n', b'\n').strip() def shell(self, command: str) -> bytes: """ Run a shell command on remote service and return output :param command: :return: """ if not command: raise ValueError("Command cannot be empty") # escape the command if "'" in command and '"' in command: # I don't know how to handle this yet... raise ValueError("Command cannot contain both single and double quotes") elif '"' in command: # double quote in command, cover with single quotes command = "'{}'".format(command) else: # no double quote in command, cover with double quotes command = '"{}"'.format(command) flags = [ "-p", str(self.__port), # port ] args = [ "{}@{}".format(self.__user, self.__host), command ] return self.__run_command( command="ssh", flags=" ".join(flags), args=" ".join(args) ) def copy(self, local_path: str, remote_path: str): """ Copies local file at local_path to remote remote_path :param local_path: :param remote_path: :return: """ if not local_path: raise ValueError("Local path cannot be empty") if not remote_path: raise ValueError("Remote path cannot be empty") flags = [ "-q", # quiet "-P", str(self.__port), # port ] args = [ local_path, "{}@{}:{}".format(self.__user, self.__host, remote_path) ] self.__run_command( command="scp", flags=" ".join(flags), args=" ".join(args) ) ================================================ FILE: src/python/system/__init__.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from .scanner import SystemScanner, SystemScannerError from .file import SystemFile ================================================ FILE: src/python/system/file.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from typing import List from datetime import datetime class SystemFile: """ Represents a system file or directory """ def __init__(self, name: str, size: int, is_dir: bool = False, time_created: datetime = None, time_modified: datetime = None): if size < 0: raise ValueError("File size must be greater than zero") self.__name = name self.__size = size # in bytes self.__is_dir = is_dir self.__timestamp_created = time_created self.__timestamp_modified = time_modified self.__children = [] def __eq__(self, other): return self.__dict__ == other.__dict__ def __repr__(self): return str(self.__dict__) @property def name(self) -> str: return self.__name @property def size(self) -> int: return self.__size @property def is_dir(self) -> bool: return self.__is_dir @property def timestamp_created(self) -> datetime: return self.__timestamp_created @property def timestamp_modified(self) -> datetime: return self.__timestamp_modified @property def children(self) -> List["SystemFile"]: return self.__children def add_child(self, file: "SystemFile"): if not self.__is_dir: raise TypeError("Cannot add children to a file") self.__children.append(file) ================================================ FILE: src/python/system/scanner.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import os import re from typing import List from datetime import datetime # my libs from common import AppError from .file import SystemFile class SystemScannerError(AppError): """ Exception indicating a bad config value """ pass class PseudoDirEntry: def __init__(self, name: str, path: str, is_dir: bool, stat): self.name = name self.path = path self._is_dir = is_dir self._stat = stat def is_dir(self): return self._is_dir def stat(self): return self._stat class SystemScanner: """ Scans system to generate list of files and sizes Children are returned in alphabetical order """ __LFTP_STATUS_FILE_SUFFIX = ".lftp-pget-status" def __init__(self, path_to_scan: str): """ :param path_to_scan: path to file or directory to scan """ self.path_to_scan = path_to_scan self.exclude_prefixes = [] self.exclude_suffixes = [SystemScanner.__LFTP_STATUS_FILE_SUFFIX] self.__lftp_temp_file_suffix = None def add_exclude_prefix(self, prefix: str): """ Exclude files that begin with the given prefix :param prefix: :return: """ self.exclude_prefixes.append(prefix) def add_exclude_suffix(self, suffix: str): """ Exclude files that end with the given suffix :param suffix: :return: """ self.exclude_suffixes.append(suffix) def set_lftp_temp_suffix(self, suffix: str): """ Set the suffix used by LFTP temp files Scanner will ignore the suffix and show these files with their original name :return: """ self.__lftp_temp_file_suffix = suffix def scan(self) -> List[SystemFile]: """ Scan the path to generate list of system files :return: """ if not os.path.exists(self.path_to_scan): raise SystemScannerError("Path does not exist: {}".format(self.path_to_scan)) elif not os.path.isdir(self.path_to_scan): raise SystemScannerError("Path is not a directory: {}".format(self.path_to_scan)) return self.__create_children(self.path_to_scan) def scan_single(self, name: str) -> SystemFile: """ Scan a single file/dir :param name: :return: """ path = os.path.join(self.path_to_scan, name) temp_path = (path + self.__lftp_temp_file_suffix) if self.__lftp_temp_file_suffix else None if os.path.exists(path): # We're good to go pass elif temp_path and os.path.isfile(temp_path): # There's a temp file, use that path = temp_path else: raise SystemScannerError("Path does not exist: {}".format(path)) return self.__create_system_file( PseudoDirEntry( name=name, path=path, is_dir=os.path.isdir(path), stat=os.stat(path) ) ) def __create_system_file(self, entry) -> SystemFile: """ Creates a system file from a DirEntry. Note: Strips out any characters not-supported in utf-8. This prevents problems in other systems. Args: entry: DirEntry object Returns: The SystemFile object """ if entry.is_dir(): sub_children = self.__create_children(entry.path) name = entry.name.encode('utf-8', 'surrogateescape').decode('utf-8', 'replace') size = sum(sub_child.size for sub_child in sub_children) time_created = None try: time_created = datetime.fromtimestamp(entry.stat().st_birthtime) except AttributeError: pass time_modified = datetime.fromtimestamp(entry.stat().st_mtime) sys_file = SystemFile(name, size, True, time_created=time_created, time_modified=time_modified) for sub_child in sub_children: sys_file.add_child(sub_child) else: file_size = entry.stat().st_size # Check if it's a partial lftp file, and if so, use the lftp # status to get the real file size lftp_status_file_path = entry.path + SystemScanner.__LFTP_STATUS_FILE_SUFFIX if os.path.isfile(lftp_status_file_path): with open(lftp_status_file_path, "r") as f: file_size = SystemScanner._lftp_status_file_size(f.read()) # Check to see if this is a lftp temp file, and if so, use the real name file_name = entry.name.encode('utf-8', 'surrogateescape').decode('utf-8', 'replace') if self.__lftp_temp_file_suffix is not None and \ file_name != self.__lftp_temp_file_suffix and \ file_name.endswith(self.__lftp_temp_file_suffix): file_name = file_name[:-len(self.__lftp_temp_file_suffix)] time_created = None try: time_created = datetime.fromtimestamp(entry.stat().st_birthtime) except AttributeError: pass time_modified = datetime.fromtimestamp(entry.stat().st_mtime) sys_file = SystemFile(file_name, file_size, False, time_created=time_created, time_modified=time_modified) return sys_file def __create_children(self, path: str) -> List[SystemFile]: children = [] # Files may get deleted while scanning, ignore the error for entry in os.scandir(path): # Skip excluded entries skip = False for prefix in self.exclude_prefixes: if entry.name.startswith(prefix): skip = True for suffix in self.exclude_suffixes: if entry.name.endswith(suffix): skip = True if skip: continue try: sys_file = self.__create_system_file(entry) except FileNotFoundError: continue children.append(sys_file) children.sort(key=lambda fl: fl.name) return children @staticmethod def _lftp_status_file_size(status: str) -> int: """ Returns the real file size as indicated by an lftp status content :param status: :return: """ size_pattern_m = re.compile("^size=(\d+)$") pos_pattern_m = re.compile("^\d+\.pos=(\d+)$") limit_pattern_m = re.compile("^\d+\.limit=(\d+)$") lines = [s.strip() for s in status.splitlines()] lines = list(filter(None, lines)) # remove blank lines if not lines: return 0 empty_size = 0 # First line should be a size result = size_pattern_m.search(lines[0]) if not result: return 0 total_size = int(result.group(1)) lines.pop(0) while lines: # There should be pairs of lines if len(lines) < 2: return 0 result_pos = pos_pattern_m.search(lines[0]) result_limit = limit_pattern_m.search(lines[1]) if not result_pos or not result_limit: return 0 pos = int(result_pos.group(1)) limit = int(result_limit.group(1)) empty_size += limit - pos lines.pop(0) lines.pop(0) return total_size-empty_size ================================================ FILE: src/python/tests/__init__.py ================================================ ================================================ FILE: src/python/tests/integration/__init__.py ================================================ ================================================ FILE: src/python/tests/integration/test_controller/__init__.py ================================================ ================================================ FILE: src/python/tests/integration/test_controller/test_controller.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest from unittest.mock import MagicMock, patch, PropertyMock import os import tempfile import shutil from filecmp import dircmp, cmp import logging import sys import zipfile import subprocess from datetime import datetime import stat import timeout_decorator from tests.utils import TestUtils from common import overrides, Context, Config, Args, AppError, Localization, Status from controller import Controller, ControllerPersist from model import ModelFile, IModelListener class DummyListener(IModelListener): @overrides(IModelListener) def file_added(self, file: ModelFile): pass @overrides(IModelListener) def file_updated(self, old_file: ModelFile, new_file: ModelFile): pass @overrides(IModelListener) def file_removed(self, file: ModelFile): pass class DummyCommandCallback(Controller.Command.ICallback): @overrides(Controller.Command.ICallback) def on_failure(self, error: str): pass @overrides(Controller.Command.ICallback) def on_success(self): pass # noinspection SpellCheckingInspection class TestController(unittest.TestCase): __KEEP_FILES = False # for debugging maxDiff = None temp_dir = None work_dir = None @staticmethod def my_mkdir(*args): os.mkdir(os.path.join(TestController.temp_dir, *args)) @staticmethod def my_touch(size, *args): path = os.path.join(TestController.temp_dir, *args) with open(path, 'wb') as f: f.write(bytearray([0xff] * size)) @staticmethod def create_archive(*args): """ Creates a archive of a text file containing name of archive The text file is named ".txt" Returns archive file size """ path = os.path.join(TestController.temp_dir, *args) archive_name = os.path.basename(path) temp_file_path = os.path.join(TestController.work_dir, archive_name+".txt") with open(temp_file_path, "w") as f: f.write(os.path.basename(path)) ext = os.path.splitext(os.path.basename(path))[1] ext = ext[1:] if ext == "zip": zf = zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) zf.write(temp_file_path, os.path.basename(temp_file_path)) zf.close() elif ext == "rar": fnull = open(os.devnull, 'w') subprocess.Popen( [ "rar", "a", "-ep", path, temp_file_path ], stdout=fnull ).communicate() else: raise ValueError("Unsupported archive format: {}".format(os.path.basename(path))) return os.path.getsize(path) @overrides(unittest.TestCase) def setUp(self): # Create a temp directory TestController.temp_dir = tempfile.mkdtemp(prefix="test_controller") # Allow group access for the seedsynctest account TestUtils.chmod_from_to(self.temp_dir, tempfile.gettempdir(), 0o775) # Create a work directory for temp files TestController.work_dir = os.path.join(TestController.temp_dir, "work") os.mkdir(TestController.work_dir) # Create a bunch of files and directories # remote # ra [dir] # raa [file, 1*1024 bytes] # rab [dir] # raba [file, 5*1024 bytes] # rabb [file, 2*1024 bytes] # rb [dir] # rba [file, 4*1024 bytes] # rbb [file, 5*1024 bytes] # rc [file, 10*1024 bytes] # local # la [dir] # laa [file, 1*1024 bytes] # lab [file, 1*1024 bytes] # lb [file, 2*1024 bytes] TestController.my_mkdir("remote") TestController.my_mkdir("remote", "ra") TestController.my_touch(1*1024, "remote", "ra", "raa") TestController.my_mkdir("remote", "ra", "rab") TestController.my_touch(5*1024, "remote", "ra", "rab", "raba") TestController.my_touch(2*1024, "remote", "ra", "rab", "rabb") TestController.my_mkdir("remote", "rb") TestController.my_touch(4*1024, "remote", "rb", "rba") TestController.my_touch(5*1024, "remote", "rb", "rbb") TestController.my_touch(10*1024, "remote", "rc") TestController.my_mkdir("local") TestController.my_mkdir("local", "la") TestController.my_touch(1*1024, "local", "la", "laa") TestController.my_touch(1*1024, "local", "la", "lab") TestController.my_touch(2*1024, "local", "lb") # Also create some archives # Store the true archive file sizes in a dict # remote # rd [dir] # rd.zip [file] # re.rar [file] # rf [dir] # rfa [dir] # rfa.zip [file] # rfb [dir] # rfb.zip [file] # local # lc [dir] # lca.rar [file] # lcb.zip [file] self.archive_sizes = {} TestController.my_mkdir("remote", "rd") self.archive_sizes["rd.zip"] = TestController.create_archive("remote", "rd", "rd.zip") self.archive_sizes["re.rar"] = TestController.create_archive("remote", "re.rar") TestController.my_mkdir("remote", "rf") TestController.my_mkdir("remote", "rf", "rfa") self.archive_sizes["rfa.zip"] = TestController.create_archive("remote", "rf", "rfa", "rfa.zip") TestController.my_mkdir("remote", "rf", "rfb") self.archive_sizes["rfb.zip"] = TestController.create_archive("remote", "rf", "rfb", "rfb.zip") TestController.my_mkdir("local", "lc") self.archive_sizes["lca.rar"] = TestController.create_archive("local", "lc", "lca.rar") self.archive_sizes["lcb.zip"] = TestController.create_archive("local", "lc", "lcb.zip") # Allow group access to remote files for seedsynctest account # This is necessary for seedsynctest can do remote-delete commands # We are basically doing a chmod g+w on all of remote/ directory remote_dir = os.path.join(self.temp_dir, "remote") st = os.stat(remote_dir) os.chmod(remote_dir, st.st_mode | stat.S_IWGRP) for root, dirs, files in os.walk(remote_dir): for momo in dirs: path = os.path.join(root, momo) st = os.stat(path) os.chmod(path, st.st_mode | stat.S_IWGRP) for momo in files: path = os.path.join(root, momo) st = os.stat(path) os.chmod(path, st.st_mode | stat.S_IWGRP) # Helper object to store the intial state f_ra = ModelFile("ra", True) f_ra.remote_size = 8*1024 f_raa = ModelFile("raa", False) f_raa.remote_size = 1*1024 f_ra.add_child(f_raa) f_rab = ModelFile("rab", True) f_rab.remote_size = 7*1024 f_ra.add_child(f_rab) f_raba = ModelFile("raba", False) f_raba.remote_size = 5*1024 f_rab.add_child(f_raba) f_rabb = ModelFile("rabb", False) f_rabb.remote_size = 2*1024 f_rab.add_child(f_rabb) f_rb = ModelFile("rb", True) f_rb.remote_size = 9*1024 f_rba = ModelFile("rba", False) f_rba.remote_size = 4*1024 f_rb.add_child(f_rba) f_rbb = ModelFile("rbb", False) f_rbb.remote_size = 5*1024 f_rb.add_child(f_rbb) f_rc = ModelFile("rc", False) f_rc.remote_size = 10*1024 f_rd = ModelFile("rd", True) f_rd.remote_size = self.archive_sizes["rd.zip"] f_rd.is_extractable = True f_rdx = ModelFile("rd.zip", False) f_rdx.remote_size = self.archive_sizes["rd.zip"] f_rdx.is_extractable = True f_rd.add_child(f_rdx) f_re = ModelFile("re.rar", False) f_re.remote_size = self.archive_sizes["re.rar"] f_re.is_extractable = True f_rf = ModelFile("rf", True) f_rf.remote_size = self.archive_sizes["rfa.zip"] + self.archive_sizes["rfb.zip"] f_rf.is_extractable = True f_rfa = ModelFile("rfa", True) f_rfa.remote_size = self.archive_sizes["rfa.zip"] f_rfa.is_extractable = True f_rfax = ModelFile("rfa.zip", False) f_rfax.remote_size = self.archive_sizes["rfa.zip"] f_rfax.is_extractable = True f_rfa.add_child(f_rfax) f_rf.add_child(f_rfa) f_rfb = ModelFile("rfb", True) f_rfb.remote_size = self.archive_sizes["rfb.zip"] f_rfb.is_extractable = True f_rf.add_child(f_rfb) f_rfbx = ModelFile("rfb.zip", False) f_rfbx.remote_size = self.archive_sizes["rfb.zip"] f_rfbx.is_extractable = True f_rfb.add_child(f_rfbx) f_la = ModelFile("la", True) f_la.local_size = 2*1024 f_laa = ModelFile("laa", False) f_laa.local_size = 1*1024 f_la.add_child(f_laa) f_lab = ModelFile("lab", False) f_lab.local_size = 1*1024 f_la.add_child(f_lab) f_lb = ModelFile("lb", False) f_lb.local_size = 2*1024 f_lc = ModelFile("lc", True) f_lc.local_size = self.archive_sizes["lca.rar"] + self.archive_sizes["lcb.zip"] f_lc.is_extractable = True f_lca = ModelFile("lca.rar", False) f_lca.local_size = self.archive_sizes["lca.rar"] f_lca.is_extractable = True f_lc.add_child(f_lca) f_lcb = ModelFile("lcb.zip", False) f_lcb.local_size = self.archive_sizes["lcb.zip"] f_lcb.is_extractable = True f_lc.add_child(f_lcb) self.initial_state = {f.name: f for f in [ f_ra, f_rb, f_rc, f_rd, f_re, f_rf, f_la, f_lb, f_lc ]} # We need to overwrite the timestamp properties since it's too tedious to make # them match manually for all the model files pm = patch("model.file.ModelFile.remote_modified_timestamp", new_callable=PropertyMock) self.addCleanup(pm.stop) pm_cls = pm.start() pm_cls.return_value = None pm = patch("model.file.ModelFile.local_modified_timestamp", new_callable=PropertyMock) self.addCleanup(pm.stop) pm_cls = pm.start() pm_cls.return_value = None # config file # Note: seedsynctest account must be set up. See DeveloperReadme.md for details # We also need to create an executable that the controller can install on remote # Since we don't have a packaged scanfs executable here, we simply # create an sh script that points to the python script # Note: the executable must be the venv one so any custom imports work current_dir_path = os.path.dirname(os.path.realpath(__file__)) local_script_path = os.path.abspath(os.path.join(current_dir_path, "..", "..", "..", "scan_fs.py")) local_exe_dir = os.path.join(TestController.temp_dir, "scanfs_local") remote_exe_dir = os.path.join(TestController.temp_dir, "scanfs_remote") os.makedirs(local_exe_dir, exist_ok=True) os.makedirs(remote_exe_dir, exist_ok=True) # Allow group access for the seedsynctest account os.chmod(remote_exe_dir, 0o775) local_exe_path = os.path.join(local_exe_dir, "scanfs") remote_exe_path = remote_exe_dir with open(local_exe_path, "w") as f: f.write("#!/bin/sh\n") f.write("{} {} $*".format(sys.executable, local_script_path)) os.chmod(local_exe_path, 0o775) ctx_args = Args() ctx_args.local_path_to_scanfs = local_exe_path config_dict = { "General": { "debug": "True", "verbose": "True" }, "Lftp": { "remote_address": "localhost", "remote_username": "seedsynctest", "remote_password": "seedsyncpass", "remote_port": 22, "remote_path": os.path.join(self.temp_dir, "remote"), "local_path": os.path.join(self.temp_dir, "local"), "remote_path_to_scan_script": remote_exe_path, "use_ssh_key": "True", "num_max_parallel_downloads": "1", "num_max_parallel_files_per_download": "3", "num_max_connections_per_root_file": "4", "num_max_connections_per_dir_file": "4", "num_max_total_connections": "12", "use_temp_file": "False" }, "Controller": { "interval_ms_remote_scan": "100", "interval_ms_local_scan": "100", "interval_ms_downloading_scan": "100", "extract_path": "/unused/path", "use_local_path_as_extract_path": True }, "Web": { "port": "8800", }, "AutoQueue": { "enabled": "True", "patterns_only": "True", "auto_extract": "True" } } logger = logging.getLogger(TestController.__name__) handler = logging.StreamHandler(sys.stdout) logger.addHandler(handler) logger.setLevel(logging.DEBUG) formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") handler.setFormatter(formatter) self.context = Context(logger=logger, web_access_logger=logger, config=Config.from_dict(config_dict), args=ctx_args, status=Status()) self.controller_persist = ControllerPersist() self.controller = None @overrides(unittest.TestCase) def tearDown(self): if self.controller: self.controller.exit() # Cleanup if not TestController.__KEEP_FILES: shutil.rmtree(self.temp_dir) # noinspection PyMethodMayBeStatic def __wait_for_initial_model(self): while len(self.controller.get_model_files()) < 5: self.controller.process() @timeout_decorator.timeout(20) def test_bad_config_doesnot_raise_ctor_exception(self): self.context.config.lftp.remote_address = "" self.context.config.lftp.remote_username = "" self.context.config.lftp.remote_path = "" self.context.config.lftp.local_path = "" self.context.config.lftp.remote_path_to_scan_script = "" # noinspection PyBroadException try: self.controller = Controller(self.context, self.controller_persist) except Exception: self.fail("Controller ctor raised exception unexpectedly") @timeout_decorator.timeout(20) def test_bad_config_remote_address_raises_exception(self): self.context.config.lftp.remote_address = "" self.controller = Controller(self.context, self.controller_persist) self.controller.start() # noinspection PyUnusedLocal with self.assertRaises(AppError) as error: while True: self.controller.process() # noinspection PyUnreachableCode self.assertEqual( Localization.Error.REMOTE_SERVER_INSTALL.format("Bad hostname: "), str(error.exception) ) @timeout_decorator.timeout(20) def test_bad_config_remote_username_raises_exception(self): self.context.config.lftp.remote_username = "" self.controller = Controller(self.context, self.controller_persist) self.controller.start() # noinspection PyUnusedLocal with self.assertRaises(AppError) as error: while True: self.controller.process() # noinspection PyUnreachableCode self.assertEqual( Localization.Error.REMOTE_SERVER_INSTALL.format("@localhost: Permission denied (publickey,password)."), str(error.exception) ) @timeout_decorator.timeout(20) def test_bad_config_remote_path_raises_exception(self): self.context.config.lftp.remote_path = "" self.controller = Controller(self.context, self.controller_persist) self.controller.start() # noinspection PyUnusedLocal with self.assertRaises(AppError) as error: while True: self.controller.process() # noinspection PyUnreachableCode self.assertEqual( Localization.Error.REMOTE_SERVER_SCAN.format("SystemScannerError: Path does not exist: "), str(error.exception) ) @timeout_decorator.timeout(20) def test_bad_config_local_path_raises_exception(self): self.context.config.lftp.local_path = "" self.controller = Controller(self.context, self.controller_persist) self.controller.start() # noinspection PyUnusedLocal with self.assertRaises(AppError) as error: while True: self.controller.process() # noinspection PyUnreachableCode self.assertEqual(Localization.Error.LOCAL_SERVER_SCAN, str(error.exception)) @timeout_decorator.timeout(20) def test_bad_config_remote_path_to_scan_script_raises_exception(self): self.context.config.lftp.remote_path_to_scan_script = "" self.controller = Controller(self.context, self.controller_persist) self.controller.start() # noinspection PyUnusedLocal with self.assertRaises(AppError) as error: while True: self.controller.process() # noinspection PyUnreachableCode self.assertEqual( Localization.Error.REMOTE_SERVER_INSTALL.format( "Connection refused by server - bash: bad: No such file or directory" ), str(error.exception) ) @timeout_decorator.timeout(20) def test_bad_remote_password_raises_exception(self): self.context.config.lftp.remote_password = "bad password" self.context.config.lftp.use_ssh_key = False self.controller = Controller(self.context, self.controller_persist) self.controller.start() # noinspection PyUnusedLocal with self.assertRaises(AppError) as error: while True: self.controller.process() # noinspection PyUnreachableCode self.assertEqual( Localization.Error.REMOTE_SERVER_INSTALL.format("Incorrect password"), str(error.exception) ) @timeout_decorator.timeout(20) def test_initial_model(self): self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() model_files = self.controller.get_model_files() self.assertEqual(len(self.initial_state.keys()), len(model_files)) files_dict = {f.name: f for f in model_files} self.assertEqual(self.initial_state.keys(), files_dict.keys()) for filename in self.initial_state.keys(): # Note: put items in a list for a better diff output self.assertEqual([self.initial_state[filename]], [files_dict[filename]], "Mismatch in file: {}".format(filename)) @timeout_decorator.timeout(20) def test_local_file_added(self): self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() # Add a local file TestController.my_touch(1515, "local", "lnew") # Process until discovered while True: self.controller.process() call = listener.file_added.call_args if call: new_file = call[0][0] self.assertEqual("lnew", new_file.name) break # Verify self.controller.process() lnew = ModelFile("lnew", False) lnew.local_size = 1515 listener.file_added.assert_called_once_with(lnew) listener.file_updated.assert_not_called() listener.file_removed.assert_not_called() @timeout_decorator.timeout(20) def test_local_file_updated(self): self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() # Update a local file TestController.my_touch(1717, "local", "lb") # Process until discovered while True: self.controller.process() call = listener.file_updated.call_args if call: new_file = call[0][1] self.assertEqual("lb", new_file.name) break # Verify self.controller.process() lb_old = ModelFile("lb", False) lb_old.local_size = 2*1024 lb_new = ModelFile("lb", False) lb_new.local_size = 1717 listener.file_updated.assert_called_once_with(lb_old, lb_new) listener.file_added.assert_not_called() listener.file_removed.assert_not_called() @timeout_decorator.timeout(20) def test_local_file_removed(self): self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() # Remove the local file os.remove(os.path.join(TestController.temp_dir, "local", "lb")) # Process until discovered while True: self.controller.process() call = listener.file_removed.call_args if call: new_file = call[0][0] self.assertEqual("lb", new_file.name) break # Verify self.controller.process() lb = ModelFile("lb", False) lb.local_size = 2*1024 listener.file_removed.assert_called_once_with(lb) listener.file_added.assert_not_called() listener.file_updated.assert_not_called() @timeout_decorator.timeout(20) def test_remote_file_added(self): self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() # Add a local file TestController.my_touch(1515, "remote", "rnew") # Verify while listener.file_added.call_count < 1: self.controller.process() rnew = ModelFile("rnew", False) rnew.remote_size = 1515 listener.file_added.assert_called_once_with(rnew) listener.file_updated.assert_not_called() listener.file_removed.assert_not_called() @timeout_decorator.timeout(20) def test_remote_file_updated(self): self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() # Update a local file TestController.my_touch(1717, "remote", "rc") # Verify while listener.file_updated.call_count < 1: self.controller.process() rc_old = ModelFile("rc", False) rc_old.remote_size = 10*1024 rc_new = ModelFile("rc", False) rc_new.remote_size = 1717 listener.file_updated.assert_called_once_with(rc_old, rc_new) listener.file_added.assert_not_called() listener.file_removed.assert_not_called() @timeout_decorator.timeout(20) def test_remote_file_removed(self): self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() # Remove the local file os.remove(os.path.join(TestController.temp_dir, "remote", "rc")) # Verify while listener.file_removed.call_count < 1: self.controller.process() rc = ModelFile("rc", False) rc.remote_size = 10*1024 listener.file_removed.assert_called_once_with(rc) listener.file_added.assert_not_called() listener.file_updated.assert_not_called() @timeout_decorator.timeout(20) def test_command_queue_directory(self): self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() callback = DummyCommandCallback() callback.on_success = MagicMock() callback.on_failure = MagicMock() # Queue a download command = Controller.Command(Controller.Command.Action.QUEUE, "ra") command.add_callback(callback) self.controller.queue_command(command) # Process until done while True: self.controller.process() call = listener.file_updated.call_args if call: new_file = call[0][1] self.assertEqual("ra", new_file.name) if new_file.local_size == 8*1024: break # Verify listener.file_added.assert_not_called() listener.file_removed.assert_not_called() callback.on_success.assert_called_once_with() callback.on_failure.assert_not_called() dcmp = dircmp(os.path.join(TestController.temp_dir, "remote", "ra"), os.path.join(TestController.temp_dir, "local", "ra")) self.assertFalse(dcmp.left_only) self.assertFalse(dcmp.right_only) self.assertFalse(dcmp.diff_files) @timeout_decorator.timeout(20) def test_command_queue_file(self): self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() callback = DummyCommandCallback() callback.on_success = MagicMock() callback.on_failure = MagicMock() # Queue a download command = Controller.Command(Controller.Command.Action.QUEUE, "rc") command.add_callback(callback) self.controller.queue_command(command) # Process until done while True: self.controller.process() call = listener.file_updated.call_args if call: new_file = call[0][1] self.assertEqual("rc", new_file.name) if new_file.local_size == 10*1024: break # Verify listener.file_added.assert_not_called() listener.file_removed.assert_not_called() callback.on_success.assert_called_once_with() callback.on_failure.assert_not_called() fcmp = cmp(os.path.join(TestController.temp_dir, "remote", "rc"), os.path.join(TestController.temp_dir, "local", "rc")) self.assertTrue(fcmp) @timeout_decorator.timeout(20) def test_command_queue_invalid(self): self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() callback = DummyCommandCallback() callback.on_success = MagicMock() callback.on_failure = MagicMock() # Queue a download command = Controller.Command(Controller.Command.Action.QUEUE, "invaliddir") command.add_callback(callback) self.controller.queue_command(command) # Process until done while callback.on_failure.call_count < 1: self.controller.process() # Verify listener.file_added.assert_not_called() listener.file_updated.assert_not_called() listener.file_removed.assert_not_called() callback.on_success.assert_not_called() self.assertEqual(1, len(callback.on_failure.call_args_list)) error = callback.on_failure.call_args[0][0] self.assertEqual("File 'invaliddir' not found", error) @timeout_decorator.timeout(20) def test_command_queue_local_directory(self): self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() callback = DummyCommandCallback() callback.on_success = MagicMock() callback.on_failure = MagicMock() # Queue a download command = Controller.Command(Controller.Command.Action.QUEUE, "la") command.add_callback(callback) self.controller.queue_command(command) # Process until done while callback.on_failure.call_count < 1: self.controller.process() listener.file_added.assert_not_called() listener.file_updated.assert_not_called() listener.file_removed.assert_not_called() callback.on_success.assert_not_called() self.assertEqual(1, len(callback.on_failure.call_args_list)) error = callback.on_failure.call_args[0][0] self.assertEqual("File 'la' does not exist remotely", error) @timeout_decorator.timeout(20) def test_command_queue_local_file(self): self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() callback = DummyCommandCallback() callback.on_success = MagicMock() callback.on_failure = MagicMock() # Queue a download command = Controller.Command(Controller.Command.Action.QUEUE, "lb") command.add_callback(callback) self.controller.queue_command(command) # Process until done while callback.on_failure.call_count < 1: self.controller.process() listener.file_added.assert_not_called() listener.file_updated.assert_not_called() listener.file_removed.assert_not_called() callback.on_success.assert_not_called() self.assertEqual(1, len(callback.on_failure.call_args_list)) error = callback.on_failure.call_args[0][0] self.assertEqual("File 'lb' does not exist remotely", error) @timeout_decorator.timeout(20) def test_command_stop_directory(self): # White box hack: limit the rate of lftp so download doesn't finish # noinspection PyUnresolvedReferences self.controller = Controller(self.context, self.controller_persist) self.controller.start() # noinspection PyUnresolvedReferences self.controller._Controller__lftp.rate_limit = 100 # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() callback = DummyCommandCallback() callback.on_success = MagicMock() callback.on_failure = MagicMock() # Queue a download command = Controller.Command(Controller.Command.Action.QUEUE, "ra") command.add_callback(callback) self.controller.queue_command(command) # Process until download starts while True: self.controller.process() call = listener.file_updated.call_args if call: new_file = call[0][1] self.assertEqual("ra", new_file.name) if new_file.local_size and new_file.local_size > 0: break # Now stop the download self.controller.queue_command(Controller.Command(Controller.Command.Action.STOP, "ra")) # Process until download stops while True: self.controller.process() call = listener.file_updated.call_args if call: new_file = call[0][1] self.assertEqual("ra", new_file.name) if new_file.state == ModelFile.State.DEFAULT: break # Verify call = listener.file_updated.call_args new_file = call[0][1] self.assertEqual("ra", new_file.name) self.assertEqual(ModelFile.State.DEFAULT, new_file.state) self.assertLess(new_file.local_size, new_file.remote_size) listener.file_added.assert_not_called() listener.file_removed.assert_not_called() callback.on_success.assert_called_once_with() callback.on_failure.assert_not_called() @timeout_decorator.timeout(20) def test_command_stop_file(self): # White box hack: limit the rate of lftp so download doesn't finish # noinspection PyUnresolvedReferences self.controller = Controller(self.context, self.controller_persist) self.controller.start() # noinspection PyUnresolvedReferences self.controller._Controller__lftp.rate_limit = 100 # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() callback = DummyCommandCallback() callback.on_success = MagicMock() callback.on_failure = MagicMock() # Queue a download command = Controller.Command(Controller.Command.Action.QUEUE, "rc") command.add_callback(callback) self.controller.queue_command(command) # Process until download starts while True: self.controller.process() call = listener.file_updated.call_args if call: new_file = call[0][1] self.assertEqual("rc", new_file.name) if new_file.local_size and new_file.local_size > 0: break # Now stop the download self.controller.queue_command(Controller.Command(Controller.Command.Action.STOP, "rc")) # Process until download stops while True: self.controller.process() call = listener.file_updated.call_args if call: new_file = call[0][1] self.assertEqual("rc", new_file.name) if new_file.state == ModelFile.State.DEFAULT: break # Verify call = listener.file_updated.call_args new_file = call[0][1] self.assertEqual("rc", new_file.name) self.assertEqual(ModelFile.State.DEFAULT, new_file.state) self.assertLess(new_file.local_size, new_file.remote_size) listener.file_added.assert_not_called() listener.file_removed.assert_not_called() callback.on_success.assert_called_once_with() callback.on_failure.assert_not_called() @timeout_decorator.timeout(20) def test_command_stop_default(self): self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() callback = DummyCommandCallback() callback.on_success = MagicMock() callback.on_failure = MagicMock() # Verify that rc is Default files = self.controller.get_model_files() files_dict = {f.name: f for f in files} self.assertEqual(ModelFile.State.DEFAULT, files_dict["rc"].state) # Now stop the download command = Controller.Command(Controller.Command.Action.STOP, "rc") command.add_callback(callback) self.controller.queue_command(command) self.controller.process() # Verify nothing happened listener.file_updated.assert_not_called() listener.file_added.assert_not_called() listener.file_removed.assert_not_called() callback.on_success.assert_not_called() self.assertEqual(1, len(callback.on_failure.call_args_list)) error = callback.on_failure.call_args[0][0] self.assertEqual("File 'rc' is not Queued or Downloading", error) @timeout_decorator.timeout(20) def test_command_stop_queued(self): # White box hack: limit the rate of lftp so download doesn't finish # noinspection PyUnresolvedReferences self.controller = Controller(self.context, self.controller_persist) self.controller.start() # noinspection PyUnresolvedReferences self.controller._Controller__lftp.rate_limit = 100 # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() callback = DummyCommandCallback() callback.on_success = MagicMock() callback.on_failure = MagicMock() # Queue two downloads # This one will be Downloading self.controller.queue_command(Controller.Command(Controller.Command.Action.QUEUE, "rc")) # This one will be Queued command = Controller.Command(Controller.Command.Action.QUEUE, "rb") command.add_callback(callback) self.controller.queue_command(command) # Process until download starts while True: self.controller.process() call = listener.file_updated.call_args if call: new_file = call[0][1] if new_file.name == "rc" and new_file.local_size and new_file.local_size > 0: break # Verify that rb is Queued files = self.controller.get_model_files() files_dict = {f.name: f for f in files} self.assertEqual(ModelFile.State.QUEUED, files_dict["rb"].state) # Now stop the queued self.controller.queue_command(Controller.Command(Controller.Command.Action.STOP, "rb")) # Process until queued stops while True: self.controller.process() break_out = False for call in listener.file_updated.call_args_list: new_file = call[0][1] if new_file.name == "rb" and new_file.state == ModelFile.State.DEFAULT: break_out = True if break_out: break # Verify that rc is Downloading, rb is Default files = self.controller.get_model_files() files_dict = {f.name: f for f in files} self.assertEqual(ModelFile.State.DOWNLOADING, files_dict["rc"].state) self.assertEqual(ModelFile.State.DEFAULT, files_dict["rb"].state) listener.file_added.assert_not_called() listener.file_removed.assert_not_called() callback.on_success.assert_called_once_with() callback.on_failure.assert_not_called() @timeout_decorator.timeout(20) def test_command_stop_wrong(self): # White box hack: limit the rate of lftp so download doesn't finish # noinspection PyUnresolvedReferences self.controller = Controller(self.context, self.controller_persist) self.controller.start() # noinspection PyUnresolvedReferences self.controller._Controller__lftp.rate_limit = 100 # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() callback = DummyCommandCallback() callback.on_success = MagicMock() callback.on_failure = MagicMock() # Queue a download self.controller.queue_command(Controller.Command(Controller.Command.Action.QUEUE, "ra")) # Process until download starts while True: self.controller.process() call = listener.file_updated.call_args if call: new_file = call[0][1] self.assertEqual("ra", new_file.name) if new_file.local_size and new_file.local_size > 0: break # Now stop the download with wrong name command = Controller.Command(Controller.Command.Action.STOP, "rb") command.add_callback(callback) self.controller.queue_command(command) # Process until done while callback.on_failure.call_count < 1: self.controller.process() # Verify that downloading is still going call = listener.file_updated.call_args new_file = call[0][1] self.assertEqual("ra", new_file.name) self.assertEqual(ModelFile.State.DOWNLOADING, new_file.state) listener.file_added.assert_not_called() listener.file_removed.assert_not_called() callback.on_success.assert_not_called() self.assertEqual(1, len(callback.on_failure.call_args_list)) error = callback.on_failure.call_args[0][0] self.assertEqual("File 'rb' is not Queued or Downloading", error) @timeout_decorator.timeout(20) def test_command_stop_invalid(self): # White box hack: limit the rate of lftp so download doesn't finish # noinspection PyUnresolvedReferences self.controller = Controller(self.context, self.controller_persist) self.controller.start() # noinspection PyUnresolvedReferences self.controller._Controller__lftp.rate_limit = 100 # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() callback = DummyCommandCallback() callback.on_success = MagicMock() callback.on_failure = MagicMock() # Queue a download self.controller.queue_command(Controller.Command(Controller.Command.Action.QUEUE, "ra")) # Process until download starts while True: self.controller.process() call = listener.file_updated.call_args if call: new_file = call[0][1] self.assertEqual("ra", new_file.name) if new_file.local_size and new_file.local_size > 0: break # Now stop the download with wrong name command = Controller.Command(Controller.Command.Action.STOP, "invalidfile") command.add_callback(callback) self.controller.queue_command(command) # Process until done while callback.on_failure.call_count < 1: self.controller.process() # Verify that downloading is still going call = listener.file_updated.call_args new_file = call[0][1] self.assertEqual("ra", new_file.name) self.assertEqual(ModelFile.State.DOWNLOADING, new_file.state) listener.file_added.assert_not_called() listener.file_removed.assert_not_called() callback.on_success.assert_not_called() self.assertEqual(1, len(callback.on_failure.call_args_list)) error = callback.on_failure.call_args[0][0] self.assertEqual("File 'invalidfile' not found", error) @timeout_decorator.timeout(20) def test_command_extract_after_downloading_remote_file(self): self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() callback = DummyCommandCallback() callback.on_success = MagicMock() callback.on_failure = MagicMock() # Queue a download command = Controller.Command(Controller.Command.Action.QUEUE, "re.rar") command.add_callback(callback) self.controller.queue_command(command) # Process until download complete while True: self.controller.process() call = listener.file_updated.call_args if call: new_file = call[0][1] self.assertEqual("re.rar", new_file.name) if new_file.state == ModelFile.State.DOWNLOADED: break callback.on_success.assert_called_once_with() callback.on_failure.assert_not_called() callback.on_success.reset_mock() # Queue an extraction command = Controller.Command(Controller.Command.Action.EXTRACT, "re.rar") command.add_callback(callback) self.controller.queue_command(command) # Process until extract complete while True: self.controller.process() call = listener.file_updated.call_args if call: new_file = call[0][1] self.assertEqual("re.rar", new_file.name) if new_file.state == ModelFile.State.EXTRACTED: break callback.on_success.assert_called_once_with() callback.on_failure.assert_not_called() # Verify re_txt_path = os.path.join(TestController.temp_dir, "local", "re.rar.txt") self.assertTrue(os.path.isfile(re_txt_path)) with open(re_txt_path, "r") as f: self.assertEqual("re.rar", f.read()) @timeout_decorator.timeout(20) def test_command_extract_after_downloading_remote_directory(self): self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() callback = DummyCommandCallback() callback.on_success = MagicMock() callback.on_failure = MagicMock() # Queue a download command = Controller.Command(Controller.Command.Action.QUEUE, "rd") command.add_callback(callback) self.controller.queue_command(command) # Process until download complete while True: self.controller.process() call = listener.file_updated.call_args if call: new_file = call[0][1] self.assertEqual("rd", new_file.name) if new_file.state == ModelFile.State.DOWNLOADED: break callback.on_success.assert_called_once_with() callback.on_failure.assert_not_called() callback.on_success.reset_mock() # Queue an extraction command = Controller.Command(Controller.Command.Action.EXTRACT, "rd") command.add_callback(callback) self.controller.queue_command(command) # Process until extract complete while True: self.controller.process() call = listener.file_updated.call_args if call: new_file = call[0][1] self.assertEqual("rd", new_file.name) if new_file.state == ModelFile.State.EXTRACTED: break callback.on_success.assert_called_once_with() callback.on_failure.assert_not_called() # Verify rd_txt_path = os.path.join(TestController.temp_dir, "local", "rd", "rd.zip.txt") self.assertTrue(os.path.isfile(rd_txt_path)) with open(rd_txt_path, "r") as f: self.assertEqual("rd.zip", f.read()) @timeout_decorator.timeout(20) def test_command_extract_after_downloading_remote_directory_multilevel(self): self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() callback = DummyCommandCallback() callback.on_success = MagicMock() callback.on_failure = MagicMock() # Queue a download command = Controller.Command(Controller.Command.Action.QUEUE, "rf") command.add_callback(callback) self.controller.queue_command(command) # Process until download complete while True: self.controller.process() call = listener.file_updated.call_args if call: new_file = call[0][1] self.assertEqual("rf", new_file.name) if new_file.state == ModelFile.State.DOWNLOADED: break callback.on_success.assert_called_once_with() callback.on_failure.assert_not_called() callback.on_success.reset_mock() # Queue an extraction command = Controller.Command(Controller.Command.Action.EXTRACT, "rf") command.add_callback(callback) self.controller.queue_command(command) # Process until extract complete while True: self.controller.process() call = listener.file_updated.call_args if call: new_file = call[0][1] self.assertEqual("rf", new_file.name) if new_file.state == ModelFile.State.EXTRACTED: break callback.on_success.assert_called_once_with() callback.on_failure.assert_not_called() # Verify rfa_txt_path = os.path.join(TestController.temp_dir, "local", "rf", "rfa", "rfa.zip.txt") self.assertTrue(os.path.isfile(rfa_txt_path)) with open(rfa_txt_path, "r") as f: self.assertEqual("rfa.zip", f.read()) rfb_txt_path = os.path.join(TestController.temp_dir, "local", "rf", "rfb", "rfb.zip.txt") self.assertTrue(os.path.isfile(rfb_txt_path)) with open(rfb_txt_path, "r") as f: self.assertEqual("rfb.zip", f.read()) @timeout_decorator.timeout(20) def test_command_extract_local_directory(self): self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() callback = DummyCommandCallback() callback.on_success = MagicMock() callback.on_failure = MagicMock() # Queue an extraction command = Controller.Command(Controller.Command.Action.EXTRACT, "lc") command.add_callback(callback) self.controller.queue_command(command) # Process until extract complete # Can't rely on state changes since final state is back to Default # Look for presence of extracted files lca_txt_path = os.path.join(TestController.temp_dir, "local", "lc", "lca.rar.txt") lcb_txt_path = os.path.join(TestController.temp_dir, "local", "lc", "lcb.zip.txt") while True: self.controller.process() if os.path.isfile(lca_txt_path) and os.path.isfile(lcb_txt_path): break callback.on_success.assert_called_once_with() callback.on_failure.assert_not_called() # Verify with open(lca_txt_path, "r") as f: self.assertEqual("lca.rar", f.read()) with open(lcb_txt_path, "r") as f: self.assertEqual("lcb.zip", f.read()) @timeout_decorator.timeout(20) def test_command_reextract_after_extracting_remote_file(self): self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() callback = DummyCommandCallback() callback.on_success = MagicMock() callback.on_failure = MagicMock() # Queue a download command = Controller.Command(Controller.Command.Action.QUEUE, "re.rar") command.add_callback(callback) self.controller.queue_command(command) # Process until download complete while True: self.controller.process() call = listener.file_updated.call_args if call: new_file = call[0][1] self.assertEqual("re.rar", new_file.name) if new_file.state == ModelFile.State.DOWNLOADED: break callback.on_success.assert_called_once_with() callback.on_failure.assert_not_called() callback.on_success.reset_mock() # Queue an extraction command = Controller.Command(Controller.Command.Action.EXTRACT, "re.rar") command.add_callback(callback) self.controller.queue_command(command) # Process until extract complete while True: self.controller.process() call = listener.file_updated.call_args if call: new_file = call[0][1] self.assertEqual("re.rar", new_file.name) if new_file.state == ModelFile.State.EXTRACTED: break callback.on_success.assert_called_once_with() callback.on_success.reset_mock() callback.on_failure.assert_not_called() # Verify re_txt_path = os.path.join(TestController.temp_dir, "local", "re.rar.txt") self.assertTrue(os.path.isfile(re_txt_path)) with open(re_txt_path, "r") as f: self.assertEqual("re.rar", f.read()) # Delete the extracted file os.remove(re_txt_path) self.assertFalse(os.path.isfile(re_txt_path)) # Queue a re-extraction command = Controller.Command(Controller.Command.Action.EXTRACT, "re.rar") command.add_callback(callback) self.controller.queue_command(command) # Process until extract complete # Can't rely on state changes since final state is back to Extracted # Look for presence of extracted file while True: self.controller.process() if os.path.isfile(re_txt_path): break # Verify again self.assertTrue(os.path.isfile(re_txt_path)) with open(re_txt_path, "r") as f: self.assertEqual("re.rar", f.read()) @timeout_decorator.timeout(20) def test_command_extract_remote_only_fails(self): self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() callback = DummyCommandCallback() callback.on_success = MagicMock() callback.on_failure = MagicMock() # Verify that rc is Default files = self.controller.get_model_files() files_dict = {f.name: f for f in files} self.assertEqual(ModelFile.State.DEFAULT, files_dict["re.rar"].state) # Queue an extraction command = Controller.Command(Controller.Command.Action.EXTRACT, "re.rar") command.add_callback(callback) self.controller.queue_command(command) self.controller.process() # Verify nothing happened listener.file_updated.assert_not_called() listener.file_added.assert_not_called() listener.file_removed.assert_not_called() callback.on_success.assert_not_called() self.assertEqual(1, len(callback.on_failure.call_args_list)) error = callback.on_failure.call_args[0][0] self.assertEqual("File 're.rar' does not exist locally", error) @timeout_decorator.timeout(20) def test_command_extract_after_downloading_remote_directory_to_separate_path(self): # Change the extract path extract_path = os.path.join(TestController.temp_dir, "extract") os.mkdir(extract_path) self.context.config.controller.extract_path = extract_path self.context.config.controller.use_local_path_as_extract_path = False self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() callback = DummyCommandCallback() callback.on_success = MagicMock() callback.on_failure = MagicMock() # Queue a download command = Controller.Command(Controller.Command.Action.QUEUE, "rd") command.add_callback(callback) self.controller.queue_command(command) # Process until download complete while True: self.controller.process() call = listener.file_updated.call_args if call: new_file = call[0][1] self.assertEqual("rd", new_file.name) if new_file.state == ModelFile.State.DOWNLOADED: break callback.on_success.assert_called_once_with() callback.on_failure.assert_not_called() callback.on_success.reset_mock() # Queue an extraction command = Controller.Command(Controller.Command.Action.EXTRACT, "rd") command.add_callback(callback) self.controller.queue_command(command) # Process until extract complete while True: self.controller.process() call = listener.file_updated.call_args if call: new_file = call[0][1] self.assertEqual("rd", new_file.name) if new_file.state == ModelFile.State.EXTRACTED: break callback.on_success.assert_called_once_with() callback.on_failure.assert_not_called() # Verify rd_txt_path = os.path.join(extract_path, "rd", "rd.zip.txt") self.assertTrue(os.path.isfile(rd_txt_path)) with open(rd_txt_path, "r") as f: self.assertEqual("rd.zip", f.read()) @timeout_decorator.timeout(20) def test_command_redownload_after_deleting_extracted_file(self): """ File is downloaded, then extracted, then deleted, then redownloaded Verify that final state is Downloaded and NOT Extracted """ self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() callback = DummyCommandCallback() callback.on_success = MagicMock() callback.on_failure = MagicMock() # Queue a download command = Controller.Command(Controller.Command.Action.QUEUE, "rd") command.add_callback(callback) self.controller.queue_command(command) # Process until download complete while True: self.controller.process() call = listener.file_updated.call_args if call: new_file = call[0][1] self.assertEqual("rd", new_file.name) if new_file.state == ModelFile.State.DOWNLOADED: break callback.on_success.assert_called_once_with() callback.on_failure.assert_not_called() callback.on_success.reset_mock() # Queue an extraction command = Controller.Command(Controller.Command.Action.EXTRACT, "rd") command.add_callback(callback) self.controller.queue_command(command) # Process until extract complete while True: self.controller.process() call = listener.file_updated.call_args if call: new_file = call[0][1] self.assertEqual("rd", new_file.name) if new_file.state == ModelFile.State.EXTRACTED: break callback.on_success.assert_called_once_with() callback.on_success.reset_mock() callback.on_failure.assert_not_called() # Verify re_txt_path = os.path.join(TestController.temp_dir, "local", "rd", "rd.zip.txt") self.assertTrue(os.path.isfile(re_txt_path)) with open(re_txt_path, "r") as f: self.assertEqual("rd.zip", f.read()) # Delete the whole thing shutil.rmtree(os.path.join(TestController.temp_dir, "local", "rd")) # Process until deleted state while True: self.controller.process() call = listener.file_updated.call_args if call: new_file = call[0][1] self.assertEqual("rd", new_file.name) if new_file.state == ModelFile.State.DELETED: break # Queue the download AGAIN command = Controller.Command(Controller.Command.Action.QUEUE, "rd") command.add_callback(callback) self.controller.queue_command(command) # Process until download complete while True: self.controller.process() call = listener.file_updated.call_args if call: new_file = call[0][1] self.assertEqual("rd", new_file.name) # EXTRACTED is wrong, but we check for that later on if new_file.state == ModelFile.State.DOWNLOADED or \ new_file.state == ModelFile.State.EXTRACTED: break callback.on_success.assert_called_once_with() callback.on_failure.assert_not_called() callback.on_success.reset_mock() # Verify file is in DOWNLOADED state files = self.controller.get_model_files() files_dict = {f.name: f for f in files} self.assertEqual(ModelFile.State.DOWNLOADED, files_dict["rd"].state) @timeout_decorator.timeout(20) def test_config_num_max_parallel_downloads(self): self.context.config.lftp.num_max_parallel_downloads = 2 self.controller = Controller(self.context, ControllerPersist()) self.controller.start() # White box hack: limit the rate of lftp so download doesn't finish # noinspection PyUnresolvedReferences self.controller._Controller__lftp.rate_limit = 100 # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() # Queue 3 downloads self.controller.queue_command(Controller.Command(Controller.Command.Action.QUEUE, "ra")) self.controller.queue_command(Controller.Command(Controller.Command.Action.QUEUE, "rb")) self.controller.queue_command(Controller.Command(Controller.Command.Action.QUEUE, "rc")) # Process until 2 downloads starts ra_downloading = False rb_downloading = False # noinspection PyUnusedLocal def updated_side_effect(old_file: ModelFile, new_file: ModelFile): nonlocal ra_downloading, rb_downloading if new_file.local_size and new_file.local_size > 0: if new_file.name == "ra": ra_downloading = True elif new_file.name == "rb": rb_downloading = True return listener.file_updated.side_effect = updated_side_effect while True: self.controller.process() if ra_downloading and rb_downloading: break # Verify that ra, rb is Downloading, rc is Queued files = self.controller.get_model_files() files_dict = {f.name: f for f in files} self.assertEqual(ModelFile.State.DOWNLOADING, files_dict["ra"].state) self.assertEqual(ModelFile.State.DOWNLOADING, files_dict["rb"].state) self.assertEqual(ModelFile.State.QUEUED, files_dict["rc"].state) @timeout_decorator.timeout(20) def test_downloading_scan(self): # Test that downloading scan is independent of local scan # Set a very large local scan interval and verify that downloading # updates are still propagated self.context.config.controller.interval_ms_downloading_scan = 200 self.context.config.controller.interval_ms_local_scan = 10000 self.controller = Controller(self.context, ControllerPersist()) self.controller.start() # White box hack: limit the rate of lftp so download doesn't finish # noinspection PyUnresolvedReferences self.controller._Controller__lftp.rate_limit = 100 # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() # Queue a download self.controller.queue_command(Controller.Command(Controller.Command.Action.QUEUE, "ra")) # Process until the downloads starts ra_downloading = False # noinspection PyUnusedLocal def updated_side_effect(old_file: ModelFile, new_file: ModelFile): nonlocal ra_downloading if new_file.local_size and new_file.local_size > 0: if new_file.name == "ra": ra_downloading = True return listener.file_updated.side_effect = updated_side_effect while True: self.controller.process() if ra_downloading: break # Verify that ra is Downloading files = self.controller.get_model_files() files_dict = {f.name: f for f in files} self.assertEqual(ModelFile.State.DOWNLOADING, files_dict["ra"].state) @timeout_decorator.timeout(20) def test_persist_downloaded(self): self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() # Verify empty download state self.assertEqual(0, len(self.controller_persist.downloaded_file_names)) # Download rc self.controller.queue_command(Controller.Command(Controller.Command.Action.QUEUE, "rc")) # Process until the downloads starts rc_downloaded = False # noinspection PyUnusedLocal def updated_side_effect(old_file: ModelFile, new_file: ModelFile): nonlocal rc_downloaded if new_file.state == ModelFile.State.DOWNLOADED and new_file.name == "rc": rc_downloaded = True return listener.file_updated.side_effect = updated_side_effect while True: self.controller.process() if rc_downloaded: break self.assertTrue(rc_downloaded) # Verify downloaded state was persisted self.assertTrue("rc" in self.controller_persist.downloaded_file_names) @timeout_decorator.timeout(20) def test_redownload_deleted_file(self): # Test that a previously downloaded then deleted file can be redownloaded # We set the downloaded state in controller persist self.controller_persist.downloaded_file_names.add("ra") self.controller = Controller(self.context, self.controller_persist) self.controller.start() # White box hack: limit the rate of lftp so download doesn't finish # noinspection PyUnresolvedReferences self.controller._Controller__lftp.rate_limit = 100 # wait for initial scan self.__wait_for_initial_model() # Verify that ra is marked as Deleted self.controller.process() files = self.controller.get_model_files() files_dict = {f.name: f for f in files} self.assertEqual(ModelFile.State.DELETED, files_dict["ra"].state) # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() # Queue a download self.controller.queue_command(Controller.Command(Controller.Command.Action.QUEUE, "ra")) # Process until the downloads starts ra_downloading = False # noinspection PyUnusedLocal def updated_side_effect(old_file: ModelFile, new_file: ModelFile): nonlocal ra_downloading if new_file.local_size and new_file.local_size > 0: if new_file.name == "ra": ra_downloading = True return listener.file_updated.side_effect = updated_side_effect while True: self.controller.process() if ra_downloading: break # Verify that ra is Downloading files = self.controller.get_model_files() files_dict = {f.name: f for f in files} self.assertEqual(ModelFile.State.DOWNLOADING, files_dict["ra"].state) @timeout_decorator.timeout(20) def test_command_delete_local_file(self): self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() callback = DummyCommandCallback() callback.on_success = MagicMock() callback.on_failure = MagicMock() file_path = os.path.join(TestController.temp_dir, "local", "lb") self.assertTrue(os.path.isfile(file_path)) # Send delete command command = Controller.Command(Controller.Command.Action.DELETE_LOCAL, "lb") command.add_callback(callback) self.controller.queue_command(command) # Process until file is removed from model while True: self.controller.process() call = listener.file_removed.call_args if call: file = call[0][0] self.assertEqual("lb", file.name) break callback.on_success.assert_called_once_with() callback.on_failure.assert_not_called() self.assertFalse(os.path.exists(file_path)) @timeout_decorator.timeout(20) def test_command_delete_local_dir(self): self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() callback = DummyCommandCallback() callback.on_success = MagicMock() callback.on_failure = MagicMock() file_path = os.path.join(TestController.temp_dir, "local", "la") self.assertTrue(os.path.isdir(file_path)) # Send delete command command = Controller.Command(Controller.Command.Action.DELETE_LOCAL, "la") command.add_callback(callback) self.controller.queue_command(command) # Process until file is removed from model while True: self.controller.process() call = listener.file_removed.call_args if call: file = call[0][0] self.assertEqual("la", file.name) break callback.on_success.assert_called_once_with() callback.on_failure.assert_not_called() self.assertFalse(os.path.exists(file_path)) @timeout_decorator.timeout(20) def test_command_delete_remote_dir(self): self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() callback = DummyCommandCallback() callback.on_success = MagicMock() callback.on_failure = MagicMock() file_path = os.path.join(TestController.temp_dir, "remote", "ra") self.assertTrue(os.path.isdir(file_path)) # Send delete command command = Controller.Command(Controller.Command.Action.DELETE_REMOTE, "ra") command.add_callback(callback) self.controller.queue_command(command) # Process until file is removed from model while True: self.controller.process() call = listener.file_removed.call_args if call: file = call[0][0] self.assertEqual("ra", file.name) break callback.on_success.assert_called_once_with() callback.on_failure.assert_not_called() self.assertFalse(os.path.exists(file_path)) @timeout_decorator.timeout(20) def test_command_delete_local_fails_on_remote_file(self): self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() callback = DummyCommandCallback() callback.on_success = MagicMock() callback.on_failure = MagicMock() file_path = os.path.join(TestController.temp_dir, "remote", "ra") self.assertTrue(os.path.isdir(file_path)) # Send delete command command = Controller.Command(Controller.Command.Action.DELETE_LOCAL, "ra") command.add_callback(callback) self.controller.queue_command(command) self.controller.process() # Verify nothing happened listener.file_updated.assert_not_called() listener.file_added.assert_not_called() listener.file_removed.assert_not_called() callback.on_success.assert_not_called() self.assertEqual(1, len(callback.on_failure.call_args_list)) error = callback.on_failure.call_args[0][0] self.assertEqual("File 'ra' does not exist locally", error) self.assertTrue(os.path.isdir(file_path)) @timeout_decorator.timeout(20) def test_command_delete_remote_fails_on_local_file(self): self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() callback = DummyCommandCallback() callback.on_success = MagicMock() callback.on_failure = MagicMock() file_path = os.path.join(TestController.temp_dir, "local", "la") self.assertTrue(os.path.isdir(file_path)) # Send delete command command = Controller.Command(Controller.Command.Action.DELETE_REMOTE, "la") command.add_callback(callback) self.controller.queue_command(command) self.controller.process() # Verify nothing happened listener.file_updated.assert_not_called() listener.file_added.assert_not_called() listener.file_removed.assert_not_called() callback.on_success.assert_not_called() self.assertEqual(1, len(callback.on_failure.call_args_list)) error = callback.on_failure.call_args[0][0] self.assertEqual("File 'la' does not exist remotely", error) self.assertTrue(os.path.isdir(file_path)) @timeout_decorator.timeout(20) def test_command_delete_remote_forces_immediate_rescan(self): # Test that after a remote delete a remote scan is immediately done # Test this by simply setting the remote scan interval to a really large value # that would timeout the test if it wasn't forced self.context.config.controller.interval_ms_remote_scan = 90000 self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() callback = DummyCommandCallback() callback.on_success = MagicMock() callback.on_failure = MagicMock() file_path = os.path.join(TestController.temp_dir, "remote", "ra") self.assertTrue(os.path.isdir(file_path)) # Send delete command command = Controller.Command(Controller.Command.Action.DELETE_REMOTE, "ra") command.add_callback(callback) self.controller.queue_command(command) # Process until file is removed from model while True: self.controller.process() call = listener.file_removed.call_args if call: file = call[0][0] self.assertEqual("ra", file.name) break callback.on_success.assert_called_once_with() callback.on_failure.assert_not_called() self.assertFalse(os.path.exists(file_path)) @timeout_decorator.timeout(20) def test_command_delete_local_forces_immediate_rescan(self): # Test that after a local delete a local scan is immediately done # Test this by simply setting the local scan interval to a really large value # that would timeout the test if it wasn't forced self.context.config.controller.interval_ms_local_scan = 90000 self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() callback = DummyCommandCallback() callback.on_success = MagicMock() callback.on_failure = MagicMock() file_path = os.path.join(TestController.temp_dir, "local", "la") self.assertTrue(os.path.isdir(file_path)) # Send delete command command = Controller.Command(Controller.Command.Action.DELETE_LOCAL, "la") command.add_callback(callback) self.controller.queue_command(command) # Process until file is removed from model while True: self.controller.process() call = listener.file_removed.call_args if call: file = call[0][0] self.assertEqual("la", file.name) break callback.on_success.assert_called_once_with() callback.on_failure.assert_not_called() self.assertFalse(os.path.exists(file_path)) @timeout_decorator.timeout(20) @unittest.skip def test_download_with_excessive_connections(self): # Note: this test sometimes crashes the dbus # reset with: sudo systemctl restart systemd-logind # Test excessive connections and a large LFTP status output # - large files names to blow up the status # - large max num connections, connections per file # - download many files in parallel def create_large_file(_path, size): f = open(_path, "wb") f.seek(size - 1) f.write(b"\0") f.close() print("File size: ", os.stat(_path).st_size) # Create a bunch of large files that can be downloaded in chunks path = os.path.join(TestController.temp_dir, "remote", "large") local_path = os.path.join(TestController.temp_dir, "local", "large") os.mkdir(path) a_path = os.path.join(path, "a"*200 + ".txt") create_large_file(a_path, 20*1024*1024) b_path = os.path.join(path, "b"*200 + ".txt") create_large_file(b_path, 20*1024*1024) c_path = os.path.join(path, "c"*200 + ".txt") create_large_file(c_path, 20*1024*1024) d_path = os.path.join(path, "d"*200 + ".txt") create_large_file(d_path, 20*1024*1024) e_path = os.path.join(path, "e"*200 + ".txt") create_large_file(e_path, 20*1024*1024) f_path = os.path.join(path, "f"*200 + ".txt") create_large_file(f_path, 20*1024*1024) g_path = os.path.join(path, "g"*200 + ".txt") create_large_file(g_path, 20*1024*1024) h_path = os.path.join(path, "h"*200 + ".txt") create_large_file(h_path, 20*1024*1024) # White box hack: limit the rate of lftp so download doesn't finish # also set min-chunk size to a small value for lots of connections self.context.config.lftp.num_max_total_connections = 20 self.context.config.lftp.num_max_connections_per_dir_file = 20 self.context.config.lftp.num_max_parallel_files_per_download = 8 # noinspection PyUnresolvedReferences self.controller = Controller(self.context, self.controller_persist) self.controller.start() # noinspection PyUnresolvedReferences self.controller._Controller__lftp.rate_limit = 5*1024 # noinspection PyUnresolvedReferences self.controller._Controller__lftp.min_chunk_size = "10" # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() callback = DummyCommandCallback() callback.on_success = MagicMock() callback.on_failure = MagicMock() # Queue command = Controller.Command(Controller.Command.Action.QUEUE, "large") command.add_callback(callback) self.controller.queue_command(command) # Process until download starts while True: self.controller.process() call = listener.file_updated.call_args if call: new_file = call[0][1] if new_file.name == "large" and new_file.state == ModelFile.State.DOWNLOADING: break # Wait for a bit so we start getting large statuses start_time = datetime.now() elapsed_secs = 0 while elapsed_secs < 5: print("Elapsed secs: ", elapsed_secs) self.controller.process() elapsed_secs = (datetime.now()-start_time).total_seconds() # Verify that download is still ongoing files = self.controller.get_model_files() files_dict = {f.name: f for f in files} self.assertEqual(ModelFile.State.DOWNLOADING, files_dict["large"].state) # Stop the download self.controller.queue_command(Controller.Command(Controller.Command.Action.STOP, "large")) self.controller.process() # Process until download stops while True: self.controller.process() call = listener.file_updated.call_args if call: new_file = call[0][1] self.assertEqual("large", new_file.name) if new_file.state == ModelFile.State.DEFAULT: break # Verify that download is stopped files = self.controller.get_model_files() files_dict = {f.name: f for f in files} self.assertEqual(ModelFile.State.DEFAULT, files_dict["large"].state) # Remove the files shutil.rmtree(path) shutil.rmtree(local_path) @timeout_decorator.timeout(20) def test_password_auth(self): # Test password-based auth by downloading a file to completion self.context.config.lftp.use_ssh_key = False self.controller = Controller(self.context, self.controller_persist) self.controller.start() # wait for initial scan self.__wait_for_initial_model() # Ignore the initial state listener = DummyListener() self.controller.add_model_listener(listener) self.controller.process() # Setup mock listener.file_added = MagicMock() listener.file_updated = MagicMock() listener.file_removed = MagicMock() callback = DummyCommandCallback() callback.on_success = MagicMock() callback.on_failure = MagicMock() # Queue a download command = Controller.Command(Controller.Command.Action.QUEUE, "rc") command.add_callback(callback) self.controller.queue_command(command) # Process until done while True: self.controller.process() call = listener.file_updated.call_args if call: new_file = call[0][1] self.assertEqual("rc", new_file.name) if new_file.local_size == 10*1024: break # Verify listener.file_added.assert_not_called() listener.file_removed.assert_not_called() callback.on_success.assert_called_once_with() callback.on_failure.assert_not_called() fcmp = cmp(os.path.join(TestController.temp_dir, "remote", "rc"), os.path.join(TestController.temp_dir, "local", "rc")) self.assertTrue(fcmp) ================================================ FILE: src/python/tests/integration/test_controller/test_extract/__init__.py ================================================ ================================================ FILE: src/python/tests/integration/test_controller/test_extract/test_extract.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest import shutil import tempfile import os import subprocess import zipfile from common import overrides from controller.extract import Extract, ExtractError class TestExtract(unittest.TestCase): temp_root = None temp_dir = None ar_zip = None ar_rar = None ar_rar_split_p1 = None ar_rar_split_p2 = None ar_tar_gz = None __FILE_CONTENT = "12345678"*10*1024 # 80 KB # For debugging __KEEP_TMP_FILES = False @classmethod def setUpClass(cls): TestExtract.temp_root = tempfile.mkdtemp(prefix="test_extract_") # Create a temp file to archive temp_file = os.path.join(TestExtract.temp_root, "file") with open(temp_file, "w") as f: f.write(TestExtract.__FILE_CONTENT) # Create archives archive_dir = os.path.join(TestExtract.temp_root, "archives") os.mkdir(archive_dir) # zip TestExtract.ar_zip = os.path.join(archive_dir, "file.zip") zf = zipfile.ZipFile(TestExtract.ar_zip, "w", zipfile.ZIP_DEFLATED) zf.write(temp_file, os.path.basename(temp_file)) zf.close() # rar fnull = open(os.devnull, 'w') TestExtract.ar_rar = os.path.join(archive_dir, "file.rar") subprocess.Popen(["rar", "a", "-ep", TestExtract.ar_rar, temp_file], stdout=fnull) # rar split subprocess.Popen(["rar", "a", "-ep", "-m0", "-v50k", os.path.join(archive_dir, "file.split.rar"), temp_file], stdout=fnull) TestExtract.ar_rar_split_p1 = os.path.join(archive_dir, "file.split.part1.rar") TestExtract.ar_rar_split_p2 = os.path.join(archive_dir, "file.split.part2.rar") # tar.gz TestExtract.ar_tar_gz = os.path.join(archive_dir, "file.tar.gz") subprocess.Popen(["tar", "czvf", TestExtract.ar_tar_gz, "-C", os.path.dirname(temp_file), os.path.basename(temp_file)]) @classmethod def tearDownClass(cls): # Cleanup if not TestExtract.__KEEP_TMP_FILES: shutil.rmtree(TestExtract.temp_root) @overrides(unittest.TestCase) def setUp(self): TestExtract.temp_dir = os.path.join(TestExtract.temp_root, "tmp") os.mkdir(TestExtract.temp_dir) @overrides(unittest.TestCase) def tearDown(self): if not TestExtract.__KEEP_TMP_FILES: shutil.rmtree(TestExtract.temp_dir) def _assert_extracted_files(self, dir_path): path = os.path.join(dir_path, "file") self.assertTrue(os.path.isfile(path)) with open(path, "r") as f: self.assertEqual(TestExtract.__FILE_CONTENT, f.read()) def test_is_archive_fast(self): self.assertTrue(Extract.is_archive_fast("a.zip")) self.assertTrue(Extract.is_archive_fast("b.rar")) self.assertTrue(Extract.is_archive_fast("c.bz2")) self.assertTrue(Extract.is_archive_fast("d.tar.gz")) self.assertTrue(Extract.is_archive_fast("e.7z")) self.assertFalse(Extract.is_archive_fast("a")) self.assertFalse(Extract.is_archive_fast("a.b")) self.assertFalse(Extract.is_archive_fast(".b")) self.assertFalse(Extract.is_archive_fast(".zip")) self.assertFalse(Extract.is_archive_fast("")) self.assertFalse(Extract.is_archive_fast("7")) self.assertFalse(Extract.is_archive_fast("z")) def test_is_archive_fast_works_with_full_paths(self): self.assertTrue(Extract.is_archive_fast("/full/path/a.zip")) self.assertFalse(Extract.is_archive_fast("/full/path/a")) self.assertFalse(Extract.is_archive_fast("/full/path/.zip")) def test_is_archive_false_on_nonexisting_file(self): self.assertFalse(Extract.is_archive(os.path.join(TestExtract.temp_dir, "no_file"))) def test_is_archive_false_on_dir(self): path = os.path.join(TestExtract.temp_dir, "dir") os.mkdir(path) self.assertTrue(os.path.isdir(path)) self.assertFalse(Extract.is_archive(path)) def test_is_archive_false_on_bad_archive(self): path = os.path.join(TestExtract.temp_dir, "bad_file") with open(path, 'wb') as f: f.write(bytearray(os.urandom(100))) self.assertTrue(os.path.isfile(path)) self.assertFalse(Extract.is_archive(path)) def test_is_archive_zip(self): self.assertTrue(Extract.is_archive(TestExtract.ar_zip)) def test_is_archive_rar(self): self.assertTrue(Extract.is_archive(TestExtract.ar_rar)) def test_is_archive_rar_split(self): self.assertTrue(Extract.is_archive(TestExtract.ar_rar_split_p1)) self.assertTrue(Extract.is_archive(TestExtract.ar_rar_split_p2)) def test_is_archive_tar_gz(self): self.assertTrue(Extract.is_archive(TestExtract.ar_tar_gz)) def test_extract_archive_fails_on_nonexisting_file(self): with self.assertRaises(ExtractError) as ctx: Extract.extract_archive(archive_path=os.path.join(TestExtract.temp_dir, "no_file"), out_dir_path=TestExtract.temp_dir) self.assertTrue(str(ctx.exception).startswith("Path is not a valid archive")) def test_extract_archive_fails_on_dir(self): with self.assertRaises(ExtractError) as ctx: Extract.extract_archive(archive_path=TestExtract.temp_dir, out_dir_path=TestExtract.temp_dir) self.assertTrue(str(ctx.exception).startswith("Path is not a valid archive")) def test_extract_archive_fails_on_bad_file(self): path = os.path.join(TestExtract.temp_dir, "bad_file") with open(path, 'wb') as f: f.write(bytearray(os.urandom(100))) self.assertTrue(os.path.isfile(path)) with self.assertRaises(ExtractError) as ctx: Extract.extract_archive(archive_path=path, out_dir_path=TestExtract.temp_dir) self.assertTrue(str(ctx.exception).startswith("Path is not a valid archive")) def test_extract_archive_creates_sub_directories(self): out_path = os.path.join(TestExtract.temp_dir, "bunch", "of", "sub", "dir") Extract.extract_archive(archive_path=TestExtract.ar_rar, out_dir_path=out_path) self._assert_extracted_files(out_path) def test_extract_archive_zip(self): Extract.extract_archive(archive_path=TestExtract.ar_zip, out_dir_path=TestExtract.temp_dir) self._assert_extracted_files(TestExtract.temp_dir) def test_extract_archive_overwrites_existing(self): path = os.path.join(TestExtract.temp_dir, "file") with open(path, "w") as f: f.write("Dummy file") Extract.extract_archive(archive_path=TestExtract.ar_zip, out_dir_path=TestExtract.temp_dir) self._assert_extracted_files(TestExtract.temp_dir) def test_extract_archive_rar(self): Extract.extract_archive(archive_path=TestExtract.ar_rar, out_dir_path=TestExtract.temp_dir) self._assert_extracted_files(TestExtract.temp_dir) def test_extract_archive_rar_split(self): Extract.extract_archive(archive_path=TestExtract.ar_rar_split_p1, out_dir_path=TestExtract.temp_dir) self._assert_extracted_files(TestExtract.temp_dir) def test_extract_archive_tar_gz(self): Extract.extract_archive(archive_path=TestExtract.ar_tar_gz, out_dir_path=TestExtract.temp_dir) self._assert_extracted_files(TestExtract.temp_dir) ================================================ FILE: src/python/tests/integration/test_lftp/__init__.py ================================================ ================================================ FILE: src/python/tests/integration/test_lftp/test_lftp.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import logging import os import shutil import sys import tempfile import unittest from filecmp import dircmp import timeout_decorator from tests.utils import TestUtils from lftp import Lftp class TestLftp(unittest.TestCase): temp_dir = None @classmethod def setUpClass(cls): # Create a temp directory TestLftp.temp_dir = tempfile.mkdtemp(prefix="test_lftp_") # Allow group access for the seedsynctest account TestUtils.chmod_from_to(TestLftp.temp_dir, tempfile.gettempdir(), 0o775) @classmethod def tearDownClass(cls): # Cleanup shutil.rmtree(TestLftp.temp_dir) def setUp(self): os.mkdir(os.path.join(TestLftp.temp_dir, "remote")) os.mkdir(os.path.join(TestLftp.temp_dir, "local")) # Note: seedsynctest account must be set up. See DeveloperReadme.md for details self.lftp = Lftp(address="localhost", port=22, user="seedsynctest", password=None) self.lftp.set_base_remote_dir_path(os.path.join(TestLftp.temp_dir, "remote")) self.lftp.set_base_local_dir_path(os.path.join(TestLftp.temp_dir, "local")) logger = logging.getLogger() logger.setLevel(logging.DEBUG) handler = logging.StreamHandler(sys.stdout) formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") handler.setFormatter(formatter) logger.addHandler(handler) self.lftp.set_base_logger(logger) # Verbose logging self.lftp.set_verbose_logging(True) def tearDown(self): self.lftp.exit() shutil.rmtree(os.path.join(TestLftp.temp_dir, "remote")) shutil.rmtree(os.path.join(TestLftp.temp_dir, "local")) # noinspection PyMethodMayBeStatic def my_mkdir(self, *args): os.mkdir(os.path.join(TestLftp.temp_dir, "remote", *args)) # noinspection PyMethodMayBeStatic def my_touch(self, size: int, *args): path = os.path.join(TestLftp.temp_dir, "remote", *args) with open(path, 'wb') as f: f.write(bytearray(os.urandom(size))) def assert_local_equals_remote(self): dcmp = dircmp(os.path.join(TestLftp.temp_dir, "remote"), os.path.join(TestLftp.temp_dir, "local")) self.assertFalse(dcmp.left_only) self.assertFalse(dcmp.right_only) self.assertFalse(dcmp.diff_files) @timeout_decorator.timeout(5) def test_download_1(self): """File names with single quotes""" self.lftp.num_parallel_jobs = 2 self.lftp.rate_limit = 300 self.my_mkdir("aaa'aaa") self.my_touch(128, "aaa'aaa", "aa'aa'aa.txt") self.my_touch(256, "b''b''b.txt") self.my_mkdir("c'c'c'c") self.my_touch(100, "c'c'c'c", "c'''c.txt") self.my_touch(200, "d'''d.txt") self.lftp.queue("aaa'aaa", True) self.lftp.queue("b''b''b.txt", False) self.lftp.queue("c'c'c'c", True) self.lftp.queue("d'''d.txt", False) # Wait until all downloads are done while self.lftp.status(): pass self.assert_local_equals_remote() @timeout_decorator.timeout(5) def test_download_2(self): """File names with double quotes""" self.lftp.num_parallel_jobs = 2 self.lftp.rate_limit = 300 self.my_mkdir("aaa\"aaa") self.my_touch(128, "aaa\"aaa", "aa\"aa\"aa.txt") self.my_touch(256, "b\"\"b\"\"b.txt") self.my_mkdir("c\"c\"c\"c") self.my_touch(100, "c\"c\"c\"c", "c\"\"\"c.txt") self.my_touch(200, "d\"\"\"d.txt") self.lftp.queue("aaa\"aaa", True) self.lftp.queue("b\"\"b\"\"b.txt", False) self.lftp.queue("c\"c\"c\"c", True) self.lftp.queue("d\"\"\"d.txt", False) # Wait until all downloads are done while self.lftp.status(): pass self.assert_local_equals_remote() @timeout_decorator.timeout(5) def test_download_3(self): """File names with quotes and spaces""" self.lftp.num_parallel_jobs = 2 self.lftp.rate_limit = 300 self.my_mkdir("a' aa\"aaa") self.my_touch(128, "a' aa\"aaa", "aa\"a ' a\"aa.txt") self.my_touch(256, "\"b ' \"b\" ' \"b.txt") self.my_mkdir("'c\" c \" 'c' \"c\"") self.my_touch(100, "'c\" c \" 'c' \"c\"", "c' \" ' \" ' \"c.txt") self.my_touch(200, "d\" ' \" ' \"d.txt") self.lftp.queue("a' aa\"aaa", True) self.lftp.queue("\"b ' \"b\" ' \"b.txt", False) self.lftp.queue("'c\" c \" 'c' \"c\"", True) self.lftp.queue("d\" ' \" ' \"d.txt", False) # Wait until all downloads are done while self.lftp.status(): pass self.assert_local_equals_remote() @timeout_decorator.timeout(5) def test_download_4(self): """File names with ' -o '""" self.lftp.num_parallel_jobs = 2 self.lftp.rate_limit = 300 self.my_mkdir("a -o a") self.my_touch(128, "a -o a", "a -o a.txt") self.my_touch(256, "b -o b.txt") self.my_mkdir("c -o c") self.my_touch(100, "c -o c", "c -o c.txt") self.my_touch(200, "d -o d.txt") self.lftp.queue("a -o a", True) self.lftp.queue("b -o b.txt", False) self.lftp.queue("c -o c", True) self.lftp.queue("d -o d.txt", False) # Wait until all downloads are done while self.lftp.status(): pass self.assert_local_equals_remote() ================================================ FILE: src/python/tests/integration/test_web/__init__.py ================================================ ================================================ FILE: src/python/tests/integration/test_web/test_handler/__init__.py ================================================ ================================================ FILE: src/python/tests/integration/test_web/test_handler/test_auto_queue.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import json from urllib.parse import quote from controller import AutoQueuePattern from tests.integration.test_web.test_web_app import BaseTestWebApp class TestAutoQueueHandler(BaseTestWebApp): def test_get(self): self.auto_queue_persist.add_pattern(AutoQueuePattern(pattern="one")) self.auto_queue_persist.add_pattern(AutoQueuePattern(pattern="t wo")) self.auto_queue_persist.add_pattern(AutoQueuePattern(pattern="thr'ee")) self.auto_queue_persist.add_pattern(AutoQueuePattern(pattern="fo\"ur")) self.auto_queue_persist.add_pattern(AutoQueuePattern(pattern="fi%ve")) resp = self.test_app.get("/server/autoqueue/get") self.assertEqual(200, resp.status_int) json_list = json.loads(str(resp.html)) self.assertEqual(5, len(json_list)) self.assertIn({"pattern": "one"}, json_list) self.assertIn({"pattern": "t wo"}, json_list) self.assertIn({"pattern": "thr'ee"}, json_list) self.assertIn({"pattern": "fo\"ur"}, json_list) self.assertIn({"pattern": "fi%ve"}, json_list) def test_get_is_ordered(self): self.auto_queue_persist.add_pattern(AutoQueuePattern(pattern="a")) self.auto_queue_persist.add_pattern(AutoQueuePattern(pattern="b")) self.auto_queue_persist.add_pattern(AutoQueuePattern(pattern="c")) self.auto_queue_persist.add_pattern(AutoQueuePattern(pattern="d")) self.auto_queue_persist.add_pattern(AutoQueuePattern(pattern="e")) resp = self.test_app.get("/server/autoqueue/get") self.assertEqual(200, resp.status_int) json_list = json.loads(str(resp.html)) self.assertEqual(5, len(json_list)) self.assertEqual([ {"pattern": "a"}, {"pattern": "b"}, {"pattern": "c"}, {"pattern": "d"}, {"pattern": "e"} ], json_list) def test_add_good(self): resp = self.test_app.get("/server/autoqueue/add/one") self.assertEqual(200, resp.status_int) self.assertEqual(1, len(self.auto_queue_persist.patterns)) self.assertIn(AutoQueuePattern("one"), self.auto_queue_persist.patterns) uri = quote(quote("/value/with/slashes", safe=""), safe="") resp = self.test_app.get("/server/autoqueue/add/" + uri) self.assertEqual(200, resp.status_int) self.assertEqual(2, len(self.auto_queue_persist.patterns)) self.assertIn(AutoQueuePattern("/value/with/slashes"), self.auto_queue_persist.patterns) uri = quote(quote(" value with spaces", safe=""), safe="") resp = self.test_app.get("/server/autoqueue/add/" + uri) self.assertEqual(200, resp.status_int) self.assertEqual(3, len(self.auto_queue_persist.patterns)) self.assertIn(AutoQueuePattern(" value with spaces"), self.auto_queue_persist.patterns) uri = quote(quote("value'with'singlequote", safe=""), safe="") resp = self.test_app.get("/server/autoqueue/add/" + uri) self.assertEqual(200, resp.status_int) self.assertEqual(4, len(self.auto_queue_persist.patterns)) self.assertIn(AutoQueuePattern("value'with'singlequote"), self.auto_queue_persist.patterns) uri = quote(quote("value\"with\"doublequote", safe=""), safe="") resp = self.test_app.get("/server/autoqueue/add/" + uri) self.assertEqual(200, resp.status_int) self.assertEqual(5, len(self.auto_queue_persist.patterns)) self.assertIn(AutoQueuePattern("value\"with\"doublequote"), self.auto_queue_persist.patterns) def test_add_double(self): resp = self.test_app.get("/server/autoqueue/add/one") self.assertEqual(200, resp.status_int) resp = self.test_app.get("/server/autoqueue/add/one", expect_errors=True) self.assertEqual(400, resp.status_int) self.assertEqual("Auto-queue pattern 'one' already exists.", str(resp.html)) def test_add_empty_value(self): uri = quote(quote(" ", safe=""), safe="") resp = self.test_app.get("/server/autoqueue/add/" + uri, expect_errors=True) self.assertEqual(400, resp.status_int) self.assertEqual(0, len(self.auto_queue_persist.patterns)) resp = self.test_app.get("/server/autoqueue/add/", expect_errors=True) self.assertEqual(404, resp.status_int) self.assertEqual(0, len(self.auto_queue_persist.patterns)) def test_remove_good(self): self.auto_queue_persist.add_pattern(AutoQueuePattern("one")) self.auto_queue_persist.add_pattern(AutoQueuePattern("/value/with/slashes")) self.auto_queue_persist.add_pattern(AutoQueuePattern(" value with spaces")) self.auto_queue_persist.add_pattern(AutoQueuePattern("value'with'singlequote")) self.auto_queue_persist.add_pattern(AutoQueuePattern("value\"with\"doublequote")) resp = self.test_app.get("/server/autoqueue/remove/one") self.assertEqual(200, resp.status_int) self.assertEqual(4, len(self.auto_queue_persist.patterns)) self.assertNotIn(AutoQueuePattern("one"), self.auto_queue_persist.patterns) uri = quote(quote("/value/with/slashes", safe=""), safe="") resp = self.test_app.get("/server/autoqueue/remove/" + uri) self.assertEqual(200, resp.status_int) self.assertEqual(3, len(self.auto_queue_persist.patterns)) self.assertNotIn(AutoQueuePattern("/value/with/slashes"), self.auto_queue_persist.patterns) uri = quote(quote(" value with spaces", safe=""), safe="") resp = self.test_app.get("/server/autoqueue/remove/" + uri) self.assertEqual(200, resp.status_int) self.assertEqual(2, len(self.auto_queue_persist.patterns)) self.assertNotIn(AutoQueuePattern(" value with spaces"), self.auto_queue_persist.patterns) uri = quote(quote("value'with'singlequote", safe=""), safe="") resp = self.test_app.get("/server/autoqueue/remove/" + uri) self.assertEqual(200, resp.status_int) self.assertEqual(1, len(self.auto_queue_persist.patterns)) self.assertNotIn(AutoQueuePattern("value'with'singlequote"), self.auto_queue_persist.patterns) uri = quote(quote("value\"with\"doublequote", safe=""), safe="") resp = self.test_app.get("/server/autoqueue/remove/" + uri) self.assertEqual(200, resp.status_int) self.assertEqual(0, len(self.auto_queue_persist.patterns)) self.assertNotIn(AutoQueuePattern("value\"with\"doublequote"), self.auto_queue_persist.patterns) def test_remove_non_existing(self): resp = self.test_app.get("/server/autoqueue/remove/one", expect_errors=True) self.assertEqual(400, resp.status_int) self.assertEqual("Auto-queue pattern 'one' doesn't exist.", str(resp.html)) def test_remove_empty_value(self): uri = quote(quote(" ", safe=""), safe="") resp = self.test_app.get("/server/autoqueue/remove/" + uri, expect_errors=True) self.assertEqual(400, resp.status_int) self.assertEqual("Auto-queue pattern ' ' doesn't exist.", str(resp.html)) self.assertEqual(0, len(self.auto_queue_persist.patterns)) resp = self.test_app.get("/server/autoqueue/remove/", expect_errors=True) self.assertEqual(404, resp.status_int) self.assertEqual(0, len(self.auto_queue_persist.patterns)) ================================================ FILE: src/python/tests/integration/test_web/test_handler/test_config.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import json from urllib.parse import quote from tests.integration.test_web.test_web_app import BaseTestWebApp class TestConfigHandler(BaseTestWebApp): def test_get(self): self.context.config.general.debug = True self.context.config.lftp.remote_path = "/remote/server/path" self.context.config.controller.interval_ms_local_scan = 5678 self.context.config.web.port = 8080 resp = self.test_app.get("/server/config/get") self.assertEqual(200, resp.status_int) json_dict = json.loads(str(resp.html)) self.assertEqual(True, json_dict["general"]["debug"]) self.assertEqual("/remote/server/path", json_dict["lftp"]["remote_path"]) self.assertEqual(5678, json_dict["controller"]["interval_ms_local_scan"]) self.assertEqual(8080, json_dict["web"]["port"]) def test_set_good(self): self.assertEqual(None, self.context.config.general.debug) resp = self.test_app.get("/server/config/set/general/debug/True") self.assertEqual(200, resp.status_int) self.assertEqual(True, self.context.config.general.debug) self.assertEqual(None, self.context.config.lftp.remote_path) uri = quote(quote("/path/to/somewhere", safe=""), safe="") resp = self.test_app.get("/server/config/set/lftp/remote_path/" + uri) self.assertEqual(200, resp.status_int) self.assertEqual("/path/to/somewhere", self.context.config.lftp.remote_path) self.assertEqual(None, self.context.config.controller.interval_ms_local_scan) resp = self.test_app.get("/server/config/set/controller/interval_ms_local_scan/5678") self.assertEqual(200, resp.status_int) self.assertEqual(5678, self.context.config.controller.interval_ms_local_scan) self.assertEqual(None, self.context.config.web.port) resp = self.test_app.get("/server/config/set/web/port/8080") self.assertEqual(200, resp.status_int) self.assertEqual(8080, self.context.config.web.port) def test_set_missing_section(self): self.assertFalse(self.context.config.has_section("bad_section")) resp = self.test_app.get("/server/config/set/bad_section/option/value", expect_errors=True) self.assertEqual(400, resp.status_int) self.assertEqual("There is no section 'bad_section' in config", str(resp.html)) self.assertFalse(self.context.config.has_section("bad_section")) def test_set_missing_option(self): self.assertFalse(self.context.config.general.has_property("bad_option")) resp = self.test_app.get("/server/config/set/general/bad_option/value", expect_errors=True) self.assertEqual(400, resp.status_int) self.assertEqual("Section 'general' in config has no option 'bad_option'", str(resp.html)) self.assertFalse(self.context.config.general.has_property("bad_option")) def test_set_bad_value(self): # boolean self.assertEqual(None, self.context.config.general.debug) resp = self.test_app.get("/server/config/set/general/debug/cat", expect_errors=True) self.assertEqual(400, resp.status_int) self.assertEqual("Bad config: General.debug (cat) must be a boolean value", str(resp.html)) self.assertEqual(None, self.context.config.general.debug) # positive int self.assertEqual(None, self.context.config.controller.interval_ms_local_scan) resp = self.test_app.get("/server/config/set/controller/interval_ms_local_scan/-1", expect_errors=True) self.assertEqual(400, resp.status_int) self.assertEqual("Bad config: Controller.interval_ms_local_scan (-1) must be greater than 0", str(resp.html)) self.assertEqual(None, self.context.config.controller.interval_ms_local_scan) def test_set_empty_value(self): self.assertEqual(None, self.context.config.lftp.remote_path) resp = self.test_app.get("/server/config/set/lftp/remote_path/", expect_errors=True) self.assertEqual(404, resp.status_int) self.assertEqual(None, self.context.config.lftp.remote_path) self.assertEqual(None, self.context.config.lftp.remote_path) resp = self.test_app.get("/server/config/set/lftp/remote_path/%20%20", expect_errors=True) self.assertEqual(400, resp.status_int) self.assertEqual("Bad config: Lftp.remote_path is empty", str(resp.html)) self.assertEqual(None, self.context.config.lftp.remote_path) ================================================ FILE: src/python/tests/integration/test_web/test_handler/test_controller.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from unittest.mock import MagicMock from urllib.parse import quote from tests.integration.test_web.test_web_app import BaseTestWebApp from controller import Controller class TestControllerHandler(BaseTestWebApp): def test_queue(self): def side_effect(cmd: Controller.Command): cmd.callbacks[0].on_success() self.controller.queue_command = MagicMock() self.controller.queue_command.side_effect = side_effect print(self.test_app.get("/server/command/queue/test1")) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.QUEUE, command.action) self.assertEqual("test1", command.filename) uri = quote(quote("/value/with/slashes", safe=""), safe="") print(self.test_app.get("/server/command/queue/"+uri)) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.QUEUE, command.action) self.assertEqual("/value/with/slashes", command.filename) uri = quote(quote(" value with spaces", safe=""), safe="") print(self.test_app.get("/server/command/queue/"+uri)) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.QUEUE, command.action) self.assertEqual(" value with spaces", command.filename) uri = quote(quote("value'with'singlequote", safe=""), safe="") print(self.test_app.get("/server/command/queue/"+uri)) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.QUEUE, command.action) self.assertEqual("value'with'singlequote", command.filename) uri = quote(quote("value\"with\"doublequote", safe=""), safe="") print(self.test_app.get("/server/command/queue/"+uri)) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.QUEUE, command.action) self.assertEqual("value\"with\"doublequote", command.filename) def test_stop(self): def side_effect(cmd: Controller.Command): cmd.callbacks[0].on_success() self.controller.queue_command = MagicMock() self.controller.queue_command.side_effect = side_effect print(self.test_app.get("/server/command/stop/test1")) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.STOP, command.action) self.assertEqual("test1", command.filename) uri = quote(quote("/value/with/slashes", safe=""), safe="") print(self.test_app.get("/server/command/stop/"+uri)) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.STOP, command.action) self.assertEqual("/value/with/slashes", command.filename) uri = quote(quote(" value with spaces", safe=""), safe="") print(self.test_app.get("/server/command/stop/"+uri)) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.STOP, command.action) self.assertEqual(" value with spaces", command.filename) uri = quote(quote("value'with'singlequote", safe=""), safe="") print(self.test_app.get("/server/command/stop/"+uri)) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.STOP, command.action) self.assertEqual("value'with'singlequote", command.filename) uri = quote(quote("value\"with\"doublequote", safe=""), safe="") print(self.test_app.get("/server/command/stop/"+uri)) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.STOP, command.action) self.assertEqual("value\"with\"doublequote", command.filename) def test_extract(self): def side_effect(cmd: Controller.Command): cmd.callbacks[0].on_success() self.controller.queue_command = MagicMock() self.controller.queue_command.side_effect = side_effect print(self.test_app.get("/server/command/extract/test1")) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.EXTRACT, command.action) self.assertEqual("test1", command.filename) uri = quote(quote("/value/with/slashes", safe=""), safe="") print(self.test_app.get("/server/command/extract/"+uri)) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.EXTRACT, command.action) self.assertEqual("/value/with/slashes", command.filename) uri = quote(quote(" value with spaces", safe=""), safe="") print(self.test_app.get("/server/command/extract/"+uri)) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.EXTRACT, command.action) self.assertEqual(" value with spaces", command.filename) uri = quote(quote("value'with'singlequote", safe=""), safe="") print(self.test_app.get("/server/command/extract/"+uri)) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.EXTRACT, command.action) self.assertEqual("value'with'singlequote", command.filename) uri = quote(quote("value\"with\"doublequote", safe=""), safe="") print(self.test_app.get("/server/command/extract/"+uri)) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.EXTRACT, command.action) self.assertEqual("value\"with\"doublequote", command.filename) def test_delete_local(self): def side_effect(cmd: Controller.Command): cmd.callbacks[0].on_success() self.controller.queue_command = MagicMock() self.controller.queue_command.side_effect = side_effect print(self.test_app.get("/server/command/delete_local/test1")) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.DELETE_LOCAL, command.action) self.assertEqual("test1", command.filename) uri = quote(quote("/value/with/slashes", safe=""), safe="") print(self.test_app.get("/server/command/delete_local/"+uri)) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.DELETE_LOCAL, command.action) self.assertEqual("/value/with/slashes", command.filename) uri = quote(quote(" value with spaces", safe=""), safe="") print(self.test_app.get("/server/command/delete_local/"+uri)) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.DELETE_LOCAL, command.action) self.assertEqual(" value with spaces", command.filename) uri = quote(quote("value'with'singlequote", safe=""), safe="") print(self.test_app.get("/server/command/delete_local/"+uri)) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.DELETE_LOCAL, command.action) self.assertEqual("value'with'singlequote", command.filename) uri = quote(quote("value\"with\"doublequote", safe=""), safe="") print(self.test_app.get("/server/command/delete_local/"+uri)) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.DELETE_LOCAL, command.action) self.assertEqual("value\"with\"doublequote", command.filename) def test_delete_remote(self): def side_effect(cmd: Controller.Command): cmd.callbacks[0].on_success() self.controller.queue_command = MagicMock() self.controller.queue_command.side_effect = side_effect print(self.test_app.get("/server/command/delete_remote/test1")) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.DELETE_REMOTE, command.action) self.assertEqual("test1", command.filename) uri = quote(quote("/value/with/slashes", safe=""), safe="") print(self.test_app.get("/server/command/delete_remote/"+uri)) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.DELETE_REMOTE, command.action) self.assertEqual("/value/with/slashes", command.filename) uri = quote(quote(" value with spaces", safe=""), safe="") print(self.test_app.get("/server/command/delete_remote/"+uri)) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.DELETE_REMOTE, command.action) self.assertEqual(" value with spaces", command.filename) uri = quote(quote("value'with'singlequote", safe=""), safe="") print(self.test_app.get("/server/command/delete_remote/"+uri)) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.DELETE_REMOTE, command.action) self.assertEqual("value'with'singlequote", command.filename) uri = quote(quote("value\"with\"doublequote", safe=""), safe="") print(self.test_app.get("/server/command/delete_remote/"+uri)) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.DELETE_REMOTE, command.action) self.assertEqual("value\"with\"doublequote", command.filename) ================================================ FILE: src/python/tests/integration/test_web/test_handler/test_server.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from tests.integration.test_web.test_web_app import BaseTestWebApp class TestServerHandler(BaseTestWebApp): def test_restart(self): self.assertFalse(self.web_app_builder.server_handler.is_restart_requested()) print(self.test_app.get("/server/command/restart")) self.assertTrue(self.web_app_builder.server_handler.is_restart_requested()) print(self.test_app.get("/server/command/restart")) self.assertTrue(self.web_app_builder.server_handler.is_restart_requested()) ================================================ FILE: src/python/tests/integration/test_web/test_handler/test_status.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import json from tests.integration.test_web.test_web_app import BaseTestWebApp class TestStatusHandler(BaseTestWebApp): def test_status(self): resp = self.test_app.get("/server/status") self.assertEqual(200, resp.status_int) json_dict = json.loads(str(resp.html)) self.assertEqual(True, json_dict["server"]["up"]) ================================================ FILE: src/python/tests/integration/test_web/test_handler/test_stream_log.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import logging from unittest.mock import patch from threading import Timer from tests.integration.test_web.test_web_app import BaseTestWebApp class TestLogStreamHandler(BaseTestWebApp): @patch("web.handler.stream_log.SerializeLogRecord") def test_stream_log_serializes_record(self, mock_serialize_log_record_cls): # Schedule server stop Timer(0.5, self.web_app.stop).start() # Schedule status update def issue_logs(): self.context.logger.debug("Debug msg") self.context.logger.info("Info msg") self.context.logger.warning("Warning msg") self.context.logger.error("Error msg") Timer(0.3, issue_logs).start() # Setup mock serialize instance mock_serialize = mock_serialize_log_record_cls.return_value mock_serialize.record.return_value = "\n" self.test_app.get("/server/stream") self.assertEqual(4, len(mock_serialize.record.call_args_list)) call1, call2, call3, call4 = mock_serialize.record.call_args_list record1 = call1[0][0] self.assertEqual("Debug msg", record1.msg) self.assertEqual(logging.DEBUG, record1.levelno) record2 = call2[0][0] self.assertEqual("Info msg", record2.msg) self.assertEqual(logging.INFO, record2.levelno) record3 = call3[0][0] self.assertEqual("Warning msg", record3.msg) self.assertEqual(logging.WARNING, record3.levelno) record4 = call4[0][0] self.assertEqual("Error msg", record4.msg) self.assertEqual(logging.ERROR, record4.levelno) ================================================ FILE: src/python/tests/integration/test_web/test_handler/test_stream_model.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest from unittest.mock import MagicMock, patch from threading import Timer from tests.integration.test_web.test_web_app import BaseTestWebApp from web.serialize import SerializeModel from model import ModelFile class TestModelStreamHandler(BaseTestWebApp): def test_stream_model_fetches_model_and_adds_listener(self): # Schedule server stop Timer(0.5, self.web_app.stop).start() self.test_app.get("/server/stream") self.controller.get_model_files_and_add_listener.assert_called_once_with(unittest.mock.ANY) def test_stream_model_removes_listener(self): # Schedule server stop Timer(0.5, self.web_app.stop).start() self.test_app.get("/server/stream") self.controller.remove_model_listener.assert_called_once_with(self.model_listener) @patch("web.handler.stream_model.SerializeModel") def test_stream_model_serializes_initial_model(self, mock_serialize_model_cls): # Schedule server stop Timer(0.5, self.web_app.stop).start() # Setup mock serialize instance mock_serialize = mock_serialize_model_cls.return_value mock_serialize.model.return_value = "\n" # Initial model self.model_files = [ModelFile("a", True), ModelFile("b", False)] self.test_app.get("/server/stream") mock_serialize.model.assert_called_once_with([ModelFile("a", True), ModelFile("b", False)]) @patch("web.handler.stream_model.SerializeModel") def test_stream_model_serializes_updates(self, mock_serialize_model_cls): # Schedule server stop Timer(2.0, self.web_app.stop).start() # Setup mock serialize instance mock_serialize = mock_serialize_model_cls.return_value mock_serialize.model.return_value = "\n" mock_serialize.update_event.return_value = "\n" # Use the real UpdateEvent class mock_serialize_model_cls.UpdateEvent = SerializeModel.UpdateEvent # Queue updates added_file = ModelFile("a", True) removed_file = ModelFile("b", False) old_file = ModelFile("c", False) old_file.local_size = 100 new_file = ModelFile("c", False) new_file.local_size = 200 def send_updates(): self.assertIsNotNone(self.model_listener) self.model_listener.file_added(added_file) self.model_listener.file_removed(removed_file) self.model_listener.file_updated(old_file, new_file) Timer(0.5, send_updates).start() self.test_app.get("/server/stream") self.assertEqual(3, len(mock_serialize.update_event.call_args_list)) call1, call2, call3 = mock_serialize.update_event.call_args_list self.assertEqual(SerializeModel.UpdateEvent.Change.ADDED, call1[0][0].change) self.assertEqual(None, call1[0][0].old_file) self.assertEqual(added_file, call1[0][0].new_file) self.assertEqual(SerializeModel.UpdateEvent.Change.REMOVED, call2[0][0].change) self.assertEqual(removed_file, call2[0][0].old_file) self.assertEqual(None, call2[0][0].new_file) self.assertEqual(SerializeModel.UpdateEvent.Change.UPDATED, call3[0][0].change) self.assertEqual(old_file, call3[0][0].old_file) self.assertEqual(new_file, call3[0][0].new_file) ================================================ FILE: src/python/tests/integration/test_web/test_handler/test_stream_status.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from unittest.mock import patch from threading import Timer from tests.integration.test_web.test_web_app import BaseTestWebApp class TestStatusStreamHandler(BaseTestWebApp): @patch("web.handler.stream_status.SerializeStatus") def test_stream_status_serializes_initial_status(self, mock_serialize_status_cls): # Schedule server stop Timer(0.5, self.web_app.stop).start() # Setup mock serialize instance mock_serialize = mock_serialize_status_cls.return_value mock_serialize.status.return_value = "\n" self.test_app.get("/server/stream") self.assertEqual(1, len(mock_serialize.status.call_args_list)) call1 = mock_serialize.status.call_args_list[0] status = call1[0][0] self.assertEqual(True, status.server.up) self.assertEqual(None, status.server.error_msg) @patch("web.handler.stream_status.SerializeStatus") def test_stream_status_serializes_new_status(self, mock_serialize_status_cls): # Schedule server stop Timer(0.5, self.web_app.stop).start() # Schedule status update def update_status(): self.context.status.server.up = False self.context.status.server.error_msg = "Something bad happened" Timer(0.3, update_status).start() # Setup mock serialize instance mock_serialize = mock_serialize_status_cls.return_value mock_serialize.status.return_value = "\n" self.test_app.get("/server/stream") self.assertEqual(3, len(mock_serialize.status.call_args_list)) call1, call2, call3 = mock_serialize.status.call_args_list status1 = call1[0][0] self.assertEqual(True, status1.server.up) self.assertEqual(None, status1.server.error_msg) status2 = call2[0][0] self.assertEqual(False, status2.server.up) self.assertEqual(None, status2.server.error_msg) status3 = call3[0][0] self.assertEqual(False, status3.server.up) self.assertEqual("Something bad happened", status3.server.error_msg) ================================================ FILE: src/python/tests/integration/test_web/test_web_app.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest from unittest.mock import MagicMock import logging import sys from webtest import TestApp from common import overrides, Status, Config from controller import AutoQueuePersist from web import WebAppBuilder class BaseTestWebApp(unittest.TestCase): """ Base class for testing web app Sets up the web app with mocks """ @overrides(unittest.TestCase) def setUp(self): self.context = MagicMock() self.controller = MagicMock() # Mock the base logger logger = logging.getLogger() handler = logging.StreamHandler(sys.stdout) logger.addHandler(handler) logger.setLevel(logging.DEBUG) formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") handler.setFormatter(formatter) self.context.logger = logger # Model files self.model_files = [] # Real status self.context.status = Status() # Real config self.context.config = Config() # Real auto-queue persist self.auto_queue_persist = AutoQueuePersist() # Capture the model listener def capture_listener(listener): self.model_listener = listener return self.model_files self.model_listener = None self.controller.get_model_files_and_add_listener = MagicMock() self.controller.get_model_files_and_add_listener.side_effect = capture_listener self.controller.remove_model_listener = MagicMock() # noinspection PyTypeChecker self.web_app_builder = WebAppBuilder(self.context, self.controller, self.auto_queue_persist) self.web_app = self.web_app_builder.build() self.test_app = TestApp(self.web_app) class TestWebApp(BaseTestWebApp): def test_process(self): self.web_app.process() ================================================ FILE: src/python/tests/unittests/__init__.py ================================================ ================================================ FILE: src/python/tests/unittests/test_common/__init__.py ================================================ ================================================ FILE: src/python/tests/unittests/test_common/test_app_process.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest import logging import sys import time from multiprocessing import Value import threading import timeout_decorator from common import AppProcess, AppOneShotProcess class DummyException(Exception): pass class DummyProcess(AppProcess): def __init__(self, fail: bool): super().__init__(name=self.__class__.__name__) self.fail = fail self.time = Value('i', 0) self.last_loop_time = Value('i', -1) self.last_init_time = Value('i', -1) self.last_cleanup_time = Value('i', -1) def run_loop(self): self.last_loop_time.value = self.time.value self.time.value += 1 if self.fail: raise DummyException() def run_init(self): self.last_init_time.value = self.time.value self.time.value += 1 def run_cleanup(self): self.last_cleanup_time.value = self.time.value self.time.value += 1 class LongRunningProcess(AppProcess): def __init__(self): super().__init__(name=self.__class__.__name__) def run_init(self): pass def run_loop(self): while True: pass def run_cleanup(self): pass class LongRunningThreadProcess(AppProcess): def __init__(self): super().__init__(name=self.__class__.__name__) self.thread = threading.Thread(target=self.long_task) def run_init(self): self.thread.start() def run_loop(self): pass def run_cleanup(self): pass # noinspection PyMethodMayBeStatic def long_task(self): print("Thread task started") while True: pass class DummyOneShotProcess(AppOneShotProcess): def __init__(self): super().__init__(name=self.__class__.__name__) self.time = Value('i', 0) def run_once(self): self.time.value += 1 class OneShotLongRunningProcess(AppOneShotProcess): def __init__(self): super().__init__(name=self.__class__.__name__) def run_once(self): while True: pass class TestAppProcess(unittest.TestCase): def setUp(self): logger = logging.getLogger() handler = logging.StreamHandler(sys.stdout) logger.addHandler(handler) logger.setLevel(logging.DEBUG) formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") handler.setFormatter(formatter) # Assign process to this variable so that it can be cleaned up # even after an error self.process = None def tearDown(self): if self.process: self.process.terminate() @timeout_decorator.timeout(2) def test_exception_propagates(self): self.process = DummyProcess(fail=True) self.process.start() time.sleep(0.2) with self.assertRaises(DummyException): self.process.propagate_exception() @timeout_decorator.timeout(2) def test_process_terminates(self): self.process = DummyProcess(fail=False) self.process.start() self.process.terminate() self.process.join() self.process = None @timeout_decorator.timeout(2) def test_init_called_before_loop(self): self.process = DummyProcess(fail=False) self.process.start() time.sleep(0.2) self.assertGreater(self.process.last_init_time.value, -1) self.assertGreater(self.process.last_loop_time.value, -1) self.assertGreater(self.process.last_loop_time.value, self.process.last_init_time.value) @timeout_decorator.timeout(2) def test_cleanup_called_after_loop(self): self.process = DummyProcess(fail=False) self.process.start() time.sleep(0.2) self.process.terminate() self.process.join() self.assertGreater(self.process.last_cleanup_time.value, -1) self.assertGreater(self.process.last_loop_time.value, -1) self.assertLess(self.process.last_loop_time.value, self.process.last_cleanup_time.value) self.process = None @timeout_decorator.timeout(5) def test_long_running_process_is_force_terminated(self): self.process = LongRunningProcess() self.process.start() time.sleep(0.2) self.process.terminate() self.process.join() self.process = None @timeout_decorator.timeout(5) def test_process_with_long_running_thread_terminates_properly(self): self.process = LongRunningThreadProcess() self.process.start() time.sleep(0.2) self.process.terminate() self.process.join() self.process = None class TestAppOneShotProcess(unittest.TestCase): def setUp(self): logger = logging.getLogger() handler = logging.StreamHandler(sys.stdout) logger.addHandler(handler) logger.setLevel(logging.DEBUG) formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") handler.setFormatter(formatter) # Assign process to this variable so that it can be cleaned up # even after an error self.process = None def tearDown(self): if self.process: self.process.terminate() @timeout_decorator.timeout(2) def test_run_once_called_once(self): self.process = DummyOneShotProcess() self.process.start() time.sleep(0.2) self.assertEqual(self.process.time.value, 1) @timeout_decorator.timeout(5) def test_long_running_process_is_force_terminated(self): self.process = OneShotLongRunningProcess() self.process.start() time.sleep(0.2) self.process.terminate() self.process.join() self.process = None ================================================ FILE: src/python/tests/unittests/test_common/test_config.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest import os import tempfile from common import Config, ConfigError, PersistError from common.config import InnerConfig, Checkers, Converters class TestConverters(unittest.TestCase): def test_int(self): self.assertEqual(0, Converters.int(None, "", "0")) self.assertEqual(1, Converters.int(None, "", "1")) self.assertEqual(-1, Converters.int(None, "", "-1")) self.assertEqual(5000, Converters.int(None, "", "5000")) self.assertEqual(-5000, Converters.int(None, "", "-5000")) with self.assertRaises(ConfigError) as e: Converters.int(TestConverters, "bad", "") self.assertEqual("Bad config: TestConverters.bad is empty", str(e.exception)) with self.assertRaises(ConfigError) as e: Converters.int(TestConverters, "bad", "3.14") self.assertEqual("Bad config: TestConverters.bad (3.14) must be an integer value", str(e.exception)) with self.assertRaises(ConfigError) as e: Converters.int(TestConverters, "bad", "cat") self.assertEqual("Bad config: TestConverters.bad (cat) must be an integer value", str(e.exception)) def test_bool(self): self.assertEqual(True, Converters.bool(None, "", "True")) self.assertEqual(False, Converters.bool(None, "", "False")) self.assertEqual(True, Converters.bool(None, "", "true")) self.assertEqual(False, Converters.bool(None, "", "false")) self.assertEqual(True, Converters.bool(None, "", "TRUE")) self.assertEqual(False, Converters.bool(None, "", "FALSE")) self.assertEqual(True, Converters.bool(None, "", "1")) self.assertEqual(False, Converters.bool(None, "", "0")) with self.assertRaises(ConfigError) as e: Converters.bool(TestConverters, "bad", "") self.assertEqual("Bad config: TestConverters.bad is empty", str(e.exception)) with self.assertRaises(ConfigError) as e: Converters.bool(TestConverters, "bad", "cat") self.assertEqual("Bad config: TestConverters.bad (cat) must be a boolean value", str(e.exception)) with self.assertRaises(ConfigError) as e: Converters.bool(TestConverters, "bad", "-3.14") self.assertEqual("Bad config: TestConverters.bad (-3.14) must be a boolean value", str(e.exception)) class DummyInnerConfig(InnerConfig): c_prop1 = InnerConfig._create_property("prop1", Checkers.null, Converters.null) a_prop2 = InnerConfig._create_property("prop2", Checkers.null, Converters.null) b_prop3 = InnerConfig._create_property("prop3", Checkers.null, Converters.null) def __init__(self): self.c_prop1 = "1" self.a_prop2 = "2" self.b_prop3 = "3" class DummyInnerConfig2(InnerConfig): prop_int = InnerConfig._create_property("prop_int", Checkers.null, Converters.int) prop_str = InnerConfig._create_property("prop_str", Checkers.string_nonempty, Converters.null) def __init__(self): self.prop_int = None self.prop_str = None class TestInnerConfig(unittest.TestCase): def test_property_order(self): dummy_config = DummyInnerConfig() self.assertEqual(["c_prop1", "a_prop2", "b_prop3"], list(dummy_config.as_dict().keys())) def test_has_property(self): dummy_config = DummyInnerConfig() self.assertTrue(dummy_config.has_property("c_prop1")) self.assertTrue(dummy_config.has_property("a_prop2")) self.assertTrue(dummy_config.has_property("b_prop3")) self.assertFalse(dummy_config.has_property("not_prop")) self.assertFalse(dummy_config.has_property("__init__")) self.assertFalse(dummy_config.has_property("")) def test_checker_is_called(self): dummy_config = DummyInnerConfig2() dummy_config.prop_str = "a string" self.assertEqual("a string", dummy_config.prop_str) with self.assertRaises(ConfigError) as e: dummy_config.prop_str = "" self.assertEqual("Bad config: DummyInnerConfig2.prop_str is empty", str(e.exception)) def test_converter_is_called(self): dummy_config = DummyInnerConfig2.from_dict({"prop_int": "5", "prop_str": "a"}) self.assertEqual(5, dummy_config.prop_int) with self.assertRaises(ConfigError) as e: DummyInnerConfig2.from_dict({"prop_int": "cat", "prop_str": "a"}) self.assertEqual("Bad config: DummyInnerConfig2.prop_int (cat) must be an integer value", str(e.exception)) class TestConfig(unittest.TestCase): def __check_unknown_error(self, cls, good_dict): """ Helper method to check that a config class raises an error on an unknown key :param cls: :param good_dict: :return: """ bad_dict = dict(good_dict) bad_dict["unknown"] = "how did this get here" with self.assertRaises(ConfigError) as error: cls.from_dict(bad_dict) self.assertTrue(str(error.exception).startswith("Unknown config")) def __check_missing_error(self, cls, good_dict, key): """ Helper method to check that a config class raises an error on a missing key :param cls: :param good_dict: :param key: :return: """ bad_dict = dict(good_dict) del bad_dict[key] with self.assertRaises(ConfigError) as error: cls.from_dict(bad_dict) self.assertTrue(str(error.exception).startswith("Missing config")) def __check_empty_error(self, cls, good_dict, key): """ Helper method to check that a config class raises an error on a empty value :param cls: :param good_dict: :param key: :return: """ bad_dict = dict(good_dict) bad_dict[key] = "" with self.assertRaises(ConfigError) as error: cls.from_dict(bad_dict) self.assertTrue(str(error.exception).startswith("Bad config")) bad_dict[key] = " " with self.assertRaises(ConfigError) as error: cls.from_dict(bad_dict) self.assertTrue(str(error.exception).startswith("Bad config")) def check_common(self, cls, good_dict, keys): """ Helper method to run some common checks :param cls: :param good_dict: :param keys: :return: """ # unknown self.__check_unknown_error(cls, good_dict) for key in keys: # missing key self.__check_missing_error(cls, good_dict, key) # empty value self.__check_empty_error(cls, good_dict, key) def check_bad_value_error(self, cls, good_dict, key, value): """ Helper method to check that a config class raises an error on a bad value :param cls: :param good_dict: :param key: :param value: :return: """ bad_dict = dict(good_dict) bad_dict[key] = value with self.assertRaises(ConfigError) as error: cls.from_dict(bad_dict) self.assertTrue(str(error.exception).startswith("Bad config")) def test_has_section(self): config = Config() self.assertTrue(config.has_section("general")) self.assertTrue(config.has_section("lftp")) self.assertTrue(config.has_section("controller")) self.assertTrue(config.has_section("web")) self.assertTrue(config.has_section("autoqueue")) self.assertFalse(config.has_section("nope")) self.assertFalse(config.has_section("from_file")) self.assertFalse(config.has_section("__init__")) def test_general(self): good_dict = { "debug": "True", "verbose": "False", } general = Config.General.from_dict(good_dict) self.assertEqual(True, general.debug) self.assertEqual(False, general.verbose) self.check_common(Config.General, good_dict, { "debug", "verbose" }) # bad values self.check_bad_value_error(Config.General, good_dict, "debug", "SomeString") self.check_bad_value_error(Config.General, good_dict, "debug", "-1") self.check_bad_value_error(Config.General, good_dict, "verbose", "SomeString") self.check_bad_value_error(Config.General, good_dict, "verbose", "-1") def test_lftp(self): good_dict = { "remote_address": "remote.server.com", "remote_username": "remote-user", "remote_password": "password", "remote_port": "3456", "remote_path": "/path/on/remote/server", "local_path": "/path/on/local/server", "remote_path_to_scan_script": "/path/on/remote/server/to/scan/script", "use_ssh_key": "False", "num_max_parallel_downloads": "2", "num_max_parallel_files_per_download": "3", "num_max_connections_per_root_file": "4", "num_max_connections_per_dir_file": "6", "num_max_total_connections": "7", "use_temp_file": "True" } lftp = Config.Lftp.from_dict(good_dict) self.assertEqual("remote.server.com", lftp.remote_address) self.assertEqual("remote-user", lftp.remote_username) self.assertEqual("password", lftp.remote_password) self.assertEqual(3456, lftp.remote_port) self.assertEqual("/path/on/remote/server", lftp.remote_path) self.assertEqual("/path/on/local/server", lftp.local_path) self.assertEqual("/path/on/remote/server/to/scan/script", lftp.remote_path_to_scan_script) self.assertEqual(False, lftp.use_ssh_key) self.assertEqual(2, lftp.num_max_parallel_downloads) self.assertEqual(3, lftp.num_max_parallel_files_per_download) self.assertEqual(4, lftp.num_max_connections_per_root_file) self.assertEqual(6, lftp.num_max_connections_per_dir_file) self.assertEqual(7, lftp.num_max_total_connections) self.assertEqual(True, lftp.use_temp_file) self.check_common(Config.Lftp, good_dict, { "remote_address", "remote_username", "remote_password", "remote_port", "remote_path", "local_path", "remote_path_to_scan_script", "use_ssh_key", "num_max_parallel_downloads", "num_max_parallel_files_per_download", "num_max_connections_per_root_file", "num_max_connections_per_dir_file", "num_max_total_connections", "use_temp_file" }) # bad values self.check_bad_value_error(Config.Lftp, good_dict, "remote_port", "-1") self.check_bad_value_error(Config.Lftp, good_dict, "remote_port", "0") self.check_bad_value_error(Config.Lftp, good_dict, "use_ssh_key", "-1") self.check_bad_value_error(Config.Lftp, good_dict, "use_ssh_key", "SomeString") self.check_bad_value_error(Config.Lftp, good_dict, "num_max_parallel_downloads", "-1") self.check_bad_value_error(Config.Lftp, good_dict, "num_max_parallel_downloads", "0") self.check_bad_value_error(Config.Lftp, good_dict, "num_max_parallel_files_per_download", "-1") self.check_bad_value_error(Config.Lftp, good_dict, "num_max_parallel_files_per_download", "0") self.check_bad_value_error(Config.Lftp, good_dict, "num_max_connections_per_root_file", "-1") self.check_bad_value_error(Config.Lftp, good_dict, "num_max_connections_per_root_file", "0") self.check_bad_value_error(Config.Lftp, good_dict, "num_max_connections_per_dir_file", "-1") self.check_bad_value_error(Config.Lftp, good_dict, "num_max_connections_per_dir_file", "0") self.check_bad_value_error(Config.Lftp, good_dict, "num_max_total_connections", "-1") self.check_bad_value_error(Config.Lftp, good_dict, "use_temp_file", "-1") self.check_bad_value_error(Config.Lftp, good_dict, "use_temp_file", "SomeString") def test_controller(self): good_dict = { "interval_ms_remote_scan": "30000", "interval_ms_local_scan": "10000", "interval_ms_downloading_scan": "2000", "extract_path": "/extract/path", "use_local_path_as_extract_path": "True" } controller = Config.Controller.from_dict(good_dict) self.assertEqual(30000, controller.interval_ms_remote_scan) self.assertEqual(10000, controller.interval_ms_local_scan) self.assertEqual(2000, controller.interval_ms_downloading_scan) self.assertEqual("/extract/path", controller.extract_path) self.assertEqual(True, controller.use_local_path_as_extract_path) self.check_common(Config.Controller, good_dict, { "interval_ms_remote_scan", "interval_ms_local_scan", "interval_ms_downloading_scan", "extract_path", "use_local_path_as_extract_path" }) # bad values self.check_bad_value_error(Config.Controller, good_dict, "interval_ms_remote_scan", "-1") self.check_bad_value_error(Config.Controller, good_dict, "interval_ms_remote_scan", "0") self.check_bad_value_error(Config.Controller, good_dict, "interval_ms_local_scan", "-1") self.check_bad_value_error(Config.Controller, good_dict, "interval_ms_local_scan", "0") self.check_bad_value_error(Config.Controller, good_dict, "interval_ms_downloading_scan", "-1") self.check_bad_value_error(Config.Controller, good_dict, "interval_ms_downloading_scan", "0") self.check_bad_value_error(Config.Controller, good_dict, "use_local_path_as_extract_path", "SomeString") self.check_bad_value_error(Config.Controller, good_dict, "use_local_path_as_extract_path", "-1") def test_web(self): good_dict = { "port": "1234", } web = Config.Web.from_dict(good_dict) self.assertEqual(1234, web.port) self.check_common(Config.Web, good_dict, { "port" }) # bad values self.check_bad_value_error(Config.Web, good_dict, "port", "-1") self.check_bad_value_error(Config.Web, good_dict, "port", "0") def test_autoqueue(self): good_dict = { "enabled": "True", "patterns_only": "False", "auto_extract": "True" } autoqueue = Config.AutoQueue.from_dict(good_dict) self.assertEqual(True, autoqueue.enabled) self.assertEqual(False, autoqueue.patterns_only) self.check_common(Config.AutoQueue, good_dict, { "enabled", "patterns_only", "auto_extract" }) # bad values self.check_bad_value_error(Config.AutoQueue, good_dict, "enabled", "SomeString") self.check_bad_value_error(Config.AutoQueue, good_dict, "enabled", "-1") self.check_bad_value_error(Config.AutoQueue, good_dict, "patterns_only", "SomeString") self.check_bad_value_error(Config.AutoQueue, good_dict, "patterns_only", "-1") self.check_bad_value_error(Config.AutoQueue, good_dict, "auto_extract", "SomeString") self.check_bad_value_error(Config.AutoQueue, good_dict, "auto_extract", "-1") def test_from_file(self): # Create empty config file config_file = open(tempfile.mktemp(suffix="test_config"), "w") config_file.write(""" [General] debug=False verbose=True [Lftp] remote_address=remote.server.com remote_username=remote-user remote_password=remote-pass remote_port = 3456 remote_path=/path/on/remote/server local_path=/path/on/local/server remote_path_to_scan_script=/path/on/remote/server/to/scan/script use_ssh_key=True num_max_parallel_downloads=2 num_max_parallel_files_per_download=3 num_max_connections_per_root_file=4 num_max_connections_per_dir_file=5 num_max_total_connections=7 use_temp_file=False [Controller] interval_ms_remote_scan=30000 interval_ms_local_scan=10000 interval_ms_downloading_scan=2000 extract_path=/path/where/to/extract/stuff use_local_path_as_extract_path=False [Web] port=88 [AutoQueue] enabled=False patterns_only=True auto_extract=True """) config_file.flush() config = Config.from_file(config_file.name) self.assertEqual(False, config.general.debug) self.assertEqual(True, config.general.verbose) self.assertEqual("remote.server.com", config.lftp.remote_address) self.assertEqual("remote-user", config.lftp.remote_username) self.assertEqual("remote-pass", config.lftp.remote_password) self.assertEqual(3456, config.lftp.remote_port) self.assertEqual("/path/on/remote/server", config.lftp.remote_path) self.assertEqual("/path/on/local/server", config.lftp.local_path) self.assertEqual("/path/on/remote/server/to/scan/script", config.lftp.remote_path_to_scan_script) self.assertEqual(True, config.lftp.use_ssh_key) self.assertEqual(2, config.lftp.num_max_parallel_downloads) self.assertEqual(3, config.lftp.num_max_parallel_files_per_download) self.assertEqual(4, config.lftp.num_max_connections_per_root_file) self.assertEqual(5, config.lftp.num_max_connections_per_dir_file) self.assertEqual(7, config.lftp.num_max_total_connections) self.assertEqual(False, config.lftp.use_temp_file) self.assertEqual(30000, config.controller.interval_ms_remote_scan) self.assertEqual(10000, config.controller.interval_ms_local_scan) self.assertEqual(2000, config.controller.interval_ms_downloading_scan) self.assertEqual("/path/where/to/extract/stuff", config.controller.extract_path) self.assertEqual(False, config.controller.use_local_path_as_extract_path) self.assertEqual(88, config.web.port) self.assertEqual(False, config.autoqueue.enabled) self.assertEqual(True, config.autoqueue.patterns_only) self.assertEqual(True, config.autoqueue.auto_extract) # unknown section error config_file.write(""" [Unknown] key=value """) config_file.flush() with self.assertRaises(ConfigError) as error: Config.from_file(config_file.name) self.assertTrue(str(error.exception).startswith("Unknown section")) # Remove config file config_file.close() os.remove(config_file.name) def test_to_file(self): config_file_path = tempfile.mktemp(suffix="test_config") config = Config() config.general.debug = True config.general.verbose = False config.lftp.remote_address = "server.remote.com" config.lftp.remote_username = "user-on-remote-server" config.lftp.remote_password = "pass-on-remote-server" config.lftp.remote_port = 3456 config.lftp.remote_path = "/remote/server/path" config.lftp.local_path = "/local/server/path" config.lftp.remote_path_to_scan_script = "/remote/server/path/to/script" config.lftp.use_ssh_key = True config.lftp.num_max_parallel_downloads = 6 config.lftp.num_max_parallel_files_per_download = 7 config.lftp.num_max_connections_per_root_file = 2 config.lftp.num_max_connections_per_dir_file = 3 config.lftp.num_max_total_connections = 4 config.lftp.use_temp_file = True config.controller.interval_ms_remote_scan = 1234 config.controller.interval_ms_local_scan = 5678 config.controller.interval_ms_downloading_scan = 9012 config.controller.extract_path = "/path/extract/stuff" config.controller.use_local_path_as_extract_path = True config.web.port = 13 config.autoqueue.enabled = True config.autoqueue.patterns_only = True config.autoqueue.auto_extract = False config.to_file(config_file_path) with open(config_file_path, "r") as f: actual_str = f.read() print(actual_str) golden_str = """ [General] debug = True verbose = False [Lftp] remote_address = server.remote.com remote_username = user-on-remote-server remote_password = pass-on-remote-server remote_port = 3456 remote_path = /remote/server/path local_path = /local/server/path remote_path_to_scan_script = /remote/server/path/to/script use_ssh_key = True num_max_parallel_downloads = 6 num_max_parallel_files_per_download = 7 num_max_connections_per_root_file = 2 num_max_connections_per_dir_file = 3 num_max_total_connections = 4 use_temp_file = True [Controller] interval_ms_remote_scan = 1234 interval_ms_local_scan = 5678 interval_ms_downloading_scan = 9012 extract_path = /path/extract/stuff use_local_path_as_extract_path = True [Web] port = 13 [AutoQueue] enabled = True patterns_only = True auto_extract = False """ golden_lines = [s.strip() for s in golden_str.splitlines()] golden_lines = list(filter(None, golden_lines)) # remove blank lines actual_lines = [s.strip() for s in actual_str.splitlines()] actual_lines = list(filter(None, actual_lines)) # remove blank lines self.assertEqual(len(golden_lines), len(actual_lines)) for i, _ in enumerate(golden_lines): self.assertEqual(golden_lines[i], actual_lines[i]) def test_persist_read_error(self): # bad section content = """ [Web port=88 """ with self.assertRaises(PersistError): Config.from_str(content) # bad value content = """ [Web] port88 """ with self.assertRaises(PersistError): Config.from_str(content) # bad line content = """ [Web] port=88 what am i doing here """ with self.assertRaises(PersistError): Config.from_str(content) ================================================ FILE: src/python/tests/unittests/test_common/test_job.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest from unittest.mock import MagicMock import time from common import Job class DummyError(Exception): pass class DummyFailingJob(Job): def setup(self): # noinspection PyAttributeOutsideInit self.cleanup_run = False def execute(self): raise DummyError() def cleanup(self): # noinspection PyAttributeOutsideInit self.cleanup_run = True class TestJob(unittest.TestCase): def test_exception_propagates(self): context = MagicMock() # noinspection PyTypeChecker job = DummyFailingJob("DummyFailingJob", context) job.start() time.sleep(0.2) with self.assertRaises(DummyError): job.propagate_exception() job.terminate() job.join() def test_cleanup_executes_on_execute_error(self): context = MagicMock() # noinspection PyTypeChecker job = DummyFailingJob("DummyFailingJob", context) job.start() time.sleep(0.2) job.terminate() job.join() self.assertTrue(job.cleanup_run) ================================================ FILE: src/python/tests/unittests/test_common/test_multiprocessing_logger.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest import logging import sys import time import multiprocessing from testfixtures import LogCapture import timeout_decorator from common import MultiprocessingLogger class TestMultiprocessingLogger(unittest.TestCase): def setUp(self): self.logger = logging.getLogger(TestMultiprocessingLogger.__name__) handler = logging.StreamHandler(sys.stdout) self.logger.addHandler(handler) self.logger.setLevel(logging.DEBUG) formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") handler.setFormatter(formatter) @timeout_decorator.timeout(5) def test_main_logger_receives_records(self): def process_1(_mp_logger: MultiprocessingLogger): logger = _mp_logger.get_process_safe_logger().getChild("process_1") logger.debug("Debug line") time.sleep(0.1) logger.info("Info line") time.sleep(0.1) logger.warning("Warning line") time.sleep(0.1) logger.error("Error line") mp_logger = MultiprocessingLogger(self.logger) p_1 = multiprocessing.Process(target=process_1, args=(mp_logger,)) with LogCapture("TestMultiprocessingLogger.MPLogger.process_1") as log_capture: p_1.start() mp_logger.start() time.sleep(1) p_1.join() mp_logger.stop() log_capture.check( ("process_1", "DEBUG", "Debug line"), ("process_1", "INFO", "Info line"), ("process_1", "WARNING", "Warning line"), ("process_1", "ERROR", "Error line") ) @timeout_decorator.timeout(5) def test_children_names(self): def process_1(_mp_logger: MultiprocessingLogger): logger = _mp_logger.get_process_safe_logger().getChild("process_1") logger.debug("Debug line") logger.getChild("child_1").debug("Debug line") logger.getChild("child_1_1").debug("Debug line") mp_logger = MultiprocessingLogger(self.logger) p_1 = multiprocessing.Process(target=process_1, args=(mp_logger,)) with LogCapture("TestMultiprocessingLogger.MPLogger.process_1") as log_capture: p_1.start() mp_logger.start() time.sleep(1) p_1.join() mp_logger.stop() log_capture.check( ("process_1", "DEBUG", "Debug line"), ("process_1.child_1", "DEBUG", "Debug line"), ("process_1.child_1_1", "DEBUG", "Debug line"), ) @timeout_decorator.timeout(5) def test_logger_levels(self): def process_1(_mp_logger: MultiprocessingLogger): logger = _mp_logger.get_process_safe_logger().getChild("process_1") logger.debug("Debug line") logger.info("Info line") logger.warning("Warning line") logger.error("Error line") # Debug level self.logger.setLevel(logging.DEBUG) with LogCapture("TestMultiprocessingLogger.MPLogger.process_1") as log_capture: mp_logger = MultiprocessingLogger(self.logger) p_1 = multiprocessing.Process(target=process_1, args=(mp_logger,)) p_1.start() mp_logger.start() time.sleep(0.2) p_1.join() mp_logger.stop() log_capture.check( ("process_1", "DEBUG", "Debug line"), ("process_1", "INFO", "Info line"), ("process_1", "WARNING", "Warning line"), ("process_1", "ERROR", "Error line") ) # Info level self.logger.setLevel(logging.INFO) with LogCapture("TestMultiprocessingLogger.MPLogger.process_1") as log_capture: mp_logger = MultiprocessingLogger(self.logger) p_1 = multiprocessing.Process(target=process_1, args=(mp_logger,)) p_1.start() mp_logger.start() time.sleep(0.2) p_1.join() mp_logger.stop() log_capture.check( ("process_1", "INFO", "Info line"), ("process_1", "WARNING", "Warning line"), ("process_1", "ERROR", "Error line") ) # Warning level self.logger.setLevel(logging.WARNING) with LogCapture("TestMultiprocessingLogger.MPLogger.process_1") as log_capture: mp_logger = MultiprocessingLogger(self.logger) p_1 = multiprocessing.Process(target=process_1, args=(mp_logger,)) p_1.start() mp_logger.start() time.sleep(0.2) p_1.join() mp_logger.stop() log_capture.check( ("process_1", "WARNING", "Warning line"), ("process_1", "ERROR", "Error line") ) # Error level self.logger.setLevel(logging.ERROR) with LogCapture("TestMultiprocessingLogger.MPLogger.process_1") as log_capture: mp_logger = MultiprocessingLogger(self.logger) p_1 = multiprocessing.Process(target=process_1, args=(mp_logger,)) p_1.start() mp_logger.start() time.sleep(0.2) p_1.join() mp_logger.stop() log_capture.check( ("process_1", "ERROR", "Error line") ) ================================================ FILE: src/python/tests/unittests/test_common/test_persist.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest import tempfile import shutil import os from common import overrides, Persist, AppError, Localization class DummyPersist(Persist): def __init__(self): self.my_content = None @classmethod @overrides(Persist) def from_str(cls: "DummyPersist", content: str) -> "DummyPersist": persist = DummyPersist() persist.my_content = content return persist @overrides(Persist) def to_str(self) -> str: return self.my_content class TestPersist(unittest.TestCase): @overrides(unittest.TestCase) def setUp(self): # Create a temp directory self.temp_dir = tempfile.mkdtemp(prefix="test_persist") @overrides(unittest.TestCase) def tearDown(self): # Cleanup shutil.rmtree(self.temp_dir) def test_from_file(self): file_path = os.path.join(self.temp_dir, "persist") with open(file_path, "w") as f: f.write("some test content") persist = DummyPersist.from_file(file_path) self.assertEqual("some test content", persist.my_content) def test_from_file_non_existing(self): file_path = os.path.join(self.temp_dir, "persist") with self.assertRaises(AppError) as context: DummyPersist.from_file(file_path) self.assertEqual(Localization.Error.MISSING_FILE.format(file_path), str(context.exception)) def test_to_file_non_existing(self): file_path = os.path.join(self.temp_dir, "persist") persist = DummyPersist() persist.my_content = "write out some content" persist.to_file(file_path) self.assertTrue(os.path.isfile(file_path)) with open(file_path, "r") as f: self.assertEqual("write out some content", f.read()) def test_to_file_overwrite(self): file_path = os.path.join(self.temp_dir, "persist") with open(file_path, "w") as f: f.write("pre-existing content") f.flush() persist = DummyPersist() persist.my_content = "write out some new content" persist.to_file(file_path) self.assertTrue(os.path.isfile(file_path)) with open(file_path, "r") as f: self.assertEqual("write out some new content", f.read()) ================================================ FILE: src/python/tests/unittests/test_common/test_status.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest from unittest.mock import MagicMock from datetime import datetime from common import overrides, Status, IStatusListener, StatusComponent, IStatusComponentListener class DummyStatusComponent(StatusComponent): a = StatusComponent._create_property("a") b = StatusComponent._create_property("b") def __init__(self): super().__init__() self.a = None self.b = None class DummyStatusComponentListener(IStatusComponentListener): @overrides(IStatusComponentListener) def notify(self, name): pass class DummyStatusListener(IStatusListener): @overrides(IStatusListener) def notify(self): pass class TestStatusComponent(unittest.TestCase): def test_property_values(self): d = DummyStatusComponent() d.a = "hello" d.b = 33 self.assertEqual("hello", d.a) self.assertEqual(33, d.b) def test_listeners(self): listener = DummyStatusComponentListener() listener.notify = MagicMock() d = DummyStatusComponent() d.add_listener(listener) d.a = "hello world" listener.notify.assert_called_once_with("a") listener.notify.reset_mock() d.b = 44 listener.notify.assert_called_once_with("b") # remove listener listener.notify.reset_mock() d.remove_listener(listener) d.a = "bye world" listener.notify.assert_not_called() d.b = 22 listener.notify.assert_not_called() def test_copy_values(self): d = DummyStatusComponent() d.a = "hello world" d.b = 55 e = DummyStatusComponent() DummyStatusComponent.copy(d, e) self.assertEqual("hello world", e.a) self.assertEqual(55, e.b) # Modifying original doesn't touch copy d.a = "bye world" d.b = 66 self.assertEqual("bye world", d.a) self.assertEqual(66, d.b) self.assertEqual("hello world", e.a) self.assertEqual(55, e.b) # Modifying copy doesn't touch original e.a = "copied world" e.b = 77 self.assertEqual("bye world", d.a) self.assertEqual(66, d.b) self.assertEqual("copied world", e.a) self.assertEqual(77, e.b) def test_copy_doesnt_copy_listeners(self): d = DummyStatusComponent() d.a = "hello world" d.b = 55 listener = DummyStatusComponentListener() listener.notify = MagicMock() d.add_listener(listener) e = DummyStatusComponent() DummyStatusComponent.copy(d, e) d.a = "bye world" listener.notify.assert_called_once_with("a") listener.notify.reset_mock() e.a = "copied world" listener.notify.assert_not_called() class TestStatus(unittest.TestCase): def test_property_values(self): status = Status() status.server.up = True status.server.error_msg = "Everything's good" self.assertEqual(True, status.server.up) self.assertEqual("Everything's good", status.server.error_msg) def test_listeners(self): listener = DummyStatusListener() listener.notify = MagicMock() status = Status() status.add_listener(listener) status.server.up = False listener.notify.assert_called_once_with() listener.notify.reset_mock() status.server.error_msg = "Everything's good" listener.notify.assert_called_once_with() def test_cannot_replace_component(self): status = Status() new_server = Status.ServerStatus() with self.assertRaises(ValueError) as e: status.server = new_server self.assertEqual("Cannot reassign component", str(e.exception)) def test_default_values(self): status = Status() self.assertEqual(True, status.server.up) self.assertEqual(None, status.server.error_msg) self.assertEqual(None, status.controller.latest_local_scan_time) self.assertEqual(None, status.controller.latest_remote_scan_time) def test_components_registered(self): # Test that all components were registered # This is done through the copy method status = Status() status.server.up = False status.server.error_msg = "an error message" copy = status.copy() self.assertEqual(False, copy.server.up) self.assertEqual("an error message", copy.server.error_msg) time1 = datetime.now() time2 = datetime.now() status.controller.latest_local_scan_time = time1 status.controller.latest_remote_scan_time = time2 copy = status.copy() self.assertEqual(time1, copy.controller.latest_local_scan_time) self.assertEqual(time2, copy.controller.latest_remote_scan_time) def test_copy_values(self): status = Status() status.server.up = False status.server.error_msg = "Bad error" copy = status.copy() self.assertEqual(False, copy.server.up) self.assertEqual("Bad error", copy.server.error_msg) # Modifying original doesn't touch copy status.server.up = True status.server.error_msg = "No error" self.assertEqual(True, status.server.up) self.assertEqual("No error", status.server.error_msg) self.assertEqual(False, copy.server.up) self.assertEqual("Bad error", copy.server.error_msg) # Modifying copy doesn't touch original copy.server.up = False copy.server.error_msg = "Worse error" self.assertEqual(True, status.server.up) self.assertEqual("No error", status.server.error_msg) self.assertEqual(False, copy.server.up) self.assertEqual("Worse error", copy.server.error_msg) def test_copy_doesnt_copy_listeners(self): status = Status() listener = DummyStatusListener() listener.notify = MagicMock() status.add_listener(listener) copy = status.copy() status.server.error_msg = "a" listener.notify.assert_called_once_with() listener.notify.reset_mock() copy.server.error_msg = "b" listener.notify.assert_not_called() ================================================ FILE: src/python/tests/unittests/test_controller/__init__.py ================================================ ================================================ FILE: src/python/tests/unittests/test_controller/test_auto_queue.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest from unittest.mock import MagicMock import logging import sys import json from common import overrides, PersistError, Config from controller import AutoQueue, AutoQueuePersist, IAutoQueuePersistListener, AutoQueuePattern from controller import Controller from model import IModelListener, ModelFile class TestAutoQueuePattern(unittest.TestCase): def test_pattern(self): aqp = AutoQueuePattern(pattern="file.one") self.assertEqual(aqp.pattern, "file.one") aqp = AutoQueuePattern(pattern="file.two") self.assertEqual(aqp.pattern, "file.two") def test_equality(self): aqp_1 = AutoQueuePattern(pattern="file.one") aqp_2 = AutoQueuePattern(pattern="file.two") aqp_1b = AutoQueuePattern(pattern="file.one") self.assertEqual(aqp_1, aqp_1b) self.assertNotEqual(aqp_1, aqp_2) def test_to_str(self): self.assertEqual( "{\"pattern\": \"file.one\"}", AutoQueuePattern(pattern="file.one").to_str() ) self.assertEqual( "{\"pattern\": \"file'one\"}", AutoQueuePattern(pattern="file'one").to_str() ) self.assertEqual( "{\"pattern\": \"file\\\"one\"}", AutoQueuePattern(pattern="file\"one").to_str() ) self.assertEqual( "{\"pattern\": \"fil(eo)ne\"}", AutoQueuePattern(pattern="fil(eo)ne").to_str() ) def test_from_str(self): self.assertEqual( AutoQueuePattern(pattern="file.one"), AutoQueuePattern.from_str("{\"pattern\": \"file.one\"}"), ) self.assertEqual( AutoQueuePattern(pattern="file'one"), AutoQueuePattern.from_str("{\"pattern\": \"file'one\"}"), ) self.assertEqual( AutoQueuePattern(pattern="file\"one"), AutoQueuePattern.from_str("{\"pattern\": \"file\\\"one\"}"), ) self.assertEqual( AutoQueuePattern(pattern="fil(eo)ne"), AutoQueuePattern.from_str("{\"pattern\": \"fil(eo)ne\"}"), ) def test_to_and_from_str(self): self.assertEqual( AutoQueuePattern(pattern="file.one"), AutoQueuePattern.from_str(AutoQueuePattern(pattern="file.one").to_str()) ) self.assertEqual( AutoQueuePattern(pattern="file'one"), AutoQueuePattern.from_str(AutoQueuePattern(pattern="file'one").to_str()) ) self.assertEqual( AutoQueuePattern(pattern="file\"one"), AutoQueuePattern.from_str(AutoQueuePattern(pattern="file\"one").to_str()) ) self.assertEqual( AutoQueuePattern(pattern="fil(eo)ne"), AutoQueuePattern.from_str(AutoQueuePattern(pattern="fil(eo)ne").to_str()) ) class TestAutoQueuePersistListener(IAutoQueuePersistListener): @overrides(IAutoQueuePersistListener) def pattern_added(self, pattern: AutoQueuePattern): pass @overrides(IAutoQueuePersistListener) def pattern_removed(self, pattern: AutoQueuePattern): pass class TestAutoQueuePersist(unittest.TestCase): def test_add_pattern(self): persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="one")) persist.add_pattern(AutoQueuePattern(pattern="two")) self.assertEqual({ AutoQueuePattern(pattern="one"), AutoQueuePattern(pattern="two") }, persist.patterns) persist.add_pattern(AutoQueuePattern(pattern="one")) persist.add_pattern(AutoQueuePattern(pattern="three")) self.assertEqual({ AutoQueuePattern(pattern="one"), AutoQueuePattern(pattern="two"), AutoQueuePattern(pattern="three") }, persist.patterns) def test_add_blank_pattern_fails(self): persist = AutoQueuePersist() with self.assertRaises(ValueError): persist.add_pattern(AutoQueuePattern(pattern="")) with self.assertRaises(ValueError): persist.add_pattern(AutoQueuePattern(pattern=" ")) with self.assertRaises(ValueError): persist.add_pattern(AutoQueuePattern(pattern=" ")) def test_remove_pattern(self): persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="one")) persist.add_pattern(AutoQueuePattern(pattern="two")) persist.remove_pattern(AutoQueuePattern(pattern="one")) self.assertEqual({AutoQueuePattern(pattern="two")}, persist.patterns) persist.add_pattern(AutoQueuePattern(pattern="one")) persist.add_pattern(AutoQueuePattern(pattern="three")) persist.remove_pattern(AutoQueuePattern(pattern="two")) self.assertEqual({ AutoQueuePattern(pattern="one"), AutoQueuePattern(pattern="three") }, persist.patterns) def test_listener_pattern_added(self): listener = TestAutoQueuePersistListener() listener.pattern_added = MagicMock() persist = AutoQueuePersist() persist.add_listener(listener) persist.add_pattern(AutoQueuePattern(pattern="one")) listener.pattern_added.assert_called_once_with(AutoQueuePattern(pattern="one")) listener.pattern_added.reset_mock() persist.add_pattern(AutoQueuePattern(pattern="two")) listener.pattern_added.assert_called_once_with(AutoQueuePattern(pattern="two")) listener.pattern_added.reset_mock() def test_listener_pattern_added_duplicate(self): listener = TestAutoQueuePersistListener() listener.pattern_added = MagicMock() persist = AutoQueuePersist() persist.add_listener(listener) persist.add_pattern(AutoQueuePattern(pattern="one")) listener.pattern_added.assert_called_once_with(AutoQueuePattern(pattern="one")) listener.pattern_added.reset_mock() persist.add_pattern(AutoQueuePattern(pattern="one")) listener.pattern_added.assert_not_called() def test_listener_pattern_removed(self): listener = TestAutoQueuePersistListener() listener.pattern_removed = MagicMock() persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="one")) persist.add_pattern(AutoQueuePattern(pattern="two")) persist.add_pattern(AutoQueuePattern(pattern="three")) persist.add_listener(listener) persist.remove_pattern(AutoQueuePattern(pattern="one")) listener.pattern_removed.assert_called_once_with(AutoQueuePattern(pattern="one")) listener.pattern_removed.reset_mock() persist.remove_pattern(AutoQueuePattern(pattern="two")) listener.pattern_removed.assert_called_once_with(AutoQueuePattern(pattern="two")) listener.pattern_removed.reset_mock() def test_listener_pattern_removed_non_existing(self): listener = TestAutoQueuePersistListener() listener.pattern_removed = MagicMock() persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="one")) persist.add_pattern(AutoQueuePattern(pattern="two")) persist.add_pattern(AutoQueuePattern(pattern="three")) persist.add_listener(listener) persist.remove_pattern(AutoQueuePattern(pattern="four")) listener.pattern_removed.assert_not_called() def test_from_str(self): content = """ {{ "patterns": [ "{}", "{}", "{}", "{}", "{}", "{}" ] }} """.format( AutoQueuePattern(pattern="one").to_str().replace("\\", "\\\\").replace("\"", "\\\""), AutoQueuePattern(pattern="two").to_str().replace("\\", "\\\\").replace("\"", "\\\""), AutoQueuePattern(pattern="th ree").to_str().replace("\\", "\\\\").replace("\"", "\\\""), AutoQueuePattern(pattern="fo.ur").to_str().replace("\\", "\\\\").replace("\"", "\\\""), AutoQueuePattern(pattern="fi\"ve").to_str().replace("\\", "\\\\").replace("\"", "\\\""), AutoQueuePattern(pattern="si'x").to_str().replace("\\", "\\\\").replace("\"", "\\\"") ) print(content) print(AutoQueuePattern(pattern="fi\"ve").to_str()) persist = AutoQueuePersist.from_str(content) golden_patterns = { AutoQueuePattern(pattern="one"), AutoQueuePattern(pattern="two"), AutoQueuePattern(pattern="th ree"), AutoQueuePattern(pattern="fo.ur"), AutoQueuePattern(pattern="fi\"ve"), AutoQueuePattern(pattern="si'x") } self.assertEqual(golden_patterns, persist.patterns) def test_to_str(self): persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="one")) persist.add_pattern(AutoQueuePattern(pattern="two")) persist.add_pattern(AutoQueuePattern(pattern="th ree")) persist.add_pattern(AutoQueuePattern(pattern="fo.ur")) persist.add_pattern(AutoQueuePattern(pattern="fi\"ve")) persist.add_pattern(AutoQueuePattern(pattern="si'x")) print(persist.to_str()) dct = json.loads(persist.to_str()) self.assertTrue("patterns" in dct) self.assertEqual( [ AutoQueuePattern(pattern="one").to_str(), AutoQueuePattern(pattern="two").to_str(), AutoQueuePattern(pattern="th ree").to_str(), AutoQueuePattern(pattern="fo.ur").to_str(), AutoQueuePattern(pattern="fi\"ve").to_str(), AutoQueuePattern(pattern="si'x").to_str() ], dct["patterns"] ) def test_to_and_from_str(self): persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="one")) persist.add_pattern(AutoQueuePattern(pattern="two")) persist.add_pattern(AutoQueuePattern(pattern="th ree")) persist.add_pattern(AutoQueuePattern(pattern="fo.ur")) persist.add_pattern(AutoQueuePattern(pattern="fi\"ve")) persist.add_pattern(AutoQueuePattern(pattern="si'x")) persist_actual = AutoQueuePersist.from_str(persist.to_str()) self.assertEqual( persist.patterns, persist_actual.patterns ) def test_persist_read_error(self): # bad pattern content = """ { "patterns": [ "bad string" ] } """ with self.assertRaises(PersistError): AutoQueuePersist.from_str(content) # empty json content = "" with self.assertRaises(PersistError): AutoQueuePersist.from_str(content) # missing keys content = "{}" with self.assertRaises(PersistError): AutoQueuePersist.from_str(content) # malformed content = "{" with self.assertRaises(PersistError): AutoQueuePersist.from_str(content) class TestAutoQueue(unittest.TestCase): def setUp(self): self.logger = logging.getLogger(TestAutoQueue.__name__) handler = logging.StreamHandler(sys.stdout) self.logger.addHandler(handler) self.logger.setLevel(logging.DEBUG) formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") handler.setFormatter(formatter) self.context = MagicMock() self.context.config = Config() self.context.config.autoqueue.enabled = True self.context.config.autoqueue.patterns_only = True self.context.config.autoqueue.auto_extract = True self.context.logger = self.logger self.controller = MagicMock() self.controller.get_model_files_and_add_listener = MagicMock() self.controller.queue_command = MagicMock() self.model_listener = None self.initial_model = [] def get_model(): return self.initial_model def get_model_and_capture_listener(listener: IModelListener): self.model_listener = listener return get_model() self.controller.get_model_files.side_effect = get_model self.controller.get_model_files_and_add_listener.side_effect = get_model_and_capture_listener def test_matching_new_files_are_queued(self): persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="File.One")) persist.add_pattern(AutoQueuePattern(pattern="File.Two")) persist.add_pattern(AutoQueuePattern(pattern="File.Three")) # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) file_one = ModelFile("File.One", True) file_one.remote_size = 100 file_two = ModelFile("File.Two", True) file_two.remote_size = 200 file_three = ModelFile("File.Three", True) file_three.remote_size = 300 self.model_listener.file_added(file_one) auto_queue.process() command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.QUEUE, command.action) self.assertEqual("File.One", command.filename) self.model_listener.file_added(file_two) auto_queue.process() command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.QUEUE, command.action) self.assertEqual("File.Two", command.filename) self.model_listener.file_added(file_three) auto_queue.process() command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.QUEUE, command.action) self.assertEqual("File.Three", command.filename) # All at once self.model_listener.file_added(file_one) self.model_listener.file_added(file_two) self.model_listener.file_added(file_three) auto_queue.process() calls = self.controller.queue_command.call_args_list[-3:] commands = [calls[i][0][0] for i in range(3)] self.assertEqual(set([Controller.Command.Action.QUEUE]*3), {c.action for c in commands}) self.assertEqual({"File.One", "File.Two", "File.Three"}, {c.filename for c in commands}) def test_matching_initial_files_are_queued(self): persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="File.One")) persist.add_pattern(AutoQueuePattern(pattern="File.Two")) persist.add_pattern(AutoQueuePattern(pattern="File.Three")) file_one = ModelFile("File.One", True) file_one.remote_size = 100 file_two = ModelFile("File.Two", True) file_two.remote_size = 200 file_three = ModelFile("File.Three", True) file_three.remote_size = 300 file_four = ModelFile("File.Four", True) file_four.remote_size = 400 file_five = ModelFile("File.Five", True) file_five.remote_size = 500 self.initial_model = [file_one, file_two, file_three, file_four, file_five] # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) auto_queue.process() calls = self.controller.queue_command.call_args_list self.assertEqual(3, len(calls)) commands = [calls[i][0][0] for i in range(3)] self.assertEqual(set([Controller.Command.Action.QUEUE]*3), {c.action for c in commands}) self.assertEqual({"File.One", "File.Two", "File.Three"}, {c.filename for c in commands}) def test_non_matches(self): persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="One")) # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) file_one = ModelFile("Two", True) file_one.remote_size = 100 self.model_listener.file_added(file_one) auto_queue.process() self.controller.queue_command.assert_not_called() def test_matching_is_case_insensitive(self): persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="FiLe.oNe")) # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) file_one = ModelFile("File.One", True) file_one.remote_size = 100 self.model_listener.file_added(file_one) auto_queue.process() command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.QUEUE, command.action) self.assertEqual("File.One", command.filename) persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="File.One")) # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) file_one = ModelFile("FiLe.oNe", True) file_one.remote_size = 100 self.model_listener.file_added(file_one) auto_queue.process() command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.QUEUE, command.action) self.assertEqual("FiLe.oNe", command.filename) def test_partial_matches(self): persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="file")) # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) file_one = ModelFile("fileone", True) # at start file_one.remote_size = 100 file_two = ModelFile("twofile", True) # at end file_two.remote_size = 100 file_three = ModelFile("onefiletwo", True) # in middle file_three.remote_size = 100 file_four = ModelFile("fionele", True) # no match file_four.remote_size = 100 self.model_listener.file_added(file_one) self.model_listener.file_added(file_two) self.model_listener.file_added(file_three) self.model_listener.file_added(file_four) auto_queue.process() self.assertEqual(3, self.controller.queue_command.call_count) commands = [call[0][0] for call in self.controller.queue_command.call_args_list] commands_dict = {command.filename: command for command in commands} self.assertTrue("fileone" in commands_dict) self.assertEqual(Controller.Command.Action.QUEUE, commands_dict["fileone"].action) self.assertTrue("twofile" in commands_dict) self.assertEqual(Controller.Command.Action.QUEUE, commands_dict["twofile"].action) self.assertTrue("onefiletwo" in commands_dict) self.assertEqual(Controller.Command.Action.QUEUE, commands_dict["onefiletwo"].action) def test_wildcard_at_start_matches(self): persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="*.mkv")) # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) file_one = ModelFile("File.One.mkv", True) file_one.remote_size = 100 file_two = ModelFile("File.Two.jpg", True) file_two.remote_size = 100 file_three = ModelFile(".mkvFile.Three", True) file_three.remote_size = 100 file_four = ModelFile("FileFour.mkv", True) file_four.remote_size = 100 file_five = ModelFile("FileFive.mkv.more", True) file_five.remote_size = 100 self.model_listener.file_added(file_one) self.model_listener.file_added(file_two) self.model_listener.file_added(file_three) self.model_listener.file_added(file_four) self.model_listener.file_added(file_five) auto_queue.process() self.assertEqual(2, self.controller.queue_command.call_count) commands = [call[0][0] for call in self.controller.queue_command.call_args_list] commands_dict = {command.filename: command for command in commands} self.assertTrue("File.One.mkv" in commands_dict) self.assertEqual(Controller.Command.Action.QUEUE, commands_dict["File.One.mkv"].action) self.assertTrue("FileFour.mkv" in commands_dict) self.assertEqual(Controller.Command.Action.QUEUE, commands_dict["FileFour.mkv"].action) def test_wildcard_at_end_matches(self): persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="File*")) # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) file_one = ModelFile("File.One.mkv", True) file_one.remote_size = 100 file_two = ModelFile("File.Two.jpg", True) file_two.remote_size = 100 file_three = ModelFile(".mkvFile.Three", True) file_three.remote_size = 100 file_four = ModelFile("FileFour.mkv", True) file_four.remote_size = 100 file_five = ModelFile("FileFive.mkv.more", True) file_five.remote_size = 100 self.model_listener.file_added(file_one) self.model_listener.file_added(file_two) self.model_listener.file_added(file_three) self.model_listener.file_added(file_four) self.model_listener.file_added(file_five) auto_queue.process() self.assertEqual(4, self.controller.queue_command.call_count) commands = [call[0][0] for call in self.controller.queue_command.call_args_list] commands_dict = {command.filename: command for command in commands} self.assertTrue("File.One.mkv" in commands_dict) self.assertEqual(Controller.Command.Action.QUEUE, commands_dict["File.One.mkv"].action) self.assertTrue("File.Two.jpg" in commands_dict) self.assertEqual(Controller.Command.Action.QUEUE, commands_dict["File.Two.jpg"].action) self.assertTrue("FileFour.mkv" in commands_dict) self.assertEqual(Controller.Command.Action.QUEUE, commands_dict["FileFour.mkv"].action) self.assertTrue("FileFive.mkv.more" in commands_dict) self.assertEqual(Controller.Command.Action.QUEUE, commands_dict["FileFive.mkv.more"].action) def test_wildcard_in_middle_matches(self): persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="*mkv*")) # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) file_one = ModelFile("File.One.mkv", True) file_one.remote_size = 100 file_two = ModelFile("File.Two.jpg", True) file_two.remote_size = 100 file_three = ModelFile(".mkvFile.Three", True) file_three.remote_size = 100 file_four = ModelFile("FileFour.mkv", True) file_four.remote_size = 100 file_five = ModelFile("FileFive.mkv.more", True) file_five.remote_size = 100 self.model_listener.file_added(file_one) self.model_listener.file_added(file_two) self.model_listener.file_added(file_three) self.model_listener.file_added(file_four) self.model_listener.file_added(file_five) auto_queue.process() self.assertEqual(4, self.controller.queue_command.call_count) commands = [call[0][0] for call in self.controller.queue_command.call_args_list] commands_dict = {command.filename: command for command in commands} self.assertTrue("File.One.mkv" in commands_dict) self.assertEqual(Controller.Command.Action.QUEUE, commands_dict["File.One.mkv"].action) self.assertTrue(".mkvFile.Three" in commands_dict) self.assertEqual(Controller.Command.Action.QUEUE, commands_dict[".mkvFile.Three"].action) self.assertTrue("FileFour.mkv" in commands_dict) self.assertEqual(Controller.Command.Action.QUEUE, commands_dict["FileFour.mkv"].action) self.assertTrue("FileFive.mkv.more" in commands_dict) self.assertEqual(Controller.Command.Action.QUEUE, commands_dict["FileFive.mkv.more"].action) def test_wildcard_matches_are_case_insensitive(self): persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="*.mkv")) # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) file_one = ModelFile("File.One.mKV", True) file_one.remote_size = 100 file_two = ModelFile("File.Two.jpg", True) file_two.remote_size = 100 file_three = ModelFile(".mkvFile.Three", True) file_three.remote_size = 100 file_four = ModelFile("FileFour.MKV", True) file_four.remote_size = 100 file_five = ModelFile("FileFive.mkv.more", True) file_five.remote_size = 100 self.model_listener.file_added(file_one) self.model_listener.file_added(file_two) self.model_listener.file_added(file_three) self.model_listener.file_added(file_four) self.model_listener.file_added(file_five) auto_queue.process() self.assertEqual(2, self.controller.queue_command.call_count) commands = [call[0][0] for call in self.controller.queue_command.call_args_list] commands_dict = {command.filename: command for command in commands} self.assertTrue("File.One.mKV" in commands_dict) self.assertEqual(Controller.Command.Action.QUEUE, commands_dict["File.One.mKV"].action) self.assertTrue("FileFour.MKV" in commands_dict) self.assertEqual(Controller.Command.Action.QUEUE, commands_dict["FileFour.MKV"].action) def test_matching_local_files_are_not_queued(self): persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="File.One")) # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) file_one = ModelFile("File.One", True) file_one.remote_size = None file_one.local_size = 100 self.model_listener.file_added(file_one) auto_queue.process() self.controller.queue_command.assert_not_called() def test_matching_deleted_files_are_not_queued(self): persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="File.One")) # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) file_one = ModelFile("File.One", True) file_one.remote_size = 100 file_one.local_size = None file_one.state = ModelFile.State.DELETED self.model_listener.file_added(file_one) auto_queue.process() self.controller.queue_command.assert_not_called() def test_matching_downloading_files_are_not_queued(self): persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="File.One")) # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) file_one = ModelFile("File.One", True) file_one.remote_size = 100 file_one.local_size = 0 file_one.state = ModelFile.State.DOWNLOADING self.model_listener.file_added(file_one) auto_queue.process() self.controller.queue_command.assert_not_called() file_one_new = ModelFile("File.One", True) file_one_new.remote_size = 100 file_one_new.local_size = 50 file_one_new.state = ModelFile.State.DOWNLOADING self.model_listener.file_updated(file_one, file_one_new) auto_queue.process() self.controller.queue_command.assert_not_called() def test_matching_queued_files_are_not_queued(self): persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="File.One")) # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) file_one = ModelFile("File.One", True) file_one.remote_size = 100 file_one.state = ModelFile.State.QUEUED self.model_listener.file_added(file_one) auto_queue.process() self.controller.queue_command.assert_not_called() def test_matching_downloaded_files_are_not_queued(self): # Disable auto-extract self.context.config.autoqueue.auto_extract = False persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="File.One")) # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) file_one = ModelFile("File.One", True) file_one.remote_size = 100 file_one.state = ModelFile.State.DOWNLOADED self.model_listener.file_added(file_one) auto_queue.process() self.controller.queue_command.assert_not_called() def test_auto_queued_file_not_re_queued_after_stopping(self): persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="File.One")) # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) file_one = ModelFile("File.One", True) file_one.remote_size = 100 self.model_listener.file_added(file_one) auto_queue.process() self.controller.queue_command.assert_called_once_with(unittest.mock.ANY) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.QUEUE, command.action) self.assertEqual("File.One", command.filename) file_one_updated = ModelFile("File.One", True) file_one_updated.remote_size = 100 file_one_updated.local_size = 50 self.model_listener.file_updated(file_one, file_one_updated) auto_queue.process() self.controller.queue_command.assert_called_once_with(unittest.mock.ANY) def test_partial_file_is_auto_queued_after_remote_discovery(self): # Test that a partial local file is auto-queued when discovered on remote some time later persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="File.One")) # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) # Local discovery file_one = ModelFile("File.One", True) file_one.local_size = 100 self.model_listener.file_added(file_one) auto_queue.process() self.controller.queue_command.assert_not_called() # Remote discovery file_one_new = ModelFile("File.One", True) file_one_new.local_size = 100 file_one_new.remote_size = 200 self.model_listener.file_updated(file_one, file_one_new) auto_queue.process() self.controller.queue_command.assert_called_once_with(unittest.mock.ANY) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.QUEUE, command.action) self.assertEqual("File.One", command.filename) def test_new_matching_pattern_queues_existing_files(self): persist = AutoQueuePersist() file_one = ModelFile("File.One", True) file_one.remote_size = 100 file_two = ModelFile("File.Two", True) file_two.remote_size = 200 file_three = ModelFile("File.Three", True) file_three.remote_size = 300 file_four = ModelFile("File.Four", True) file_four.remote_size = 400 file_five = ModelFile("File.Five", True) file_five.remote_size = 500 self.initial_model = [file_one, file_two, file_three, file_four, file_five] # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) auto_queue.process() self.controller.queue_command.assert_not_called() persist.add_pattern(AutoQueuePattern(pattern="File.One")) auto_queue.process() self.controller.queue_command.assert_called_once_with(unittest.mock.ANY) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.QUEUE, command.action) self.assertEqual("File.One", command.filename) self.controller.queue_command.reset_mock() persist.add_pattern(AutoQueuePattern(pattern="File.Two")) auto_queue.process() self.controller.queue_command.assert_called_once_with(unittest.mock.ANY) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.QUEUE, command.action) self.assertEqual("File.Two", command.filename) self.controller.queue_command.reset_mock() persist.add_pattern(AutoQueuePattern(pattern="File.Three")) auto_queue.process() self.controller.queue_command.assert_called_once_with(unittest.mock.ANY) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.QUEUE, command.action) self.assertEqual("File.Three", command.filename) self.controller.queue_command.reset_mock() auto_queue.process() self.controller.queue_command.assert_not_called() def test_new_matching_pattern_doesnt_queue_local_file(self): persist = AutoQueuePersist() file_one = ModelFile("File.One", True) file_one.local_size = 100 self.initial_model = [file_one] # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) auto_queue.process() self.controller.queue_command.assert_not_called() persist.add_pattern(AutoQueuePattern(pattern="File.One")) auto_queue.process() self.controller.queue_command.assert_not_called() def test_removed_pattern_doesnt_queue_new_file(self): persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="One")) persist.add_pattern(AutoQueuePattern(pattern="Two")) # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) file_one = ModelFile("File.One", True) file_one.remote_size = 100 self.model_listener.file_added(file_one) auto_queue.process() command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.QUEUE, command.action) self.assertEqual("File.One", command.filename) self.controller.queue_command.reset_mock() persist.remove_pattern(AutoQueuePattern(pattern="Two")) file_two = ModelFile("File.Two", True) file_two.remote_size = 100 self.model_listener.file_added(file_two) auto_queue.process() self.controller.queue_command.assert_not_called() def test_adding_then_removing_pattern_doesnt_queue_existing_file(self): persist = AutoQueuePersist() file_one = ModelFile("File.One", True) file_one.remote_size = 100 file_two = ModelFile("File.Two", True) file_two.remote_size = 200 file_three = ModelFile("File.Three", True) file_three.remote_size = 300 file_four = ModelFile("File.Four", True) file_four.remote_size = 400 file_five = ModelFile("File.Five", True) file_five.remote_size = 500 self.initial_model = [file_one, file_two, file_three, file_four, file_five] # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) auto_queue.process() self.controller.queue_command.assert_not_called() persist.add_pattern(AutoQueuePattern(pattern="File.One")) persist.remove_pattern(AutoQueuePattern(pattern="File.One")) auto_queue.process() self.controller.queue_command.assert_not_called() def test_downloaded_file_with_changed_remote_size_is_queued(self): # Disable auto-extract self.context.config.autoqueue.auto_extract = False persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="File.One")) # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) file_one = ModelFile("File.One", True) file_one.remote_size = 100 file_one.local_size = 100 file_one.state = ModelFile.State.DOWNLOADED self.model_listener.file_added(file_one) auto_queue.process() self.controller.queue_command.assert_not_called() file_one_updated = ModelFile("File.One", True) file_one_updated.remote_size = 200 file_one_updated.local_size = 100 file_one_updated.state = ModelFile.State.DEFAULT self.model_listener.file_updated(file_one, file_one_updated) auto_queue.process() self.controller.queue_command.assert_called_once_with(unittest.mock.ANY) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.QUEUE, command.action) self.assertEqual("File.One", command.filename) def test_no_files_are_queued_when_disabled(self): self.context.config.autoqueue.enabled = False persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="File.One")) persist.add_pattern(AutoQueuePattern(pattern="File.Two")) persist.add_pattern(AutoQueuePattern(pattern="File.Three")) file_one = ModelFile("File.One", True) file_one.remote_size = 100 file_two = ModelFile("File.Two", True) file_two.remote_size = 200 file_three = ModelFile("File.Three", True) file_three.remote_size = 300 file_four = ModelFile("File.Four", True) file_four.remote_size = 400 file_five = ModelFile("File.Five", True) file_five.remote_size = 500 self.initial_model = [file_one, file_two, file_three, file_four, file_five] # First with patterns_only ON self.context.config.autoqueue.patterns_only = True # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) auto_queue.process() self.controller.queue_command.assert_not_called() # Second with patterns_only OFF self.context.config.autoqueue.patterns_only = False # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) auto_queue.process() self.controller.queue_command.assert_not_called() def test_all_files_are_queued_when_patterns_only_disabled(self): self.context.config.autoqueue.patterns_only = False persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="File.One")) persist.add_pattern(AutoQueuePattern(pattern="File.Two")) persist.add_pattern(AutoQueuePattern(pattern="File.Three")) file_one = ModelFile("File.One", True) file_one.remote_size = 100 file_two = ModelFile("File.Two", True) file_two.remote_size = 200 file_three = ModelFile("File.Three", True) file_three.remote_size = 300 file_four = ModelFile("File.Four", True) file_four.remote_size = 400 file_five = ModelFile("File.Five", True) file_five.remote_size = 500 self.initial_model = [file_one, file_two, file_three, file_four, file_five] # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) auto_queue.process() calls = self.controller.queue_command.call_args_list self.assertEqual(5, len(calls)) commands = [calls[i][0][0] for i in range(5)] self.assertEqual(set([Controller.Command.Action.QUEUE]*5), {c.action for c in commands}) self.assertEqual({"File.One", "File.Two", "File.Three", "File.Four", "File.Five"}, {c.filename for c in commands}) def test_all_files_are_queued_when_patterns_only_disabled_and_no_patterns_exist(self): self.context.config.autoqueue.patterns_only = False persist = AutoQueuePersist() file_one = ModelFile("File.One", True) file_one.remote_size = 100 file_two = ModelFile("File.Two", True) file_two.remote_size = 200 file_three = ModelFile("File.Three", True) file_three.remote_size = 300 file_four = ModelFile("File.Four", True) file_four.remote_size = 400 file_five = ModelFile("File.Five", True) file_five.remote_size = 500 self.initial_model = [file_one, file_two, file_three, file_four, file_five] # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) auto_queue.process() calls = self.controller.queue_command.call_args_list self.assertEqual(5, len(calls)) commands = [calls[i][0][0] for i in range(5)] self.assertEqual(set([Controller.Command.Action.QUEUE]*5), {c.action for c in commands}) self.assertEqual({"File.One", "File.Two", "File.Three", "File.Four", "File.Five"}, {c.filename for c in commands}) def test_matching_new_files_are_extracted(self): persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="File.One")) persist.add_pattern(AutoQueuePattern(pattern="File.Two")) persist.add_pattern(AutoQueuePattern(pattern="File.Three")) # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) file_one = ModelFile("File.One", True) file_one.state = ModelFile.State.DOWNLOADED file_one.local_size = 100 file_one.is_extractable = True file_two = ModelFile("File.Two", True) file_two.state = ModelFile.State.DOWNLOADED file_two.local_size = 200 file_two.is_extractable = True file_three = ModelFile("File.Three", True) file_three.state = ModelFile.State.DOWNLOADED file_three.local_size = 300 file_three.is_extractable = True self.model_listener.file_added(file_one) auto_queue.process() command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.EXTRACT, command.action) self.assertEqual("File.One", command.filename) self.model_listener.file_added(file_two) auto_queue.process() command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.EXTRACT, command.action) self.assertEqual("File.Two", command.filename) self.model_listener.file_added(file_three) auto_queue.process() command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.EXTRACT, command.action) self.assertEqual("File.Three", command.filename) # All at once self.model_listener.file_added(file_one) self.model_listener.file_added(file_two) self.model_listener.file_added(file_three) auto_queue.process() calls = self.controller.queue_command.call_args_list[-3:] commands = [calls[i][0][0] for i in range(3)] self.assertEqual(set([Controller.Command.Action.EXTRACT]*3), {c.action for c in commands}) self.assertEqual({"File.One", "File.Two", "File.Three"}, {c.filename for c in commands}) def test_matching_initial_files_are_extracted(self): persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="File.One")) persist.add_pattern(AutoQueuePattern(pattern="File.Two")) persist.add_pattern(AutoQueuePattern(pattern="File.Three")) file_one = ModelFile("File.One", True) file_one.state = ModelFile.State.DOWNLOADED file_one.local_size = 100 file_one.is_extractable = True file_two = ModelFile("File.Two", True) file_two.state = ModelFile.State.DOWNLOADED file_two.local_size = 200 file_two.is_extractable = True file_three = ModelFile("File.Three", True) file_three.state = ModelFile.State.DOWNLOADED file_three.local_size = 300 file_three.is_extractable = True file_four = ModelFile("File.Four", True) file_four.state = ModelFile.State.DOWNLOADED file_four.local_size = 400 file_four.is_extractable = True file_five = ModelFile("File.Five", True) file_five.state = ModelFile.State.DOWNLOADED file_five.local_size = 500 file_five.is_extractable = True self.initial_model = [file_one, file_two, file_three, file_four, file_five] # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) auto_queue.process() calls = self.controller.queue_command.call_args_list self.assertEqual(3, len(calls)) commands = [calls[i][0][0] for i in range(3)] self.assertEqual(set([Controller.Command.Action.EXTRACT]*3), {c.action for c in commands}) self.assertEqual({"File.One", "File.Two", "File.Three"}, {c.filename for c in commands}) def test_new_matching_pattern_extracts_existing_files(self): persist = AutoQueuePersist() file_one = ModelFile("File.One", True) file_one.local_size = 100 file_one.state = ModelFile.State.DOWNLOADED file_one.is_extractable = True file_two = ModelFile("File.Two", True) file_two.local_size = 200 file_two.state = ModelFile.State.DOWNLOADED file_two.is_extractable = True file_three = ModelFile("File.Three", True) file_three.local_size = 300 file_three.state = ModelFile.State.DOWNLOADED file_three.is_extractable = True file_four = ModelFile("File.Four", True) file_four.local_size = 400 file_four.state = ModelFile.State.DOWNLOADED file_four.is_extractable = True file_five = ModelFile("File.Five", True) file_five.local_size = 500 file_five.state = ModelFile.State.DOWNLOADED file_five.is_extractable = True self.initial_model = [file_one, file_two, file_three, file_four, file_five] # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) auto_queue.process() self.controller.queue_command.assert_not_called() persist.add_pattern(AutoQueuePattern(pattern="File.One")) auto_queue.process() self.controller.queue_command.assert_called_once_with(unittest.mock.ANY) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.EXTRACT, command.action) self.assertEqual("File.One", command.filename) self.controller.queue_command.reset_mock() persist.add_pattern(AutoQueuePattern(pattern="File.Two")) auto_queue.process() self.controller.queue_command.assert_called_once_with(unittest.mock.ANY) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.EXTRACT, command.action) self.assertEqual("File.Two", command.filename) self.controller.queue_command.reset_mock() persist.add_pattern(AutoQueuePattern(pattern="File.Three")) auto_queue.process() self.controller.queue_command.assert_called_once_with(unittest.mock.ANY) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.EXTRACT, command.action) self.assertEqual("File.Three", command.filename) self.controller.queue_command.reset_mock() auto_queue.process() self.controller.queue_command.assert_not_called() def test_non_extractable_files_are_not_extracted(self): persist = AutoQueuePersist() file_one = ModelFile("File.One", True) file_one.local_size = 100 file_one.state = ModelFile.State.DOWNLOADED file_one.is_extractable = True file_two = ModelFile("File.Two", True) file_two.local_size = 200 file_two.state = ModelFile.State.DOWNLOADED file_two.is_extractable = False self.initial_model = [file_one, file_two] # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) auto_queue.process() self.controller.queue_command.assert_not_called() persist.add_pattern(AutoQueuePattern(pattern="File.One")) auto_queue.process() self.controller.queue_command.assert_called_once_with(unittest.mock.ANY) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.EXTRACT, command.action) self.assertEqual("File.One", command.filename) self.controller.queue_command.reset_mock() persist.add_pattern(AutoQueuePattern(pattern="File.Two")) auto_queue.process() self.controller.queue_command.assert_not_called() def test_no_files_are_extracted_when_disabled(self): self.context.config.autoqueue.enabled = False persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="File.One")) persist.add_pattern(AutoQueuePattern(pattern="File.Two")) persist.add_pattern(AutoQueuePattern(pattern="File.Three")) file_one = ModelFile("File.One", True) file_one.local_size = 100 file_one.state = ModelFile.State.DOWNLOADED file_two = ModelFile("File.Two", True) file_two.local_size = 200 file_two.state = ModelFile.State.DOWNLOADED file_three = ModelFile("File.Three", True) file_three.local_size = 300 file_three.state = ModelFile.State.DOWNLOADED file_four = ModelFile("File.Four", True) file_four.local_size = 400 file_four.state = ModelFile.State.DOWNLOADED file_five = ModelFile("File.Five", True) file_five.local_size = 500 file_five.state = ModelFile.State.DOWNLOADED self.initial_model = [file_one, file_two, file_three, file_four, file_five] # First with patterns_only ON self.context.config.autoqueue.patterns_only = True # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) auto_queue.process() self.controller.queue_command.assert_not_called() # Second with patterns_only OFF self.context.config.autoqueue.patterns_only = False # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) auto_queue.process() self.controller.queue_command.assert_not_called() def test_no_files_are_extracted_when_auto_extract_disabled(self): self.context.config.autoqueue.enabled = True self.context.config.autoqueue.auto_extract = False persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="File.One")) persist.add_pattern(AutoQueuePattern(pattern="File.Two")) persist.add_pattern(AutoQueuePattern(pattern="File.Three")) file_one = ModelFile("File.One", True) file_one.local_size = 100 file_one.state = ModelFile.State.DOWNLOADED file_two = ModelFile("File.Two", True) file_two.local_size = 200 file_two.state = ModelFile.State.DOWNLOADED file_three = ModelFile("File.Three", True) file_three.local_size = 300 file_three.state = ModelFile.State.DOWNLOADED file_four = ModelFile("File.Four", True) file_four.local_size = 400 file_four.state = ModelFile.State.DOWNLOADED file_five = ModelFile("File.Five", True) file_five.local_size = 500 file_five.state = ModelFile.State.DOWNLOADED self.initial_model = [file_one, file_two, file_three, file_four, file_five] # First with patterns_only ON self.context.config.autoqueue.patterns_only = True # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) auto_queue.process() self.controller.queue_command.assert_not_called() # Second with patterns_only OFF self.context.config.autoqueue.patterns_only = False # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) auto_queue.process() self.controller.queue_command.assert_not_called() def test_all_files_are_extracted_when_patterns_only_disabled(self): self.context.config.autoqueue.patterns_only = False persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="File.One")) persist.add_pattern(AutoQueuePattern(pattern="File.Two")) persist.add_pattern(AutoQueuePattern(pattern="File.Three")) file_one = ModelFile("File.One", True) file_one.local_size = 100 file_one.state = ModelFile.State.DOWNLOADED file_one.is_extractable = True file_two = ModelFile("File.Two", True) file_two.local_size = 200 file_two.state = ModelFile.State.DOWNLOADED file_two.is_extractable = True file_three = ModelFile("File.Three", True) file_three.local_size = 300 file_three.state = ModelFile.State.DOWNLOADED file_three.is_extractable = True file_four = ModelFile("File.Four", True) file_four.local_size = 400 file_four.state = ModelFile.State.DOWNLOADED file_four.is_extractable = True file_five = ModelFile("File.Five", True) file_five.local_size = 500 file_five.state = ModelFile.State.DOWNLOADED file_five.is_extractable = True self.initial_model = [file_one, file_two, file_three, file_four, file_five] # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) auto_queue.process() calls = self.controller.queue_command.call_args_list self.assertEqual(5, len(calls)) commands = [calls[i][0][0] for i in range(5)] self.assertEqual(set([Controller.Command.Action.EXTRACT]*5), {c.action for c in commands}) self.assertEqual({"File.One", "File.Two", "File.Three", "File.Four", "File.Five"}, {c.filename for c in commands}) def test_all_files_are_extracted_when_patterns_only_disabled_and_no_patterns_exist(self): self.context.config.autoqueue.patterns_only = False persist = AutoQueuePersist() file_one = ModelFile("File.One", True) file_one.local_size = 100 file_one.state = ModelFile.State.DOWNLOADED file_one.is_extractable = True file_two = ModelFile("File.Two", True) file_two.local_size = 200 file_two.state = ModelFile.State.DOWNLOADED file_two.is_extractable = True file_three = ModelFile("File.Three", True) file_three.local_size = 300 file_three.state = ModelFile.State.DOWNLOADED file_three.is_extractable = True file_four = ModelFile("File.Four", True) file_four.local_size = 400 file_four.state = ModelFile.State.DOWNLOADED file_four.is_extractable = True file_five = ModelFile("File.Five", True) file_five.local_size = 500 file_five.state = ModelFile.State.DOWNLOADED file_five.is_extractable = True self.initial_model = [file_one, file_two, file_three, file_four, file_five] # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) auto_queue.process() calls = self.controller.queue_command.call_args_list self.assertEqual(5, len(calls)) commands = [calls[i][0][0] for i in range(5)] self.assertEqual(set([Controller.Command.Action.EXTRACT]*5), {c.action for c in commands}) self.assertEqual({"File.One", "File.Two", "File.Three", "File.Four", "File.Five"}, {c.filename for c in commands}) def test_file_is_extracted_after_finishing_download(self): persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="File.One")) # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) # File exists remotely and is auto-queued file_one = ModelFile("File.One", True) file_one.remote_size = 100 file_one.is_extractable = True self.model_listener.file_added(file_one) auto_queue.process() self.controller.queue_command.assert_called_once_with(unittest.mock.ANY) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.QUEUE, command.action) self.assertEqual("File.One", command.filename) self.controller.queue_command.reset_mock() # File starts downloading file_one_new = ModelFile("File.One", True) file_one_new.remote_size = 100 file_one_new.local_size = 50 file_one_new.state = ModelFile.State.DOWNLOADING file_one_new.is_extractable = True self.model_listener.file_updated(file_one, file_one_new) auto_queue.process() self.controller.queue_command.assert_not_called() # File finishes downloading file_one = file_one_new file_one_new = ModelFile("File.One", True) file_one_new.remote_size = 100 file_one_new.local_size = 100 file_one_new.state = ModelFile.State.DOWNLOADED file_one_new.is_extractable = True self.model_listener.file_updated(file_one, file_one_new) auto_queue.process() self.controller.queue_command.assert_called_once_with(unittest.mock.ANY) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.EXTRACT, command.action) self.assertEqual("File.One", command.filename) def test_downloaded_file_is_NOT_re_extracted_after_modified(self): persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="File.One")) # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) # File is auto-extracted file_one = ModelFile("File.One", True) file_one.local_size = 100 file_one.state = ModelFile.State.DOWNLOADED file_one.is_extractable = True self.model_listener.file_added(file_one) auto_queue.process() self.controller.queue_command.assert_called_once_with(unittest.mock.ANY) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.EXTRACT, command.action) self.assertEqual("File.One", command.filename) self.controller.queue_command.reset_mock() # File is modified file_one_new = ModelFile("File.One", True) file_one_new.local_size = 101 file_one_new.state = ModelFile.State.DOWNLOADED file_one_new.is_extractable = True self.model_listener.file_updated(file_one, file_one_new) auto_queue.process() self.controller.queue_command.assert_not_called() def test_downloaded_file_is_NOT_re_extracted_after_failed_extraction(self): persist = AutoQueuePersist() persist.add_pattern(AutoQueuePattern(pattern="File.One")) # noinspection PyTypeChecker auto_queue = AutoQueue(self.context, persist, self.controller) # File is auto-extracted file_one = ModelFile("File.One", True) file_one.local_size = 100 file_one.state = ModelFile.State.DOWNLOADED file_one.is_extractable = True self.model_listener.file_added(file_one) auto_queue.process() self.controller.queue_command.assert_called_once_with(unittest.mock.ANY) command = self.controller.queue_command.call_args[0][0] self.assertEqual(Controller.Command.Action.EXTRACT, command.action) self.assertEqual("File.One", command.filename) self.controller.queue_command.reset_mock() # File is extracting file_one_new = ModelFile("File.One", True) file_one_new.local_size = 101 file_one_new.state = ModelFile.State.EXTRACTING file_one_new.is_extractable = True self.model_listener.file_updated(file_one, file_one_new) auto_queue.process() self.controller.queue_command.assert_not_called() # Extraction fails and file goes back to DOWNLOADED file_one_newer = ModelFile("File.One", True) file_one_newer.local_size = 101 file_one_newer.state = ModelFile.State.DOWNLOADED file_one_newer.is_extractable = True self.model_listener.file_updated(file_one_new, file_one_newer) auto_queue.process() self.controller.queue_command.assert_not_called() ================================================ FILE: src/python/tests/unittests/test_controller/test_controller_persist.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest import json from common import PersistError from controller import ControllerPersist class TestControllerPersist(unittest.TestCase): def test_from_str(self): content = """ { "downloaded": ["one", "two", "th ree", "fo.ur"], "extracted": ["fi\\"ve", "si@x", "se\\\\ven", "ei-ght"] } """ persist = ControllerPersist.from_str(content) golden_downloaded = {"one", "two", "th ree", "fo.ur"} golden_extracted = {"fi\"ve", "si@x", "se\\ven", "ei-ght"} self.assertEqual(golden_downloaded, persist.downloaded_file_names) self.assertEqual(golden_extracted, persist.extracted_file_names) def test_to_str(self): persist = ControllerPersist() persist.downloaded_file_names.add("one") persist.downloaded_file_names.add("two") persist.downloaded_file_names.add("th ree") persist.downloaded_file_names.add("fo.ur") persist.extracted_file_names.add("fi\"ve") persist.extracted_file_names.add("si@x") persist.extracted_file_names.add("se\\ven") persist.extracted_file_names.add("ei-ght") dct = json.loads(persist.to_str()) self.assertTrue("downloaded" in dct) self.assertEqual({"one", "two", "th ree", "fo.ur"}, set(dct["downloaded"])) self.assertTrue("extracted" in dct) self.assertEqual({"fi\"ve", "si@x", "se\\ven", "ei-ght"}, set(dct["extracted"])) def test_to_and_from_str(self): persist = ControllerPersist() persist.downloaded_file_names.add("one") persist.downloaded_file_names.add("two") persist.downloaded_file_names.add("th ree") persist.downloaded_file_names.add("fo.ur") persist.extracted_file_names.add("fi\"ve") persist.extracted_file_names.add("si@x") persist.extracted_file_names.add("se\\ven") persist.extracted_file_names.add("ei-ght") persist_actual = ControllerPersist.from_str(persist.to_str()) self.assertEqual( persist.downloaded_file_names, persist_actual.downloaded_file_names ) self.assertEqual( persist.extracted_file_names, persist_actual.extracted_file_names ) def test_persist_read_error(self): # bad pattern content = """ { "downloaded": [bad string], "extracted": [] } """ with self.assertRaises(PersistError): ControllerPersist.from_str(content) content = """ { "downloaded": [], "extracted": [bad string] } """ with self.assertRaises(PersistError): ControllerPersist.from_str(content) # empty json content = "" with self.assertRaises(PersistError): ControllerPersist.from_str(content) # missing keys content = """ { "downloaded": [] } """ with self.assertRaises(PersistError): ControllerPersist.from_str(content) content = """ { "extracted": [] } """ with self.assertRaises(PersistError): ControllerPersist.from_str(content) # malformed content = "{" with self.assertRaises(PersistError): ControllerPersist.from_str(content) ================================================ FILE: src/python/tests/unittests/test_controller/test_extract/__init__.py ================================================ ================================================ FILE: src/python/tests/unittests/test_controller/test_extract/test_dispatch.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest import os from unittest.mock import patch, MagicMock, call import time import logging import sys import timeout_decorator from common import overrides from model import ModelFile from controller.extract import ExtractDispatch, ExtractDispatchError, ExtractListener, \ ExtractError, ExtractStatus class DummyExtractListener(ExtractListener): @overrides(ExtractListener) def extract_completed(self, name: str, is_dir: bool): pass @overrides(ExtractListener) def extract_failed(self, name: str, is_dir: bool): pass class TestExtractDispatch(unittest.TestCase): def setUp(self): extract_patcher = patch('controller.extract.dispatch.Extract') self.addCleanup(extract_patcher.stop) mock_extract_module = extract_patcher.start() self.mock_is_archive = mock_extract_module.is_archive self.mock_extract_archive = mock_extract_module.extract_archive self.out_dir_path = os.path.join("out", "dir") self.local_path = os.path.join("local", "path") self.dispatch = ExtractDispatch( out_dir_path=self.out_dir_path, local_path=self.local_path ) self.listener = DummyExtractListener() self.listener.extract_completed = MagicMock() self.listener.extract_failed = MagicMock() logger = logging.getLogger() handler = logging.StreamHandler(sys.stdout) logger.addHandler(handler) logger.setLevel(logging.DEBUG) formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") handler.setFormatter(formatter) self.dispatch.start() @timeout_decorator.timeout(2) def tearDown(self): if self.dispatch: self.dispatch.stop() def test_extract_single_raises_error_on_remote_only_file(self): mf = ModelFile("aaa", False) mf.local_size = None with self.assertRaises(ExtractDispatchError) as ctx: self.dispatch.extract(mf) self.assertTrue(str(ctx.exception).startswith("File does not exist locally")) mf = ModelFile("aaa", False) mf.local_size = 0 with self.assertRaises(ExtractDispatchError) as ctx: self.dispatch.extract(mf) self.assertTrue(str(ctx.exception).startswith("File does not exist locally")) def test_extract_single_raises_error_on_bad_archive(self): self.mock_is_archive.return_value = False mf = ModelFile("aaa", False) mf.local_size = 100 with self.assertRaises(ExtractDispatchError) as ctx: self.dispatch.extract(mf) self.assertTrue(str(ctx.exception).startswith("File is not an archive")) self.mock_is_archive.assert_called_once_with(os.path.join(self.local_path, mf.name)) @timeout_decorator.timeout(2) def test_extract_single(self): self.mock_is_archive.return_value = True mf = ModelFile("aaa", False) mf.local_size = 100 self.dispatch.extract(mf) while self.mock_extract_archive.call_count < 1: pass self.mock_extract_archive.assert_called_once_with( archive_path=os.path.join(self.local_path, "aaa"), out_dir_path=self.out_dir_path ) @timeout_decorator.timeout(2) def test_extract_maintains_order(self): self.mock_is_archive.return_value = True mf1 = ModelFile("aaa", False) mf1.local_size = 100 mf2 = ModelFile("bbb", False) mf2.local_size = 100 mf3 = ModelFile("ccc", False) mf3.local_size = 100 self.dispatch.extract(mf1) self.dispatch.extract(mf2) self.dispatch.extract(mf3) while self.mock_extract_archive.call_count < 3: pass self.assertEqual(3, self.mock_extract_archive.call_count) args_list = self.mock_extract_archive.call_args_list self.assertEqual(args_list, [ call( archive_path=os.path.join(self.local_path, "aaa"), out_dir_path=self.out_dir_path ), call( archive_path=os.path.join(self.local_path, "bbb"), out_dir_path=self.out_dir_path ), call( archive_path=os.path.join(self.local_path, "ccc"), out_dir_path=self.out_dir_path ) ]) @timeout_decorator.timeout(2) def test_extract_calls_listener_on_completed(self): self.mock_is_archive.return_value = True mf1 = ModelFile("aaa", False) mf1.local_size = 100 self.dispatch.add_listener(self.listener) self.dispatch.extract(mf1) while self.mock_extract_archive.call_count < 1 \ or self.listener.extract_completed.call_count < 1: pass self.assertEqual(1, self.mock_extract_archive.call_count) self.listener.extract_completed.assert_called_once_with("aaa", False) self.listener.extract_failed.assert_not_called() @timeout_decorator.timeout(2) def test_extract_calls_listener_on_failed(self): self.mock_is_archive.return_value = True # noinspection PyUnusedLocal def _extract_archive(**kwargs): raise ExtractError() self.mock_extract_archive.side_effect = _extract_archive mf1 = ModelFile("aaa", False) mf1.local_size = 100 self.dispatch.add_listener(self.listener) self.dispatch.extract(mf1) while self.mock_extract_archive.call_count < 1 \ or self.listener.extract_failed.call_count < 1: pass self.assertEqual(1, self.mock_extract_archive.call_count) self.listener.extract_completed.assert_not_called() self.listener.extract_failed.assert_called_once_with("aaa", False) @timeout_decorator.timeout(5) def test_extract_calls_listeners_in_correct_sequence(self): self.mock_is_archive.return_value = True self.count = 0 # noinspection PyUnusedLocal def _extract_archive(**kwargs): # raise error for first and third extractions self.count += 1 if self.count in (1, 3): raise ExtractError() self.mock_extract_archive.side_effect = _extract_archive mf1 = ModelFile("aaa", False) mf1.local_size = 100 mf2 = ModelFile("bbb", False) mf2.local_size = 100 mf3 = ModelFile("ccc", False) mf3.local_size = 100 listener_calls = [] def _completed(name, is_dir): listener_calls.append((True, name, is_dir)) def _failed(name, is_dir): listener_calls.append((False, name, is_dir)) self.listener.extract_completed.side_effect = _completed self.listener.extract_failed.side_effect = _failed self.dispatch.add_listener(self.listener) self.dispatch.extract(mf1) self.dispatch.extract(mf2) self.dispatch.extract(mf3) while self.mock_extract_archive.call_count < 3 \ or self.listener.extract_failed.call_count < 2 \ or self.listener.extract_completed.call_count < 1: pass self.assertEqual(3, self.mock_extract_archive.call_count) self.assertEqual( [(False, "aaa", False), (True, "bbb", False), (False, "ccc", False)], listener_calls ) @timeout_decorator.timeout(2) def test_extract_skips_remaining_on_shutdown(self): # Send two extract commands # Call shutdown after first one runs # Check that second command did not run self.mock_is_archive.return_value = True self.call_stop = False def _extract_archive(**kwargs): print(kwargs) self.call_stop = True time.sleep(0.5) # wait a bit so shutdown is called self.mock_extract_archive.side_effect = _extract_archive mf1 = ModelFile("aaa", False) mf1.local_size = 100 mf2 = ModelFile("bbb", False) mf2.local_size = 100 self.dispatch.add_listener(self.listener) self.dispatch.extract(mf1) self.dispatch.extract(mf2) while not self.call_stop: pass self.dispatch.stop() while self.mock_extract_archive.call_count < 1 \ or self.listener.extract_completed.call_count < 1: pass self.assertEqual(1, self.mock_extract_archive.call_count) self.listener.extract_completed.assert_called_once_with("aaa", False) self.listener.extract_failed.assert_not_called() def test_extract_dir_raises_error_on_empty_dir(self): mf = ModelFile("aaa", True) with self.assertRaises(ExtractDispatchError) as ctx: self.dispatch.extract(mf) self.assertTrue(str(ctx.exception).startswith("Directory does not contain any archives")) def test_extract_dir_raises_error_on_no_archives(self): self.mock_is_archive.return_value = False a = ModelFile("a", True) a.local_size = 100 aa = ModelFile("aa", False) aa.local_size = 50 a.add_child(aa) ab = ModelFile("ab", False) ab.local_size = 50 a.add_child(ab) with self.assertRaises(ExtractDispatchError) as ctx: self.dispatch.extract(a) self.assertTrue(str(ctx.exception).startswith("Directory does not contain any archives")) def test_extract_dir_raises_error_on_no_local_files(self): self.mock_is_archive.return_value = True a = ModelFile("a", True) a.remote_size = 100 aa = ModelFile("aa", False) aa.remote_size = 50 a.add_child(aa) ab = ModelFile("ab", False) ab.remote_size = 50 a.add_child(ab) with self.assertRaises(ExtractDispatchError) as ctx: self.dispatch.extract(a) self.assertTrue(str(ctx.exception).startswith("Directory does not contain any archives")) # noinspection SpellCheckingInspection @timeout_decorator.timeout(2) def test_extract_dir(self): self.mock_is_archive.return_value = True self.actual_calls = set() def _extract(archive_path: str, out_dir_path: str): self.actual_calls.add((archive_path, out_dir_path)) self.mock_extract_archive.side_effect = _extract a = ModelFile("a", True) a.local_size = 500 aa = ModelFile("aa", True) aa.local_size = 300 a.add_child(aa) aaa = ModelFile("aaa", False) aaa.local_size = 100 aa.add_child(aaa) aab = ModelFile("aab", False) aab.local_size = 100 aa.add_child(aab) aac = ModelFile("aac", True) aac.local_size = 100 aa.add_child(aac) aaca = ModelFile("aaca", False) aaca.local_size = 100 aac.add_child(aaca) ab = ModelFile("ab", True) ab.local_size = 100 a.add_child(ab) aba = ModelFile("aba", False) aba.local_size = 100 ab.add_child(aba) ac = ModelFile("ac", False) ac.local_size = 100 a.add_child(ac) self.dispatch.add_listener(self.listener) self.dispatch.extract(a) while self.listener.extract_completed.call_count < 1: pass self.listener.extract_completed.assert_called_once_with("a", True) golden_calls = { ( os.path.join(self.local_path, "a", "aa", "aaa"), os.path.join(self.out_dir_path, "a", "aa") ), ( os.path.join(self.local_path, "a", "aa", "aab"), os.path.join(self.out_dir_path, "a", "aa") ), ( os.path.join(self.local_path, "a", "aa", "aac", "aaca"), os.path.join(self.out_dir_path, "a", "aa", "aac") ), ( os.path.join(self.local_path, "a", "ab", "aba"), os.path.join(self.out_dir_path, "a", "ab") ), ( os.path.join(self.local_path, "a", "ac"), os.path.join(self.out_dir_path, "a") ), } self.assertEqual(5, self.mock_extract_archive.call_count) self.assertEqual(golden_calls, self.actual_calls) # noinspection SpellCheckingInspection @timeout_decorator.timeout(2) def test_extract_dir_skips_remote_files(self): self.mock_is_archive.return_value = True self.actual_calls = set() def _extract(archive_path: str, out_dir_path: str): self.actual_calls.add((archive_path, out_dir_path)) self.mock_extract_archive.side_effect = _extract a = ModelFile("a", True) a.local_size = 500 aa = ModelFile("aa", True) aa.local_size = 300 a.add_child(aa) aaa = ModelFile("aaa", False) aaa.local_size = 100 aa.add_child(aaa) aab = ModelFile("aab", False) aab.remote_size = 100 aa.add_child(aab) aac = ModelFile("aac", True) aac.local_size = 100 aa.add_child(aac) aaca = ModelFile("aaca", False) aaca.local_size = 100 aac.add_child(aaca) ab = ModelFile("ab", True) ab.local_size = 100 a.add_child(ab) aba = ModelFile("aba", False) aba.local_size = 100 ab.add_child(aba) ac = ModelFile("ac", False) ac.remote_size = 100 a.add_child(ac) self.dispatch.add_listener(self.listener) self.dispatch.extract(a) while self.listener.extract_completed.call_count < 1: pass self.listener.extract_completed.assert_called_once_with("a", True) golden_calls = { ( os.path.join(self.local_path, "a", "aa", "aaa"), os.path.join(self.out_dir_path, "a", "aa") ), ( os.path.join(self.local_path, "a", "aa", "aac", "aaca"), os.path.join(self.out_dir_path, "a", "aa", "aac") ), ( os.path.join(self.local_path, "a", "ab", "aba"), os.path.join(self.out_dir_path, "a", "ab") ), } self.assertEqual(3, self.mock_extract_archive.call_count) self.assertEqual(golden_calls, self.actual_calls) # noinspection SpellCheckingInspection @timeout_decorator.timeout(2) def test_extract_dir_skips_non_archive_files(self): # noinspection SpellCheckingInspection def _is_archive(archive_path: str): return archive_path in ( os.path.join(self.local_path, "a", "aa", "aaa"), os.path.join(self.local_path, "a", "aa", "aac", "aaca"), os.path.join(self.local_path, "a", "ab", "aba") ) self.mock_is_archive.side_effect = _is_archive self.actual_calls = set() def _extract(archive_path: str, out_dir_path: str): self.actual_calls.add((archive_path, out_dir_path)) self.mock_extract_archive.side_effect = _extract a = ModelFile("a", True) a.local_size = 500 aa = ModelFile("aa", True) aa.local_size = 300 a.add_child(aa) aaa = ModelFile("aaa", False) aaa.local_size = 100 aa.add_child(aaa) aab = ModelFile("aab", False) aab.local_size = 100 aa.add_child(aab) aac = ModelFile("aac", True) aac.local_size = 100 aa.add_child(aac) aaca = ModelFile("aaca", False) aaca.local_size = 100 aac.add_child(aaca) ab = ModelFile("ab", True) ab.local_size = 100 a.add_child(ab) aba = ModelFile("aba", False) aba.local_size = 100 ab.add_child(aba) ac = ModelFile("ac", False) ac.local_size = 100 a.add_child(ac) self.dispatch.add_listener(self.listener) self.dispatch.extract(a) while self.listener.extract_completed.call_count < 1: pass self.listener.extract_completed.assert_called_once_with("a", True) golden_calls = { ( os.path.join(self.local_path, "a", "aa", "aaa"), os.path.join(self.out_dir_path, "a", "aa") ), ( os.path.join(self.local_path, "a", "aa", "aac", "aaca"), os.path.join(self.out_dir_path, "a", "aa", "aac") ), ( os.path.join(self.local_path, "a", "ab", "aba"), os.path.join(self.out_dir_path, "a", "ab") ), } self.assertEqual(3, self.mock_extract_archive.call_count) self.assertEqual(golden_calls, self.actual_calls) # noinspection SpellCheckingInspection @timeout_decorator.timeout(2) def test_extract_dir_does_not_extract_split_rar_files(self): self.mock_is_archive.return_value = True self.actual_calls = set() def _extract(archive_path: str, out_dir_path: str): self.actual_calls.add((archive_path, out_dir_path)) self.mock_extract_archive.side_effect = _extract a = ModelFile("a", True) a.local_size = 80 aa = ModelFile("aa.rar", False) aa.local_size = 10 a.add_child(aa) aa0 = ModelFile("aa.r00", False) aa0.local_size = 10 a.add_child(aa0) aa1 = ModelFile("aa.r01", False) aa1.local_size = 10 a.add_child(aa1) aa2 = ModelFile("aa.r02", False) aa2.local_size = 10 a.add_child(aa2) aa15 = ModelFile("aa.r15", False) aa15.local_size = 10 a.add_child(aa15) ab = ModelFile("ab.rar", False) ab.local_size = 10 a.add_child(ab) ab0 = ModelFile("ab.r000", False) ab0.local_size = 10 a.add_child(ab0) ab1 = ModelFile("ab.r001", False) ab1.local_size = 10 a.add_child(ab1) ac = ModelFile("ac", True) ac.local_size = 20 a.add_child(ac) aca = ModelFile("aca", True) aca.local_size = 20 ac.add_child(aca) acaa = ModelFile("acaa.rar", False) acaa.local_size = 10 aca.add_child(acaa) acaa0 = ModelFile("acaa.r00", False) acaa0.local_size = 10 aca.add_child(acaa0) self.dispatch.add_listener(self.listener) self.dispatch.extract(a) while self.listener.extract_completed.call_count < 1: pass self.listener.extract_completed.assert_called_once_with("a", True) golden_calls = { ( os.path.join(self.local_path, "a", "aa.rar"), os.path.join(self.out_dir_path, "a") ), ( os.path.join(self.local_path, "a", "ab.rar"), os.path.join(self.out_dir_path, "a") ), ( os.path.join(self.local_path, "a", "ac", "aca", "acaa.rar"), os.path.join(self.out_dir_path, "a", "ac", "aca") ), } self.assertEqual(3, self.mock_extract_archive.call_count) self.assertEqual(golden_calls, self.actual_calls) @timeout_decorator.timeout(2) def test_extract_dir_exits_command_early_on_shutdown(self): # Send extract dir command with two archives # Call shutdown after first extract but before second # Verify second extract is not called self.mock_is_archive.return_value = True self.call_stop = False def _extract_archive(**kwargs): print(kwargs) self.call_stop = True time.sleep(0.5) # wait a bit so shutdown is called self.mock_extract_archive.side_effect = _extract_archive a = ModelFile("a", True) a.local_size = 200 aa = ModelFile("aa", False) aa.local_size = 100 a.add_child(aa) ab = ModelFile("ab", False) ab.local_size = 100 a.add_child(ab) self.dispatch.add_listener(self.listener) self.dispatch.extract(a) while not self.call_stop: pass self.dispatch.stop() while self.mock_extract_archive.call_count < 1 \ or self.listener.extract_failed.call_count < 1: pass self.listener.extract_completed.assert_not_called() self.listener.extract_failed.assert_called_once_with("a", True) self.assertEqual(1, self.mock_extract_archive.call_count) @timeout_decorator.timeout(2) def test_status(self): self.mock_is_archive.return_value = True self.send_count = 0 self.rx_count = 0 # noinspection PyUnusedLocal def _extract(**kwargs): # barrier implementation while self.send_count <= self.rx_count: pass self.rx_count += 1 self.mock_extract_archive.side_effect = _extract a = ModelFile("a", True) a.local_size = 200 aa = ModelFile("aa", False) aa.local_size = 100 a.add_child(aa) ab = ModelFile("ab", False) ab.local_size = 100 a.add_child(ab) b = ModelFile("b", True) b.local_size = 100 ba = ModelFile("ba", False) ba.local_size = 100 b.add_child(ba) c = ModelFile("c", False) c.local_size = 100 # Initial status should be empty status = self.dispatch.status() self.assertEqual(0, len(status)) self.dispatch.add_listener(self.listener) self.dispatch.extract(a) self.dispatch.extract(b) self.dispatch.extract(c) status = self.dispatch.status() self.assertEqual(3, len(status)) self.assertEqual("a", status[0].name) self.assertEqual(True, status[0].is_dir) self.assertEqual(ExtractStatus.State.EXTRACTING, status[0].state) self.assertEqual("b", status[1].name) self.assertEqual(True, status[1].is_dir) self.assertEqual(ExtractStatus.State.EXTRACTING, status[1].state) self.assertEqual("c", status[2].name) self.assertEqual(False, status[2].is_dir) self.assertEqual(ExtractStatus.State.EXTRACTING, status[2].state) # Wait for first dir to start extracting self.send_count = 1 while self.rx_count < self.send_count: pass status = self.dispatch.status() self.assertEqual(3, len(status)) self.assertEqual("a", status[0].name) self.assertEqual(True, status[0].is_dir) self.assertEqual(ExtractStatus.State.EXTRACTING, status[0].state) self.assertEqual("b", status[1].name) self.assertEqual(True, status[1].is_dir) self.assertEqual(ExtractStatus.State.EXTRACTING, status[1].state) self.assertEqual("c", status[2].name) self.assertEqual(False, status[2].is_dir) self.assertEqual(ExtractStatus.State.EXTRACTING, status[2].state) # After first directory finishes self.send_count = 2 while self.listener.extract_completed.call_count < 1: pass self.listener.extract_completed.assert_called_with("a", True) status = self.dispatch.status() self.assertEqual(2, len(status)) self.assertEqual("b", status[0].name) self.assertEqual(True, status[0].is_dir) self.assertEqual(ExtractStatus.State.EXTRACTING, status[0].state) self.assertEqual("c", status[1].name) self.assertEqual(False, status[1].is_dir) self.assertEqual(ExtractStatus.State.EXTRACTING, status[1].state) # After second directory finishes self.send_count = 3 while self.listener.extract_completed.call_count < 2: pass self.listener.extract_completed.assert_called_with("b", True) status = self.dispatch.status() self.assertEqual(1, len(status)) self.assertEqual("c", status[0].name) self.assertEqual(False, status[0].is_dir) self.assertEqual(ExtractStatus.State.EXTRACTING, status[0].state) # After third/last file finishes self.send_count = 4 while self.listener.extract_completed.call_count < 3: pass self.listener.extract_completed.assert_called_with("c", False) status = self.dispatch.status() self.assertEqual(0, len(status)) @timeout_decorator.timeout(2) def test_extract_ignores_duplicate_calls(self): # Send two extract commands to same file # Expect that only one extract operation is performed self.mock_is_archive.return_value = True self.barrier = False def _extract_archive(**kwargs): print(kwargs) while not self.barrier: pass self.mock_extract_archive.side_effect = _extract_archive a = ModelFile("a", False) a.local_size = 200 self.dispatch.add_listener(self.listener) self.dispatch.extract(a) self.dispatch.extract(a) time.sleep(0.1) self.barrier = True time.sleep(0.1) while self.mock_extract_archive.call_count < 1 and \ self.listener.extract_completed.call_count < 1: pass time.sleep(0.1) self.listener.extract_completed.assert_called_once_with("a", False) self.listener.extract_failed.assert_not_called() self.assertEqual(1, self.mock_extract_archive.call_count) ================================================ FILE: src/python/tests/unittests/test_controller/test_extract/test_extract_process.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest import logging from unittest.mock import patch import sys import multiprocessing import ctypes import threading import time import timeout_decorator from model import ModelFile from controller.extract import ExtractProcess, ExtractListener, ExtractStatus class TestExtractProcess(unittest.TestCase): def setUp(self): dispatch_patcher = patch('controller.extract.extract_process.ExtractDispatch') self.addCleanup(dispatch_patcher.stop) self.mock_dispatch_cls = dispatch_patcher.start() self.mock_dispatch = self.mock_dispatch_cls.return_value # by default mock returns empty statuses self.mock_dispatch.status.return_value = [] logger = logging.getLogger() handler = logging.StreamHandler(sys.stdout) logger.addHandler(handler) logger.setLevel(logging.DEBUG) formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") handler.setFormatter(formatter) # Assign process to this variable so that it can be cleaned up # even after an error self.process = None def tearDown(self): if self.process: self.process.terminate() @timeout_decorator.timeout(2) def test_param_out_dir_path(self): self.out_dir_path = multiprocessing.Array(ctypes.c_char, 100) self.ctor_called = multiprocessing.Value('i', 0) def mock_ctor(**kwargs): self.out_dir_path.value = str.encode(kwargs["out_dir_path"]) self.ctor_called.value = 1 return self.mock_dispatch self.mock_dispatch_cls.side_effect = mock_ctor self.process = ExtractProcess(out_dir_path="/test/out/path", local_path="/test/local/path") self.process.start() # Wait for ctor to be called while self.ctor_called.value == 0: pass self.assertEqual("/test/out/path", self.out_dir_path.value.decode()) @timeout_decorator.timeout(2) def test_param_out_local_path(self): self.local_path = multiprocessing.Array(ctypes.c_char, 100) self.ctor_called = multiprocessing.Value('i', 0) def mock_ctor(**kwargs): self.local_path.value = str.encode(kwargs["local_path"]) self.ctor_called.value = 1 return self.mock_dispatch self.mock_dispatch_cls.side_effect = mock_ctor self.process = ExtractProcess(out_dir_path="/test/out/path", local_path="/test/local/path") self.process.start() # Wait for ctor to be called while self.ctor_called.value == 0: pass self.assertEqual("/test/local/path", self.local_path.value.decode()) @timeout_decorator.timeout(2) def test_calls_start_dispatch(self): self.start_called = multiprocessing.Value('i', 0) def _start(): self.start_called.value = 1 self.mock_dispatch.start.side_effect = _start self.process = ExtractProcess(out_dir_path="/test/out/path", local_path="/test/local/path") self.process.start() while self.start_called.value == 0: pass @timeout_decorator.timeout(10) def test_retrieves_status(self): # Use this as a signal to mock to control which status to send self.status_signal = multiprocessing.Value('i', 0) self.status_counter = multiprocessing.Value('i', 0) s_a = ExtractStatus(name="a", is_dir=True, state=ExtractStatus.State.EXTRACTING) s_b = ExtractStatus(name="b", is_dir=False, state=ExtractStatus.State.EXTRACTING) s_c = ExtractStatus(name="c", is_dir=True, state=ExtractStatus.State.EXTRACTING) def _status(): ret = None if self.status_signal.value == 0: ret = [s_a] elif self.status_signal.value == 1: ret = [s_a, s_b] elif self.status_signal.value == 2: ret = [s_c] elif self.status_signal.value == 3: ret = [] self.status_counter.value += 1 return ret self.mock_dispatch.status.side_effect = _status self.process = ExtractProcess(out_dir_path="", local_path="") self.process.start() # wait for first call to status (actually second call to guarantee first status is queued) while self.status_counter.value < 2: pass status_result = self.process.pop_latest_statuses() self.assertEqual(1, len(status_result.statuses)) self.assertEqual("a", status_result.statuses[0].name) self.assertEqual(True, status_result.statuses[0].is_dir) self.assertEqual(ExtractStatus.State.EXTRACTING, status_result.statuses[0].state) # signal for status #1 and wait status fetch self.status_signal.value = 1 orig_counter = self.status_counter.value while self.status_counter.value < orig_counter+2: pass status_result = self.process.pop_latest_statuses() self.assertEqual(2, len(status_result.statuses)) self.assertEqual("a", status_result.statuses[0].name) self.assertEqual(True, status_result.statuses[0].is_dir) self.assertEqual(ExtractStatus.State.EXTRACTING, status_result.statuses[0].state) self.assertEqual("b", status_result.statuses[1].name) self.assertEqual(False, status_result.statuses[1].is_dir) self.assertEqual(ExtractStatus.State.EXTRACTING, status_result.statuses[1].state) # signal for status #2 and wait status fetch self.status_signal.value = 2 orig_counter = self.status_counter.value while self.status_counter.value < orig_counter+2: pass status_result = self.process.pop_latest_statuses() self.assertEqual(1, len(status_result.statuses)) self.assertEqual("c", status_result.statuses[0].name) self.assertEqual(True, status_result.statuses[0].is_dir) self.assertEqual(ExtractStatus.State.EXTRACTING, status_result.statuses[0].state) # signal for status #3 and wait status fetch self.status_signal.value = 3 orig_counter = self.status_counter.value while self.status_counter.value < orig_counter+2: pass status_result = self.process.pop_latest_statuses() self.assertEqual(0, len(status_result.statuses)) @timeout_decorator.timeout(10) def test_retrieves_completed(self): # Use this as a signal to mock to control which completed list to send self.completed_signal = multiprocessing.Value('i', 0) self.completed_counter = multiprocessing.Value('i', 0) def _add_listener(listener: ExtractListener): print("Listener added") def _callback_sequence(): listener.extract_completed(name="a", is_dir=True) time.sleep(0.1) self.completed_signal.value = 1 time.sleep(1.0) listener.extract_completed(name="b", is_dir=False) listener.extract_completed(name="c", is_dir=True) time.sleep(0.1) self.completed_signal.value = 2 threading.Thread(target=_callback_sequence()).start() self.mock_dispatch.add_listener.side_effect = _add_listener self.process = ExtractProcess(out_dir_path="", local_path="") self.process.start() while self.completed_signal.value < 1: pass completed = self.process.pop_completed() self.assertEqual(1, len(completed)) self.assertEqual("a", completed[0].name) self.assertEqual(True, completed[0].is_dir) # next one should be empty completed = self.process.pop_completed() self.assertEqual(0, len(completed)) while self.completed_signal.value < 2: pass completed = self.process.pop_completed() self.assertEqual(2, len(completed)) self.assertEqual("b", completed[0].name) self.assertEqual(False, completed[0].is_dir) self.assertEqual("c", completed[1].name) self.assertEqual(True, completed[1].is_dir) # next one should be empty completed = self.process.pop_completed() self.assertEqual(0, len(completed)) @timeout_decorator.timeout(5) def test_forwards_extract_commands(self): a = ModelFile("a", True) a.local_size = 100 aa = ModelFile("aa", False) aa.local_size = 60 a.add_child(aa) ab = ModelFile("ab", False) ab.local_size = 40 a.add_child(ab) b = ModelFile("b", True) b.local_size = 10 ba = ModelFile("ba", True) ba.local_size = 10 b.add_child(ba) baa = ModelFile("baa", False) baa.local_size = 10 ba.add_child(baa) c = ModelFile("c", False) c.local_size = 1234 self.extract_counter = multiprocessing.Value('i', 0) def _extract(file: ModelFile): print(file.name) if self.extract_counter.value == 0: self.assertEqual("a", file.name) self.assertEqual(True, file.is_dir) self.assertEqual(100, file.local_size) children = file.get_children() self.assertEqual(2, len(children)) self.assertEqual("aa", children[0].name) self.assertEqual(False, children[0].is_dir) self.assertEqual(60, children[0].local_size) self.assertEqual("ab", children[1].name) self.assertEqual(False, children[0].is_dir) self.assertEqual(40, children[1].local_size) elif self.extract_counter.value == 1: self.assertEqual("b", file.name) self.assertEqual(True, file.is_dir) self.assertEqual(10, file.local_size) self.assertEqual(1, len(file.get_children())) child = file.get_children()[0] self.assertEqual("ba", child.name) self.assertEqual(True, child.is_dir) self.assertEqual(10, child.local_size) self.assertEqual(1, len(child.get_children())) subchild = child.get_children()[0] self.assertEqual("baa", subchild.name) self.assertEqual(False, subchild.is_dir) self.assertEqual(10, subchild.local_size) elif self.extract_counter.value == 2: self.assertEqual("c", file.name) self.assertEqual(False, file.is_dir) self.assertEqual(1234, file.local_size) self.extract_counter.value += 1 self.mock_dispatch.extract.side_effect = _extract self.process = ExtractProcess(out_dir_path="", local_path="") self.process.start() self.process.extract(a) time.sleep(1) self.process.extract(b) self.process.extract(c) while self.extract_counter.value < 3: pass ================================================ FILE: src/python/tests/unittests/test_controller/test_model_builder.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import logging import sys import unittest from unittest.mock import patch from datetime import datetime from system import SystemFile from lftp import LftpJobStatus from model import ModelError, ModelFile, Model from controller import ModelBuilder from controller.extract import ExtractStatus class TestModelBuilder(unittest.TestCase): def setUp(self): logger = logging.getLogger(TestModelBuilder.__name__) handler = logging.StreamHandler(sys.stdout) logger.addHandler(handler) logger.setLevel(logging.DEBUG) formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") handler.setFormatter(formatter) self.model_builder = ModelBuilder() self.model_builder.set_base_logger(logger) def __build_test_model_children_tree_1(self) -> Model: """Build a test model for children testing""" self.model_builder.clear() r_a = SystemFile("a", 1024, True) r_aa = SystemFile("aa", 512, False) r_a.add_child(r_aa) r_ab = SystemFile("ab", 512, False) r_a.add_child(r_ab) r_b = SystemFile("b", 3090, True) r_ba = SystemFile("ba", 2048, True) r_b.add_child(r_ba) r_baa = SystemFile("baa", 2048, False) r_ba.add_child(r_baa) r_bb = SystemFile("bb", 42, True) # only in remote r_b.add_child(r_bb) r_bba = SystemFile("bba", 42, False) # only in remote r_bb.add_child(r_bba) r_bd = SystemFile("bd", 1000, False) r_b.add_child(r_bd) r_c = SystemFile("c", 1234, False) # only in remote r_d = SystemFile("d", 5678, True) # only in remote r_da = SystemFile("da", 5678, False) # only in remote r_d.add_child(r_da) l_a = SystemFile("a", 1024, True) l_aa = SystemFile("aa", 512, False) l_a.add_child(l_aa) l_ab = SystemFile("ab", 512, False) l_a.add_child(l_ab) l_b = SystemFile("b", 1611, True) l_ba = SystemFile("ba", 512, True) l_b.add_child(l_ba) l_baa = SystemFile("baa", 512, False) l_ba.add_child(l_baa) l_bc = SystemFile("bc", 99, True) # only in local l_b.add_child(l_bc) l_bca = SystemFile("bca", 99, False) # only in local l_bc.add_child(l_bca) l_bd = SystemFile("bd", 1000, False) l_b.add_child(l_bd) s_b = LftpJobStatus(0, LftpJobStatus.Type.MIRROR, LftpJobStatus.State.RUNNING, "b", "") s_b.total_transfer_state = LftpJobStatus.TransferState(1611, 3090, 52, 10, 1000) s_b.add_active_file_transfer_state("ba/baa", LftpJobStatus.TransferState(512, 2048, 25, 5, 500)) s_c = LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.QUEUED, "c", "") s_d = LftpJobStatus(0, LftpJobStatus.Type.MIRROR, LftpJobStatus.State.QUEUED, "d", "") self.model_builder.set_remote_files([r_a, r_b, r_c, r_d]) self.model_builder.set_local_files([l_a, l_b]) self.model_builder.set_lftp_statuses([s_b, s_c, s_d]) return self.model_builder.build_model() def test_build_file_names(self): remote_files = [SystemFile("a", 0, False), SystemFile("b", 0, False)] local_files = [SystemFile("b", 0, False), SystemFile("c", 0, False)] statuses = [LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.QUEUED, "b", ""), LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.QUEUED, "d", "")] self.model_builder.set_remote_files(remote_files) self.model_builder.set_local_files(local_files) self.model_builder.set_lftp_statuses(statuses) model = self.model_builder.build_model() self.assertEqual({"a", "b", "c", "d"}, model.get_file_names()) def test_build_is_dir(self): # remote self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 0, False)]) model = self.model_builder.build_model() self.assertEqual(False, model.get_file("a").is_dir) self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 0, True)]) model = self.model_builder.build_model() self.assertEqual(True, model.get_file("a").is_dir) # local self.model_builder.clear() self.model_builder.set_local_files([SystemFile("a", 0, False)]) model = self.model_builder.build_model() self.assertEqual(False, model.get_file("a").is_dir) self.model_builder.clear() self.model_builder.set_local_files([SystemFile("a", 0, True)]) model = self.model_builder.build_model() self.assertEqual(True, model.get_file("a").is_dir) # statuses self.model_builder.clear() self.model_builder.set_lftp_statuses([ LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.QUEUED, "a", "") ]) model = self.model_builder.build_model() self.assertEqual(False, model.get_file("a").is_dir) self.model_builder.clear() self.model_builder.set_lftp_statuses([ LftpJobStatus(0, LftpJobStatus.Type.MIRROR, LftpJobStatus.State.QUEUED, "a", "") ]) model = self.model_builder.build_model() self.assertEqual(True, model.get_file("a").is_dir) # all three self.model_builder.set_remote_files([SystemFile("a", 0, False)]) self.model_builder.set_local_files([SystemFile("a", 0, False)]) self.model_builder.set_lftp_statuses([ LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.QUEUED, "a", "") ]) model = self.model_builder.build_model() self.assertEqual(False, model.get_file("a").is_dir) self.model_builder.set_remote_files([SystemFile("a", 0, True)]) self.model_builder.set_local_files([SystemFile("a", 0, True)]) self.model_builder.set_lftp_statuses([ LftpJobStatus(0, LftpJobStatus.Type.MIRROR, LftpJobStatus.State.QUEUED, "a", "") ]) model = self.model_builder.build_model() self.assertEqual(True, model.get_file("a").is_dir) def test_build_mismatch_is_dir(self): """Mismatching is_dir raises error""" # remote mismatches self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 0, True)]) self.model_builder.set_local_files([SystemFile("a", 0, False)]) self.model_builder.set_lftp_statuses([ LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.QUEUED, "a", "") ]) with self.assertRaises(ModelError) as context: self.model_builder.build_model() self.assertTrue(str(context.exception).startswith("Mismatch in is_dir")) # local mismatches self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 0, False)]) self.model_builder.set_local_files([SystemFile("a", 0, True)]) self.model_builder.set_lftp_statuses([ LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.QUEUED, "a", "") ]) with self.assertRaises(ModelError) as context: self.model_builder.build_model() self.assertTrue(str(context.exception).startswith("Mismatch in is_dir")) # status mismatches self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 0, False)]) self.model_builder.set_local_files([SystemFile("a", 0, False)]) self.model_builder.set_lftp_statuses([ LftpJobStatus(0, LftpJobStatus.Type.MIRROR, LftpJobStatus.State.QUEUED, "a", "") ]) with self.assertRaises(ModelError) as context: self.model_builder.build_model() self.assertTrue(str(context.exception).startswith("Mismatch in is_dir")) # extracting mismatches self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 0, False)]) self.model_builder.set_local_files([SystemFile("a", 0, False)]) self.model_builder.set_extract_statuses([ExtractStatus("a", True, ExtractStatus.State.EXTRACTING)]) with self.assertRaises(ModelError) as context: self.model_builder.build_model() self.assertTrue(str(context.exception).startswith("Mismatch in is_dir between file and extract status")) def test_build_state(self): # Queued self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 0, False)]) self.model_builder.set_local_files([SystemFile("a", 0, False)]) self.model_builder.set_lftp_statuses([ LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.QUEUED, "a", "") ]) model = self.model_builder.build_model() self.assertEqual(ModelFile.State.QUEUED, model.get_file("a").state) # Downloading self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 0, False)]) self.model_builder.set_local_files([SystemFile("a", 0, False)]) self.model_builder.set_lftp_statuses([ LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.RUNNING, "a", "") ]) model = self.model_builder.build_model() self.assertEqual(ModelFile.State.DOWNLOADING, model.get_file("a").state) # Default self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 100, False)]) self.model_builder.set_local_files([SystemFile("a", 0, False)]) model = self.model_builder.build_model() self.assertEqual(ModelFile.State.DEFAULT, model.get_file("a").state) # Default - local only self.model_builder.clear() self.model_builder.set_local_files([SystemFile("a", 100, False)]) model = self.model_builder.build_model() self.assertEqual(ModelFile.State.DEFAULT, model.get_file("a").state) # Downloaded self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 100, False)]) self.model_builder.set_local_files([SystemFile("a", 100, False)]) model = self.model_builder.build_model() self.assertEqual(ModelFile.State.DOWNLOADED, model.get_file("a").state) # Deleted self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 100, False)]) self.model_builder.set_downloaded_files({"a"}) model = self.model_builder.build_model() self.assertEqual(ModelFile.State.DELETED, model.get_file("a").state) # Deleted but Queued self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 100, False)]) self.model_builder.set_downloaded_files({"a"}) self.model_builder.set_lftp_statuses([ LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.QUEUED, "a", "") ]) model = self.model_builder.build_model() self.assertEqual(ModelFile.State.QUEUED, model.get_file("a").state) # Deleted but Downloading self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 100, False)]) self.model_builder.set_downloaded_files({"a"}) self.model_builder.set_lftp_statuses([ LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.RUNNING, "a", "") ]) model = self.model_builder.build_model() self.assertEqual(ModelFile.State.DOWNLOADING, model.get_file("a").state) # Deleted, then partially Downloaded self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 100, False)]) self.model_builder.set_local_files([SystemFile("a", 50, False)]) self.model_builder.set_downloaded_files({"a"}) model = self.model_builder.build_model() self.assertEqual(ModelFile.State.DEFAULT, model.get_file("a").state) # Downloaded, and Extracting self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 100, False)]) self.model_builder.set_local_files([SystemFile("a", 100, False)]) self.model_builder.set_extract_statuses([ExtractStatus("a", False, ExtractStatus.State.EXTRACTING)]) model = self.model_builder.build_model() self.assertEqual(ModelFile.State.EXTRACTING, model.get_file("a").state) # Local-only, and Extracting self.model_builder.clear() self.model_builder.set_local_files([SystemFile("a", 100, False)]) self.model_builder.set_extract_statuses([ExtractStatus("a", False, ExtractStatus.State.EXTRACTING)]) model = self.model_builder.build_model() self.assertEqual(ModelFile.State.EXTRACTING, model.get_file("a").state) # Remote-only, and Extracting (unexpected: should fall-back to Default) self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 100, False)]) self.model_builder.set_extract_statuses([ExtractStatus("a", False, ExtractStatus.State.EXTRACTING)]) model = self.model_builder.build_model() self.assertEqual(ModelFile.State.DEFAULT, model.get_file("a").state) # Extracting and Downloading/Queued (unexpected: should ignore Extracting) self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 100, False)]) self.model_builder.set_local_files([SystemFile("a", 50, False)]) self.model_builder.set_lftp_statuses([ LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.RUNNING, "a", "") ]) self.model_builder.set_extract_statuses([ExtractStatus("a", False, ExtractStatus.State.EXTRACTING)]) model = self.model_builder.build_model() self.assertEqual(ModelFile.State.DOWNLOADING, model.get_file("a").state) # Extracting and Deleted (unexpected: should ignore Extracting) self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 100, False)]) self.model_builder.set_downloaded_files({"a"}) self.model_builder.set_extract_statuses([ExtractStatus("a", False, ExtractStatus.State.EXTRACTING)]) model = self.model_builder.build_model() self.assertEqual(ModelFile.State.DELETED, model.get_file("a").state) # Downloaded+Extracted, but extracting again self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 100, False)]) self.model_builder.set_local_files([SystemFile("a", 100, False)]) self.model_builder.set_downloaded_files({"a"}) self.model_builder.set_extracted_files({"a"}) self.model_builder.set_extract_statuses([ExtractStatus("a", False, ExtractStatus.State.EXTRACTING)]) model = self.model_builder.build_model() self.assertEqual(ModelFile.State.EXTRACTING, model.get_file("a").state) # Downloaded, and Extracted self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 100, False)]) self.model_builder.set_local_files([SystemFile("a", 100, False)]) self.model_builder.set_extracted_files({"a"}) model = self.model_builder.build_model() self.assertEqual(ModelFile.State.EXTRACTED, model.get_file("a").state) # Local-only, and Extracted self.model_builder.clear() self.model_builder.set_local_files([SystemFile("a", 100, False)]) self.model_builder.set_extracted_files({"a"}) model = self.model_builder.build_model() self.assertEqual(ModelFile.State.DEFAULT, model.get_file("a").state) # Remote-only, and Extracted self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 100, False)]) self.model_builder.set_extracted_files({"a"}) model = self.model_builder.build_model() self.assertEqual(ModelFile.State.DEFAULT, model.get_file("a").state) # Extracted, but Downloading/Queued (possible after deletion) self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 100, False)]) self.model_builder.set_local_files([SystemFile("a", 50, False)]) self.model_builder.set_lftp_statuses([ LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.RUNNING, "a", "") ]) self.model_builder.set_extracted_files({"a"}) model = self.model_builder.build_model() self.assertEqual(ModelFile.State.DOWNLOADING, model.get_file("a").state) # Extracted and Deleted self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 100, False)]) self.model_builder.set_downloaded_files({"a"}) self.model_builder.set_extracted_files({"a"}) model = self.model_builder.build_model() self.assertEqual(ModelFile.State.DELETED, model.get_file("a").state) def test_build_remote_size(self): self.model_builder.set_remote_files([SystemFile("a", 42, False)]) model = self.model_builder.build_model() self.assertEqual(42, model.get_file("a").remote_size) self.model_builder.clear() self.model_builder.set_local_files([SystemFile("a", 42, False)]) model = self.model_builder.build_model() self.assertEqual(None, model.get_file("a").remote_size) self.model_builder.clear() self.model_builder.set_lftp_statuses([ LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.QUEUED, "a", "") ]) model = self.model_builder.build_model() self.assertEqual(None, model.get_file("a").remote_size) def test_build_remote_size_from_status_is_ignored(self): self.model_builder.set_remote_files([SystemFile("a", 42, False)]) s = LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.RUNNING, "a", "") s.total_transfer_state = LftpJobStatus.TransferState(None, 12345, None, None, None) self.model_builder.set_lftp_statuses([s]) model = self.model_builder.build_model() self.assertEqual(42, model.get_file("a").remote_size) def test_build_local_size(self): self.model_builder.set_local_files([SystemFile("a", 42, False)]) model = self.model_builder.build_model() self.assertEqual(42, model.get_file("a").local_size) self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 42, False)]) model = self.model_builder.build_model() self.assertEqual(None, model.get_file("a").local_size) self.model_builder.clear() self.model_builder.set_lftp_statuses([ LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.QUEUED, "a", "") ]) model = self.model_builder.build_model() self.assertEqual(None, model.get_file("a").local_size) def test_build_local_size_from_status_is_ignored(self): self.model_builder.set_local_files([SystemFile("a", 42, False)]) s = LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.RUNNING, "a", "") s.total_transfer_state = LftpJobStatus.TransferState(12345, 1000, 0.25, None, None) self.model_builder.set_lftp_statuses([s]) model = self.model_builder.build_model() self.assertEqual(42, model.get_file("a").local_size) def test_build_local_size_downloading(self): self.model_builder.set_local_files([SystemFile("a", 42, False)]) self.model_builder.set_active_files([SystemFile("a", 99, False)]) s = LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.RUNNING, "a", "") s.total_transfer_state = LftpJobStatus.TransferState(12345, 1000, 0.25, None, None) self.model_builder.set_lftp_statuses([s]) model = self.model_builder.build_model() self.assertEqual(99, model.get_file("a").local_size) def test_build_downloading_state_is_retained(self): # downloading files latest info should be retained even after # they have stopped downloading self.model_builder.set_local_files([SystemFile("a", 42, False)]) self.model_builder.set_active_files([SystemFile("a", 99, False)]) s = LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.RUNNING, "a", "") s.total_transfer_state = LftpJobStatus.TransferState(12345, 1000, 0.25, None, None) self.model_builder.set_lftp_statuses([s]) model = self.model_builder.build_model() self.assertEqual(99, model.get_file("a").local_size) # set active files to empty self.model_builder.set_active_files([]) s = LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.RUNNING, "a", "") s.total_transfer_state = LftpJobStatus.TransferState(12345, 1000, 0.25, None, None) self.model_builder.set_lftp_statuses([s]) model = self.model_builder.build_model() self.assertEqual(99, model.get_file("a").local_size) def test_build_downloading_speed(self): s = LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.RUNNING, "a", "") s.total_transfer_state = LftpJobStatus.TransferState(None, None, None, 1234, None) self.model_builder.set_lftp_statuses([s]) model = self.model_builder.build_model() self.assertEqual(1234, model.get_file("a").downloading_speed) self.model_builder.clear() s = LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.RUNNING, "a", "") self.model_builder.set_lftp_statuses([s]) model = self.model_builder.build_model() self.assertEqual(None, model.get_file("a").downloading_speed) self.model_builder.clear() self.model_builder.set_local_files([SystemFile("a", 42, False)]) model = self.model_builder.build_model() self.assertEqual(None, model.get_file("a").downloading_speed) self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 42, False)]) model = self.model_builder.build_model() self.assertEqual(None, model.get_file("a").downloading_speed) def test_build_eta(self): s = LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.RUNNING, "a", "") s.total_transfer_state = LftpJobStatus.TransferState(None, None, None, None, 4567) self.model_builder.set_lftp_statuses([s]) model = self.model_builder.build_model() self.assertEqual(4567, model.get_file("a").eta) self.model_builder.clear() s = LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.RUNNING, "a", "") self.model_builder.set_lftp_statuses([s]) model = self.model_builder.build_model() self.assertEqual(None, model.get_file("a").eta) self.model_builder.clear() self.model_builder.set_local_files([SystemFile("a", 42, False)]) model = self.model_builder.build_model() self.assertEqual(None, model.get_file("a").eta) self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 42, False)]) model = self.model_builder.build_model() self.assertEqual(None, model.get_file("a").eta) def test_build_estimated_eta(self): s = LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.RUNNING, "a", "") s.total_transfer_state = LftpJobStatus.TransferState(None, None, None, 100, None) self.model_builder.set_lftp_statuses([s]) self.model_builder.set_remote_files([SystemFile("a", 2000, False)]) self.model_builder.set_local_files([SystemFile("a", 1000, False)]) model = self.model_builder.build_model() self.assertEqual(10, model.get_file("a").eta) # round up self.model_builder.clear() s = LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.RUNNING, "a", "") s.total_transfer_state = LftpJobStatus.TransferState(None, None, None, 133, None) self.model_builder.set_lftp_statuses([s]) self.model_builder.set_remote_files([SystemFile("a", 2000, False)]) self.model_builder.set_local_files([SystemFile("a", 1000, False)]) model = self.model_builder.build_model() self.assertEqual(8, model.get_file("a").eta) # round up self.model_builder.clear() s = LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.RUNNING, "a", "") s.total_transfer_state = LftpJobStatus.TransferState(None, None, None, 133, None) self.model_builder.set_lftp_statuses([s]) self.model_builder.set_remote_files([SystemFile("a", 2000, False)]) self.model_builder.set_local_files([SystemFile("a", 1999, False)]) model = self.model_builder.build_model() self.assertEqual(1, model.get_file("a").eta) # zero downloading speed self.model_builder.clear() s = LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.RUNNING, "a", "") s.total_transfer_state = LftpJobStatus.TransferState(None, None, None, 0, None) self.model_builder.set_lftp_statuses([s]) self.model_builder.set_remote_files([SystemFile("a", 2000, False)]) self.model_builder.set_local_files([SystemFile("a", 1000, False)]) model = self.model_builder.build_model() self.assertEqual(None, model.get_file("a").eta) # finished self.model_builder.clear() s = LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.RUNNING, "a", "") s.total_transfer_state = LftpJobStatus.TransferState(None, None, None, 200, None) self.model_builder.set_lftp_statuses([s]) self.model_builder.set_remote_files([SystemFile("a", 2000, False)]) self.model_builder.set_local_files([SystemFile("a", 2000, False)]) model = self.model_builder.build_model() self.assertEqual(0, model.get_file("a").eta) # local size larger than remote self.model_builder.clear() s = LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.RUNNING, "a", "") s.total_transfer_state = LftpJobStatus.TransferState(None, None, None, 200, None) self.model_builder.set_lftp_statuses([s]) self.model_builder.set_remote_files([SystemFile("a", 2000, False)]) self.model_builder.set_local_files([SystemFile("a", 3000, False)]) model = self.model_builder.build_model() self.assertEqual(0, model.get_file("a").eta) def test_build_children_names(self): model = self.__build_test_model_children_tree_1() self.assertEqual({"a", "b", "c", "d"}, model.get_file_names()) m_a_ch = {m.name: m for m in model.get_file("a").get_children()} self.assertEqual({"aa", "ab"}, m_a_ch.keys()) m_b_ch = {m.name: m for m in model.get_file("b").get_children()} self.assertEqual({"ba", "bb", "bc", "bd"}, m_b_ch.keys()) m_ba_ch = {m.name: m for m in m_b_ch["ba"].get_children()} self.assertEqual({"baa"}, m_ba_ch.keys()) m_baa_ch = {m.name: m for m in m_ba_ch["baa"].get_children()} self.assertEqual(0, len(m_baa_ch.keys())) m_bb_ch = {m.name: m for m in m_b_ch["bb"].get_children()} self.assertEqual({"bba"}, m_bb_ch.keys()) m_bba_ch = {m.name: m for m in m_bb_ch["bba"].get_children()} self.assertEqual(0, len(m_bba_ch.keys())) m_bc_ch = {m.name: m for m in m_b_ch["bc"].get_children()} self.assertEqual({"bca"}, m_bc_ch.keys()) m_bca_ch = {m.name: m for m in m_bc_ch["bca"].get_children()} self.assertEqual(0, len(m_bca_ch.keys())) m_c_ch = {m.name: m for m in model.get_file("c").get_children()} self.assertEqual(0, len(m_c_ch.keys())) m_d_ch = {m.name: m for m in model.get_file("d").get_children()} self.assertEqual({"da"}, m_d_ch.keys()) m_da_ch = {m.name: m for m in m_d_ch["da"].get_children()} self.assertEqual(0, len(m_da_ch.keys())) def test_build_children_is_dir(self): model = self.__build_test_model_children_tree_1() m_a = model.get_file("a") self.assertEqual(True, m_a.is_dir) m_a_ch = {m.name: m for m in model.get_file("a").get_children()} m_aa = m_a_ch["aa"] self.assertEqual(False, m_aa.is_dir) m_ab = m_a_ch["ab"] self.assertEqual(False, m_ab.is_dir) m_b = model.get_file("b") self.assertEqual(True, m_b.is_dir) m_b_ch = {m.name: m for m in model.get_file("b").get_children()} m_ba = m_b_ch["ba"] self.assertEqual(True, m_ba.is_dir) m_baa = m_ba.get_children()[0] self.assertEqual(False, m_baa.is_dir) m_bb = m_b_ch["bb"] self.assertEqual(True, m_bb.is_dir) m_bba = m_bb.get_children()[0] self.assertEqual(False, m_bba.is_dir) m_bc = m_b_ch["bc"] self.assertEqual(True, m_bc.is_dir) m_bca = m_bc.get_children()[0] self.assertEqual(False, m_bca.is_dir) m_bd = m_b_ch["bd"] self.assertEqual(False, m_bd.is_dir) m_c = model.get_file("c") self.assertEqual(False, m_c.is_dir) m_d = model.get_file("d") self.assertEqual(True, m_d.is_dir) m_d_ch = {m.name: m for m in model.get_file("d").get_children()} m_da = m_d_ch["da"] self.assertEqual(False, m_da.is_dir) def test_build_children_mismatch_is_dir(self): """Mismatching is_dir in a child raises error""" r_a = SystemFile("a", 0, True) r_aa = SystemFile("aa", 0, True) r_a.add_child(r_aa) l_a = SystemFile("a", 0, True) l_aa = SystemFile("aa", 0, False) l_a.add_child(l_aa) self.model_builder.set_remote_files([r_a]) self.model_builder.set_local_files([l_a]) with self.assertRaises(ModelError) as context: self.model_builder.build_model() self.assertTrue(str(context.exception).startswith("Mismatch in is_dir between child")) def test_build_children_sizes(self): model = self.__build_test_model_children_tree_1() m_a = model.get_file("a") self.assertEqual((1024, 1024, 1024), (m_a.remote_size, m_a.local_size, m_a.transferred_size)) m_a_ch = {m.name: m for m in model.get_file("a").get_children()} m_aa = m_a_ch["aa"] self.assertEqual((512, 512, 512), (m_aa.remote_size, m_aa.local_size, m_aa.transferred_size)) m_ab = m_a_ch["ab"] self.assertEqual((512, 512, 512), (m_ab.remote_size, m_ab.local_size, m_ab.transferred_size)) m_b = model.get_file("b") self.assertEqual((3090, 1611, 1512), (m_b.remote_size, m_b.local_size, m_b.transferred_size)) m_b_ch = {m.name: m for m in model.get_file("b").get_children()} m_ba = m_b_ch["ba"] self.assertEqual((2048, 512, 512), (m_ba.remote_size, m_ba.local_size, m_ba.transferred_size)) m_baa = m_ba.get_children()[0] self.assertEqual((2048, 512, 512), (m_baa.remote_size, m_baa.local_size, m_baa.transferred_size)) m_bb = m_b_ch["bb"] self.assertEqual((42, None, None), (m_bb.remote_size, m_bb.local_size, m_bb.transferred_size)) m_bba = m_bb.get_children()[0] self.assertEqual((42, None, None), (m_bba.remote_size, m_bba.local_size, m_bba.transferred_size)) m_bc = m_b_ch["bc"] self.assertEqual((None, 99, None), (m_bc.remote_size, m_bc.local_size, m_bc.transferred_size)) m_bca = m_bc.get_children()[0] self.assertEqual((None, 99, None), (m_bca.remote_size, m_bca.local_size, m_bca.transferred_size)) m_bd = m_b_ch["bd"] self.assertEqual((1000, 1000, 1000), (m_bd.remote_size, m_bd.local_size, m_bd.transferred_size)) m_c = model.get_file("c") self.assertEqual((1234, None, None), (m_c.remote_size, m_c.local_size, m_c.transferred_size)) m_d = model.get_file("d") self.assertEqual((5678, None, None), (m_d.remote_size, m_d.local_size, m_d.transferred_size)) m_d_ch = {m.name: m for m in model.get_file("d").get_children()} m_da = m_d_ch["da"] self.assertEqual((5678, None, None), (m_da.remote_size, m_da.local_size, m_da.transferred_size)) def test_build_children_state_default(self): """File only exists remotely""" r_a = SystemFile("a", 300, True) r_aa = SystemFile("aa", 100, True) r_a.add_child(r_aa) r_aaa = SystemFile("aaa", 100, False) r_aa.add_child(r_aaa) r_ab = SystemFile("ab", 200, False) r_a.add_child(r_ab) self.model_builder.set_remote_files([r_a]) model = self.model_builder.build_model() m_a = model.get_file("a") self.assertEqual(ModelFile.State.DEFAULT, m_a.state) m_a_ch = {m.name: m for m in model.get_file("a").get_children()} m_aa = m_a_ch["aa"] self.assertEqual(ModelFile.State.DEFAULT, m_aa.state) m_aaa = m_aa.get_children()[0] self.assertEqual(ModelFile.State.DEFAULT, m_aaa.state) m_ab = m_a_ch["ab"] self.assertEqual(ModelFile.State.DEFAULT, m_ab.state) def test_build_children_state_default_partial(self): """File is partially downloaded""" r_a = SystemFile("a", 300, True) r_aa = SystemFile("aa", 100, True) r_a.add_child(r_aa) r_aaa = SystemFile("aaa", 100, False) r_aa.add_child(r_aaa) r_ab = SystemFile("ab", 200, False) r_a.add_child(r_ab) l_a = SystemFile("a", 150, True) l_aa = SystemFile("aa", 50, True) l_a.add_child(l_aa) l_aaa = SystemFile("aaa", 50, False) l_aa.add_child(l_aaa) l_ab = SystemFile("ab", 100, False) l_a.add_child(l_ab) self.model_builder.set_remote_files([r_a]) self.model_builder.set_local_files([l_a]) model = self.model_builder.build_model() m_a = model.get_file("a") self.assertEqual(ModelFile.State.DEFAULT, m_a.state) m_a_ch = {m.name: m for m in model.get_file("a").get_children()} m_aa = m_a_ch["aa"] self.assertEqual(ModelFile.State.DEFAULT, m_aa.state) m_aaa = m_aa.get_children()[0] self.assertEqual(ModelFile.State.DEFAULT, m_aaa.state) m_ab = m_a_ch["ab"] self.assertEqual(ModelFile.State.DEFAULT, m_ab.state) def test_build_children_state_default_extra(self): """File only exists locally""" l_a = SystemFile("a", 150, True) l_aa = SystemFile("aa", 50, True) l_a.add_child(l_aa) l_aaa = SystemFile("aaa", 50, False) l_aa.add_child(l_aaa) l_ab = SystemFile("ab", 100, False) l_a.add_child(l_ab) self.model_builder.set_local_files([l_a]) model = self.model_builder.build_model() m_a = model.get_file("a") self.assertEqual(ModelFile.State.DEFAULT, m_a.state) m_a_ch = {m.name: m for m in model.get_file("a").get_children()} m_aa = m_a_ch["aa"] self.assertEqual(ModelFile.State.DEFAULT, m_aa.state) m_aaa = m_aa.get_children()[0] self.assertEqual(ModelFile.State.DEFAULT, m_aaa.state) m_ab = m_a_ch["ab"] self.assertEqual(ModelFile.State.DEFAULT, m_ab.state) def test_build_children_state_downloaded_full(self): r_a = SystemFile("a", 300, True) r_aa = SystemFile("aa", 100, True) r_a.add_child(r_aa) r_aaa = SystemFile("aaa", 100, False) r_aa.add_child(r_aaa) r_ab = SystemFile("ab", 200, False) r_a.add_child(r_ab) l_a = SystemFile("a", 300, True) l_aa = SystemFile("aa", 100, True) l_a.add_child(l_aa) l_aaa = SystemFile("aaa", 100, False) l_aa.add_child(l_aaa) l_ab = SystemFile("ab", 200, False) l_a.add_child(l_ab) self.model_builder.set_remote_files([r_a]) self.model_builder.set_local_files([l_a]) model = self.model_builder.build_model() m_a = model.get_file("a") self.assertEqual(ModelFile.State.DOWNLOADED, m_a.state) m_a_ch = {m.name: m for m in model.get_file("a").get_children()} m_aa = m_a_ch["aa"] self.assertEqual(ModelFile.State.DEFAULT, m_aa.state) m_aaa = m_aa.get_children()[0] self.assertEqual(ModelFile.State.DOWNLOADED, m_aaa.state) m_ab = m_a_ch["ab"] self.assertEqual(ModelFile.State.DOWNLOADED, m_ab.state) def test_build_children_state_downloaded_full_extra(self): """Fully downloaded but with an extra local-only file""" r_a = SystemFile("a", 300, True) r_aa = SystemFile("aa", 100, True) r_a.add_child(r_aa) r_aaa = SystemFile("aaa", 100, False) r_aa.add_child(r_aaa) r_ab = SystemFile("ab", 200, False) r_a.add_child(r_ab) l_a = SystemFile("a", 400, True) l_aa = SystemFile("aa", 100, True) l_a.add_child(l_aa) l_aaa = SystemFile("aaa", 100, False) l_aa.add_child(l_aaa) l_ab = SystemFile("ab", 200, False) l_a.add_child(l_ab) l_ac = SystemFile("ac", 100, True) # local only l_a.add_child(l_ac) l_aca = SystemFile("aca", 100, False) # local only l_ac.add_child(l_aca) self.model_builder.set_remote_files([r_a]) self.model_builder.set_local_files([l_a]) model = self.model_builder.build_model() m_a = model.get_file("a") self.assertEqual(ModelFile.State.DOWNLOADED, m_a.state) m_a_ch = {m.name: m for m in model.get_file("a").get_children()} m_aa = m_a_ch["aa"] self.assertEqual(ModelFile.State.DEFAULT, m_aa.state) m_aaa = m_aa.get_children()[0] self.assertEqual(ModelFile.State.DOWNLOADED, m_aaa.state) m_ab = m_a_ch["ab"] self.assertEqual(ModelFile.State.DOWNLOADED, m_ab.state) l_ac = SystemFile("ac", 100, True) # local only l_a.add_child(l_ac) l_aca = SystemFile("aca", 100, False) # local only l_ac.add_child(l_aca) def test_build_children_state_downloaded_partial(self): r_a = SystemFile("a", 300, True) r_aa = SystemFile("aa", 100, True) r_a.add_child(r_aa) r_aaa = SystemFile("aaa", 100, False) r_aa.add_child(r_aaa) r_ab = SystemFile("ab", 200, False) r_a.add_child(r_ab) l_a = SystemFile("a", 250, True) l_aa = SystemFile("aa", 50, True) l_a.add_child(l_aa) l_aaa = SystemFile("aaa", 50, False) l_aa.add_child(l_aaa) l_ab = SystemFile("ab", 200, False) l_a.add_child(l_ab) self.model_builder.set_remote_files([r_a]) self.model_builder.set_local_files([l_a]) model = self.model_builder.build_model() m_a = model.get_file("a") self.assertEqual(ModelFile.State.DEFAULT, m_a.state) m_a_ch = {m.name: m for m in model.get_file("a").get_children()} m_aa = m_a_ch["aa"] self.assertEqual(ModelFile.State.DEFAULT, m_aa.state) m_aaa = m_aa.get_children()[0] self.assertEqual(ModelFile.State.DEFAULT, m_aaa.state) m_ab = m_a_ch["ab"] self.assertEqual(ModelFile.State.DOWNLOADED, m_ab.state) def test_build_children_state_downloaded_partial_extra(self): """Partially downloaded but with an extra local-only file""" r_a = SystemFile("a", 300, True) r_aa = SystemFile("aa", 100, True) r_a.add_child(r_aa) r_aaa = SystemFile("aaa", 100, False) r_aa.add_child(r_aaa) r_ab = SystemFile("ab", 200, False) r_a.add_child(r_ab) l_a = SystemFile("a", 350, True) l_aa = SystemFile("aa", 50, True) l_a.add_child(l_aa) l_aaa = SystemFile("aaa", 50, False) l_aa.add_child(l_aaa) l_ab = SystemFile("ab", 200, False) l_a.add_child(l_ab) l_ac = SystemFile("ac", 100, True) # local only l_a.add_child(l_ac) l_aca = SystemFile("aca", 100, False) # local only l_ac.add_child(l_aca) self.model_builder.set_remote_files([r_a]) self.model_builder.set_local_files([l_a]) model = self.model_builder.build_model() m_a = model.get_file("a") self.assertEqual(ModelFile.State.DEFAULT, m_a.state) m_a_ch = {m.name: m for m in model.get_file("a").get_children()} m_aa = m_a_ch["aa"] self.assertEqual(ModelFile.State.DEFAULT, m_aa.state) m_aaa = m_aa.get_children()[0] self.assertEqual(ModelFile.State.DEFAULT, m_aaa.state) m_ab = m_a_ch["ab"] self.assertEqual(ModelFile.State.DOWNLOADED, m_ab.state) m_ac = m_a_ch["ac"] self.assertEqual(ModelFile.State.DEFAULT, m_ac.state) m_aca = m_ac.get_children()[0] self.assertEqual(ModelFile.State.DEFAULT, m_aca.state) def test_build_children_state_queued(self): r_a = SystemFile("a", 0, True) r_aa = SystemFile("aa", 0, True) r_a.add_child(r_aa) r_aaa = SystemFile("aaa", 0, False) r_aa.add_child(r_aaa) r_ab = SystemFile("ab", 0, False) r_a.add_child(r_ab) s_a = LftpJobStatus(0, LftpJobStatus.Type.MIRROR, LftpJobStatus.State.QUEUED, "a", "") self.model_builder.set_remote_files([r_a]) self.model_builder.set_lftp_statuses([s_a]) model = self.model_builder.build_model() m_a = model.get_file("a") self.assertEqual(ModelFile.State.QUEUED, m_a.state) m_a_ch = {m.name: m for m in model.get_file("a").get_children()} m_aa = m_a_ch["aa"] self.assertEqual(ModelFile.State.DEFAULT, m_aa.state) m_aaa = m_aa.get_children()[0] self.assertEqual(ModelFile.State.QUEUED, m_aaa.state) m_ab = m_a_ch["ab"] self.assertEqual(ModelFile.State.QUEUED, m_ab.state) def test_build_children_state_downloading_1(self): # Child files are active r_a = SystemFile("a", 0, True) r_aa = SystemFile("aa", 0, True) r_a.add_child(r_aa) r_aaa = SystemFile("aaa", 0, False) r_aa.add_child(r_aaa) r_ab = SystemFile("ab", 0, False) r_a.add_child(r_ab) s_a = LftpJobStatus(0, LftpJobStatus.Type.MIRROR, LftpJobStatus.State.RUNNING, "a", "") s_a.add_active_file_transfer_state("aa/aaa", LftpJobStatus.TransferState(None, None, None, None, None)) s_a.add_active_file_transfer_state("ab", LftpJobStatus.TransferState(None, None, None, None, None)) self.model_builder.set_remote_files([r_a]) self.model_builder.set_lftp_statuses([s_a]) model = self.model_builder.build_model() m_a = model.get_file("a") self.assertEqual(ModelFile.State.DOWNLOADING, m_a.state) m_a_ch = {m.name: m for m in model.get_file("a").get_children()} m_aa = m_a_ch["aa"] self.assertEqual(ModelFile.State.DEFAULT, m_aa.state) m_aaa = m_aa.get_children()[0] self.assertEqual(ModelFile.State.DOWNLOADING, m_aaa.state) m_ab = m_a_ch["ab"] self.assertEqual(ModelFile.State.DOWNLOADING, m_ab.state) def test_build_children_state_downloading_2(self): # Child files are finished r_a = SystemFile("a", 150, True) r_aa = SystemFile("aa", 100, True) r_a.add_child(r_aa) r_aaa = SystemFile("aaa", 100, False) r_aa.add_child(r_aaa) r_ab = SystemFile("ab", 50, False) r_a.add_child(r_ab) s_a = LftpJobStatus(0, LftpJobStatus.Type.MIRROR, LftpJobStatus.State.RUNNING, "a", "") self.model_builder.set_remote_files([r_a]) self.model_builder.set_local_files([r_a]) self.model_builder.set_lftp_statuses([s_a]) model = self.model_builder.build_model() m_a = model.get_file("a") self.assertEqual(ModelFile.State.DOWNLOADING, m_a.state) m_a_ch = {m.name: m for m in model.get_file("a").get_children()} m_aa = m_a_ch["aa"] self.assertEqual(ModelFile.State.DEFAULT, m_aa.state) m_aaa = m_aa.get_children()[0] self.assertEqual(ModelFile.State.DOWNLOADED, m_aaa.state) m_ab = m_a_ch["ab"] self.assertEqual(ModelFile.State.DOWNLOADED, m_ab.state) def test_build_children_state_downloading_3(self): # Child files are queued r_a = SystemFile("a", 0, True) r_aa = SystemFile("aa", 0, True) r_a.add_child(r_aa) r_aaa = SystemFile("aaa", 0, False) r_aa.add_child(r_aaa) r_ab = SystemFile("ab", 0, False) r_a.add_child(r_ab) s_a = LftpJobStatus(0, LftpJobStatus.Type.MIRROR, LftpJobStatus.State.RUNNING, "a", "") self.model_builder.set_remote_files([r_a]) self.model_builder.set_lftp_statuses([s_a]) model = self.model_builder.build_model() m_a = model.get_file("a") self.assertEqual(ModelFile.State.DOWNLOADING, m_a.state) m_a_ch = {m.name: m for m in model.get_file("a").get_children()} m_aa = m_a_ch["aa"] self.assertEqual(ModelFile.State.DEFAULT, m_aa.state) m_aaa = m_aa.get_children()[0] self.assertEqual(ModelFile.State.QUEUED, m_aaa.state) m_ab = m_a_ch["ab"] self.assertEqual(ModelFile.State.QUEUED, m_ab.state) def test_build_children_state_downloading_4(self): # Child files are only present in local r_a = SystemFile("a", 0, True) r_aa = SystemFile("aa", 0, True) r_a.add_child(r_aa) l_a = SystemFile("a", 0, True) l_aa = SystemFile("aa", 0, True) l_a.add_child(l_aa) l_aaa = SystemFile("aaa", 0, False) l_aa.add_child(l_aaa) l_ab = SystemFile("ab", 0, False) l_a.add_child(l_ab) s_a = LftpJobStatus(0, LftpJobStatus.Type.MIRROR, LftpJobStatus.State.RUNNING, "a", "") self.model_builder.set_remote_files([r_a]) self.model_builder.set_local_files([l_a]) self.model_builder.set_lftp_statuses([s_a]) model = self.model_builder.build_model() m_a = model.get_file("a") self.assertEqual(ModelFile.State.DOWNLOADING, m_a.state) m_a_ch = {m.name: m for m in model.get_file("a").get_children()} m_aa = m_a_ch["aa"] self.assertEqual(ModelFile.State.DEFAULT, m_aa.state) m_aaa = m_aa.get_children()[0] self.assertEqual(ModelFile.State.DEFAULT, m_aaa.state) m_ab = m_a_ch["ab"] self.assertEqual(ModelFile.State.DEFAULT, m_ab.state) def test_build_children_state_all(self): model = self.__build_test_model_children_tree_1() m_a = model.get_file("a") self.assertEqual(ModelFile.State.DOWNLOADED, m_a.state) m_a_ch = {m.name: m for m in model.get_file("a").get_children()} m_aa = m_a_ch["aa"] self.assertEqual(ModelFile.State.DOWNLOADED, m_aa.state) m_ab = m_a_ch["ab"] self.assertEqual(ModelFile.State.DOWNLOADED, m_ab.state) m_b = model.get_file("b") self.assertEqual(ModelFile.State.DOWNLOADING, m_b.state) m_b_ch = {m.name: m for m in model.get_file("b").get_children()} m_ba = m_b_ch["ba"] self.assertEqual(ModelFile.State.DEFAULT, m_ba.state) m_baa = m_ba.get_children()[0] self.assertEqual(ModelFile.State.DOWNLOADING, m_baa.state) m_bb = m_b_ch["bb"] self.assertEqual(ModelFile.State.DEFAULT, m_bb.state) m_bba = m_bb.get_children()[0] self.assertEqual(ModelFile.State.QUEUED, m_bba.state) m_bc = m_b_ch["bc"] self.assertEqual(ModelFile.State.DEFAULT, m_bc.state) m_bca = m_bc.get_children()[0] self.assertEqual(ModelFile.State.DEFAULT, m_bca.state) m_bd = m_b_ch["bd"] self.assertEqual(ModelFile.State.DOWNLOADED, m_bd.state) m_c = model.get_file("c") self.assertEqual(ModelFile.State.QUEUED, m_c.state) m_d = model.get_file("d") self.assertEqual(ModelFile.State.QUEUED, m_d.state) m_d_ch = {m.name: m for m in model.get_file("d").get_children()} m_da = m_d_ch["da"] self.assertEqual(ModelFile.State.QUEUED, m_da.state) def test_build_children_downloading_speed(self): model = self.__build_test_model_children_tree_1() m_a = model.get_file("a") self.assertEqual(None, m_a.downloading_speed) m_a_ch = {m.name: m for m in model.get_file("a").get_children()} m_aa = m_a_ch["aa"] self.assertEqual(None, m_aa.downloading_speed) m_ab = m_a_ch["ab"] self.assertEqual(None, m_ab.downloading_speed) m_b = model.get_file("b") self.assertEqual(10, m_b.downloading_speed) m_b_ch = {m.name: m for m in model.get_file("b").get_children()} m_ba = m_b_ch["ba"] self.assertEqual(None, m_ba.downloading_speed) m_baa = m_ba.get_children()[0] self.assertEqual(5, m_baa.downloading_speed) m_bb = m_b_ch["bb"] self.assertEqual(None, m_bb.downloading_speed) m_bba = m_bb.get_children()[0] self.assertEqual(None, m_bba.downloading_speed) m_bc = m_b_ch["bc"] self.assertEqual(None, m_bc.downloading_speed) m_bca = m_bc.get_children()[0] self.assertEqual(None, m_bca.downloading_speed) m_bd = m_b_ch["bd"] self.assertEqual(None, m_bd.downloading_speed) m_c = model.get_file("c") self.assertEqual(None, m_c.downloading_speed) m_d = model.get_file("d") self.assertEqual(None, m_d.downloading_speed) m_d_ch = {m.name: m for m in model.get_file("d").get_children()} m_da = m_d_ch["da"] self.assertEqual(None, m_da.downloading_speed) def test_build_children_eta(self): model = self.__build_test_model_children_tree_1() m_a = model.get_file("a") self.assertEqual(None, m_a.eta) m_a_ch = {m.name: m for m in model.get_file("a").get_children()} m_aa = m_a_ch["aa"] self.assertEqual(None, m_aa.eta) m_ab = m_a_ch["ab"] self.assertEqual(None, m_ab.eta) m_b = model.get_file("b") self.assertEqual(1000, m_b.eta) m_b_ch = {m.name: m for m in model.get_file("b").get_children()} m_ba = m_b_ch["ba"] self.assertEqual(None, m_ba.eta) m_baa = m_ba.get_children()[0] self.assertEqual(500, m_baa.eta) m_bb = m_b_ch["bb"] self.assertEqual(None, m_bb.eta) m_bba = m_bb.get_children()[0] self.assertEqual(None, m_bba.eta) m_bc = m_b_ch["bc"] self.assertEqual(None, m_bc.eta) m_bca = m_bc.get_children()[0] self.assertEqual(None, m_bca.eta) m_bd = m_b_ch["bd"] self.assertEqual(None, m_bd.eta) m_c = model.get_file("c") self.assertEqual(None, m_c.eta) m_d = model.get_file("d") self.assertEqual(None, m_d.eta) m_d_ch = {m.name: m for m in model.get_file("d").get_children()} m_da = m_d_ch["da"] self.assertEqual(None, m_da.eta) @patch("controller.model_builder.Extract") def test_build_sets_is_extractable(self, mock_extract_module): mock_is_archive_fast = mock_extract_module.is_archive_fast is_archive_list = [] def _is_archive_fast(name: str): return name in is_archive_list mock_is_archive_fast.side_effect = _is_archive_fast # Root local file self.model_builder.clear() is_archive_list = ["a"] self.model_builder.set_local_files([SystemFile("a", 10, False), SystemFile("b", 10, False)]) model = self.model_builder.build_model() self.assertTrue(model.get_file("a").is_extractable) self.assertFalse(model.get_file("b").is_extractable) # Root remote file self.model_builder.clear() is_archive_list = ["b"] self.model_builder.set_remote_files([SystemFile("a", 10, False), SystemFile("b", 10, False)]) model = self.model_builder.build_model() self.assertFalse(model.get_file("a").is_extractable) self.assertTrue(model.get_file("b").is_extractable) # Directory with archive self.model_builder.clear() is_archive_list = ["aa"] a = SystemFile("a", 10, True) aa = SystemFile("aa", 10, False) a.add_child(aa) self.model_builder.set_local_files([a]) model = self.model_builder.build_model() self.assertTrue(model.get_file("a").is_extractable) self.assertEqual("aa", model.get_file("a").get_children()[0].name) self.assertTrue(model.get_file("a").get_children()[0].is_extractable) # Directory with non-archive self.model_builder.clear() is_archive_list = ["aa"] a = SystemFile("a", 10, True) aa = SystemFile("ab", 10, False) a.add_child(aa) self.model_builder.set_local_files([a]) model = self.model_builder.build_model() self.assertFalse(model.get_file("a").is_extractable) self.assertEqual("ab", model.get_file("a").get_children()[0].name) self.assertFalse(model.get_file("a").get_children()[0].is_extractable) # Directory with archive and non-archive self.model_builder.clear() is_archive_list = ["ab"] a = SystemFile("a", 10, True) aa = SystemFile("aa", 10, False) ab = SystemFile("ab", 10, False) a.add_child(aa) a.add_child(ab) self.model_builder.set_local_files([a]) model = self.model_builder.build_model() self.assertTrue(model.get_file("a").is_extractable) a_children = {f.name: f for f in model.get_file("a").get_children()} self.assertFalse(a_children["aa"].is_extractable) self.assertTrue(a_children["ab"].is_extractable) # Directory with archive and non-archive sub-directories self.model_builder.clear() is_archive_list = ["aba"] a = SystemFile("a", 10, True) aa = SystemFile("aa", 10, True) aaa = SystemFile("aaa", 10, False) ab = SystemFile("ab", 10, True) aba = SystemFile("aba", 10, False) a.add_child(aa) a.add_child(ab) aa.add_child(aaa) ab.add_child(aba) self.model_builder.set_local_files([a]) model = self.model_builder.build_model() self.assertTrue(model.get_file("a").is_extractable) a_children = {f.name: f for f in model.get_file("a").get_children()} self.assertFalse(a_children["aa"].is_extractable) self.assertEqual("aaa", a_children["aa"].get_children()[0].name) self.assertFalse(a_children["aa"].get_children()[0].is_extractable) self.assertTrue(a_children["ab"].is_extractable) self.assertEqual("aba", a_children["ab"].get_children()[0].name) self.assertTrue(a_children["ab"].get_children()[0].is_extractable) # Directory name passes is_archive, but not file self.model_builder.clear() is_archive_list = ["a"] a = SystemFile("a", 10, True) aa = SystemFile("aa", 10, False) a.add_child(aa) self.model_builder.set_local_files([a]) model = self.model_builder.build_model() self.assertFalse(model.get_file("a").is_extractable) self.assertEqual("aa", model.get_file("a").get_children()[0].name) self.assertFalse(model.get_file("a").get_children()[0].is_extractable) def test_build_transferred_size(self): # both remote and local self.model_builder.set_remote_files([SystemFile("a", 42, False)]) self.model_builder.set_local_files([SystemFile("a", 22, False)]) model = self.model_builder.build_model() self.assertEqual(22, model.get_file("a").transferred_size) # remote but no local self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 42, False)]) model = self.model_builder.build_model() self.assertEqual(None, model.get_file("a").transferred_size) # local but no remote self.model_builder.clear() self.model_builder.set_local_files([SystemFile("a", 22, False)]) model = self.model_builder.build_model() self.assertEqual(None, model.get_file("a").transferred_size) # local size larger than remote self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 42, False)]) self.model_builder.set_local_files([SystemFile("a", 55, False)]) model = self.model_builder.build_model() self.assertEqual(42, model.get_file("a").transferred_size) # both remote and local directory (but no children specified) self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 42, True)]) self.model_builder.set_local_files([SystemFile("a", 22, True)]) model = self.model_builder.build_model() self.assertEqual(0, model.get_file("a").transferred_size) # remote only directory self.model_builder.clear() self.model_builder.set_remote_files([SystemFile("a", 42, True)]) model = self.model_builder.build_model() self.assertEqual(None, model.get_file("a").transferred_size) # local only directory self.model_builder.clear() self.model_builder.set_local_files([SystemFile("a", 22, True)]) model = self.model_builder.build_model() self.assertEqual(None, model.get_file("a").transferred_size) def test_build_local_created_timestamp(self): self.model_builder.set_local_files([ SystemFile("a", 42, False, time_created=datetime(2018, 11, 9, 21, 40, 18)), SystemFile("b", 42, False) ]) model = self.model_builder.build_model() self.assertEqual(datetime(2018, 11, 9, 21, 40, 18), model.get_file("a").local_created_timestamp) self.assertIsNone(model.get_file("b").local_created_timestamp) def test_build_local_modified_timestamp(self): self.model_builder.set_local_files([ SystemFile("a", 42, False, time_modified=datetime(2018, 11, 9, 21, 40, 18)), SystemFile("b", 42, False) ]) model = self.model_builder.build_model() self.assertEqual(datetime(2018, 11, 9, 21, 40, 18), model.get_file("a").local_modified_timestamp) self.assertIsNone(model.get_file("b").local_modified_timestamp) def test_build_remote_created_timestamp(self): self.model_builder.set_remote_files([ SystemFile("a", 42, False, time_created=datetime(2018, 11, 9, 21, 40, 18)), SystemFile("b", 42, False) ]) model = self.model_builder.build_model() self.assertEqual(datetime(2018, 11, 9, 21, 40, 18), model.get_file("a").remote_created_timestamp) self.assertIsNone(model.get_file("b").remote_created_timestamp) def test_build_remote_modified_timestamp(self): self.model_builder.set_remote_files([ SystemFile("a", 42, False, time_modified=datetime(2018, 11, 9, 21, 40, 18)), SystemFile("b", 42, False) ]) model = self.model_builder.build_model() self.assertEqual(datetime(2018, 11, 9, 21, 40, 18), model.get_file("a").remote_modified_timestamp) self.assertIsNone(model.get_file("b").remote_modified_timestamp) def test_rebuild(self): remote_files = [SystemFile("a", 0, False), SystemFile("b", 0, False)] local_files = [SystemFile("b", 0, False), SystemFile("c", 0, False)] statuses = [LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.QUEUED, "b", ""), LftpJobStatus(0, LftpJobStatus.Type.PGET, LftpJobStatus.State.QUEUED, "d", "")] self.model_builder.set_remote_files(remote_files) self.model_builder.set_local_files(local_files) self.model_builder.set_lftp_statuses(statuses) model = self.model_builder.build_model() self.assertEqual({"a", "b", "c", "d"}, model.get_file_names()) self.assertFalse(self.model_builder.has_changes()) # Set without any changes remote_files = [SystemFile("a", 0, False), SystemFile("b", 0, False)] self.model_builder.set_remote_files(remote_files) self.assertFalse(self.model_builder.has_changes()) model = self.model_builder.build_model() self.assertEqual({"a", "b", "c", "d"}, model.get_file_names()) # Set with changes remote_files = [SystemFile("b", 0, False), SystemFile("e", 0, False)] self.model_builder.set_remote_files(remote_files) self.assertTrue(self.model_builder.has_changes()) model = self.model_builder.build_model() self.assertEqual({"b", "c", "d", "e"}, model.get_file_names()) def test_rebuild_on_active_files(self): self.assertTrue(self.model_builder.has_changes()) # Initial set self.model_builder.set_active_files([ SystemFile("a", 10), SystemFile("b", 20) ]) self.model_builder.build_model() self.assertFalse(self.model_builder.has_changes()) # Invalidates even on same active files self.model_builder.set_active_files([ SystemFile("a", 10), SystemFile("b", 20) ]) self.assertTrue(self.model_builder.has_changes()) self.model_builder.build_model() # Does not invalidate on empty active files self.model_builder.set_active_files([]) self.assertFalse(self.model_builder.has_changes()) def test_rebuild_on_local_files(self): self.assertTrue(self.model_builder.has_changes()) # Initial set self.model_builder.set_local_files([ SystemFile("a", 10), SystemFile("b", 20) ]) self.model_builder.build_model() self.assertFalse(self.model_builder.has_changes()) # Does not invalidate on same self.model_builder.set_local_files([ SystemFile("a", 10), SystemFile("b", 20) ]) self.assertFalse(self.model_builder.has_changes()) # Invalidate on different self.model_builder.set_local_files([ SystemFile("a", 10), SystemFile("b", 21) ]) self.assertTrue(self.model_builder.has_changes()) def test_rebuild_on_remote_files(self): self.assertTrue(self.model_builder.has_changes()) # Initial set self.model_builder.set_remote_files([ SystemFile("a", 10), SystemFile("b", 20) ]) self.model_builder.build_model() self.assertFalse(self.model_builder.has_changes()) # Does not invalidate on same self.model_builder.set_remote_files([ SystemFile("a", 10), SystemFile("b", 20) ]) self.assertFalse(self.model_builder.has_changes()) # Invalidate on different self.model_builder.set_remote_files([ SystemFile("a", 10), SystemFile("b", 21) ]) self.assertTrue(self.model_builder.has_changes()) def test_rebuild_on_lftp_statuses(self): self.assertTrue(self.model_builder.has_changes()) # Initial set s1 = LftpJobStatus(3, LftpJobStatus.Type.MIRROR, LftpJobStatus.State.RUNNING, "a", "flags") s1.total_transfer_state = LftpJobStatus.TransferState(100, 200, 50, 10, 50) s2 = LftpJobStatus(3, LftpJobStatus.Type.PGET, LftpJobStatus.State.QUEUED, "b", "flags") self.model_builder.set_lftp_statuses([s1, s2]) self.model_builder.build_model() self.assertFalse(self.model_builder.has_changes()) # Does not invalidate on same s1a = LftpJobStatus(3, LftpJobStatus.Type.MIRROR, LftpJobStatus.State.RUNNING, "a", "flags") s1a.total_transfer_state = LftpJobStatus.TransferState(100, 200, 50, 10, 50) s2a = LftpJobStatus(3, LftpJobStatus.Type.PGET, LftpJobStatus.State.QUEUED, "b", "flags") self.model_builder.set_lftp_statuses([s1a, s2a]) self.assertFalse(self.model_builder.has_changes()) # Invalidate on different s1b = LftpJobStatus(3, LftpJobStatus.Type.MIRROR, LftpJobStatus.State.RUNNING, "a", "flags") s1b.total_transfer_state = LftpJobStatus.TransferState(150, 200, 50, 10, 50) s2b = LftpJobStatus(3, LftpJobStatus.Type.PGET, LftpJobStatus.State.QUEUED, "b", "flags") self.model_builder.set_lftp_statuses([s1b, s2b]) self.assertTrue(self.model_builder.has_changes()) def test_rebuild_on_downloaded_files(self): self.assertTrue(self.model_builder.has_changes()) # Initial set self.model_builder.set_downloaded_files({"a", "b"}) self.model_builder.build_model() self.assertFalse(self.model_builder.has_changes()) # Does not invalidate on same self.model_builder.set_downloaded_files({"a", "b"}) self.assertFalse(self.model_builder.has_changes()) # Invalidate on different self.model_builder.set_downloaded_files({"a", "c"}) self.assertTrue(self.model_builder.has_changes()) def test_rebuild_on_extract_statuses(self): self.assertTrue(self.model_builder.has_changes()) # Initial set self.model_builder.set_extract_statuses([ ExtractStatus("a", True, ExtractStatus.State.EXTRACTING), ExtractStatus("a", True, ExtractStatus.State.EXTRACTING) ]) self.model_builder.build_model() self.assertFalse(self.model_builder.has_changes()) # Does not invalidate on same self.model_builder.set_extract_statuses([ ExtractStatus("a", True, ExtractStatus.State.EXTRACTING), ExtractStatus("a", True, ExtractStatus.State.EXTRACTING) ]) self.assertFalse(self.model_builder.has_changes()) # Invalidate on different self.model_builder.set_extract_statuses([ ExtractStatus("a", True, ExtractStatus.State.EXTRACTING), ExtractStatus("c", True, ExtractStatus.State.EXTRACTING) ]) self.assertTrue(self.model_builder.has_changes()) def test_rebuild_on_extracted_files(self): self.assertTrue(self.model_builder.has_changes()) # Initial set self.model_builder.set_extracted_files({"a", "b"}) self.model_builder.build_model() self.assertFalse(self.model_builder.has_changes()) # Does not invalidate on same self.model_builder.set_extracted_files({"a", "b"}) self.assertFalse(self.model_builder.has_changes()) # Invalidate on different self.model_builder.set_extracted_files({"a", "c"}) self.assertTrue(self.model_builder.has_changes()) ================================================ FILE: src/python/tests/unittests/test_controller/test_scan/__init__.py ================================================ ================================================ FILE: src/python/tests/unittests/test_controller/test_scan/test_remote_scanner.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest import logging import sys from unittest.mock import patch, call, ANY import tempfile import os import pickle import shutil from controller.scan import RemoteScanner, ScannerError from ssh import SshcpError from common import Localization class TestRemoteScanner(unittest.TestCase): temp_dir = None temp_scan_script = None def setUp(self): ssh_patcher = patch('controller.scan.remote_scanner.Sshcp') self.addCleanup(ssh_patcher.stop) self.mock_ssh_cls = ssh_patcher.start() self.mock_ssh = self.mock_ssh_cls.return_value logger = logging.getLogger() handler = logging.StreamHandler(sys.stdout) logger.addHandler(handler) logger.setLevel(logging.DEBUG) formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") handler.setFormatter(formatter) # Ssh to return mangled binary by default self.mock_ssh.shell.return_value = b'error' @classmethod def setUpClass(cls): TestRemoteScanner.temp_dir = tempfile.mkdtemp(prefix="test_remote_scanner") TestRemoteScanner.temp_scan_script = os.path.join(TestRemoteScanner.temp_dir, "script") with open(TestRemoteScanner.temp_scan_script, "w") as f: f.write("") @classmethod def tearDownClass(cls): shutil.rmtree(TestRemoteScanner.temp_dir) def test_correctly_initializes_ssh(self): self.ssh_args = {} def mock_ssh_ctor(**kwargs): self.ssh_args = kwargs self.mock_ssh_cls.side_effect = mock_ssh_ctor scanner = RemoteScanner( remote_address="my remote address", remote_username="my remote user", remote_password="my password", remote_port=1234, remote_path_to_scan="/remote/path/to/scan", local_path_to_scan_script=TestRemoteScanner.temp_scan_script, remote_path_to_scan_script="/remote/path/to/scan/script" ) self.assertIsNotNone(scanner) self.assertEqual("my remote address", self.ssh_args["host"]) self.assertEqual(1234, self.ssh_args["port"]) self.assertEqual("my remote user", self.ssh_args["user"]) self.assertEqual("my password", self.ssh_args["password"]) def test_installs_scan_script_on_first_scan(self): scanner = RemoteScanner( remote_address="my remote address", remote_username="my remote user", remote_password="my password", remote_port=1234, remote_path_to_scan="/remote/path/to/scan", local_path_to_scan_script=TestRemoteScanner.temp_scan_script, remote_path_to_scan_script="/remote/path/to/scan/script" ) self.ssh_run_command_count = 0 # Ssh returns error for md5sum check, empty pickle dump for later commands def ssh_shell(*args): self.ssh_run_command_count += 1 if self.ssh_run_command_count == 1: # first try return "".encode() else: # later tries return pickle.dumps([]) self.mock_ssh.shell.side_effect = ssh_shell scanner.scan() self.mock_ssh.copy.assert_called_once_with( local_path=TestRemoteScanner.temp_scan_script, remote_path="/remote/path/to/scan/script" ) self.mock_ssh.copy.reset_mock() # should not be called the second time scanner.scan() self.mock_ssh.copy.assert_not_called() def test_copy_appends_scanfs_name_to_remote_path(self): scanner = RemoteScanner( remote_address="my remote address", remote_username="my remote user", remote_password="my password", remote_port=1234, remote_path_to_scan="/remote/path/to/scan", local_path_to_scan_script=TestRemoteScanner.temp_scan_script, remote_path_to_scan_script="/remote/path/to/scan" ) self.ssh_run_command_count = 0 # Ssh returns error for md5sum check, empty pickle dump for later commands def ssh_shell(*args): self.ssh_run_command_count += 1 if self.ssh_run_command_count == 1: # first try return "".encode() else: # later tries return pickle.dumps([]) self.mock_ssh.shell.side_effect = ssh_shell scanner.scan() # check for appended path ('script') self.mock_ssh.copy.assert_called_once_with( local_path=TestRemoteScanner.temp_scan_script, remote_path="/remote/path/to/scan/script" ) def test_calls_correct_ssh_md5sum_command(self): scanner = RemoteScanner( remote_address="my remote address", remote_username="my remote user", remote_password="my password", remote_port=1234, remote_path_to_scan="/remote/path/to/scan", local_path_to_scan_script=TestRemoteScanner.temp_scan_script, remote_path_to_scan_script="/remote/path/to/scan/script" ) self.ssh_run_command_count = 0 # Ssh returns error for md5sum check, empty pickle dump for later commands def ssh_shell(*args): self.ssh_run_command_count += 1 if self.ssh_run_command_count == 1: # first try return "".encode() else: # later tries return pickle.dumps([]) self.mock_ssh.shell.side_effect = ssh_shell scanner.scan() self.assertEqual(2, self.mock_ssh.shell.call_count) self.mock_ssh.shell.assert_has_calls([ call("md5sum /remote/path/to/scan/script | awk '{print $1}' || echo"), call(ANY) ]) def test_skips_install_on_md5sum_match(self): scanner = RemoteScanner( remote_address="my remote address", remote_username="my remote user", remote_password="my password", remote_port=1234, remote_path_to_scan="/remote/path/to/scan", local_path_to_scan_script=TestRemoteScanner.temp_scan_script, remote_path_to_scan_script="/remote/path/to/scan/script" ) self.ssh_run_command_count = 0 # Ssh returns empty on md5sum, empty pickle dump for later commands def ssh_shell(*args): self.ssh_run_command_count += 1 if self.ssh_run_command_count == 1: # first try return "d41d8cd98f00b204e9800998ecf8427e".encode() else: # later tries return pickle.dumps([]) self.mock_ssh.shell.side_effect = ssh_shell scanner.scan() self.mock_ssh.copy.assert_not_called() self.mock_ssh.copy.reset_mock() # should not be called the second time either scanner.scan() self.mock_ssh.copy.assert_not_called() def test_installs_scan_script_on_any_md5sum_output(self): scanner = RemoteScanner( remote_address="my remote address", remote_username="my remote user", remote_password="my password", remote_port=1234, remote_path_to_scan="/remote/path/to/scan", local_path_to_scan_script=TestRemoteScanner.temp_scan_script, remote_path_to_scan_script="/remote/path/to/scan/script" ) self.ssh_run_command_count = 0 # Ssh returns error for md5sum check, empty pickle dump for later commands def ssh_shell(*args): self.ssh_run_command_count += 1 if self.ssh_run_command_count == 1: # first try return "some output from md5sum".encode() else: # later tries return pickle.dumps([]) self.mock_ssh.shell.side_effect = ssh_shell scanner.scan() self.mock_ssh.copy.assert_called_once_with( local_path=TestRemoteScanner.temp_scan_script, remote_path="/remote/path/to/scan/script" ) self.mock_ssh.copy.reset_mock() def test_raises_nonrecoverable_error_on_md5sum_error(self): scanner = RemoteScanner( remote_address="my remote address", remote_username="my remote user", remote_password="my password", remote_port=1234, remote_path_to_scan="/remote/path/to/scan", local_path_to_scan_script=TestRemoteScanner.temp_scan_script, remote_path_to_scan_script="/remote/path/to/scan/script" ) self.ssh_run_command_count = 0 # Ssh returns error for md5sum check, empty pickle dump for later commands def ssh_shell(*args): self.ssh_run_command_count += 1 if self.ssh_run_command_count == 1: # md5sum check raise SshcpError("an ssh error") else: # later tries return pickle.dumps([]) self.mock_ssh.shell.side_effect = ssh_shell with self.assertRaises(ScannerError) as ctx: scanner.scan() self.assertEqual(Localization.Error.REMOTE_SERVER_INSTALL.format("an ssh error"), str(ctx.exception)) self.assertFalse(ctx.exception.recoverable) def test_calls_correct_ssh_scan_command(self): scanner = RemoteScanner( remote_address="my remote address", remote_username="my remote user", remote_password="my password", remote_port=1234, remote_path_to_scan="/remote/path/to/scan", local_path_to_scan_script=TestRemoteScanner.temp_scan_script, remote_path_to_scan_script="/remote/path/to/scan/script" ) self.ssh_run_command_count = 0 # Ssh returns error for md5sum check, empty pickle dump for later commands def ssh_shell(*args): self.ssh_run_command_count += 1 if self.ssh_run_command_count == 1: # md5sum check return b'' else: # later tries return pickle.dumps([]) self.mock_ssh.shell.side_effect = ssh_shell scanner.scan() self.assertEqual(2, self.mock_ssh.shell.call_count) self.mock_ssh.shell.assert_called_with( "'/remote/path/to/scan/script' '/remote/path/to/scan'" ) def test_raises_nonrecoverable_error_on_first_failed_ssh(self): scanner = RemoteScanner( remote_address="my remote address", remote_username="my remote user", remote_password="my password", remote_port=1234, remote_path_to_scan="/remote/path/to/scan", local_path_to_scan_script=TestRemoteScanner.temp_scan_script, remote_path_to_scan_script="/remote/path/to/scan/script" ) self.ssh_run_command_count = 0 # Ssh run command fails the first time # noinspection PyUnusedLocal def ssh_shell(*args): self.ssh_run_command_count += 1 if self.ssh_run_command_count == 1: # md5sum check return b'' elif self.ssh_run_command_count == 2: # first try raise SshcpError("an ssh error") else: # later tries return pickle.dumps([]) self.mock_ssh.shell.side_effect = ssh_shell with self.assertRaises(ScannerError) as ctx: scanner.scan() self.assertEqual(Localization.Error.REMOTE_SERVER_SCAN.format("an ssh error"), str(ctx.exception)) self.assertFalse(ctx.exception.recoverable) def test_raises_recoverable_error_on_subsequent_failed_ssh(self): scanner = RemoteScanner( remote_address="my remote address", remote_username="my remote user", remote_password="my password", remote_port=1234, remote_path_to_scan="/remote/path/to/scan", local_path_to_scan_script=TestRemoteScanner.temp_scan_script, remote_path_to_scan_script="/remote/path/to/scan/script" ) self.ssh_run_command_count = 0 # Ssh run command succeeds first time, raises error the second time # noinspection PyUnusedLocal def ssh_shell(*args): self.ssh_run_command_count += 1 if self.ssh_run_command_count == 1: # md5sum check return b'' elif self.ssh_run_command_count == 2: # first try return pickle.dumps([]) elif self.ssh_run_command_count == 3: # second try raise SshcpError("an ssh error") else: # later tries return pickle.dumps([]) self.mock_ssh.shell.side_effect = ssh_shell scanner.scan() # no error first time with self.assertRaises(ScannerError) as ctx: scanner.scan() self.assertEqual(Localization.Error.REMOTE_SERVER_SCAN.format("an ssh error"), str(ctx.exception)) self.assertTrue(ctx.exception.recoverable) def test_recovers_from_failed_ssh(self): scanner = RemoteScanner( remote_address="my remote address", remote_username="my remote user", remote_password="my password", remote_port=1234, remote_path_to_scan="/remote/path/to/scan", local_path_to_scan_script=TestRemoteScanner.temp_scan_script, remote_path_to_scan_script="/remote/path/to/scan/script" ) self.ssh_run_command_count = 0 # Ssh run command succeeds first time, raises error the second time, fine after that # noinspection PyUnusedLocal def ssh_shell(*args): self.ssh_run_command_count += 1 if self.ssh_run_command_count == 1: # md5sum check return b'' elif self.ssh_run_command_count == 2: # first try return pickle.dumps([]) elif self.ssh_run_command_count == 3: # second try raise SshcpError("an ssh error") else: # later tries return pickle.dumps([]) self.mock_ssh.shell.side_effect = ssh_shell scanner.scan() # no error first time with self.assertRaises(ScannerError): scanner.scan() scanner.scan() self.assertEqual(4, self.mock_ssh.shell.call_count) def test_raises_nonrecoverable_error_on_failed_copy(self): scanner = RemoteScanner( remote_address="my remote address", remote_username="my remote user", remote_password="my password", remote_port=1234, remote_path_to_scan="/remote/path/to/scan", local_path_to_scan_script=TestRemoteScanner.temp_scan_script, remote_path_to_scan_script="/remote/path/to/scan/script" ) # noinspection PyUnusedLocal def ssh_copy(*args, **kwargs): raise SshcpError("an scp error") self.mock_ssh.copy.side_effect = ssh_copy with self.assertRaises(ScannerError) as ctx: scanner.scan() self.assertEqual(Localization.Error.REMOTE_SERVER_INSTALL.format("an scp error"), str(ctx.exception)) self.assertFalse(ctx.exception.recoverable) def test_raises_nonrecoverable_error_on_mangled_output(self): scanner = RemoteScanner( remote_address="my remote address", remote_username="my remote user", remote_password="my password", remote_port=1234, remote_path_to_scan="/remote/path/to/scan", local_path_to_scan_script=TestRemoteScanner.temp_scan_script, remote_path_to_scan_script="/remote/path/to/scan/script" ) def ssh_shell(*args): return "mangled data".encode() self.mock_ssh.shell.side_effect = ssh_shell with self.assertRaises(ScannerError) as ctx: scanner.scan() self.assertEqual(Localization.Error.REMOTE_SERVER_SCAN.format("Invalid pickled data"), str(ctx.exception)) self.assertFalse(ctx.exception.recoverable) def test_raises_nonrecoverable_error_on_failed_scan(self): scanner = RemoteScanner( remote_address="my remote address", remote_username="my remote user", remote_password="my password", remote_port=1234, remote_path_to_scan="/remote/path/to/scan", local_path_to_scan_script=TestRemoteScanner.temp_scan_script, remote_path_to_scan_script="/remote/path/to/scan/script" ) self.ssh_run_command_count = 0 # Ssh run command raises error the first time, succeeds the second time # noinspection PyUnusedLocal def ssh_shell(*args): self.ssh_run_command_count += 1 if self.ssh_run_command_count == 1: # md5sum check return b'' elif self.ssh_run_command_count == 2: # first try raise SshcpError("SystemScannerError: something failed") else: # later tries return pickle.dumps([]) self.mock_ssh.shell.side_effect = ssh_shell with self.assertRaises(ScannerError) as ctx: scanner.scan() self.assertEqual( Localization.Error.REMOTE_SERVER_SCAN.format("SystemScannerError: something failed"), str(ctx.exception) ) self.assertFalse(ctx.exception.recoverable) ================================================ FILE: src/python/tests/unittests/test_controller/test_scan/test_scanner_process.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest import multiprocessing import logging import sys from unittest.mock import MagicMock import timeout_decorator from controller import IScanner, ScannerProcess, ScannerError from system import SystemFile class DummyScanner(IScanner): def scan(self): return [] def set_base_logger(self, base_logger: logging.Logger): pass class TestScannerProcess(unittest.TestCase): def setUp(self): logger = logging.getLogger() handler = logging.StreamHandler(sys.stdout) logger.addHandler(handler) logger.setLevel(logging.DEBUG) formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") handler.setFormatter(formatter) # Assign process to this variable so that it can be cleaned up # even after an error self.process = None def tearDown(self): if self.process: self.process.terminate() @timeout_decorator.timeout(10) def test_retrieves_scan_results(self): # Use this as a signal to mock to control which result to send self.scan_signal = multiprocessing.Value('i', 0) self.scan_counter = multiprocessing.Value('i', 0) a = SystemFile("a", 100, True) aa = SystemFile("aa", 60, False) a.add_child(aa) ab = SystemFile("ab", 40, False) a.add_child(ab) b = SystemFile("b", 10, True) ba = SystemFile("ba", 10, True) b.add_child(ba) baa = SystemFile("baa", 10, False) ba.add_child(baa) c = SystemFile("c", 1234, False) mock_scanner = DummyScanner() mock_scanner.scan = MagicMock() def _scan(): ret = None if self.scan_signal.value == 0: ret = [a] elif self.scan_signal.value == 1: ret = [a, b] elif self.scan_signal.value == 2: ret = [c] elif self.scan_signal.value == 3: ret = [] self.scan_counter.value += 1 return ret mock_scanner.scan.side_effect = _scan self.process = ScannerProcess(scanner=mock_scanner, interval_in_ms=100) self.process.start() # wait for first call to scan (actually second call to guarantee first scan is queued) while self.scan_counter.value < 2: pass result = self.process.pop_latest_result() self.assertEqual(1, len(result.files)) self.assertEqual("a", result.files[0].name) self.assertEqual(True, result.files[0].is_dir) self.assertEqual(100, result.files[0].size) self.assertEqual(2, len(result.files[0].children)) self.assertEqual("aa", result.files[0].children[0].name) self.assertEqual(False, result.files[0].children[0].is_dir) self.assertEqual(60, result.files[0].children[0].size) self.assertEqual("ab", result.files[0].children[1].name) self.assertEqual(False, result.files[0].children[1].is_dir) self.assertEqual(40, result.files[0].children[1].size) # signal for scan #1 and wait scan fetch self.scan_signal.value = 1 orig_counter = self.scan_counter.value while self.scan_counter.value < orig_counter+2: pass result = self.process.pop_latest_result() self.assertEqual(2, len(result.files)) self.assertEqual("a", result.files[0].name) self.assertEqual(True, result.files[0].is_dir) self.assertEqual(100, result.files[0].size) self.assertEqual(2, len(result.files[0].children)) self.assertEqual("aa", result.files[0].children[0].name) self.assertEqual(False, result.files[0].children[0].is_dir) self.assertEqual(60, result.files[0].children[0].size) self.assertEqual("ab", result.files[0].children[1].name) self.assertEqual(False, result.files[0].children[1].is_dir) self.assertEqual(40, result.files[0].children[1].size) self.assertEqual("b", result.files[1].name) self.assertEqual(True, result.files[1].is_dir) self.assertEqual(10, result.files[1].size) self.assertEqual(1, len(result.files[1].children)) self.assertEqual("ba", result.files[1].children[0].name) self.assertEqual(True, result.files[1].children[0].is_dir) self.assertEqual(10, result.files[1].children[0].size) self.assertEqual(1, len(result.files[1].children[0].children)) self.assertEqual("baa", result.files[1].children[0].children[0].name) self.assertEqual(False, result.files[1].children[0].children[0].is_dir) self.assertEqual(10, result.files[1].children[0].children[0].size) # signal for scan #2 and wait scan fetch self.scan_signal.value = 2 orig_counter = self.scan_counter.value while self.scan_counter.value < orig_counter+2: pass result = self.process.pop_latest_result() self.assertEqual(1, len(result.files)) self.assertEqual("c", result.files[0].name) self.assertEqual(False, result.files[0].is_dir) self.assertEqual(1234, result.files[0].size) # signal for scan #3 and wait scan fetch self.scan_signal.value = 3 orig_counter = self.scan_counter.value while self.scan_counter.value < orig_counter+2: pass result = self.process.pop_latest_result() self.assertEqual(0, len(result.files)) @timeout_decorator.timeout(10) def test_sends_error_result_on_recoverable_error(self): mock_scanner = DummyScanner() mock_scanner.scan = MagicMock() mock_scanner.scan.side_effect = ScannerError("recoverable error", recoverable=True) self.process = ScannerProcess(scanner=mock_scanner, interval_in_ms=100) self.process.start() while True: result = self.process.pop_latest_result() if result: break self.assertEqual(0, len(result.files)) self.assertTrue(result.failed) self.assertEqual("recoverable error", result.error_message) @timeout_decorator.timeout(10) def test_sends_fatal_exception_on_nonrecoverable_error(self): mock_scanner = DummyScanner() mock_scanner.scan = MagicMock() mock_scanner.scan.side_effect = ScannerError("non-recoverable error", recoverable=False) self.process = ScannerProcess(scanner=mock_scanner, interval_in_ms=100) self.process.start() with self.assertRaises(ScannerError) as ctx: while True: self.process.propagate_exception() # noinspection PyUnreachableCode self.assertEqual("non-recoverable error", str(ctx.exception)) ================================================ FILE: src/python/tests/unittests/test_lftp/__init__.py ================================================ ================================================ FILE: src/python/tests/unittests/test_lftp/test_job_status.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest from lftp import LftpJobStatus class TestLftpJobStatus(unittest.TestCase): def test_id(self): status = LftpJobStatus(job_id=42, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.QUEUED, name="", flags="") self.assertEqual(42, status.id) def test_type(self): status = LftpJobStatus(job_id=-1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.QUEUED, name="", flags="") self.assertEqual(LftpJobStatus.Type.MIRROR, status.type) status = LftpJobStatus(job_id=-1, job_type=LftpJobStatus.Type.PGET, state=LftpJobStatus.State.QUEUED, name="", flags="") self.assertEqual(LftpJobStatus.Type.PGET, status.type) def test_state(self): status = LftpJobStatus(job_id=-1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.QUEUED, name="", flags="") self.assertEqual(LftpJobStatus.State.QUEUED, status.state) status = LftpJobStatus(job_id=-1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="", flags="") self.assertEqual(LftpJobStatus.State.RUNNING, status.state) def test_name(self): status = LftpJobStatus(job_id=-1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.QUEUED, name="hello", flags="") self.assertEqual("hello", status.name) status = LftpJobStatus(job_id=-1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.QUEUED, name="bye", flags="") self.assertEqual("bye", status.name) def test_total_transfer_state(self): status = LftpJobStatus(job_id=-1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="", flags="") status.total_transfer_state = LftpJobStatus.TransferState(10, 20, 50, 0, 0) self.assertEqual(LftpJobStatus.TransferState(10, 20, 50, 0, 0), status.total_transfer_state) status.total_transfer_state = LftpJobStatus.TransferState(15, 20, 75, 0, 0) self.assertEqual(LftpJobStatus.TransferState(15, 20, 75, 0, 0), status.total_transfer_state) def test_total_transfer_state_fails_on_queued(self): status = LftpJobStatus(job_id=-1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.QUEUED, name="", flags="") with self.assertRaises(TypeError) as context: status.total_transfer_state = LftpJobStatus.TransferState(10, 20, 50, 0, 0) self.assertTrue("Cannot set transfer state on job of type queue" in str(context.exception)) def test_active_transfer_state(self): status = LftpJobStatus(job_id=-1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="", flags="") status.add_active_file_transfer_state("a", LftpJobStatus.TransferState(10, 20, 50, 0, 0)) status.add_active_file_transfer_state("b", LftpJobStatus.TransferState(25, 100, 25, 0, 0)) self.assertEqual({("a", LftpJobStatus.TransferState(10, 20, 50, 0, 0)), ("b", LftpJobStatus.TransferState(25, 100, 25, 0, 0))}, set(status.get_active_file_transfer_states())) def test_active_transfer_state_fails_on_queued(self): status = LftpJobStatus(job_id=-1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.QUEUED, name="", flags="") with self.assertRaises(TypeError) as context: status.add_active_file_transfer_state("filename", LftpJobStatus.TransferState(10, 20, 50, 0, 0)) self.assertTrue("Cannot set transfer state on job of type queue" in str(context.exception)) def test_equality_operator(self): s1 = LftpJobStatus(job_id=3, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="name", flags="flags") s1.total_transfer_state = LftpJobStatus.TransferState(10, 20, 50, 1, 2) s1.add_active_file_transfer_state("aa", LftpJobStatus.TransferState(1, 2, 3, 4, 5)) s1.add_active_file_transfer_state("ab", LftpJobStatus.TransferState(6, 7, 8, 9, 0)) # test equality s2 = LftpJobStatus(job_id=3, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="name", flags="flags") s2.total_transfer_state = LftpJobStatus.TransferState(10, 20, 50, 1, 2) s2.add_active_file_transfer_state("aa", LftpJobStatus.TransferState(1, 2, 3, 4, 5)) s2.add_active_file_transfer_state("ab", LftpJobStatus.TransferState(6, 7, 8, 9, 0)) self.assertTrue(s1 == s2) # test equality - different order of active file transfer state s2 = LftpJobStatus(job_id=3, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="name", flags="flags") s2.total_transfer_state = LftpJobStatus.TransferState(10, 20, 50, 1, 2) s2.add_active_file_transfer_state("ab", LftpJobStatus.TransferState(6, 7, 8, 9, 0)) s2.add_active_file_transfer_state("aa", LftpJobStatus.TransferState(1, 2, 3, 4, 5)) self.assertTrue(s1 == s2) # inequality - job id s2 = LftpJobStatus(job_id=2, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="name", flags="flags") s2.total_transfer_state = LftpJobStatus.TransferState(10, 20, 50, 1, 2) s2.add_active_file_transfer_state("aa", LftpJobStatus.TransferState(1, 2, 3, 4, 5)) s2.add_active_file_transfer_state("ab", LftpJobStatus.TransferState(6, 7, 8, 9, 0)) self.assertFalse(s1 == s2) # inequality - job type s2 = LftpJobStatus(job_id=3, job_type=LftpJobStatus.Type.PGET, state=LftpJobStatus.State.RUNNING, name="name", flags="flags") s2.total_transfer_state = LftpJobStatus.TransferState(10, 20, 50, 1, 2) s2.add_active_file_transfer_state("aa", LftpJobStatus.TransferState(1, 2, 3, 4, 5)) s2.add_active_file_transfer_state("ab", LftpJobStatus.TransferState(6, 7, 8, 9, 0)) self.assertFalse(s1 == s2) # inequality - job state s1_q = LftpJobStatus(job_id=3, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.QUEUED, name="name", flags="flags") s2 = LftpJobStatus(job_id=3, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="name", flags="flags") self.assertFalse(s1_q == s2) # inequality - job total transfer state s2 = LftpJobStatus(job_id=3, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="name", flags="flags") s2.total_transfer_state = LftpJobStatus.TransferState(10, 20, 50, 1, 3) s2.add_active_file_transfer_state("aa", LftpJobStatus.TransferState(1, 2, 3, 4, 5)) s2.add_active_file_transfer_state("ab", LftpJobStatus.TransferState(6, 7, 8, 9, 0)) self.assertFalse(s1 == s2) # inequality - active file transfer state s2 = LftpJobStatus(job_id=3, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="name", flags="flags") s2.total_transfer_state = LftpJobStatus.TransferState(10, 20, 50, 1, 2) s2.add_active_file_transfer_state("aa", LftpJobStatus.TransferState(1, 2, 3, 4, 5)) s2.add_active_file_transfer_state("ab", LftpJobStatus.TransferState(6, 7, 8, 9, 10)) self.assertFalse(s1 == s2) ================================================ FILE: src/python/tests/unittests/test_lftp/test_job_status_parser.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest from lftp import LftpJobStatusParser, LftpJobStatus, LftpJobStatusParserError # noinspection PyPep8 class TestLftpJobStatusParser(unittest.TestCase): def setUp(self): # Show full diff self.maxDiff = None def test_size_to_bytes(self): self.assertEqual(345, LftpJobStatusParser._size_to_bytes("345")) self.assertEqual(1000, LftpJobStatusParser._size_to_bytes("1000b")) self.assertEqual(1000, LftpJobStatusParser._size_to_bytes("1000 b")) self.assertEqual(1000, LftpJobStatusParser._size_to_bytes("1000B")) self.assertEqual(1024, LftpJobStatusParser._size_to_bytes("1kb")) self.assertEqual(1024, LftpJobStatusParser._size_to_bytes("1 kb")) self.assertEqual(1536, LftpJobStatusParser._size_to_bytes("1.5kb")) self.assertEqual(2048, LftpJobStatusParser._size_to_bytes("2KiB")) self.assertEqual(1048576, LftpJobStatusParser._size_to_bytes("1mb")) self.assertEqual(1048576, LftpJobStatusParser._size_to_bytes("1 mb")) self.assertEqual(1572864, LftpJobStatusParser._size_to_bytes("1.5mb")) self.assertEqual(2097152, LftpJobStatusParser._size_to_bytes("2MiB")) self.assertEqual(1073741824, LftpJobStatusParser._size_to_bytes("1gb")) self.assertEqual(1073741824, LftpJobStatusParser._size_to_bytes("1 gb")) self.assertEqual(1610612736, LftpJobStatusParser._size_to_bytes("1.5gb")) self.assertEqual(2147483648, LftpJobStatusParser._size_to_bytes("2GiB")) def test_eta_to_seconds(self): self.assertEqual(100, LftpJobStatusParser._eta_to_seconds("100s")) self.assertEqual(100*60, LftpJobStatusParser._eta_to_seconds("100m")) self.assertEqual(100*60*60, LftpJobStatusParser._eta_to_seconds("100h")) self.assertEqual(100*24*60*60, LftpJobStatusParser._eta_to_seconds("100d")) self.assertEqual(1*24*60*60+1, LftpJobStatusParser._eta_to_seconds("1d1s")) self.assertEqual(1*24*60*60+1*60, LftpJobStatusParser._eta_to_seconds("1d1m")) self.assertEqual(1*24*60*60+1*60*60, LftpJobStatusParser._eta_to_seconds("1d1h")) self.assertEqual(1*24*60*60+1*60*60+1*60+1, LftpJobStatusParser._eta_to_seconds("1d1h1m1s")) self.assertEqual(1*60*60+1*60+1, LftpJobStatusParser._eta_to_seconds("1h1m1s")) self.assertEqual(1*60+1, LftpJobStatusParser._eta_to_seconds("1m1s")) def test_empty_output_1(self): output = "" parser = LftpJobStatusParser() statuses = parser.parse(output) self.assertEqual(0, len(statuses)) def test_empty_output_2(self): output = """ """ parser = LftpJobStatusParser() statuses = parser.parse(output) self.assertEqual(0, len(statuses)) def test_empty_output_3(self): output = """ [1] Done (queue (sftp://someone:@localhost)) """ parser = LftpJobStatusParser() statuses = parser.parse(output) self.assertEqual(0, len(statuses)) def test_empty_output_4(self): output = """ [0] queue (sftp://someone:@localhost) sftp://someone:@localhost/home/someone [0] Done (queue (sftp://someone:@localhost)) """ parser = LftpJobStatusParser() statuses = parser.parse(output) self.assertEqual(0, len(statuses)) def test_queued_items(self): """Queued items, no jobs running""" output = """ [0] queue (sftp://someone:@localhost) sftp://someone:@localhost/home/someone Queue is stopped. Commands queued: 1. mirror -c /tmp/test_lftp/remote/a /tmp/test_lftp/local/ 2. pget -c /tmp/test_lftp/remote/c -o /tmp/test_lftp/local/ 3. mirror -c /tmp/test_lftp/remote/b /tmp/test_lftp/local/ 4. mirror -c /tmp/test_lftp/remote/b /tmp/test_lftp/local/ 5. mirror -c /tmp/test_lftp/remote/b /tmp/test_lftp/local/ """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden = [ LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.QUEUED, name="a", flags="-c"), LftpJobStatus(job_id=2, job_type=LftpJobStatus.Type.PGET, state=LftpJobStatus.State.QUEUED, name="c", flags="-c"), LftpJobStatus(job_id=3, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.QUEUED, name="b", flags="-c"), LftpJobStatus(job_id=4, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.QUEUED, name="b", flags="-c"), LftpJobStatus(job_id=5, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.QUEUED, name="b", flags="-c") ] self.assertEqual(len(golden), len(statuses)) self.assertEqual(golden, statuses) def test_queued_items_with_quotes(self): """Queue with quotes""" output = """ [0] queue (sftp://someone:@localhost) sftp://someone:@localhost/home/someone Queue is stopped. Commands queued: 1. mirror -c "/tmp/test_lftp/remote/b s s" /tmp/test_lftp/local/ 2. pget -c "/tmp/test_lftp/remote/a s s" -o /tmp/test_lftp/local/ 3. mirror -c "/tmp/test_lftp/remote/b s s" "/tmp/test_lftp/local/" 4. pget -c "/tmp/test_lftp/remote/a s s" -o "/tmp/test_lftp/local/" 5. mirror -c /tmp/test_lftp/remote/b "/tmp/test_lftp/local/" 6. pget -c /tmp/test_lftp/remote/a -o "/tmp/test_lftp/local/" 7. mirror -c "/tmp/test_lftp/remote//b" "/tmp/test_lftp/local/" 8. pget -c "/tmp/test_lftp/remote//a" -o "/tmp/test_lftp/local/" """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden = [ LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.QUEUED, name="b s s", flags="-c"), LftpJobStatus(job_id=2, job_type=LftpJobStatus.Type.PGET, state=LftpJobStatus.State.QUEUED, name="a s s", flags="-c"), LftpJobStatus(job_id=3, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.QUEUED, name="b s s", flags="-c"), LftpJobStatus(job_id=4, job_type=LftpJobStatus.Type.PGET, state=LftpJobStatus.State.QUEUED, name="a s s", flags="-c"), LftpJobStatus(job_id=5, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.QUEUED, name="b", flags="-c"), LftpJobStatus(job_id=6, job_type=LftpJobStatus.Type.PGET, state=LftpJobStatus.State.QUEUED, name="a", flags="-c"), LftpJobStatus(job_id=7, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.QUEUED, name="b", flags="-c"), LftpJobStatus(job_id=8, job_type=LftpJobStatus.Type.PGET, state=LftpJobStatus.State.QUEUED, name="a", flags="-c"), ] self.assertEqual(len(golden), len(statuses)) self.assertEqual(golden, statuses) def test_queue_and_jobs_1(self): """Queued items, parallel jobs running, multiple files, multiple chunks""" output = """ [1] queue (sftp://someone:@localhost) -- 15.8 KiB/s sftp://someone:@localhost/home/someone Now executing: [2] mirror -c /tmp/test_lftp/remote/a /tmp/test_lftp/local/ -- 17k/26M (0%) 5.0 KiB/s -[3] mirror -c /tmp/test_lftp/remote/b /tmp/test_lftp/local/ -- 35k/394k (8%) 10.8 KiB/s Commands queued: 1. pget -c /tmp/test_lftp/remote/c -o /tmp/test_lftp/local/ 2. mirror -c /tmp/test_lftp/remote/b /tmp/test_lftp/local/ 3. mirror -c /tmp/test_lftp/remote/b /tmp/test_lftp/local/ [2] mirror -c /tmp/test_lftp/remote/a /tmp/test_lftp/local/ -- 17k/26M (0%) 5.0 KiB/s \\transfer `aa' `aa' at 2976 (12%) 997b/s eta:22s [Receiving data] \\transfer `ab' `ab', got 13733 of 25165824 (0%) 4.0K/s eta:1h45m \chunk 0-6291456 `ab' at 4362 (0%) 1.1K/s eta:92m [Receiving data] \chunk 18874368-25165823 `ab' at 18877569 (0%) 1001b/s eta:1h45m [Receiving data] \chunk 12582912-18874367 `ab' at 12585895 (0%) 997b/s eta:1h45m [Receiving data] \chunk 6291456-12582911 `ab' at 6294643 (0%) 999b/s eta:1h45m [Receiving data] [3] mirror -c /tmp/test_lftp/remote/b /tmp/test_lftp/local/ -- 35k/394k (8%) 10.8 KiB/s \\transfer `bb' `bb', got 12333 of 131072 (9%) 3.9K/s eta:30s \chunk 0-32768 `bb' at 2970 (2%) 996b/s eta:30s [Receiving data] \chunk 98304-131071 `bb' at 101288 (9%) 998b/s eta:30s [Receiving data] \chunk 65536-98303 `bb' at 68727 (9%) 1001b/s eta:30s [Receiving data] \chunk 32768-65535 `bb' at 35956 (9%) 998b/s eta:30s [Receiving data] \mirror `ba' -- 23k/263k (8%) 6.9 KiB/s \\transfer `ba/baa' `baa', got 9342 of 131072 (7%) 2.9K/s \chunk 0-32768 `baa' at 3192 (2%) 998b/s eta:30s [Receiving data] \chunk 98304-131071 `baa' at 98304 (0%) [ssh_exchange_identification: Connection closed by remote host] \chunk 65536-98303 `baa' at 68721 (9%) 998b/s eta:30s [Receiving data] \chunk 32768-65535 `baa' at 35733 (9%) 993b/s eta:30s [Receiving data] \\transfer `ba/bab' `bab', got 13128 of 131072 (10%) 4.0K/s eta:30s \chunk 0-32768 `bab' at 4170 (3%) 1.1K/s eta:26s [Receiving data] \chunk 98304-131071 `bab' at 101297 (9%) 1001b/s eta:30s [Receiving data] \chunk 65536-98303 `bab' at 68525 (9%) 999b/s eta:30s [Receiving data] \chunk 32768-65535 `bab' at 35744 (9%) 997b/s eta:30s [Receiving data] """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_queue = [ LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.PGET, state=LftpJobStatus.State.QUEUED, name="c", flags="-c"), LftpJobStatus(job_id=2, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.QUEUED, name="b", flags="-c"), LftpJobStatus(job_id=3, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.QUEUED, name="b", flags="-c"), ] golden_job1 = LftpJobStatus(job_id=2, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="a", flags="-c") golden_job1.total_transfer_state = LftpJobStatus.TransferState(17*1024, 26*1024*1024, 0, 5*1024, None) golden_job1.add_active_file_transfer_state( "aa", LftpJobStatus.TransferState(None, None, None, 997, 22) ) golden_job1.add_active_file_transfer_state( "ab", LftpJobStatus.TransferState(13733, 25165824, 0, 4*1024, 1*3600+45*60) ) golden_job2 = LftpJobStatus(job_id=3, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="b", flags="-c") golden_job2.total_transfer_state = LftpJobStatus.TransferState(35*1024, 394*1024, 8, 11059, None) golden_job2.add_active_file_transfer_state( "bb", LftpJobStatus.TransferState(12333, 131072, 9, 3993, 30) ) golden_job2.add_active_file_transfer_state( "ba/baa", LftpJobStatus.TransferState(9342, 131072, 7, 2969, None) ) golden_job2.add_active_file_transfer_state( "ba/bab", LftpJobStatus.TransferState(13128, 131072, 10, 4096, 30) ) golden_jobs = [golden_job1, golden_job2] self.assertEqual(len(golden_queue)+len(golden_jobs), len(statuses)) statuses_queue = [j for j in statuses if j.state == LftpJobStatus.State.QUEUED] self.assertEqual(golden_queue, statuses_queue) statuses_jobs = [j for j in statuses if j.state == LftpJobStatus.State.RUNNING] self.assertEqual(golden_jobs, statuses_jobs) def test_queue_and_jobs_2(self): """Queued items, parallel jobs running, multiple files, multiple chunks""" output = """ [0] queue (sftp://someone:@localhost) -- 15.6 KiB/s sftp://someone:@localhost/home/someone Now executing: [1] mirror -c /tmp/test_lftp/remote/a /tmp/test_lftp/local/ -- 152k/26M (0%) 3.9 KiB/s -[2] mirror -c /tmp/test_lftp/remote/b /tmp/test_lftp/local/ -- 350k/394k (88%) 11.7 KiB/s Commands queued: 1. mirror -c /tmp/test_lftp/remote/b /tmp/test_lftp/local/ 2. mirror -c /tmp/test_lftp/remote/b /tmp/test_lftp/local/ [1] mirror -c /tmp/test_lftp/remote/a /tmp/test_lftp/local/ -- 152k/26M (0%) 3.9 KiB/s \\transfer `ab' `ab', got 126558 of 25165824 (0%) 3.9K/s eta:1h45m \chunk 0-6291456 `ab' at 32115 (0%) 1006b/s eta:1h43m [Receiving data] \chunk 18874368-25165823 `ab' at 18906710 (0%) 1007b/s eta:1h43m [Receiving data] \chunk 12582912-18874367 `ab' at 12614060 (0%) 998b/s eta:1h45m [Receiving data] \chunk 6291456-12582911 `ab' at 6322409 (0%) 998b/s eta:1h45m [Receiving data] [2] mirror -c /tmp/test_lftp/remote/b /tmp/test_lftp/local/ -- 350k/394k (88%) 11.7 KiB/s \\transfer `bb' `bb', got 124150 of 131072 (94%) 3.9K/s eta:2s \chunk 0-32768 `bb' at 30932 (23%) 997b/s eta:2s [Receiving data] \chunk 98304-131071 `bb' at 129447 (95%) 998b/s eta:2s [Receiving data] \chunk 65536-98303 `bb' at 96690 (95%) 998b/s eta:2s [Receiving data] \chunk 32768-65535 `bb' at 63689 (94%) 997b/s eta:2s [Receiving data] \mirror `ba' -- 225k/263k (85%) 7.8 KiB/s \\transfer `ba/baa' `baa', got 123531 of 131072 (94%) 3.9K/s eta:2s \chunk 0-32768 `baa' at 30944 (23%) 998b/s eta:2s [Receiving data] \chunk 98304-131071 `baa' at 129234 (94%) 997b/s eta:2s [Receiving data] \chunk 65536-98303 `baa' at 96253 (93%) 998b/s eta:2s [Receiving data] \chunk 32768-65535 `baa' at 63708 (94%) 997b/s eta:2s [Receiving data] \\transfer `ba/bab' `bab', got 101391 of 131072 (77%) 3.9K/s eta:26s \chunk 0-32768 `bab' at 31890 (24%) 1003b/s eta:1s [Receiving data] \chunk 98304-131071 `bab' at 129233 (94%) 997b/s eta:2s [Receiving data] \chunk 65536-98303 `bab' at 96253 (93%) 997b/s eta:2s [Receiving data] \chunk 32768-65535 `bab' at 40623 (23%) 960b/s eta:26s [Receiving data] """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_queue = [ LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.QUEUED, name="b", flags="-c"), LftpJobStatus(job_id=2, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.QUEUED, name="b", flags="-c"), ] golden_job1 = LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="a", flags="-c") golden_job1.total_transfer_state = LftpJobStatus.TransferState(152*1024, 26*1024*1024, 0, 3993, None) golden_job1.add_active_file_transfer_state( "ab", LftpJobStatus.TransferState(126558, 25165824, 0, 3993, 1*3600+45*60) ) golden_job2 = LftpJobStatus(job_id=2, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="b", flags="-c") golden_job2.total_transfer_state = LftpJobStatus.TransferState(350*1024, 394*1024, 88, 11980, None) golden_job2.add_active_file_transfer_state( "bb", LftpJobStatus.TransferState(124150, 131072, 94, 3993, 2) ) golden_job2.add_active_file_transfer_state( "ba/baa", LftpJobStatus.TransferState(123531, 131072, 94, 3993, 2) ) golden_job2.add_active_file_transfer_state( "ba/bab", LftpJobStatus.TransferState(101391, 131072, 77, 3993, 26) ) golden_jobs = [golden_job1, golden_job2] self.assertEqual(len(golden_queue)+len(golden_jobs), len(statuses)) statuses_queue = [j for j in statuses if j.state == LftpJobStatus.State.QUEUED] self.assertEqual(golden_queue, statuses_queue) statuses_jobs = [j for j in statuses if j.state == LftpJobStatus.State.RUNNING] self.assertEqual(golden_jobs, statuses_jobs) def test_queue_and_jobs_3(self): """Queued items, parallel jobs running, 'cd' line in queued pget""" output = """ [0] queue (sftp://someone:@localhost) sftp://someone:@localhost/home/someone Now executing: [1] mirror -c /tmp/test_lftp_n_l73hx8/remote/a /tmp/test_lftp_n_l73hx8/local/ -[2] pget -c /tmp/test_lftp_n_l73hx8/remote/d d -o /tmp/test_lftp_n_l73hx8/local/ Commands queued: 1. mirror -c "/tmp/test_lftp_n_l73hx8/remote/b" "/tmp/test_lftp_n_l73hx8/local/" 2. pget -c "/tmp/test_lftp_n_l73hx8/remote/c" -o "/tmp/test_lftp_n_l73hx8/local/" cd /home/someone 3. mirror -c "/tmp/test_lftp_n_l73hx8/remote/e e" "/tmp/test_lftp_n_l73hx8/local/" [1] mirror -c /tmp/test_lftp_n_l73hx8/remote/a /tmp/test_lftp_n_l73hx8/local/ Getting file list (10) [Receiving data] [2] pget -c /tmp/test_lftp_n_l73hx8/remote/d d -o /tmp/test_lftp_n_l73hx8/local/ sftp://someone:@localhost/home/someone `/tmp/test_lftp_n_l73hx8/remote/d d' at 10 (0%) [Receiving data] """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_queue = [ LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.QUEUED, name="b", flags="-c"), LftpJobStatus(job_id=2, job_type=LftpJobStatus.Type.PGET, state=LftpJobStatus.State.QUEUED, name="c", flags="-c"), LftpJobStatus(job_id=3, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.QUEUED, name="e e", flags="-c"), ] golden_job1 = LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="a", flags="-c") golden_job2 = LftpJobStatus(job_id=2, job_type=LftpJobStatus.Type.PGET, state=LftpJobStatus.State.RUNNING, name="d d", flags="-c") golden_job2.total_transfer_state = LftpJobStatus.TransferState(None, None, None, None, None) golden_jobs = [golden_job1, golden_job2] self.assertEqual(len(golden_queue)+len(golden_jobs), len(statuses)) statuses_queue = [j for j in statuses if j.state == LftpJobStatus.State.QUEUED] self.assertEqual(golden_queue, statuses_queue) statuses_jobs = [j for j in statuses if j.state == LftpJobStatus.State.RUNNING] self.assertEqual(golden_jobs, statuses_jobs) def test_queue_and_jobs_4(self): """Queued items, parallel jobs running, '\mirror' line with 'Getting file list'""" output = """ [0] queue (sftp://someone:@localhost) sftp://someone:@localhost/home/someone Now executing: [1] mirror -c /tmp/test_controller_zph2s53/remote/ra /tmp/test_controller_zph2s53/local/ -- 0/1.1k (0%) -[2] mirror -c /tmp/test_controller_zph2s53/remote/rb /tmp/test_controller_zph2s53/local/ -- 20/9.3k (0%) Commands queued: 1. pget -c "/tmp/test_controller_zph2s53/remote/rc" -o "/tmp/test_controller_zph2s53/local/" [1] mirror -c /tmp/test_controller_zph2s53/remote/ra /tmp/test_controller_zph2s53/local/ -- 0/1.1k (0%) \\transfer `raa' `raa' at 0 (0%) [Connecting...] \mirror `rab' rab: Getting file list (27) [Receiving data] [2] mirror -c /tmp/test_controller_zph2s53/remote/rb /tmp/test_controller_zph2s53/local/ -- 20/9.3k (0%) \\transfer `rba' `rba' at 0 (0%) [Connecting...] \\transfer `rbb' `rbb' at 0 (0%) [Receiving data] """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_queue = [ LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.PGET, state=LftpJobStatus.State.QUEUED, name="rc", flags="-c"), ] golden_job1 = LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="ra", flags="-c") golden_job1.total_transfer_state = LftpJobStatus.TransferState(0, 1126, 0, None, None) golden_job1.add_active_file_transfer_state("raa", LftpJobStatus.TransferState(None, None, None, None, None)) golden_job2 = LftpJobStatus(job_id=2, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="rb", flags="-c") golden_job2.total_transfer_state = LftpJobStatus.TransferState(20, 9523, 0, None, None) golden_job2.add_active_file_transfer_state("rba", LftpJobStatus.TransferState(None, None, None, None, None)) golden_job2.add_active_file_transfer_state("rbb", LftpJobStatus.TransferState(None, None, None, None, None)) golden_jobs = [golden_job1, golden_job2] self.assertEqual(len(golden_queue)+len(golden_jobs), len(statuses)) statuses_queue = [j for j in statuses if j.state == LftpJobStatus.State.QUEUED] self.assertEqual(golden_queue, statuses_queue) statuses_jobs = [j for j in statuses if j.state == LftpJobStatus.State.RUNNING] self.assertEqual(golden_jobs, statuses_jobs) def test_queue_and_jobs_5(self): """Queued items, parallel jobs running, '\mirror' line with 'cd'""" # noinspection PyPep8 output = """ [0] queue (sftp://someone:@localhost) sftp://someone:@localhost/home/someone Now executing: [1] mirror -c /tmp/test_controllerw0sbqxe_/remote/ra /tmp/test_controllerw0sbqxe_/local/ -- 0/1.1k (0%) -[2] mirror -c /tmp/test_controllerw0sbqxe_/remote/rb /tmp/test_controllerw0sbqxe_/local/ -- 49/9.3k (0%) Commands queued: 1. pget -c "/tmp/test_controllerw0sbqxe_/remote/rc" -o "/tmp/test_controllerw0sbqxe_/local/" [1] mirror -c /tmp/test_controllerw0sbqxe_/remote/ra /tmp/test_controllerw0sbqxe_/local/ -- 0/1.1k (0%) \\transfer `raa' `raa' at 0 (0%) [Connecting...] \mirror `rab' cd `/tmp/test_controllerw0sbqxe_/remote/ra/rab' [Connecting...] [2] mirror -c /tmp/test_controllerw0sbqxe_/remote/rb /tmp/test_controllerw0sbqxe_/local/ -- 49/9.3k (0%) \\transfer `rba' `rba' at 0 (0%) [Receiving data] \\transfer `rbb' `rbb' at 0 (0%) [Receiving data] """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_queue = [ LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.PGET, state=LftpJobStatus.State.QUEUED, name="rc", flags="-c"), ] golden_job1 = LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="ra", flags="-c") golden_job1.total_transfer_state = LftpJobStatus.TransferState(0, 1126, 0, None, None) golden_job1.add_active_file_transfer_state("raa", LftpJobStatus.TransferState(None, None, None, None, None)) golden_job2 = LftpJobStatus(job_id=2, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="rb", flags="-c") golden_job2.total_transfer_state = LftpJobStatus.TransferState(49, 9523, 0, None, None) golden_job2.add_active_file_transfer_state("rba", LftpJobStatus.TransferState(None, None, None, None, None)) golden_job2.add_active_file_transfer_state("rbb", LftpJobStatus.TransferState(None, None, None, None, None)) golden_jobs = [golden_job1, golden_job2] self.assertEqual(len(golden_queue)+len(golden_jobs), len(statuses)) statuses_queue = [j for j in statuses if j.state == LftpJobStatus.State.QUEUED] self.assertEqual(golden_queue, statuses_queue) statuses_jobs = [j for j in statuses if j.state == LftpJobStatus.State.RUNNING] self.assertEqual(golden_jobs, statuses_jobs) def test_queue_and_jobs_6(self): """Queued items, parallel jobs running, '\mirror' line with no units for local_size""" output = """ [0] queue (sftp://someone:@localhost) -- 252 B/s sftp://someone:@localhost/home/someone Now executing: [1] mirror -c /tmp/test_controllerbsn4wlu2/remote/ra /tmp/test_controllerbsn4wlu2/local/ -- 249/8.2k (3%) 100 B/s -[2] mirror -c /tmp/test_controllerbsn4wlu2/remote/rb /tmp/test_controllerbsn4wlu2/local/ -- 374/9.3k (4%) 153 B/s Commands queued: 1. pget -c "/tmp/test_controllerbsn4wlu2/remote/rc" -o "/tmp/test_controllerbsn4wlu2/local/" [1] mirror -c /tmp/test_controllerbsn4wlu2/remote/ra /tmp/test_controllerbsn4wlu2/local/ -- 249/8.2k (3%) 100 B/s \\transfer `raa' `raa' at 238 (23%) 100b/s eta:8s [Receiving data] \mirror `rab' -- 0/7.2k (0%) \\transfer `rab/raba' `raba' at 0 (0%) [Connecting...] \\transfer `rab/rabb' `rabb' at 0 (0%) [Waiting for response...] [2] mirror -c /tmp/test_controllerbsn4wlu2/remote/rb /tmp/test_controllerbsn4wlu2/local/ -- 374/9.3k (4%) 153 B/s \\transfer `rba' `rba' at 159 (3%) 77b/s eta:51s [Receiving data] \\transfer `rbb' `rbb' at 153 (2%) 76b/s eta:66s [Receiving data] """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_queue = [ LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.PGET, state=LftpJobStatus.State.QUEUED, name="rc", flags="-c"), ] golden_job1 = LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="ra", flags="-c") golden_job1.total_transfer_state = LftpJobStatus.TransferState(249, 8396, 3, 100, None) golden_job1.add_active_file_transfer_state("raa", LftpJobStatus.TransferState(None, None, None, 100, 8)) golden_job1.add_active_file_transfer_state("rab/raba", LftpJobStatus.TransferState(None, None, None, None, None)) golden_job1.add_active_file_transfer_state("rab/rabb", LftpJobStatus.TransferState(None, None, None, None, None)) golden_job2 = LftpJobStatus(job_id=2, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="rb", flags="-c") golden_job2.total_transfer_state = LftpJobStatus.TransferState(374, 9523, 4, 153, None) golden_job2.add_active_file_transfer_state("rba", LftpJobStatus.TransferState(None, None, None, 77, 51)) golden_job2.add_active_file_transfer_state("rbb", LftpJobStatus.TransferState(None, None, None, 76, 66)) golden_jobs = [golden_job1, golden_job2] self.assertEqual(len(golden_queue)+len(golden_jobs), len(statuses)) statuses_queue = [j for j in statuses if j.state == LftpJobStatus.State.QUEUED] self.assertEqual(golden_queue, statuses_queue) statuses_jobs = [j for j in statuses if j.state == LftpJobStatus.State.RUNNING] self.assertEqual(golden_jobs, statuses_jobs) def test_jobs_1(self): """1 job, 1 file, no chunks""" output = """ [0] queue (sftp://someone:@localhost) sftp://someone:@localhost/home/someone Now executing: [1] pget -c /tmp/test_lftp/remote/c -o /tmp/test_lftp/local/ [1] pget -c /tmp/test_lftp/remote/c -o /tmp/test_lftp/local/ sftp://someone:@localhost/home/someone `/tmp/test_lftp/remote/c' at 4585 (3%) 1.2K/s eta:2m [Receiving data] """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_job1 = LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.PGET, state=LftpJobStatus.State.RUNNING, name="c", flags="-c") golden_job1.total_transfer_state = LftpJobStatus.TransferState(None, None, None, 1228, 2*60) golden_jobs = [golden_job1] self.assertEqual(len(golden_jobs), len(statuses)) statuses_jobs = [j for j in statuses if j.state == LftpJobStatus.State.RUNNING] self.assertEqual(golden_jobs, statuses_jobs) def test_jobs_2(self): """1 job, 1 dir, no units in local size""" output = """ [0] queue (sftp://someone:@localhost) -- 90 B/s sftp://someone:@localhost/home/someone Now executing: [1] mirror -c /tmp/test_lftp_rm_s6oau/remote/a /tmp/test_lftp_rm_s6oau/local/ -- 345/26M (0%) 90 B/s [1] mirror -c /tmp/test_lftp_rm_s6oau/remote/a /tmp/test_lftp_rm_s6oau/local/ -- 345/26M (0%) 90 B/s \\transfer `aa' `aa' at 315 (1%) 90b/s eta:4m [Receiving data] """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_job1 = LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="a", flags="-c") golden_job1.total_transfer_state = LftpJobStatus.TransferState(345, 26*1024*1024, 0, 90, None) golden_job1.add_active_file_transfer_state( "aa", LftpJobStatus.TransferState(None, None, None, 90, 4*60) ) golden_jobs = [golden_job1] self.assertEqual(len(golden_jobs), len(statuses)) statuses_jobs = [j for j in statuses if j.state == LftpJobStatus.State.RUNNING] self.assertEqual(golden_jobs, statuses_jobs) def test_jobs_3(self): """1 job, 1 file, chunks""" output = """ [0] queue (sftp://someone:@localhost) sftp://someone:@localhost/home/someone Now executing: [1] pget -c /tmp/test_lftp/remote/A.b.C.rar -o /tmp/lftp/ [1] pget -c /tmp/test_lftp/remote/A.b.C.rar -o /tmp/lftp/ sftp://someone:@localhost/home/someone `/tmp/test_lftp/remote/A.b.C.rar', got 2622559389 of 3274103236 (80%) \chunk 0-2752841944 `/tmp/test_lftp/remote/A.b.C.rar' at 2622559389 (0%) [Receiving data] \chunk 3143787913-3274103235 `/tmp/test_lftp/remote/A.b.C.rar' at 3143787913 (0%) [Connecting...] \chunk 3013472590-3143787912 `/tmp/test_lftp/remote/A.b.C.rar' at 3013472590 (0%) [Connecting...] \chunk 2883157267-3013472589 `/tmp/test_lftp/remote/A.b.C.rar' at 2883157267 (0%) [Connecting...] \chunk 2752841944-2883157266 `/tmp/test_lftp/remote/A.b.C.rar' at 2752841944 (0%) [Connecting...] """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_job1 = LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.PGET, state=LftpJobStatus.State.RUNNING, name="A.b.C.rar", flags="-c") golden_job1.total_transfer_state = LftpJobStatus.TransferState(2622559389, 3274103236, 80, None, None) golden_jobs = [golden_job1] self.assertEqual(len(golden_jobs), len(statuses)) statuses_jobs = [j for j in statuses if j.state == LftpJobStatus.State.RUNNING] self.assertEqual(golden_jobs, statuses_jobs) def test_jobs_quotes(self): output = """ [0] queue (sftp://someone:@localhost) sftp://someone:@localhost/home/someone Now executing: [1] mirror -c /tmp/test_lftp/remote/e e /tmp/test_lftp/local/ -- 0/132k (0%) -[2] pget -c /tmp/test_lftp/remote/d d -o /tmp/test_lftp/local/ [1] mirror -c /tmp/test_lftp/remote/e e /tmp/test_lftp/local/ -- 0/132k (0%) \\transfer `e e a' `e e a' at 11804 (9%) 1003b/s eta:2m [Receiving data] [2] pget -c /tmp/test_lftp/remote/d d -o /tmp/test_lftp/local/ sftp://someone:@localhost/home/someone `/tmp/test_lftp/remote/d d' at 11982 (9%) 998b/s eta:2m [Receiving data] """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_job1 = LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="e e", flags="-c") golden_job1.total_transfer_state = LftpJobStatus.TransferState(0, 132*1024, 0, None, None) golden_job1.add_active_file_transfer_state( "e e a", LftpJobStatus.TransferState(None, None, None, 1003, 2*60) ) golden_job2 = LftpJobStatus(job_id=2, job_type=LftpJobStatus.Type.PGET, state=LftpJobStatus.State.RUNNING, name="d d", flags="-c") golden_job2.total_transfer_state = LftpJobStatus.TransferState(None, None, None, 998, 2*60) golden_jobs = [golden_job1, golden_job2] self.assertEqual(len(golden_jobs), len(statuses)) statuses_jobs = [j for j in statuses if j.state == LftpJobStatus.State.RUNNING] self.assertEqual(golden_jobs, statuses_jobs) def test_jobs_getting_file_list(self): output = """ [0] queue (sftp://someone:@localhost) sftp://someone:@localhost/home/someone Now executing: [1] mirror -c /tmp/test_lftp/remote/e e /tmp/test_lftp/local/ -[2] pget -c /tmp/test_lftp/remote/d d -o /tmp/test_lftp/local/ [1] mirror -c /tmp/test_lftp/remote/e e /tmp/test_lftp/local/ Getting file list (25) [Receiving data] [2] pget -c /tmp/test_lftp/remote/d d -o /tmp/test_lftp/local/ sftp://someone:@localhost/home/someone `/tmp/test_lftp/remote/d d' at 23 (0%) [Receiving data] """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_job1 = LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="e e", flags="-c") golden_job2 = LftpJobStatus(job_id=2, job_type=LftpJobStatus.Type.PGET, state=LftpJobStatus.State.RUNNING, name="d d", flags="-c") golden_job2.total_transfer_state = LftpJobStatus.TransferState(None, None, None, None, None) golden_jobs = [golden_job1, golden_job2] self.assertEqual(len(golden_jobs), len(statuses)) statuses_jobs = [j for j in statuses if j.state == LftpJobStatus.State.RUNNING] self.assertEqual(golden_jobs, statuses_jobs) def test_jobs_mirror_empty(self): # '-o' in filename output = """ [0] queue (sftp://someone:@localhost) -- 59 B/s sftp://someone:@localhost/home/someone Now executing: [2] mirror -c /tmp/test_lftp_1d7axxcf/remote/a /tmp/test_lftp_1d7axxcf/local/ -- 100/100 (100%) 59 B/s [2] mirror -c /tmp/test_lftp_1d7axxcf/remote/a /tmp/test_lftp_1d7axxcf/local/ -- 100/100 (100%) 59 B/s \\transfer `aa' `aa' at 59 (59%) \\mirror `Sample' Sample: """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_job1 = LftpJobStatus(job_id=2, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="a", flags="-c") golden_job1.total_transfer_state = LftpJobStatus.TransferState(100, 100, 100, 59, None) golden_job1.add_active_file_transfer_state( "aa", LftpJobStatus.TransferState(None, None, None, None, None) ) golden_jobs = [golden_job1] self.assertEqual(len(golden_jobs), len(statuses)) statuses_jobs = [j for j in statuses if j.state == LftpJobStatus.State.RUNNING] self.assertEqual(golden_jobs, statuses_jobs) def test_jobs_mirror_mkdir(self): output = """ [0] queue (sftp://someone:@localhost:22) sftp://someone:@localhost:22/home/someone Now executing: [1] mirror -c /tmp/test_controllerxnx7xw6x/remote/ra /tmp/test_controllerxnx7xw6x/local/ -- 0/1.1k (0%) [1] mirror -c /tmp/test_controllerxnx7xw6x/remote/ra /tmp/test_controllerxnx7xw6x/local/ -- 0/1.1k (0%) \\transfer `raa' `raa' at 0 (0%) [Connecting...] \mirror `rab' mkdir `/tmp/test_controllerxnx7xw6x/local/ra/rab' [] """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_job1 = LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="ra", flags="-c") golden_job1.total_transfer_state = LftpJobStatus.TransferState(0, 1126, 0, None, None) golden_job1.add_active_file_transfer_state( "raa", LftpJobStatus.TransferState(None, None, None, None, None) ) golden_jobs = [golden_job1] self.assertEqual(len(golden_jobs), len(statuses)) statuses_jobs = [j for j in statuses if j.state == LftpJobStatus.State.RUNNING] self.assertEqual(golden_jobs, statuses_jobs) def test_jobs_connecting(self): output = """ [0] queue (sftp://someone:@localhost) sftp://someone:@localhost Now executing: [1] pget -c /tmp/test_lftp_jsecvi6m/remote/c -o /tmp/test_lftp_jsecvi6m/local/ [1] pget -c /tmp/test_lftp_jsecvi6m/remote/c -o /tmp/test_lftp_jsecvi6m/local/ sftp://someone:@localhost `/tmp/test_lftp_jsecvi6m/remote/c' at 0 [Connecting...] [2] mirror -c /tmp/test_lftp_yb58ogg6/remote/a /tmp/test_lftp_yb58ogg6/local/ cd `/tmp/test_lftp_yb58ogg6/remote/a' [Connecting...] """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_job1 = LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.PGET, state=LftpJobStatus.State.RUNNING, name="c", flags="-c") golden_job1.total_transfer_state = LftpJobStatus.TransferState(None, None, None, None, None) golden_job2 = LftpJobStatus(job_id=2, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="a", flags="-c") golden_jobs = [golden_job1, golden_job2] self.assertEqual(len(golden_jobs), len(statuses)) statuses_jobs = [j for j in statuses if j.state == LftpJobStatus.State.RUNNING] self.assertEqual(golden_jobs, statuses_jobs) def test_jobs_almost_done(self): """Almost done job has a special shorter 'at' line for child file""" output = """ [0] queue (sftp://someone:@localhost) -- 98 B/s sftp://someone:@localhost/home/someone Now executing: [3] mirror -c /tmp/test_lftp_sbz92f__/remote/c'c'c'c /tmp/test_lftp_sbz92f__/local/ -- 100/100 (100%) 98 B/s [3] mirror -c /tmp/test_lftp_sbz92f__/remote/c'c'c'c /tmp/test_lftp_sbz92f__/local/ -- 100/100 (100%) 98 B/s \\transfer `c'''c.txt' `c'''c.txt' at 100 (100%) """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_job1 = LftpJobStatus(job_id=3, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="c'c'c'c", flags="-c") golden_job1.total_transfer_state = LftpJobStatus.TransferState(100, 100, 100, 98, None) golden_job1.add_active_file_transfer_state( "c'''c.txt", LftpJobStatus.TransferState(None, None, None, None, None) ) golden_jobs = [golden_job1] self.assertEqual(len(golden_jobs), len(statuses)) statuses_jobs = [j for j in statuses if j.state == LftpJobStatus.State.RUNNING] self.assertEqual(golden_jobs, statuses_jobs) def test_jobs_empty(self): output = """ [0] queue (sftp://someone:@localhost) sftp://someone:@localhost Now executing: [2] mirror -c /tmp/test_lftp_yb58ogg6/remote/a /tmp/test_lftp_yb58ogg6/local/ [2] mirror -c /tmp/test_lftp_yb58ogg6/remote/a /tmp/test_lftp_yb58ogg6/local/ """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_job2 = LftpJobStatus(job_id=2, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="a", flags="-c") golden_jobs = [golden_job2] self.assertEqual(len(golden_jobs), len(statuses)) statuses_jobs = [j for j in statuses if j.state == LftpJobStatus.State.RUNNING] self.assertEqual(golden_jobs, statuses_jobs) def test_jobs_with_done_line(self): output = """ [0] queue (sftp://someone:@localhost) -- 59 B/s sftp://someone:@localhost/home/someone Now executing: [2] mirror -c /tmp/test_lftp_1d7axxcf/remote/a /tmp/test_lftp_1d7axxcf/local/ -- 100/100 (100%) 59 B/s [2] mirror -c /tmp/test_lftp_1d7axxcf/remote/a /tmp/test_lftp_1d7axxcf/local/ -- 100/100 (100%) 59 B/s \\transfer `aa' `aa' at 59 (59%) [0] Done (queue (sftp://someone:@localhost)) """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_job1 = LftpJobStatus(job_id=2, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="a", flags="-c") golden_job1.total_transfer_state = LftpJobStatus.TransferState(100, 100, 100, 59, None) golden_job1.add_active_file_transfer_state( "aa", LftpJobStatus.TransferState(None, None, None, None, None) ) golden_jobs = [golden_job1] self.assertEqual(len(golden_jobs), len(statuses)) statuses_jobs = [j for j in statuses if j.state == LftpJobStatus.State.RUNNING] self.assertEqual(golden_jobs, statuses_jobs) def test_jobs_missing_pget_data_line(self): output = """ [0] queue (sftp://seedsynctest:@localhost:22) sftp://seedsynctest:@localhost:22/home/seedsynctest Now executing: [3] mirror -c /tmp/test_lftp_ns99k0im/remote/c -o c /tmp/test_lftp_ns99k0im/local/ -[4] pget -c /tmp/test_lftp_ns99k0im/remote/d -o d.txt -o /tmp/test_lftp_ns99k0im/local/ [3] mirror -c /tmp/test_lftp_ns99k0im/remote/c -o c /tmp/test_lftp_ns99k0im/local/ Getting file list (162) [Receiving data] [4] pget -c /tmp/test_lftp_ns99k0im/remote/d -o d.txt -o /tmp/test_lftp_ns99k0im/local/ sftp://seedsynctest:@localhost:22/home/seedsynctest """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_job1 = LftpJobStatus(job_id=3, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="c -o c", flags="-c") golden_job1.total_transfer_state = LftpJobStatus.TransferState(None, None, None, None, None) golden_job2 = LftpJobStatus(job_id=4, job_type=LftpJobStatus.Type.PGET, state=LftpJobStatus.State.RUNNING, name="d -o d.txt", flags="-c") golden_job2.total_transfer_state = LftpJobStatus.TransferState(None, None, None, None, None) golden_jobs = [golden_job1, golden_job2] self.assertEqual(len(golden_jobs), len(statuses)) statuses_jobs = [j for j in statuses if j.state == LftpJobStatus.State.RUNNING] self.assertEqual(golden_jobs, statuses_jobs) def test_raises_error_on_bad_status(self): output = """ [0] queue (sftp://someone:@localhost) sftp://someone:@localhost/home/someone Now executing: [1] mirror -c /tmp/test_controllerw0sbqxe_/remote/ra /tmp/test_controllerw0sbqxe_/local/ -- 0/1.1k (0%) -[2] mirror -c /tmp/test_controllerw0sbqxe_/remote/rb /tmp/test_controllerw0sbqxe_/local/ -- 49/9.3k (0%) Commands queued: 1. pget -c "/tmp/test_controllerw0sbqxe_/remote/rc" -o "/tmp/test_controllerw0sbqxe_/local/" bad string uh oh """ parser = LftpJobStatusParser() with self.assertRaises(LftpJobStatusParserError): parser.parse(output) def test_jobs_special_char_1(self): # Apostrophe/single quote output = """ [0] queue (sftp://someone:@localhost) -- 18 B/s sftp://someone:@localhost/home/someone Now executing: [1] mirror -c /tmp/test_lftp_g6z3_el7/remote/aaa'aaa /tmp/test_lftp_g6z3_el7/local/ -- 36/128 (28%) 18 B/s -[2] pget -c /tmp/test_lftp_g6z3_el7/remote/b''b''b.txt -o /tmp/test_lftp_g6z3_el7/local/ Commands queued: 1. mirror -c "/tmp/test_lftp_g6z3_el7/remote/c'c'c'c" "/tmp/test_lftp_g6z3_el7/local/" 2. pget -c "/tmp/test_lftp_g6z3_el7/remote/d'''d.txt" -o "/tmp/test_lftp_g6z3_el7/local/" [1] mirror -c /tmp/test_lftp_g6z3_el7/remote/aaa'aaa /tmp/test_lftp_g6z3_el7/local/ -- 36/128 (28%) 18 B/s \\transfer `aa'aa'aa.txt' `aa'aa'aa.txt' at 21 (16%) [Receiving data] [2] pget -c /tmp/test_lftp_g6z3_el7/remote/b''b''b.txt -o /tmp/test_lftp_g6z3_el7/local/ sftp://someone:@localhost/home/someone `/tmp/test_lftp_g6z3_el7/remote/b''b''b.txt' at 210 (82%) 94b/s eta:0s [Receiving data] """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_queue = [ LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.QUEUED, name="c'c'c'c", flags="-c"), LftpJobStatus(job_id=2, job_type=LftpJobStatus.Type.PGET, state=LftpJobStatus.State.QUEUED, name="d'''d.txt", flags="-c"), ] golden_job1 = LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="aaa'aaa", flags="-c") golden_job1.total_transfer_state = LftpJobStatus.TransferState(36, 128, 28, 18, None) golden_job1.add_active_file_transfer_state( "aa'aa'aa.txt", LftpJobStatus.TransferState(None, None, None, None, None) ) golden_job2 = LftpJobStatus(job_id=2, job_type=LftpJobStatus.Type.PGET, state=LftpJobStatus.State.RUNNING, name="b''b''b.txt", flags="-c") golden_job2.total_transfer_state = LftpJobStatus.TransferState(None, None, None, 94, 0) golden_jobs = [golden_job1, golden_job2] self.assertEqual(len(golden_queue)+len(golden_jobs), len(statuses)) statuses_queue = [j for j in statuses if j.state == LftpJobStatus.State.QUEUED] self.assertEqual(golden_queue, statuses_queue) statuses_jobs = [j for j in statuses if j.state == LftpJobStatus.State.RUNNING] self.assertEqual(golden_jobs, statuses_jobs) def test_jobs_special_char_2(self): # Double quote output = """ [0] queue (sftp://someone:@localhost) -- 12 B/s sftp://someone:@localhost/home/someone Now executing: [1] mirror -c /tmp/test_lftp_w8d2q1ot/remote/aaa"aaa /tmp/test_lftp_w8d2q1ot/local/ -- 19/128 (14%) 12 B/s -[2] pget -c /tmp/test_lftp_w8d2q1ot/remote/b""b""b.txt -o /tmp/test_lftp_w8d2q1ot/local/ Commands queued: 1. mirror -c "/tmp/test_lftp_w8d2q1ot/remote/c\"c\"c\"c" "/tmp/test_lftp_w8d2q1ot/local/" 2. pget -c "/tmp/test_lftp_w8d2q1ot/remote/d\"\"\"d.txt" -o "/tmp/test_lftp_w8d2q1ot/local/" [1] mirror -c /tmp/test_lftp_w8d2q1ot/remote/aaa"aaa /tmp/test_lftp_w8d2q1ot/local/ -- 19/128 (14%) 12 B/s \\transfer `aa"aa"aa.txt' `aa"aa"aa.txt' at 16 (12%) [Receiving data] [2] pget -c /tmp/test_lftp_w8d2q1ot/remote/b""b""b.txt -o /tmp/test_lftp_w8d2q1ot/local/ sftp://someone:@localhost/home/someone `/tmp/test_lftp_w8d2q1ot/remote/b""b""b.txt' at 203 (79%) 29b/s eta:2s [Receiving data] """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_queue = [ LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.QUEUED, name="c\"c\"c\"c", flags="-c"), LftpJobStatus(job_id=2, job_type=LftpJobStatus.Type.PGET, state=LftpJobStatus.State.QUEUED, name="d\"\"\"d.txt", flags="-c"), ] golden_job1 = LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="aaa\"aaa", flags="-c") golden_job1.total_transfer_state = LftpJobStatus.TransferState(19, 128, 14, 12, None) golden_job1.add_active_file_transfer_state( "aa\"aa\"aa.txt", LftpJobStatus.TransferState(None, None, None, None, None) ) golden_job2 = LftpJobStatus(job_id=2, job_type=LftpJobStatus.Type.PGET, state=LftpJobStatus.State.RUNNING, name="b\"\"b\"\"b.txt", flags="-c") golden_job2.total_transfer_state = LftpJobStatus.TransferState(None, None, None, 29, 2) golden_jobs = [golden_job1, golden_job2] self.assertEqual(len(golden_queue)+len(golden_jobs), len(statuses)) statuses_queue = [j for j in statuses if j.state == LftpJobStatus.State.QUEUED] self.assertEqual(golden_queue, statuses_queue) statuses_jobs = [j for j in statuses if j.state == LftpJobStatus.State.RUNNING] self.assertEqual(golden_jobs, statuses_jobs) def test_jobs_special_char_3(self): # Mix of single quotes, double quotes and spaces output = """ [0] queue (sftp://someone:@localhost) -- 15 B/s sftp://someone:@localhost/home/someone Now executing: [1] mirror -c /tmp/test_lftp_m9mxjip7/remote/a' aa"aaa /tmp/test_lftp_m9mxjip7/local/ -- 48/128 (37%) 15 B/s -[2] pget -c /tmp/test_lftp_m9mxjip7/remote/"b ' "b" ' "b.txt -o /tmp/test_lftp_m9mxjip7/local/ Commands queued: 1. mirror -c "/tmp/test_lftp_m9mxjip7/remote/'c\" c \" 'c' \"c\"" "/tmp/test_lftp_m9mxjip7/local/" 2. pget -c "/tmp/test_lftp_m9mxjip7/remote/'d\" ' \" ' \"d.txt" -o "/tmp/test_lftp_m9mxjip7/local/" [1] mirror -c /tmp/test_lftp_m9mxjip7/remote/a' aa"aaa /tmp/test_lftp_m9mxjip7/local/ -- 48/128 (37%) 15 B/s \\transfer `aa"a ' a"aa.txt' `aa"a ' a"aa.txt' at 43 (33%) 15b/s eta:6s [Receiving data] [2] pget -c /tmp/test_lftp_m9mxjip7/remote/"b ' "b" ' "b.txt -o /tmp/test_lftp_m9mxjip7/local/ sftp://someone:@localhost/home/someone `/tmp/test_lftp_m9mxjip7/remote/"b ' "b" ' "b.txt' at 236 (92%) 26b/s eta:1s [Receiving data] """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_queue = [ LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.QUEUED, name="'c\" c \" 'c' \"c\"", flags="-c"), LftpJobStatus(job_id=2, job_type=LftpJobStatus.Type.PGET, state=LftpJobStatus.State.QUEUED, name="'d\" ' \" ' \"d.txt", flags="-c"), ] golden_job1 = LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="a' aa\"aaa", flags="-c") golden_job1.total_transfer_state = LftpJobStatus.TransferState(48, 128, 37, 15, None) golden_job1.add_active_file_transfer_state( "aa\"a ' a\"aa.txt", LftpJobStatus.TransferState(None, None, None, 15, 6) ) golden_job2 = LftpJobStatus(job_id=2, job_type=LftpJobStatus.Type.PGET, state=LftpJobStatus.State.RUNNING, name="\"b ' \"b\" ' \"b.txt", flags="-c") golden_job2.total_transfer_state = LftpJobStatus.TransferState(None, None, None, 26, 1) golden_jobs = [golden_job1, golden_job2] self.assertEqual(len(golden_queue)+len(golden_jobs), len(statuses)) statuses_queue = [j for j in statuses if j.state == LftpJobStatus.State.QUEUED] self.assertEqual(golden_queue, statuses_queue) statuses_jobs = [j for j in statuses if j.state == LftpJobStatus.State.RUNNING] self.assertEqual(golden_jobs, statuses_jobs) def test_jobs_special_char_4(self): # '-o' in filename output = """ [0] queue (sftp://someone:@localhost) -- 16 B/s sftp://someone:@localhost/home/someone Now executing: [1] mirror -c /tmp/test_lftp_xi6gkbhv/remote/a -o a /tmp/test_lftp_xi6gkbhv/local/ -- 60/128 (46%) 16 B/s -[2] pget -c /tmp/test_lftp_xi6gkbhv/remote/b -o b.txt -o /tmp/test_lftp_xi6gkbhv/local/ Commands queued: 1. mirror -c "/tmp/test_lftp_xi6gkbhv/remote/c -o c" "/tmp/test_lftp_xi6gkbhv/local/" 2. pget -c "/tmp/test_lftp_xi6gkbhv/remote/d -o d.txt" -o "/tmp/test_lftp_xi6gkbhv/local/" [1] mirror -c /tmp/test_lftp_xi6gkbhv/remote/a -o a /tmp/test_lftp_xi6gkbhv/local/ -- 60/128 (46%) 16 B/s \\transfer `a -o a.txt' `a -o a.txt' at 55 (42%) 16b/s eta:4s [Receiving data] [2] pget -c /tmp/test_lftp_xi6gkbhv/remote/b -o b.txt -o /tmp/test_lftp_xi6gkbhv/local/ sftp://someone:@localhost/home/someone `/tmp/test_lftp_xi6gkbhv/remote/b -o b.txt' at 240 (93%) 26b/s eta:1s [Receiving data] """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_queue = [ LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.QUEUED, name="c -o c", flags="-c"), LftpJobStatus(job_id=2, job_type=LftpJobStatus.Type.PGET, state=LftpJobStatus.State.QUEUED, name="d -o d.txt", flags="-c"), ] golden_job1 = LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="a -o a", flags="-c") golden_job1.total_transfer_state = LftpJobStatus.TransferState(60, 128, 46, 16, None) golden_job1.add_active_file_transfer_state( "a -o a.txt", LftpJobStatus.TransferState(None, None, None, 16, 4) ) golden_job2 = LftpJobStatus(job_id=2, job_type=LftpJobStatus.Type.PGET, state=LftpJobStatus.State.RUNNING, name="b -o b.txt", flags="-c") golden_job2.total_transfer_state = LftpJobStatus.TransferState(None, None, None, 26, 1) golden_jobs = [golden_job1, golden_job2] self.assertEqual(len(golden_queue)+len(golden_jobs), len(statuses)) statuses_queue = [j for j in statuses if j.state == LftpJobStatus.State.QUEUED] self.assertEqual(golden_queue, statuses_queue) statuses_jobs = [j for j in statuses if j.state == LftpJobStatus.State.RUNNING] self.assertEqual(golden_jobs, statuses_jobs) def test_jobs_chmod(self): output = """ [0] queue (sftp://someone:@localhost:22) -- 12.26 MiB/s sftp://someone:@localhost:22/remote/path Now executing: [3] mirror -c /remote/path/Space.Trek.S23E03.720p /local/path/ -- 985M/985M (100%) -[4] mirror -c /remote/path/Star.Battle.Movie /local/path/ -- 116M/1.2G (9%) 12.26 MiB/s [3] mirror -c /remote/path/Space.Trek.S23E03.720p /local/path/ -- 985M/985M (100%) chmod Space.Trek.S23E03.720p.r06 file:/local/path/Space.Trek.S23E03.720p `Space.Trek.S23E03.720p.r06' [] chmod Space.Trek.S23E03.720p.r07 file:/local/path/Space.Trek.S23E03.720p `Space.Trek.S23E03.720p.r07' [] chmod Space.Trek.S23E03.720p.r08 file:/local/path/Space.Trek.S23E03.720p `Space.Trek.S23E03.720p.r08' [] chmod Space.Trek.S23E03.720p.r09 file:/local/path/Space.Trek.S23E03.720p `Space.Trek.S23E03.720p.r09' [] [4] mirror -c /remote/path/Star.Battle.Movie /local/path/ -- 116M/1.2G (9%) 12.26 MiB/s \\transfer `star.battle.movie.720p.r07' `star.battle.movie.720p.r07', got 44628032 of 50000000 (89%) 1.10M/s eta:5s \chunk 9011200-25000000 `star.battle.movie.720p.r07' at 19628032 (25%) 1.10M/s eta:5s [Receiving data] \\transfer `star.battle.movie.720p.r08' `star.battle.movie.720p.r08', got 15237120 of 50000000 (30%) 2.04M/s \chunk 0-25000000 `star.battle.movie.720p.r08' at 13664256 (27%) 1.36M/s eta:8s [Receiving data] \chunk 37500000-49999999 `star.battle.movie.720p.r08' at 38581344 (8%) 696.2K/s eta:16s [Receiving data] \chunk 25000000-37499999 `star.battle.movie.720p.r08' at 25491520 (3%) [Receiving data] \\transfer `star.battle.movie.720p.r09' `star.battle.movie.720p.r09', got 21692416 of 50000000 (43%) 4.05M/s eta:16s \chunk 0-12500000 `star.battle.movie.720p.r09' at 12419072 (24%) 1.28M/s eta:0s [Receiving data] \chunk 37500000-49999999 `star.battle.movie.720p.r09' at 38843488 (10%) 662.8K/s eta:16s [Receiving data] \chunk 25000000-37499999 `star.battle.movie.720p.r09' at 28047424 (24%) 963.8K/s eta:10s [Receiving data] \chunk 12500000-24999999 `star.battle.movie.720p.r09' at 17382432 (39%) 1.19M/s eta:6s [Receiving data] \\transfer `star.battle.movie.720p.r10' `star.battle.movie.720p.r10', got 33930272 of 50000000 (67%) 5.06M/s eta:6s \chunk 37500000-49999999 `star.battle.movie.720p.r10' at 43037792 (44%) 1.16M/s eta:6s [Receiving data] \chunk 25000000-37499999 `star.battle.movie.720p.r10' at 32503872 (60%) 1.19M/s eta:4s [Receiving data] \chunk 12500000-24999999 `star.battle.movie.720p.r10' at 20888608 (67%) 1.33M/s eta:3s [Receiving data] """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_job1 = LftpJobStatus(job_id=3, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="Space.Trek.S23E03.720p", flags="-c") golden_job1.total_transfer_state = LftpJobStatus.TransferState(1032847360, 1032847360, 100, None, None) golden_job2 = LftpJobStatus(job_id=4, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="Star.Battle.Movie", flags="-c") golden_job2.total_transfer_state = LftpJobStatus.TransferState(121634816, 1288490188, 9, 12855541, None) golden_job2.add_active_file_transfer_state( "star.battle.movie.720p.r07", LftpJobStatus.TransferState(44628032, 50000000, 89, 1153433, 5) ) golden_job2.add_active_file_transfer_state( "star.battle.movie.720p.r08", LftpJobStatus.TransferState(15237120, 50000000, 30, 2139095, None) ) golden_job2.add_active_file_transfer_state( "star.battle.movie.720p.r09", LftpJobStatus.TransferState(21692416, 50000000, 43, 4246732, 16) ) golden_job2.add_active_file_transfer_state( "star.battle.movie.720p.r10", LftpJobStatus.TransferState(33930272, 50000000, 67, 5305794, 6) ) self.assertEqual(2, len(statuses)) self.assertEqual(golden_job1, statuses[0]) self.assertEqual(golden_job2, statuses[1]) def test_jobs_chmod_two_liner(self): output = """ [0] queue (sftp://someone:@localhost:22) -- 1.8 KiB/s sftp://someone:@localhost:22/remote/path Now executing: [1] mirror -c /remote/path/Space.Trek /local/path/ -- 3.1k/617M (0%) 1.8 KiB/s [1] mirror -c /remote/path/Space.Trek /local/path/ -- 3.1k/617M (0%) 1.8 KiB/s \mirror `Space.Trek.S08E04' chmod Space.Trek.S08E04.sfv file:/local/path/Space.Trek/Space.Trek.S08E04 \mirror `Space.Trek.S08E05' -- 605/51M (0%) \\transfer `Space.Trek.S08E05/space.trek.s08e05.r06' `space.trek.s08e05.r06' at 0 (0%) [Waiting for response...] \mirror `Space.Trek.S08E06' -- 1.6k/517M (0%) 932 B/s \\transfer `Space.Trek.S08E06/space.trek.s08e06.nfo' `space.trek.s08e06.nfo' at 932 (100%) [Receiving data] \mirror `Space.Trek.S08E07' -- 932/51M (0%) 932 B/s \\transfer `Space.Trek.S08E07/space.trek.s08e07.nfo' `space.trek.s08e07.nfo' at 932 (100%) [Receiving data] """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_job1 = LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="Space.Trek", flags="-c") golden_job1.total_transfer_state = LftpJobStatus.TransferState(3174, 646971392, 0, 1843, None) golden_job1.add_active_file_transfer_state( "Space.Trek.S08E05/space.trek.s08e05.r06", LftpJobStatus.TransferState(None, None, None, None, None) ) golden_job1.add_active_file_transfer_state( "Space.Trek.S08E06/space.trek.s08e06.nfo", LftpJobStatus.TransferState(None, None, None, None, None) ) golden_job1.add_active_file_transfer_state( "Space.Trek.S08E07/space.trek.s08e07.nfo", LftpJobStatus.TransferState(None, None, None, None, None) ) self.assertEqual(1, len(statuses)) self.assertEqual(golden_job1, statuses[0]) def test_jobs_chmod_2(self): output = """ jobs -v [0] queue (sftp://someone:@localhost:22) -- 3.45 MiB/s sftp://someone:@localhost:22/remote/path Now executing: [1] mirror -c /remote/path/Space.Trek /media/WD/Videos/temp/ -- 7.8M/429M (1%) 1.01 MiB/s (52%) 2.44 MiB/s [1] mirror -c /remote/path/Space.Trek /media/WD/Videos/temp/ -- 7.8M/429M (1%) 1.01 MiB/s \\transfer `Space.Trek.mkv' `Space.Trek.mkv', got 7700480 of 425302375 (1%) 1.01M/s eta:7m \chunk 0-106325596 `Space.Trek.mkv' at 1867776 (0%) 255.6K/s eta:7m [Receiving data] \chunk 318976782-425302374 `Space.Trek.mkv' at 320910094 (1%) 257.6K/s eta:7m [Receiving data] \chunk 212651189-318976781 `Space.Trek.mkv' at 214584501 (1%) 257.7K/s eta:7m [Receiving data] \chunk 106325596-212651188 `Space.Trek.mkv' at 108291676 (1%) 259.4K/s eta:7m [Receiving data] \mirror `Screens' -- 0/2.8M (0%) chmod ./Space.Trek.Screen0001.png file:/media/WD/Videos/temp/Space.Trek/Screens chmod ./Space.Trek.Screen0002.png file:/media/WD/Videos/temp/Space.Trek/Screens chmod ./Space.Trek.Screen0003.png file:/media/WD/Videos/temp/Space.Trek/Screens """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_job1 = LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="Space.Trek", flags="-c") golden_job1.total_transfer_state = LftpJobStatus.TransferState(8178892, 449839104, 1, 1059061, None) golden_job1.add_active_file_transfer_state( "Space.Trek.mkv", LftpJobStatus.TransferState(7700480, 425302375, 1, 1059061, 420) ) self.assertEqual(1, len(statuses)) self.assertEqual(golden_job1, statuses[0]) def test_removes_jobs_command(self): output = """ jobs -v [0] queue (sftp://someone:@localhost) -- 90 B/s sftp://someone:@localhost/home/someone Now executing: [1] mirror -c /tmp/test_lftp_rm_s6oau/remote/a /tmp/test_lftp_rm_s6oau/local/ -- 345/26M (0%) 90 B/s [1] mirror -c /tmp/test_lftp_rm_s6oau/remote/a /tmp/test_lftp_rm_s6oau/local/ -- 345/26M (0%) 90 B/s """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_job1 = LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="a", flags="-c") golden_job1.total_transfer_state = LftpJobStatus.TransferState(345, 26*1024*1024, 0, 90, None) golden_jobs = [golden_job1] self.assertEqual(len(golden_jobs), len(statuses)) statuses_jobs = [j for j in statuses if j.state == LftpJobStatus.State.RUNNING] self.assertEqual(golden_jobs, statuses_jobs) def test_removes_multiple_jobs_lines(self): output = """ jobs -v jobs -v jobs -v [0] queue (sftp://someone:@localhost) -- 90 B/s sftp://someone:@localhost/home/someone Now executing: [1] mirror -c /tmp/test_lftp_rm_s6oau/remote/a /tmp/test_lftp_rm_s6oau/local/ -- 345/26M (0%) 90 B/s [1] mirror -c /tmp/test_lftp_rm_s6oau/remote/a /tmp/test_lftp_rm_s6oau/local/ -- 345/26M (0%) 90 B/s """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_job1 = LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="a", flags="-c") golden_job1.total_transfer_state = LftpJobStatus.TransferState(345, 26*1024*1024, 0, 90, None) golden_jobs = [golden_job1] self.assertEqual(len(golden_jobs), len(statuses)) statuses_jobs = [j for j in statuses if j.state == LftpJobStatus.State.RUNNING] self.assertEqual(golden_jobs, statuses_jobs) def test_removes_log_line(self): output = """ 2020-06-09 04:25:46 sftp://user@example.com:22/path/on/server/file.nfo -> /path/on/local/file.nfo 0-1400 2.8 KiB/s [0] queue (sftp://someone:@localhost) -- 90 B/s sftp://someone:@localhost/home/someone Now executing: [1] mirror -c /tmp/test_lftp_rm_s6oau/remote/a /tmp/test_lftp_rm_s6oau/local/ -- 345/26M (0%) 90 B/s [1] mirror -c /tmp/test_lftp_rm_s6oau/remote/a /tmp/test_lftp_rm_s6oau/local/ -- 345/26M (0%) 90 B/s """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_job1 = LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="a", flags="-c") golden_job1.total_transfer_state = LftpJobStatus.TransferState(345, 26*1024*1024, 0, 90, None) golden_jobs = [golden_job1] self.assertEqual(len(golden_jobs), len(statuses)) statuses_jobs = [j for j in statuses if j.state == LftpJobStatus.State.RUNNING] self.assertEqual(golden_jobs, statuses_jobs) def test_removes_lines_before_first_jobs(self): output = """ /path/on/local/file.nfo.lftp 914457562-997590066 436.5 KiB/s jobs -v [0] queue (sftp://someone:@localhost) -- 90 B/s sftp://someone:@localhost/home/someone Now executing: [1] mirror -c /tmp/test_lftp_rm_s6oau/remote/a /tmp/test_lftp_rm_s6oau/local/ -- 345/26M (0%) 90 B/s [1] mirror -c /tmp/test_lftp_rm_s6oau/remote/a /tmp/test_lftp_rm_s6oau/local/ -- 345/26M (0%) 90 B/s """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_job1 = LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="a", flags="-c") golden_job1.total_transfer_state = LftpJobStatus.TransferState(345, 26*1024*1024, 0, 90, None) golden_jobs = [golden_job1] self.assertEqual(len(golden_jobs), len(statuses)) statuses_jobs = [j for j in statuses if j.state == LftpJobStatus.State.RUNNING] self.assertEqual(golden_jobs, statuses_jobs) def test_jobs_removes_multiple_jobs_lines(self): output = """ jobs -v [0] queue (sftp://user:password@server:22) -- 3.18 MiB/s sftp://user:password@server:22/home/someone Now executing: [1] mirror -c files/sync/movie /incoming// -- 628M/21G (3%) 3.18 MiB/s [1] mirror -c files/sync/movie /incoming// -- 628M/21G (3%) 3.18 MiB/s \\transfer `movie.mkv' `movie.mkv', got 627933184 of 20757383669 (3%) 3.18M/s eta:69m \chunk 0-2594672963 `movie.mkv' at 79953920 (0%) 407.2K/s eta:46m [Receiving data] \chunk 18162710711-20757383668 `movie.mkv' at 18251249847 (3%) 471.1K/s eta:42m [Receiving data] \chunk 15568037753-18162710710 `movie.mkv' at 15636785017 (2%) 341.4K/s eta:54m [Receiving data] \chunk 12973364795-15568037752 `movie.mkv' at 13027497531 (2%) 321.5K/s eta:69m [Receiving data] \chunk 10378691837-12973364794 `movie.mkv' at 10463528189 (3%) 419.7K/s eta:44m [Receiving data] \chunk 7784018879-10378691836 `movie.mkv' at 7865185215 (3%) 419.2K/s eta:46m [Receiving data] \chunk 5189345921-7784018878 `movie.mkv' at 5271265921 (3%) 426.4K/s eta:45m [Receiving data] jobs -v jobs -v \chunk 2594672963-5189345920 `movie.mkv' at 2683310403 (3%) 449.5K/s eta:42m [Receiving data] """ parser = LftpJobStatusParser() statuses = parser.parse(output) golden_job1 = LftpJobStatus(job_id=1, job_type=LftpJobStatus.Type.MIRROR, state=LftpJobStatus.State.RUNNING, name="movie", flags="-c") golden_job1.total_transfer_state = LftpJobStatus.TransferState( 628*1024*1024, 21*1024*1024*1024, 3, 3334471, None ) golden_job1.add_active_file_transfer_state( "movie.mkv", LftpJobStatus.TransferState(627933184, 20757383669, 3, 3334471, 69*60) ) golden_jobs = [golden_job1] self.assertEqual(len(golden_jobs), len(statuses)) statuses_jobs = [j for j in statuses if j.state == LftpJobStatus.State.RUNNING] self.assertEqual(golden_jobs, statuses_jobs) ================================================ FILE: src/python/tests/unittests/test_lftp/test_lftp.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import logging import os import shutil import sys import tempfile import unittest import timeout_decorator from tests.utils import TestUtils from lftp import Lftp, LftpJobStatus, LftpError # noinspection PyPep8Naming,SpellCheckingInspection class TestLftp(unittest.TestCase): temp_dir = None @classmethod def setUpClass(cls): # Create a temp directory TestLftp.temp_dir = tempfile.mkdtemp(prefix="test_lftp_") print(f"Temp dir: {TestLftp.temp_dir}") # Allow group access for the seedsynctest account TestUtils.chmod_from_to(TestLftp.temp_dir, tempfile.gettempdir(), 0o775) # Create some test directories # remote [dir] for remote path # a [dir] # aa [file, 24*1024 bytes] # ab [file, 2*1024*1024 bytes] # b [dir] # ba [dir] # baa [file, 128*1024 bytes] # bab [file, 128*1024 bytes] # bb [file, 128*1024 bytes] # c [file, 1234 bytes] # "d d" [file, 128*1024 bytes] # "e e" [dir] # "e e a" [file, 128*1024 bytes] # áßç [dir] # dőÀ [file, 128*1024 bytes] # üæÒ [file, 256*1024 bytes] # local [dir] for local path, cleared before every test def my_mkdir(*args): os.mkdir(os.path.join(TestLftp.temp_dir, *args)) def my_touch(size, *args): path = os.path.join(TestLftp.temp_dir, *args) with open(path, 'wb') as f: f.write(bytearray([0xff]*size)) def my_mkdir_latin(*args): os.mkdir(os.path.join(TestLftp.temp_dir.encode('latin-1'), *args)) def my_touch_latin(size, *args): path = os.path.join(TestLftp.temp_dir.encode('latin-1'), *args) with open(path, 'wb') as f: f.write(bytearray([0xff]*size)) my_mkdir("remote") my_mkdir("remote", "a") my_touch(24*1024, "remote", "a", "aa") my_touch(24*1024*1024, "remote", "a", "ab") my_mkdir("remote", "b") my_mkdir("remote", "b", "ba") my_touch(128*1024, "remote", "b", "ba", "baa") my_touch(128*1024, "remote", "b", "ba", "bab") my_touch(128*1024, "remote", "b", "bb") my_touch(1234, "remote", "c") my_touch(128*1024, "remote", "d d") my_mkdir("remote", "e e") my_touch(128*1024, "remote", "e e", "e e a") my_mkdir("remote", "áßç") my_touch(128*1024, "remote", "áßç", "dőÀ") my_touch(256*1024, "remote", "üæÒ") my_mkdir_latin(b"remote", b"f\xe9g") my_touch_latin(128*1024, b"remote", b"f\xe9g", b"d\xe9f") my_touch_latin(256*1024, b"remote", b"g\xe9h") my_mkdir_latin(b"remote", b"latin") my_touch_latin(128*1024, b"remote", b"latin", b"d\xe9f") my_mkdir("local") @classmethod def tearDownClass(cls): # Cleanup shutil.rmtree(TestLftp.temp_dir) def setUp(self): # Delete and recreate the local dir shutil.rmtree(os.path.join(TestLftp.temp_dir, "local")) os.mkdir(os.path.join(TestLftp.temp_dir, "local")) self.local_dir = os.path.join(TestLftp.temp_dir, "local") self.remote_dir = os.path.join(TestLftp.temp_dir, "remote") # Note: seedsynctest account must be set up. See DeveloperReadme.md for details self.host = "localhost" self.port = 22 self.user = "seedsynctest" self.password = "seedsyncpass" # Default lftp instance - use key-based login self.lftp = Lftp(address=self.host, port=self.port, user=self.user, password=None) self.lftp.set_base_remote_dir_path(self.remote_dir) self.lftp.set_base_local_dir_path(self.local_dir) self.lftp.set_verbose_logging(True) logger = logging.getLogger() logger.setLevel(logging.DEBUG) handler = logging.StreamHandler(sys.stdout) formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") handler.setFormatter(formatter) logger.addHandler(handler) def tearDown(self): self.lftp.raise_pending_error() self.lftp.exit() def test_num_connections_per_dir_file(self): self.lftp.num_connections_per_dir_file = 5 self.assertEqual(5, self.lftp.num_connections_per_dir_file) with self.assertRaises(ValueError): self.lftp.num_connections_per_dir_file = -1 def test_num_connections_per_root_file(self): self.lftp.num_connections_per_root_file = 5 self.assertEqual(5, self.lftp.num_connections_per_root_file) with self.assertRaises(ValueError): self.lftp.num_connections_per_root_file = -1 def test_num_parallel_files(self): self.lftp.num_parallel_files = 5 self.assertEqual(5, self.lftp.num_parallel_files) with self.assertRaises(ValueError): self.lftp.num_parallel_files = -1 def test_num_max_total_connections(self): self.lftp.num_max_total_connections = 5 self.assertEqual(5, self.lftp.num_max_total_connections) self.lftp.num_max_total_connections = 0 self.assertEqual(0, self.lftp.num_max_total_connections) with self.assertRaises(ValueError): self.lftp.num_max_total_connections = -1 def test_rate_limit(self): self.lftp.rate_limit = 500 self.assertEqual("500", self.lftp.rate_limit) self.lftp.rate_limit = "2k" self.assertEqual("2k", self.lftp.rate_limit) self.lftp.rate_limit = "1M" self.assertEqual("1M", self.lftp.rate_limit) def test_min_chunk_size(self): self.lftp.min_chunk_size = 500 self.assertEqual("500", self.lftp.min_chunk_size) self.lftp.min_chunk_size = "2k" self.assertEqual("2k", self.lftp.min_chunk_size) self.lftp.min_chunk_size = "1M" self.assertEqual("1M", self.lftp.min_chunk_size) def test_num_parallel_jobs(self): self.lftp.num_parallel_jobs = 5 self.assertEqual(5, self.lftp.num_parallel_jobs) with self.assertRaises(ValueError): self.lftp.num_parallel_jobs = -1 def test_move_background_on_exit(self): self.lftp.move_background_on_exit = True self.assertEqual(True, self.lftp.move_background_on_exit) self.lftp.move_background_on_exit = False self.assertEqual(False, self.lftp.move_background_on_exit) def test_use_temp_file(self): self.lftp.use_temp_file = True self.assertEqual(True, self.lftp.use_temp_file) self.lftp.use_temp_file = False self.assertEqual(False, self.lftp.use_temp_file) def test_temp_file_name(self): self.lftp.temp_file_name = "*.lftp" self.assertEqual("*.lftp", self.lftp.temp_file_name) self.lftp.temp_file_name = "*.temp" self.assertEqual("*.temp", self.lftp.temp_file_name) def test_sftp_auto_confirm(self): self.lftp.sftp_auto_confirm = True self.assertEqual(True, self.lftp.sftp_auto_confirm) self.lftp.sftp_auto_confirm = False self.assertEqual(False, self.lftp.sftp_auto_confirm) def test_sftp_connect_program(self): self.lftp.sftp_connect_program = "program -a -f" self.assertEqual("\"program -a -f\"", self.lftp.sftp_connect_program) self.lftp.sftp_connect_program = "\"abc -d\"" self.assertEqual("\"abc -d\"", self.lftp.sftp_connect_program) def test_status_empty(self): statuses = self.lftp.status() self.assertEqual(0, len(statuses)) @timeout_decorator.timeout(5) def test_queue_file(self): self.lftp.rate_limit = 10 # so jobs don't finish right away self.lftp.queue("c", False) while True: statuses = self.lftp.status() if len(statuses) > 0: break self.assertEqual(1, len(statuses)) self.assertEqual("c", statuses[0].name) self.assertEqual(LftpJobStatus.Type.PGET, statuses[0].type) self.assertEqual(LftpJobStatus.State.RUNNING, statuses[0].state) @timeout_decorator.timeout(5) def test_queue_dir(self): self.lftp.rate_limit = 10 # so jobs don't finish right away self.lftp.queue("a", True) while True: statuses = self.lftp.status() if len(statuses) > 0: break self.assertEqual(1, len(statuses)) self.assertEqual("a", statuses[0].name) self.assertEqual(LftpJobStatus.Type.MIRROR, statuses[0].type) self.assertEqual(LftpJobStatus.State.RUNNING, statuses[0].state) @timeout_decorator.timeout(5) def test_queue_file_with_spaces(self): self.lftp.rate_limit = 10 # so jobs don't finish right away self.lftp.queue("d d", False) while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) > 0: break self.assertEqual(1, len(statuses)) self.assertEqual("d d", statuses[0].name) self.assertEqual(LftpJobStatus.Type.PGET, statuses[0].type) self.assertEqual(LftpJobStatus.State.RUNNING, statuses[0].state) @timeout_decorator.timeout(5) def test_queue_dir_with_spaces(self): self.lftp.rate_limit = 10 # so jobs don't finish right away self.lftp.queue("e e", True) while True: statuses = self.lftp.status() if len(statuses) > 0: break self.assertEqual(1, len(statuses)) self.assertEqual("e e", statuses[0].name) self.assertEqual(LftpJobStatus.Type.MIRROR, statuses[0].type) self.assertEqual(LftpJobStatus.State.RUNNING, statuses[0].state) @timeout_decorator.timeout(5) def test_queue_file_with_unicode(self): self.lftp.rate_limit = 10 # so jobs don't finish right away self.lftp.queue("üæÒ", False) while True: statuses = self.lftp.status() if len(statuses) > 0: break self.assertEqual(1, len(statuses)) self.assertEqual("üæÒ", statuses[0].name) self.assertEqual(LftpJobStatus.Type.PGET, statuses[0].type) self.assertEqual(LftpJobStatus.State.RUNNING, statuses[0].state) @timeout_decorator.timeout(5) def test_queue_dir_with_latin(self): self.lftp.rate_limit = 100 # so jobs don't finish right away self.lftp.queue("latin", True) while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) > 0: break self.assertEqual(1, len(statuses)) self.assertEqual("latin", statuses[0].name) self.assertEqual(LftpJobStatus.Type.MIRROR, statuses[0].type) self.assertEqual(LftpJobStatus.State.RUNNING, statuses[0].state) # Download over 100 bytes without errors while True: statuses = self.lftp.status() self.lftp.raise_pending_error() size_local = statuses[0].total_transfer_state.size_local if size_local and size_local > 100: break @timeout_decorator.timeout(5) def test_queue_dir_with_unicode(self): self.lftp.rate_limit = 10 # so jobs don't finish right away self.lftp.queue("áßç", True) while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) > 0: break self.assertEqual(1, len(statuses)) self.assertEqual("áßç", statuses[0].name) self.assertEqual(LftpJobStatus.Type.MIRROR, statuses[0].type) self.assertEqual(LftpJobStatus.State.RUNNING, statuses[0].state) @timeout_decorator.timeout(5) def test_queue_num_parallel_jobs(self): self.lftp.num_parallel_jobs = 2 self.lftp.rate_limit = 10 # so jobs don't finish right away self.lftp.queue("a", True) self.lftp.queue("c", False) self.lftp.queue("b", True) while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) > 2: break self.assertEqual(3, len(statuses)) # queued jobs self.assertEqual("b", statuses[0].name) self.assertEqual(LftpJobStatus.Type.MIRROR, statuses[0].type) self.assertEqual(LftpJobStatus.State.QUEUED, statuses[0].state) # running jobs self.assertEqual("a", statuses[1].name) self.assertEqual(LftpJobStatus.Type.MIRROR, statuses[1].type) self.assertEqual(LftpJobStatus.State.RUNNING, statuses[1].state) self.assertEqual("c", statuses[2].name) self.assertEqual(LftpJobStatus.Type.PGET, statuses[2].type) self.assertEqual(LftpJobStatus.State.RUNNING, statuses[2].state) @timeout_decorator.timeout(5) def test_kill_all(self): self.lftp.num_parallel_jobs = 2 self.lftp.rate_limit = 10 # so jobs don't finish right away self.lftp.queue("a", True) self.lftp.queue("c", False) self.lftp.queue("b", True) while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) > 2: break self.assertEqual(3, len(statuses)) self.lftp.kill_all() statuses = self.lftp.status() while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) == 0: break statuses = self.lftp.status() self.assertEqual(0, len(statuses)) @timeout_decorator.timeout(5) def test_kill_all_and_queue_again(self): self.lftp.num_parallel_jobs = 2 self.lftp.rate_limit = 10 # so jobs don't finish right away self.lftp.queue("a", True) self.lftp.queue("c", False) self.lftp.queue("b", True) while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) > 2: break self.assertEqual(3, len(statuses)) self.lftp.kill_all() while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) == 0: break self.assertEqual(0, len(statuses)) self.lftp.queue("b", True) while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) > 0: break self.assertEqual(1, len(statuses)) self.assertEqual("b", statuses[0].name) self.assertEqual(LftpJobStatus.Type.MIRROR, statuses[0].type) self.assertEqual(LftpJobStatus.State.RUNNING, statuses[0].state) @timeout_decorator.timeout(5) def test_kill_queued_job(self): self.lftp.rate_limit = 10 # so jobs don't finish right away self.lftp.num_parallel_jobs = 1 self.lftp.queue("a", True) # this job will run self.lftp.queue("b", True) # this job will queue while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) > 1: break self.assertEqual(2, len(statuses)) self.assertEqual("b", statuses[0].name) self.assertEqual(LftpJobStatus.State.QUEUED, statuses[0].state) self.assertEqual("a", statuses[1].name) self.assertEqual(LftpJobStatus.State.RUNNING, statuses[1].state) self.assertEqual(True, self.lftp.kill("b")) while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) > 0: break self.assertEqual(1, len(statuses)) self.assertEqual("a", statuses[0].name) self.assertEqual(LftpJobStatus.State.RUNNING, statuses[0].state) @timeout_decorator.timeout(5) def test_kill_running_job(self): self.lftp.rate_limit = 10 # so jobs don't finish right away self.lftp.queue("a", True) while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) > 0: break self.assertEqual(1, len(statuses)) self.assertEqual("a", statuses[0].name) self.assertEqual(LftpJobStatus.State.RUNNING, statuses[0].state) self.assertEqual(True, self.lftp.kill("a")) while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) == 0: break self.assertEqual(0, len(statuses)) @timeout_decorator.timeout(5) def test_kill_missing_job(self): self.lftp.rate_limit = 10 # so jobs don't finish right away self.lftp.queue("a", True) while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) > 0: break self.assertEqual(1, len(statuses)) self.assertEqual("a", statuses[0].name) self.assertEqual(LftpJobStatus.State.RUNNING, statuses[0].state) self.assertEqual(False, self.lftp.kill("b")) self.assertEqual(True, self.lftp.kill("a")) while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) == 0: break self.assertEqual(0, len(statuses)) @timeout_decorator.timeout(5) def test_kill_job_1(self): """Queued and running jobs killed one at a time""" self.lftp.rate_limit = 10 # so jobs don't finish right away self.lftp.num_parallel_jobs = 2 # 2 jobs running, 3 jobs queued self.lftp.queue("a", True) # running self.lftp.queue("d d", False) # running self.lftp.queue("b", True) # queued self.lftp.queue("c", False) # queued self.lftp.queue("e e", True) # queued Q = LftpJobStatus.State.QUEUED R = LftpJobStatus.State.RUNNING while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) > 4: break self.assertEqual(5, len(statuses)) self.assertEqual(["b", "c", "e e", "a", "d d"], [s.name for s in statuses]) self.assertEqual([Q, Q, Q, R, R], [s.state for s in statuses]) # kill the queued jobs one-by-one self.lftp.kill("c") while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) == 4: break self.assertEqual(4, len(statuses)) self.assertEqual(["b", "e e", "a", "d d"], [s.name for s in statuses]) self.assertEqual([Q, Q, R, R], [s.state for s in statuses]) self.lftp.kill("b") while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) == 3: break self.assertEqual(3, len(statuses)) self.assertEqual(["e e", "a", "d d"], [s.name for s in statuses]) self.assertEqual([Q, R, R], [s.state for s in statuses]) self.lftp.kill("e e") while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) == 2: break self.assertEqual(2, len(statuses)) self.assertEqual(["a", "d d"], [s.name for s in statuses]) self.assertEqual([R, R], [s.state for s in statuses]) # kill the running jobs one-by-one self.lftp.kill("d d") statuses = self.lftp.status() while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) == 1: break self.assertEqual("a", statuses[0].name) self.assertEqual(R, statuses[0].state) self.lftp.kill("a") while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) == 0: break self.assertEqual(0, len(statuses)) @timeout_decorator.timeout(5) def test_queued_and_kill_jobs_1(self): """Queued and running jobs killed one at a time""" self.lftp.rate_limit = 10 # so jobs don't finish right away self.lftp.num_parallel_jobs = 2 Q = LftpJobStatus.State.QUEUED R = LftpJobStatus.State.RUNNING # add 3 jobs - a, dd, b self.lftp.queue("a", True) self.lftp.queue("d d", False) self.lftp.queue("b", True) while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) > 2: break self.assertEqual(3, len(statuses)) self.assertEqual(["b", "a", "d d"], [s.name for s in statuses]) self.assertEqual([Q, R, R], [s.state for s in statuses]) # remove dd (running) self.lftp.kill("d d") while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) == 2: break self.assertEqual(2, len(statuses)) self.assertEqual(["a", "b"], [s.name for s in statuses]) self.assertEqual([R, R], [s.state for s in statuses]) # remove a (running) self.lftp.kill("a") while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) == 1: break self.assertEqual(1, len(statuses)) self.assertEqual(["b"], [s.name for s in statuses]) self.assertEqual([R], [s.state for s in statuses]) # add 3 jobs - c, ee, a self.lftp.queue("c", False) self.lftp.queue("e e", True) self.lftp.queue("a", True) while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) == 4: break self.assertEqual(4, len(statuses)) self.assertEqual(["e e", "a", "b", "c"], [s.name for s in statuses]) self.assertEqual([Q, Q, R, R], [s.state for s in statuses]) # remove ee (queued) and b (running) self.lftp.kill("e e") while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) == 3: break self.assertEqual(3, len(statuses)) self.assertEqual(["a", "b", "c"], [s.name for s in statuses]) self.assertEqual([Q, R, R], [s.state for s in statuses]) self.lftp.kill("b") while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) == 2: break self.assertEqual(2, len(statuses)) self.assertEqual(["c", "a"], [s.name for s in statuses]) self.assertEqual([R, R], [s.state for s in statuses]) # remove all self.lftp.kill_all() while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) == 0: break self.assertEqual(0, len(statuses)) @timeout_decorator.timeout(5) def test_queue_dir_wrong_file_type(self): """check that queueing a dir with PGET fails gracefully""" # passing dir as a file print("Queuing dir as a file") self.lftp.queue("a", False) # wait for command to fail while True: statuses = self.lftp.status() if len(statuses) == 0: break with self.assertRaises(LftpError) as ctx: self.lftp.raise_pending_error() self.assertTrue("Access failed" in str(ctx.exception)) # next status should be empty print("Getting empty status") statuses = self.lftp.status() self.assertEqual(0, len(statuses)) @timeout_decorator.timeout(5) def test_queue_file_wrong_file_type(self): """check that queueing a file with MIRROR fails gracefully""" # passing file as a dir print("Queuing file as a dir") self.lftp.queue("c", True) # wait for command to fail while True: statuses = self.lftp.status() if len(statuses) == 0: break with self.assertRaises(LftpError) as ctx: self.lftp.raise_pending_error() self.assertTrue("Access failed" in str(ctx.exception)) # next status should be empty print("Getting empty status") statuses = self.lftp.status() self.assertEqual(0, len(statuses)) @timeout_decorator.timeout(5) def test_queue_missing_file(self): """check that queueing non-existing file fails gracefully""" self.lftp.queue("non-existing-file", False) # wait for command to fail while True: statuses = self.lftp.status() if len(statuses) == 0: break with self.assertRaises(LftpError) as ctx: self.lftp.raise_pending_error() self.assertTrue("No such file" in str(ctx.exception)) # next status should be empty print("Getting empty status") statuses = self.lftp.status() self.assertEqual(0, len(statuses)) @timeout_decorator.timeout(5) def test_queue_missing_dir(self): """check that queueing non-existing directory fails gracefully""" self.lftp.queue("non-existing-folder", True) # wait for command to fail while True: statuses = self.lftp.status() if len(statuses) == 0: break with self.assertRaises(LftpError) as ctx: self.lftp.raise_pending_error() self.assertTrue("No such file" in str(ctx.exception)) # next status should be empty print("Getting empty status") statuses = self.lftp.status() self.assertEqual(0, len(statuses)) @timeout_decorator.timeout(5) def test_password_auth(self): # exit the default instance self.lftp.exit() self.lftp = Lftp(address=self.host, port=self.port, user=self.user, password=self.password) self.lftp.set_base_remote_dir_path(self.remote_dir) self.lftp.set_base_local_dir_path(self.local_dir) self.lftp.set_verbose_logging(True) # Disable key-based auth program = self.lftp.sftp_connect_program program = program[:-1] # remove the end double-quote program += " -oPubkeyAuthentication=no\"" self.lftp.sftp_connect_program = program self.lftp.queue("a", True) while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) > 0: break self.assertEqual(1, len(statuses)) self.assertEqual("a", statuses[0].name) self.assertEqual(LftpJobStatus.Type.MIRROR, statuses[0].type) self.assertEqual(LftpJobStatus.State.RUNNING, statuses[0].state) # Wait for empty status while True: statuses = self.lftp.status() self.lftp.raise_pending_error() if len(statuses) == 0: break self.lftp.raise_pending_error() @timeout_decorator.timeout(15) def test_error_bad_password(self): # exit the default instance self.lftp.exit() self.lftp = Lftp(address=self.host, port=self.port, user=self.user, password="wrong password") self.lftp.set_base_remote_dir_path(self.remote_dir) self.lftp.set_base_local_dir_path(self.local_dir) self.lftp.set_verbose_logging(True) self.lftp.rate_limit = 10 # so jobs don't finish right away # Disable key-based auth program = self.lftp.sftp_connect_program program = program[:-1] # remove the end double-quote program += " -oPubkeyAuthentication=no\"" self.lftp.sftp_connect_program = program self.lftp.queue("a", True) while True: statuses = self.lftp.status() if len(statuses) > 0: break self.assertEqual(1, len(statuses)) self.assertEqual("a", statuses[0].name) self.assertEqual(LftpJobStatus.Type.MIRROR, statuses[0].type) self.assertEqual(LftpJobStatus.State.RUNNING, statuses[0].state) # Wait for empty status while True: statuses = self.lftp.status() if len(statuses) == 0: break with self.assertRaises(LftpError) as ctx: self.lftp.raise_pending_error() self.assertTrue("Login failed: Login incorrect" in str(ctx.exception)) ================================================ FILE: src/python/tests/unittests/test_model/__init__.py ================================================ ================================================ FILE: src/python/tests/unittests/test_model/test_diff.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest from datetime import datetime from model import Model, ModelFile, ModelDiff, ModelDiffUtil class TestModelDiff(unittest.TestCase): def test_change(self): diff = ModelDiff(ModelDiff.Change.ADDED, None, None) self.assertEqual(ModelDiff.Change.ADDED, diff.change) diff = ModelDiff(ModelDiff.Change.REMOVED, None, None) self.assertEqual(ModelDiff.Change.REMOVED, diff.change) diff = ModelDiff(ModelDiff.Change.UPDATED, None, None) self.assertEqual(ModelDiff.Change.UPDATED, diff.change) def test_old_file(self): old_file = ModelFile("a", False) old_file.local_size = 100 diff = ModelDiff(ModelDiff.Change.ADDED, old_file, None) self.assertEqual(old_file, diff.old_file) diff = ModelDiff(ModelDiff.Change.ADDED, None, None) self.assertEqual(None, diff.old_file) def test_new_file(self): new_file = ModelFile("a", False) new_file.local_size = 100 diff = ModelDiff(ModelDiff.Change.ADDED, None, new_file) self.assertEqual(new_file, diff.new_file) diff = ModelDiff(ModelDiff.Change.ADDED, None, None) self.assertEqual(None, diff.new_file) class TestModelDiffUtil(unittest.TestCase): def test_added(self): model_before = Model() model_after = Model() a = ModelFile("a", False) a.local_size = 100 model_after.add_file(a) diff = ModelDiffUtil.diff_models(model_before, model_after) self.assertEqual([ModelDiff(ModelDiff.Change.ADDED, None, a)], diff) def test_removed(self): model_before = Model() model_after = Model() a = ModelFile("a", False) a.local_size = 100 model_before.add_file(a) diff = ModelDiffUtil.diff_models(model_before, model_after) self.assertEqual([ModelDiff(ModelDiff.Change.REMOVED, a, None)], diff) def test_updated(self): model_before = Model() model_after = Model() a1 = ModelFile("a", False) a1.local_size = 100 a2 = ModelFile("a", False) a2.local_size = 200 model_before.add_file(a1) model_after.add_file(a2) diff = ModelDiffUtil.diff_models(model_before, model_after) self.assertEqual([ModelDiff(ModelDiff.Change.UPDATED, a1, a2)], diff) def test_updated_children(self): model_before = Model() model_after = Model() a1 = ModelFile("a", True) aa1 = ModelFile("aa", False) aa1.local_size = 100 a1.add_child(aa1) a2 = ModelFile("a", True) aa2 = ModelFile("aa", False) aa2.local_size = 200 a2.add_child(aa2) model_before.add_file(a1) model_after.add_file(a2) diff = ModelDiffUtil.diff_models(model_before, model_after) self.assertEqual([ModelDiff(ModelDiff.Change.UPDATED, a1, a2)], diff) def test_diff_1(self): model_before = Model() model_after = Model() a = ModelFile("a", False) a.local_size = 100 b = ModelFile("b", False) b.remote_size = 200 c1 = ModelFile("c", False) c1.downloading_speed = 40 c2 = ModelFile("c", False) c2.downloading_speed = 50 d1 = ModelFile("d", False) d1.local_size = 500 d2 = ModelFile("d", False) d2.local_size = 500 d2.update_timestamp = datetime.now() # add a, remove b, update c, no change d (but do a timestamp update) model_before.add_file(b) model_before.add_file(c1) model_before.add_file(d1) model_after.add_file(a) model_after.add_file(c2) model_after.add_file(d2) diffs = ModelDiffUtil.diff_models(model_before, model_after) self.assertEqual(3, len(diffs)) added = [d for d in diffs if d.change == ModelDiff.Change.ADDED] self.assertEqual(1, len(added)) self.assertEqual(ModelDiff(ModelDiff.Change.ADDED, None, a), added[0]) removed = [d for d in diffs if d.change == ModelDiff.Change.REMOVED] self.assertEqual(1, len(removed)) self.assertEqual(ModelDiff(ModelDiff.Change.REMOVED, b, None), removed[0]) updated = [d for d in diffs if d.change == ModelDiff.Change.UPDATED] self.assertEqual(1, len(updated)) self.assertEqual(ModelDiff(ModelDiff.Change.UPDATED, c1, c2), updated[0]) ================================================ FILE: src/python/tests/unittests/test_model/test_file.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest from datetime import datetime from model import ModelFile class TestModelFile(unittest.TestCase): def test_name(self): file = ModelFile("test", False) self.assertEqual("test", file.name) def test_is_dir(self): file = ModelFile("test", False) self.assertEqual(False, file.is_dir) file = ModelFile("test", True) self.assertEqual(True, file.is_dir) def test_state(self): file = ModelFile("test", False) file.state = ModelFile.State.DOWNLOADING self.assertEqual(ModelFile.State.DOWNLOADING, file.state) with self.assertRaises(TypeError): file.state = "BadState" def test_local_size(self): file = ModelFile("test", False) file.local_size = 100 self.assertEqual(100, file.local_size) file.local_size = None self.assertEqual(None, file.local_size) with self.assertRaises(TypeError): file.local_size = "BadValue" with self.assertRaises(ValueError): file.local_size = -100 def test_remote_size(self): file = ModelFile("test", False) file.remote_size = 100 self.assertEqual(100, file.remote_size) file.remote_size = None self.assertEqual(None, file.remote_size) with self.assertRaises(TypeError): file.remote_size = "BadValue" with self.assertRaises(ValueError): file.remote_size = -100 def test_transferred_size(self): file = ModelFile("test", False) file.transferred_size = 100 self.assertEqual(100, file.transferred_size) file.transferred_size = None self.assertEqual(None, file.transferred_size) with self.assertRaises(TypeError): file.transferred_size = "BadValue" with self.assertRaises(ValueError): file.transferred_size = -100 def test_downloading_speed(self): file = ModelFile("test", False) file.downloading_speed = 100 self.assertEqual(100, file.downloading_speed) file.downloading_speed = None self.assertEqual(None, file.downloading_speed) with self.assertRaises(TypeError): file.downloading_speed = "BadValue" with self.assertRaises(ValueError): file.downloading_speed = -100 def test_update_timestamp(self): file = ModelFile("test", False) now = datetime.now() file.update_timestamp = now self.assertEqual(now, file.update_timestamp) with self.assertRaises(TypeError): file.update_timestamp = 100 def test_eta(self): file = ModelFile("test", False) file.eta = 100 self.assertEqual(100, file.eta) file.eta = None self.assertEqual(None, file.eta) with self.assertRaises(TypeError): file.eta = "BadValue" with self.assertRaises(ValueError): file.eta = -100 def test_is_extractable(self): file = ModelFile("test", True) file.is_extractable = True self.assertTrue(file.is_extractable) file.is_extractable = False self.assertFalse(file.is_extractable) def test_local_created_timestamp(self): file = ModelFile("test", False) self.assertIsNone(file.local_created_timestamp) now = datetime.now() file.local_created_timestamp = now self.assertEqual(now, file.local_created_timestamp) with self.assertRaises(TypeError): file.local_created_timestamp = 100 def test_local_modified_timestamp(self): file = ModelFile("test", False) self.assertIsNone(file.local_modified_timestamp) now = datetime.now() file.local_modified_timestamp = now self.assertEqual(now, file.local_modified_timestamp) with self.assertRaises(TypeError): file.local_modified_timestamp = 100 def test_remote_created_timestamp(self): file = ModelFile("test", False) self.assertIsNone(file.remote_created_timestamp) now = datetime.now() file.remote_created_timestamp = now self.assertEqual(now, file.remote_created_timestamp) with self.assertRaises(TypeError): file.remote_created_timestamp = 100 def test_remote_modified_timestamp(self): file = ModelFile("test", False) self.assertIsNone(file.remote_modified_timestamp) now = datetime.now() file.remote_modified_timestamp = now self.assertEqual(now, file.remote_modified_timestamp) with self.assertRaises(TypeError): file.remote_modified_timestamp = 100 def test_equality_operator(self): # check that timestamp does not affect equality now = datetime.now() file1 = ModelFile("test", False) file1.local_size = 100 file1.update_timestamp = now file2 = ModelFile("test", False) file2.local_size = 200 file2.update_timestamp = now self.assertFalse(file1 == file2) file2.local_size = 100 file2.update_timestamp = datetime.now() self.assertTrue(file1 == file2) def test_child(self): file_parent = ModelFile("parent", True) file_child1 = ModelFile("child1", True) file_child2 = ModelFile("child2", False) self.assertEqual(0, len(file_parent.get_children())) file_parent.add_child(file_child1) self.assertEqual([file_child1], file_parent.get_children()) file_parent.add_child(file_child2) self.assertEqual([file_child1, file_child2], file_parent.get_children()) def test_child_equality(self): l_a = ModelFile("a", True) l_a.remote_size = 3+1+2 l_aa = ModelFile("aa", True) l_aa.remote_size = 3+1 l_a.add_child(l_aa) l_aaa = ModelFile("aaa", False) l_aaa.remote_size = 1 l_aa.add_child(l_aaa) l_aab = ModelFile("aab", False) l_aab.remote_size = 3 l_aa.add_child(l_aab) l_ab = ModelFile("ab", False) l_ab.remote_size = 2 l_a.add_child(l_ab) r_a = ModelFile("a", True) r_a.remote_size = 3+1+2 r_aa = ModelFile("aa", True) r_aa.remote_size = 3+1 r_a.add_child(r_aa) r_aaa = ModelFile("aaa", False) r_aaa.remote_size = 1 r_aa.add_child(r_aaa) r_aab = ModelFile("aab", False) r_aab.remote_size = 3 r_aa.add_child(r_aab) r_ab = ModelFile("ab", False) r_ab.remote_size = 2 r_a.add_child(r_ab) self.assertEqual(l_a, r_a) r_aaa.remote_size = 2 self.assertNotEqual(l_a, r_a) def test_fail_add_child_to_nondir(self): file_parent = ModelFile("parent", False) file_child1 = ModelFile("child1", True) with self.assertRaises(TypeError) as context: file_parent.add_child(file_child1) self.assertTrue(str(context.exception).startswith("Cannot add child to a non-directory")) def test_fail_add_child_twice(self): file_parent = ModelFile("parent", True) file_parent.add_child(ModelFile("child1", True)) file_parent.add_child(ModelFile("child2", True)) with self.assertRaises(ValueError) as context: file_parent.add_child(ModelFile("child1", True)) self.assertTrue(str(context.exception).startswith("Cannot add child more than once")) with self.assertRaises(ValueError) as context: file_parent.add_child(ModelFile("child2", True)) self.assertTrue(str(context.exception).startswith("Cannot add child more than once")) def test_full_path(self): file_a = ModelFile("a", True) file_aa = ModelFile("aa", True) file_a.add_child(file_aa) file_aaa = ModelFile("aaa", True) file_aa.add_child(file_aaa) file_ab = ModelFile("ab", True) file_a.add_child(file_ab) self.assertEqual("a", file_a.full_path) self.assertEqual("a/aa", file_aa.full_path) self.assertEqual("a/aa/aaa", file_aaa.full_path) self.assertEqual("a/ab", file_ab.full_path) def test_parent(self): a = ModelFile("a", True) aa = ModelFile("aa", True) a.add_child(aa) aaa = ModelFile("aaa", False) aa.add_child(aaa) self.assertIsNone(a.parent) self.assertEqual(a, aa.parent) self.assertEqual(aa, aaa.parent) ================================================ FILE: src/python/tests/unittests/test_model/test_model.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import logging import sys import unittest from unittest.mock import MagicMock from common import overrides from model import Model, ModelFile, IModelListener, ModelError class DummyModelListener(IModelListener): @overrides(IModelListener) def file_added(self, file: ModelFile): pass @overrides(IModelListener) def file_removed(self, file: ModelFile): pass @overrides(IModelListener) def file_updated(self, old_file: ModelFile, new_file: ModelFile): pass class TestLftpModel(unittest.TestCase): def setUp(self): logger = logging.getLogger(TestLftpModel.__name__) handler = logging.StreamHandler(sys.stdout) logger.addHandler(handler) logger.setLevel(logging.DEBUG) formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") handler.setFormatter(formatter) self.model = Model() self.model.set_base_logger(logger) def test_add_file(self): file = ModelFile("test", False) self.model.add_file(file) recv_file = self.model.get_file("test") self.assertEqual("test", recv_file.name) def test_get_unknown_file(self): with self.assertRaises(ModelError): self.model.get_file("test") def test_remove_file(self): file = ModelFile("test", False) self.model.add_file(file) self.model.remove_file("test") with self.assertRaises(ModelError): self.model.get_file("test") def test_remove_unknown_file(self): with self.assertRaises(ModelError): self.model.remove_file("test") def test_update_file(self): file = ModelFile("test", False) file.local_size = 100 self.model.add_file(file) recv_file = self.model.get_file("test") self.assertEqual(100, recv_file.local_size) recv_file.local_size = 200 self.model.update_file(recv_file) recv_file = self.model.get_file("test") self.assertEqual(200, recv_file.local_size) def test_update_unknown_file(self): file = ModelFile("test", False) with self.assertRaises(ModelError): self.model.update_file(file) def test_get_file_names(self): self.assertEqual(set(), self.model.get_file_names()) self.model.add_file(ModelFile("a", False)) self.assertEqual({"a"}, self.model.get_file_names()) self.model.add_file(ModelFile("b", False)) self.assertEqual({"a", "b"}, self.model.get_file_names()) self.model.add_file(ModelFile("c", False)) self.assertEqual({"a", "b", "c"}, self.model.get_file_names()) self.model.remove_file("b") self.assertEqual({"a", "c"}, self.model.get_file_names()) self.model.add_file(ModelFile("d", False)) self.assertEqual({"a", "c", "d"}, self.model.get_file_names()) def test_add_listener(self): listener = DummyModelListener() self.model.add_listener(listener) def test_remove_listener(self): listener = DummyModelListener() listener.file_added = MagicMock() self.model.add_listener(listener) file = ModelFile("test", False) self.model.add_file(file) listener.file_added.assert_called_once_with(file) self.model.remove_listener(listener) self.model.add_file(ModelFile("test2", False)) listener.file_added.assert_called_once_with(file) def test_listener_file_added(self): listener = DummyModelListener() self.model.add_listener(listener) listener.file_added = MagicMock() file = ModelFile("test", False) self.model.add_file(file) # noinspection PyUnresolvedReferences listener.file_added.assert_called_once_with(file) def test_listener_file_removed(self): listener = DummyModelListener() self.model.add_listener(listener) listener.file_removed = MagicMock() file = ModelFile("test", False) self.model.add_file(file) self.model.remove_file("test") # noinspection PyUnresolvedReferences listener.file_removed.assert_called_once_with(file) def test_listener_file_updated(self): listener = DummyModelListener() self.model.add_listener(listener) listener.file_updated = MagicMock() old_file = ModelFile("test", False) old_file.local_size = 100 self.model.add_file(old_file) new_file = ModelFile("test", False) new_file.local_size = 200 self.model.update_file(new_file) # noinspection PyUnresolvedReferences listener.file_updated.assert_called_once_with(old_file, new_file) ================================================ FILE: src/python/tests/unittests/test_seedsync.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest import sys import copy from common import overrides, Config from seedsync import Seedsync class TestSeedsync(unittest.TestCase): def test_args_config(self): argv = [] argv.append("-c") argv.append("/path/to/config") argv.append("--html") argv.append("/path/to/html") argv.append("--scanfs") argv.append("/path/to/scanfs") args = Seedsync._parse_args(argv) self.assertIsNotNone(args) self.assertEqual("/path/to/config", args.config_dir) argv = [] argv.append("--config_dir") argv.append("/path/to/config") argv.append("--html") argv.append("/path/to/html") argv.append("--scanfs") argv.append("/path/to/scanfs") args = Seedsync._parse_args(argv) self.assertIsNotNone(args) self.assertEqual("/path/to/config", args.config_dir) argv = [] with self.assertRaises(SystemExit): Seedsync._parse_args(argv) def test_args_html(self): argv = [] argv.append("-c") argv.append("/path/to/config") argv.append("--scanfs") argv.append("/path/to/scanfs") argv.append("--html") argv.append("/path/to/html") args = Seedsync._parse_args(argv) self.assertIsNotNone(args) self.assertEqual("/path/to/html", args.html) def test_args_scanfs(self): argv = [] argv.append("-c") argv.append("/path/to/config") argv.append("--html") argv.append("/path/to/html") argv.append("--scanfs") argv.append("/path/to/scanfs") args = Seedsync._parse_args(argv) self.assertIsNotNone(args) self.assertEqual("/path/to/scanfs", args.scanfs) def test_args_logdir(self): argv = [] argv.append("-c") argv.append("/path/to/config") argv.append("--logdir") argv.append("/path/to/logdir") argv.append("--html") argv.append("/path/to/html") argv.append("--scanfs") argv.append("/path/to/scanfs") args = Seedsync._parse_args(argv) self.assertIsNotNone(args) self.assertEqual("/path/to/logdir", args.logdir) argv = [] argv.append("-c") argv.append("/path/to/config") argv.append("--html") argv.append("/path/to/html") argv.append("--scanfs") argv.append("/path/to/scanfs") args = Seedsync._parse_args(argv) self.assertIsNotNone(args) self.assertIsNone(args.logdir) def test_args_debug(self): argv = [] argv.append("-c") argv.append("/path/to/config") argv.append("--html") argv.append("/path/to/html") argv.append("--scanfs") argv.append("/path/to/scanfs") argv.append("-d") args = Seedsync._parse_args(argv) self.assertIsNotNone(args) self.assertTrue(args.debug) argv = [] argv.append("-c") argv.append("/path/to/config") argv.append("--debug") argv.append("--html") argv.append("/path/to/html") argv.append("--scanfs") argv.append("/path/to/scanfs") args = Seedsync._parse_args(argv) self.assertIsNotNone(args) self.assertTrue(args.debug) argv = [] argv.append("-c") argv.append("/path/to/config") argv.append("--html") argv.append("/path/to/html") argv.append("--scanfs") argv.append("/path/to/scanfs") args = Seedsync._parse_args(argv) self.assertIsNotNone(args) self.assertFalse(args.debug) def test_default_config(self): config = Seedsync._create_default_config() # Test that default config doesn't have any uninitialized values config_dict = config.as_dict() for section, inner_config in config_dict.items(): for key in inner_config: self.assertIsNotNone(inner_config[key], msg="{}.{} is uninitialized".format(section, key)) # Test that default config is a valid config config_dict = config.as_dict() config2 = Config.from_dict(config_dict) config2_dict = config2.as_dict() self.assertEqual(config_dict, config2_dict) def test_detect_incomplete_config(self): # Test a complete config config = Seedsync._create_default_config() incomplete_value = config.lftp.remote_address config.lftp.remote_address = "value" config.lftp.remote_password = "value" config.lftp.remote_username = "value" config.lftp.remote_path = "value" config.lftp.local_path = "value" config.lftp.remote_path_to_scan_script = "value" self.assertFalse(Seedsync._detect_incomplete_config(config)) # Test incomplete configs config.lftp.remote_address = incomplete_value self.assertTrue(Seedsync._detect_incomplete_config(config)) config.lftp.remote_address = "value" config.lftp.remote_username = incomplete_value self.assertTrue(Seedsync._detect_incomplete_config(config)) config.lftp.remote_username = "value" config.lftp.remote_path = incomplete_value self.assertTrue(Seedsync._detect_incomplete_config(config)) config.lftp.remote_path = "value" config.lftp.local_path = incomplete_value self.assertTrue(Seedsync._detect_incomplete_config(config)) config.lftp.local_path = "value" config.lftp.remote_path_to_scan_script = incomplete_value self.assertTrue(Seedsync._detect_incomplete_config(config)) config.lftp.remote_path_to_scan_script = "value" ================================================ FILE: src/python/tests/unittests/test_ssh/__init__.py ================================================ ================================================ FILE: src/python/tests/unittests/test_ssh/test_sshcp.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest import os import tempfile import shutil import filecmp import logging import sys import timeout_decorator from parameterized import parameterized from tests.utils import TestUtils from common import overrides from ssh import Sshcp, SshcpError # This is outside so it can be used in the parameterized decorators # noinspection SpellCheckingInspection _PASSWORD = "seedsyncpass" # noinspection SpellCheckingInspection _PARAMS = [ ("password", _PASSWORD), ("keyauth", None) ] # noinspection SpellCheckingInspection class TestSshcp(unittest.TestCase): __KEEP_FILES = False # for debugging @overrides(unittest.TestCase) def setUp(self): self.temp_dir = tempfile.mkdtemp(prefix="test_sshcp") self.local_dir = os.path.join(self.temp_dir, "local") os.mkdir(self.local_dir) self.remote_dir = os.path.join(self.temp_dir, "remote") os.mkdir(self.remote_dir) # Allow group access for the seedsynctest account TestUtils.chmod_from_to(self.remote_dir, tempfile.gettempdir(), 0o775) # Note: seedsynctest account must be set up. See DeveloperReadme.md for details self.host = "127.0.0.1" self.port = 22 self.user = "seedsynctest" logger = logging.getLogger() handler = logging.StreamHandler(sys.stdout) logger.addHandler(handler) logger.setLevel(logging.DEBUG) formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") handler.setFormatter(formatter) # Create local file self.local_file = os.path.join(self.local_dir, "file.txt") self.remote_file = os.path.join(self.remote_dir, "file2.txt") with open(self.local_file, "w") as f: f.write("this is a test file") @overrides(unittest.TestCase) def tearDown(self): if not self.__KEEP_FILES: shutil.rmtree(self.temp_dir) def test_ctor(self): sshcp = Sshcp(host=self.host, port=self.port) self.assertIsNotNone(sshcp) @parameterized.expand(_PARAMS) @timeout_decorator.timeout(5) def test_copy(self, _, password): self.assertFalse(os.path.exists(self.remote_file)) sshcp = Sshcp(host=self.host, port=self.port, user=self.user, password=password) sshcp.copy(local_path=self.local_file, remote_path=self.remote_file) self.assertTrue(filecmp.cmp(self.local_file, self.remote_file)) @timeout_decorator.timeout(5) def test_copy_error_bad_password(self): sshcp = Sshcp(host=self.host, port=self.port, user=self.user, password="wrong password") with self.assertRaises(SshcpError) as ctx: sshcp.copy(local_path=self.local_file, remote_path=self.remote_file) self.assertEqual("Incorrect password", str(ctx.exception)) @parameterized.expand(_PARAMS) @timeout_decorator.timeout(5) def test_copy_error_missing_local_file(self, _, password): local_file = os.path.join(self.local_dir, "nofile.txt") self.assertFalse(os.path.exists(self.remote_file)) self.assertFalse(os.path.exists(local_file)) sshcp = Sshcp(host=self.host, port=self.port, user=self.user, password=password) with self.assertRaises(SshcpError) as ctx: sshcp.copy(local_path=local_file, remote_path=self.remote_file) self.assertTrue("No such file or directory" in str(ctx.exception)) @parameterized.expand(_PARAMS) @timeout_decorator.timeout(5) def test_copy_error_missing_remote_dir(self, _, password): remote_file = os.path.join(self.remote_dir, "nodir", "file2.txt") self.assertFalse(os.path.exists(remote_file)) sshcp = Sshcp(host=self.host, port=self.port, user=self.user, password=password) with self.assertRaises(SshcpError) as ctx: sshcp.copy(local_path=self.local_file, remote_path=remote_file) self.assertTrue("No such file or directory" in str(ctx.exception)) @parameterized.expand(_PARAMS) @timeout_decorator.timeout(5) def test_copy_error_bad_host(self, _, password): sshcp = Sshcp(host="badhost", port=self.port, user=self.user, password=password) with self.assertRaises(SshcpError) as ctx: sshcp.copy(local_path=self.local_file, remote_path=self.remote_file) self.assertTrue("Connection refused by server" in str(ctx.exception)) @parameterized.expand(_PARAMS) @timeout_decorator.timeout(5) def test_copy_error_bad_port(self, _, password): sshcp = Sshcp(host=self.host, port=666, user=self.user, password=password) with self.assertRaises(SshcpError) as ctx: sshcp.copy(local_path=self.local_file, remote_path=self.remote_file) print(str(ctx.exception)) self.assertTrue("Connection refused by server" in str(ctx.exception)) @parameterized.expand(_PARAMS) @timeout_decorator.timeout(5) def test_shell(self, _, password): sshcp = Sshcp(host=self.host, port=self.port, user=self.user, password=password) out = sshcp.shell("cd {}; pwd".format(self.local_dir)) out_str = out.decode().strip() self.assertEqual(self.local_dir, out_str) @parameterized.expand(_PARAMS) @timeout_decorator.timeout(5) def test_shell_with_escape_characters(self, _, password): sshcp = Sshcp(host=self.host, port=self.port, user=self.user, password=password) # single quotes _dir = os.path.join(self.remote_dir, "a a") out = sshcp.shell("mkdir '{}' && cd '{}' && pwd".format(_dir, _dir)) out_str = out.decode().strip() self.assertEqual(_dir, out_str) # double quotes _dir = os.path.join(self.remote_dir, "a b") out = sshcp.shell('mkdir "{}" && cd "{}" && pwd'.format(_dir, _dir)) out_str = out.decode().strip() self.assertEqual(_dir, out_str) # single and double quotes - error out _dir = os.path.join(self.remote_dir, "a b") with self.assertRaises(ValueError): sshcp.shell('mkdir "{}" && cd \'{}\' && pwd'.format(_dir, _dir)) @timeout_decorator.timeout(5) def test_shell_error_bad_password(self): sshcp = Sshcp(host=self.host, port=self.port, user=self.user, password="wrong password") with self.assertRaises(SshcpError) as ctx: sshcp.shell("cd {}; pwd".format(self.local_dir)) self.assertEqual("Incorrect password", str(ctx.exception)) @parameterized.expand(_PARAMS) @timeout_decorator.timeout(5) def test_shell_error_bad_host(self, _, password): sshcp = Sshcp(host="badhost", port=self.port, user=self.user, password=password) with self.assertRaises(SshcpError) as ctx: sshcp.shell("cd {}; pwd".format(self.local_dir)) self.assertTrue("Bad hostname" in str(ctx.exception)) @parameterized.expand(_PARAMS) @timeout_decorator.timeout(5) def test_shell_error_bad_port(self, _, password): sshcp = Sshcp(host=self.host, port=6666, user=self.user, password=password) with self.assertRaises(SshcpError) as ctx: sshcp.shell("cd {}; pwd".format(self.local_dir)) self.assertTrue("Connection refused by server" in str(ctx.exception)) @parameterized.expand(_PARAMS) @timeout_decorator.timeout(5) def test_shell_error_bad_command(self, _, password): sshcp = Sshcp(host=self.host, port=self.port, user=self.user, password=password) with self.assertRaises(SshcpError) as ctx: sshcp.shell("./some_bad_command.sh".format(self.local_dir)) self.assertTrue("./some_bad_command.sh" in str(ctx.exception)) ================================================ FILE: src/python/tests/unittests/test_system/__init__.py ================================================ ================================================ FILE: src/python/tests/unittests/test_system/test_file.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest from datetime import datetime from system import SystemFile class TestSystemFile(unittest.TestCase): def test_name(self): sf = SystemFile("test", 0, False) self.assertEqual("test", sf.name) def test_size(self): sf = SystemFile("", 42, False) self.assertEqual(42, sf.size) with self.assertRaises(ValueError) as context: # noinspection PyUnusedLocal sf = SystemFile("", -42, False) self.assertTrue("File size must be greater than zero" in str(context.exception)) def test_is_dir(self): sf = SystemFile("", 0, True) self.assertEqual(True, sf.is_dir) sf = SystemFile("", 0, False) self.assertEqual(False, sf.is_dir) def test_time_created(self): sf = SystemFile("", 0, True, time_created=datetime(2018, 11, 9, 21, 40, 18)) self.assertEqual(datetime(2018, 11, 9, 21, 40, 18), sf.timestamp_created) sf = SystemFile("", 0, True) self.assertIsNone(sf.timestamp_created) def test_time_modified(self): sf = SystemFile("", 0, True, time_modified=datetime(2018, 11, 9, 21, 40, 18)) self.assertEqual(datetime(2018, 11, 9, 21, 40, 18), sf.timestamp_modified) sf = SystemFile("", 0, True) self.assertIsNone(sf.timestamp_modified) def test_add_child(self): sf = SystemFile("", 0, True) sf.add_child(SystemFile("child1", 42, True)) sf.add_child(SystemFile("child2", 99, False)) self.assertEqual(2, len(sf.children)) self.assertEqual("child1", sf.children[0].name) self.assertEqual(True, sf.children[0].is_dir) self.assertEqual(42, sf.children[0].size) self.assertEqual("child2", sf.children[1].name) self.assertEqual(False, sf.children[1].is_dir) self.assertEqual(99, sf.children[1].size) def test_fail_add_child_to_file(self): sf = SystemFile("", 0, False) with self.assertRaises(TypeError) as context: sf.add_child(SystemFile("", 0, False)) self.assertTrue("Cannot add children to a file" in str(context.exception)) def test_equality_operator(self): a1 = SystemFile("a", 50, is_dir=True, time_created=datetime(2018, 11, 9, 21, 40, 18), time_modified=datetime(2018, 11, 9, 21, 40, 18)) a1.add_child(SystemFile("aa", 40, is_dir=False)) a1.add_child(SystemFile("ab", 10, is_dir=False)) a2 = SystemFile("a", 50, is_dir=True, time_created=datetime(2018, 11, 9, 21, 40, 18), time_modified=datetime(2018, 11, 9, 21, 40, 18)) a2.add_child(SystemFile("aa", 40, is_dir=False)) a2.add_child(SystemFile("ab", 10, is_dir=False)) a3 = SystemFile("a", 50, is_dir=True, time_created=datetime(2018, 11, 9, 21, 40, 18), time_modified=datetime(2018, 11, 9, 21, 40, 18)) a3.add_child(SystemFile("aa", 40, is_dir=False)) a3.add_child(SystemFile("ab", 11, is_dir=False)) # different child size a4 = SystemFile("a", 50, is_dir=True, time_created=datetime(2018, 11, 9, 21, 40, 19), # different timestamp time_modified=datetime(2018, 11, 9, 21, 40, 18)) a4.add_child(SystemFile("aa", 40, is_dir=False)) a4.add_child(SystemFile("ab", 10, is_dir=False)) self.assertTrue(a1 == a2) self.assertFalse(a1 == a3) self.assertFalse(a1 == a4) ================================================ FILE: src/python/tests/unittests/test_system/test_scanner.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import os import shutil import tempfile import unittest from threading import Thread from datetime import datetime from system import SystemScanner, SystemScannerError def my_mkdir(*args): os.mkdir(os.path.join(TestSystemScanner.temp_dir, *args)) def my_touch(size, *args): path = os.path.join(TestSystemScanner.temp_dir, *args) with open(path, 'wb') as f: f.write(bytearray([0xff] * size)) def my_mkdir_latin(*args): os.mkdir(os.path.join(TestSystemScanner.temp_dir.encode('latin-1'), *args)) def my_touch_latin(size, *args): path = os.path.join(TestSystemScanner.temp_dir.encode('latin-1'), *args) with open(path, 'wb') as f: f.write(bytearray([0xff] * size)) # noinspection SpellCheckingInspection class TestSystemScanner(unittest.TestCase): temp_dir = None def setUp(self): # Create a temp directory TestSystemScanner.temp_dir = tempfile.mkdtemp(prefix="test_system_scanner") def tearDown(self): # Cleanup shutil.rmtree(TestSystemScanner.temp_dir) def setup_default_tree(self): # Create a bunch files and directories # a [dir] # aa [dir] # .aaa [dir] # .aab [file, 512 bytes] # ab [file, 12*1024 + 4 bytes] # b [dir] # ba [dir] # baa [file, 512 + 7 bytes] # bb [dir] # bba [dir] # bbb [file, 24*1024*1024 + 24 bytes] # bbc [dir] # bbca [dir] # .bbcaa [file, 1 byte # c [file, 1234 bytes] my_mkdir("a") my_mkdir("a", "aa") my_mkdir("a", "aa", ".aaa") my_touch(512, "a", "aa", ".aab") my_touch(12*1024+4, "a", "ab") my_mkdir("b") my_mkdir("b", "ba") my_touch(512+7, "b", "ba", "baa") my_mkdir("b", "bb") my_mkdir("b", "bb", "bba") my_touch(24*1024*1024+24, "b", "bb", "bbb") my_mkdir("b", "bb", "bbc") my_mkdir("b", "bb", "bbc", "bbca") my_touch(1, "b", "bb", "bbc", "bbca", ".bbcaa") my_touch(1234, "c") def test_scan_tree(self): self.setup_default_tree() scanner = SystemScanner(TestSystemScanner.temp_dir) files = scanner.scan() self.assertEqual(3, len(files)) a, b, c = tuple(files) self.assertEqual("a", a.name) self.assertTrue(a.is_dir) self.assertEqual("b", b.name) self.assertTrue(b.is_dir) self.assertEqual("c", c.name) self.assertFalse(c.is_dir) self.assertEqual(2, len(a.children)) aa, ab = tuple(a.children) self.assertEqual("aa", aa.name) self.assertTrue(aa.is_dir) self.assertEqual(2, len(aa.children)) aaa, aab = tuple(aa.children) self.assertEqual(".aaa", aaa.name) self.assertTrue(aaa.is_dir) self.assertEqual(".aab", aab.name) self.assertFalse(aab.is_dir) self.assertEqual("ab", ab.name) self.assertFalse(ab.is_dir) self.assertEqual(2, len(b.children)) ba, bb = tuple(b.children) self.assertEqual("ba", ba.name) self.assertTrue(ba.is_dir) self.assertEqual(1, len(ba.children)) baa = ba.children[0] self.assertEqual("baa", baa.name) self.assertFalse(baa.is_dir) self.assertEqual("bb", bb.name) self.assertTrue(bb.is_dir) self.assertEqual(3, len(bb.children)) bba, bbb, bbc = tuple(bb.children) self.assertEqual("bba", bba.name) self.assertTrue(bba.is_dir) self.assertEqual("bbb", bbb.name) self.assertFalse(bbb.is_dir) self.assertEqual("bbc", bbc.name) self.assertTrue(bbc.is_dir) self.assertEqual(1, len(bbc.children)) bbca = bbc.children[0] self.assertEqual("bbca", bbca.name) self.assertTrue(bbca.is_dir) self.assertEqual(1, len(bbca.children)) bbcaa = bbca.children[0] self.assertEqual(".bbcaa", bbcaa.name) self.assertFalse(bbcaa.is_dir) def test_scan_size(self): self.setup_default_tree() scanner = SystemScanner(TestSystemScanner.temp_dir) files = scanner.scan() self.assertEqual(3, len(files)) a, b, c = tuple(files) aa, ab = tuple(a.children) aaa, aab = tuple(aa.children) ba, bb = tuple(b.children) baa = ba.children[0] bba, bbb, bbc = tuple(bb.children) bbca = bbc.children[0] bbcaa = bbca.children[0] self.assertEqual(12*1024+4+512, a.size) self.assertEqual(512, aa.size) self.assertEqual(0, aaa.size) self.assertEqual(512, aab.size) self.assertEqual(12*1024+4, ab.size) self.assertEqual(512+7+24*1024*1024+24+1, b.size) self.assertEqual(512+7, ba.size) self.assertEqual(512+7, baa.size) self.assertEqual(24*1024*1024+24+1, bb.size) self.assertEqual(0, bba.size) self.assertEqual(24*1024*1024+24, bbb.size) self.assertEqual(1, bbc.size) self.assertEqual(1, bbca.size) self.assertEqual(1, bbcaa.size) self.assertEqual(1234, c.size) def test_scan_non_existing_dir_fails(self): self.setup_default_tree() scanner = SystemScanner( path_to_scan=os.path.join(TestSystemScanner.temp_dir, "nonexisting") ) with self.assertRaises(SystemScannerError) as ex: scanner.scan() self.assertTrue(str(ex.exception).startswith("Path does not exist")) def test_scan_file_fails(self): self.setup_default_tree() scanner = SystemScanner( path_to_scan=os.path.join(TestSystemScanner.temp_dir, "c") ) with self.assertRaises(SystemScannerError) as ex: scanner.scan() self.assertTrue(str(ex.exception).startswith("Path is not a directory")) def test_scan_single_dir(self): self.setup_default_tree() scanner = SystemScanner(TestSystemScanner.temp_dir) a = scanner.scan_single("a") self.assertEqual("a", a.name) self.assertTrue(a.is_dir) self.assertEqual(2, len(a.children)) aa, ab = tuple(a.children) self.assertEqual("aa", aa.name) self.assertTrue(aa.is_dir) self.assertEqual(2, len(aa.children)) aaa, aab = tuple(aa.children) self.assertEqual(".aaa", aaa.name) self.assertTrue(aaa.is_dir) self.assertEqual(".aab", aab.name) self.assertFalse(aab.is_dir) self.assertEqual("ab", ab.name) self.assertFalse(ab.is_dir) self.assertEqual(12*1024+4+512, a.size) self.assertEqual(512, aa.size) self.assertEqual(0, aaa.size) self.assertEqual(512, aab.size) self.assertEqual(12*1024+4, ab.size) def test_scan_single_file(self): self.setup_default_tree() scanner = SystemScanner(TestSystemScanner.temp_dir) c = scanner.scan_single("c") self.assertEqual("c", c.name) self.assertFalse(c.is_dir) self.assertEqual(1234, c.size) def test_scan_single_non_existing_path_fails(self): self.setup_default_tree() scanner = SystemScanner( path_to_scan=os.path.join(TestSystemScanner.temp_dir) ) with self.assertRaises(SystemScannerError) as ex: scanner.scan_single("nonexisting") self.assertTrue(str(ex.exception).startswith("Path does not exist")) def test_scan_tree_excluded_prefix(self): self.setup_default_tree() scanner = SystemScanner(TestSystemScanner.temp_dir) scanner.add_exclude_prefix(".") files = scanner.scan() self.assertEqual(3, len(files)) a, b, c = tuple(files) aa, ab = tuple(a.children) ba, bb = tuple(b.children) bba, bbb, bbc = tuple(bb.children) bbca = bbc.children[0] self.assertEqual(0, len(aa.children)) self.assertEqual(0, len(bbca.children)) scanner.add_exclude_prefix("ab") files = scanner.scan() self.assertEqual(3, len(files)) a, b, c = tuple(files) self.assertEqual(1, len(a.children)) aa = a.children[0] ba, bb = tuple(b.children) bba, bbb, bbc = tuple(bb.children) bbca = bbc.children[0] self.assertEqual("aa", aa.name) self.assertEqual(0, len(bbca.children)) def test_scan_size_excluded_prefix(self): self.setup_default_tree() scanner = SystemScanner(TestSystemScanner.temp_dir) scanner.add_exclude_prefix(".") files = scanner.scan() self.assertEqual(3, len(files)) a, b, c = tuple(files) aa, ab = tuple(a.children) ba, bb = tuple(b.children) bba, bbb, bbc = tuple(bb.children) bbca = bbc.children[0] self.assertEqual(12*1024+4, a.size) self.assertEqual(0, aa.size) self.assertEqual(24*1024*1024+24+0, bb.size) self.assertEqual(0, bbc.size) self.assertEqual(0, bbca.size) scanner.add_exclude_prefix("ab") files = scanner.scan() self.assertEqual(3, len(files)) a, b, c = tuple(files) self.assertEqual(1, len(a.children)) aa = a.children[0] self.assertEqual("aa", aa.name) self.assertEqual(0, a.size) self.assertEqual(0, aa.size) def test_scan_tree_excluded_suffix(self): self.setup_default_tree() scanner = SystemScanner(TestSystemScanner.temp_dir) scanner.add_exclude_suffix("ab") scanner.add_exclude_suffix("bb") files = scanner.scan() self.assertEqual(3, len(files)) a, b, c = tuple(files) self.assertEqual(1, len(a.children)) aa = a.children[0] self.assertEqual("aa", aa.name) self.assertEqual(1, len(aa.children)) aaa = aa.children[0] self.assertEqual(".aaa", aaa.name) self.assertEqual(1, len(b.children)) ba = b.children[0] self.assertEqual("ba", ba.name) def test_scan_size_excluded_suffix(self): self.setup_default_tree() scanner = SystemScanner(TestSystemScanner.temp_dir) scanner.add_exclude_suffix("ab") scanner.add_exclude_suffix("bb") files = scanner.scan() a, b, c = tuple(files) aa = a.children[0] aaa = aa.children[0] ba = b.children[0] self.assertEqual(0, a.size) self.assertEqual(0, aa.size) self.assertEqual(0, aaa.size) self.assertEqual(512+7, b.size) self.assertEqual(512+7, ba.size) self.assertEqual(1234, c.size) def test_lftp_status_file_size(self): self.setup_default_tree() scanner = SystemScanner(TestSystemScanner.temp_dir) size = scanner._lftp_status_file_size(""" size=243644865 0.pos=31457280 0.limit=60911217 1.pos=87060081 1.limit=121822433 2.pos=144268513 2.limit=182733649 3.pos=207473489 3.limit=243644865 """) self.assertEqual(104792064, size) def test_scan_lftp_partial_file(self): tempdir = TestSystemScanner.temp_dir # Create a partial file os.mkdir(os.path.join(tempdir, "t")) path = os.path.join(tempdir, "t", "partial.mkv") with open(path, 'wb') as f: f.write(bytearray([0xff] * 24588)) # Write the lftp status out path = os.path.join(tempdir, "t", "partial.mkv.lftp-pget-status") with open(path, "w") as f: f.write(""" size=24588 0.pos=3157 0.limit=6147 1.pos=11578 1.limit=12294 2.pos=12295 2.limit=18441 3.pos=20000 3.limit=24588 """) scanner = SystemScanner(tempdir) files = scanner.scan() self.assertEqual(1, len(files)) t = files[0] self.assertEqual("t", t.name) self.assertEqual(10148, t.size) self.assertEqual(1, len(t.children)) partial_mkv = t.children[0] self.assertEqual("partial.mkv", partial_mkv.name) self.assertEqual(10148, partial_mkv.size) def test_scan_single_lftp_partial_file(self): # Scan a single partial file tempdir = TestSystemScanner.temp_dir # Create a partial file path = os.path.join(tempdir, "partial.mkv") with open(path, 'wb') as f: f.write(bytearray([0xff] * 24588)) # Write the lftp status out path = os.path.join(tempdir, "partial.mkv.lftp-pget-status") with open(path, "w") as f: f.write(""" size=24588 0.pos=3157 0.limit=6147 1.pos=11578 1.limit=12294 2.pos=12295 2.limit=18441 3.pos=20000 3.limit=24588 """) scanner = SystemScanner(tempdir) partial_mkv = scanner.scan_single("partial.mkv") self.assertEqual("partial.mkv", partial_mkv.name) self.assertEqual(10148, partial_mkv.size) def test_scan_lftp_temp_file(self): tempdir = TestSystemScanner.temp_dir # Create some temp and non-temp files temp1 = os.path.join(tempdir, "a.mkv.lftp") with open(temp1, 'wb') as f: f.write(bytearray([0xff] * 100)) temp2 = os.path.join(tempdir, "b.rar.lftp") with open(temp2, 'wb') as f: f.write(bytearray([0xff] * 200)) nontemp1 = os.path.join(tempdir, "c.rar") with open(nontemp1, 'wb') as f: f.write(bytearray([0xff] * 300)) nontemp2 = os.path.join(tempdir, "d.lftp.avi") with open(nontemp2, 'wb') as f: f.write(bytearray([0xff] * 400)) nontemp3 = os.path.join(tempdir, "e") os.mkdir(nontemp3) temp3 = os.path.join(nontemp3, "ea.txt.lftp") with open(temp3, 'wb') as f: f.write(bytearray([0xff] * 500)) nontemp4 = os.path.join(tempdir, "f.lftp") os.mkdir(nontemp4) scanner = SystemScanner(tempdir) # No temp suffix set files = scanner.scan() self.assertEqual(6, len(files)) a, b, c, d, e, f = tuple(files) self.assertEqual("a.mkv.lftp", a.name) self.assertEqual(100, a.size) self.assertEqual(False, a.is_dir) self.assertEqual("b.rar.lftp", b.name) self.assertEqual(200, b.size) self.assertEqual(False, b.is_dir) self.assertEqual("c.rar", c.name) self.assertEqual(300, c.size) self.assertEqual(False, c.is_dir) self.assertEqual("d.lftp.avi", d.name) self.assertEqual(400, d.size) self.assertEqual(False, d.is_dir) self.assertEqual("e", e.name) self.assertEqual(500, e.size) self.assertEqual(True, e.is_dir) self.assertEqual(1, len(e.children)) ea = e.children[0] self.assertEqual("ea.txt.lftp", ea.name) self.assertEqual(500, ea.size) self.assertEqual(False, ea.is_dir) self.assertEqual("f.lftp", f.name) self.assertEqual(0, f.size) self.assertEqual(True, f.is_dir) # Temp suffix set scanner.set_lftp_temp_suffix(".lftp") files = scanner.scan() self.assertEqual(6, len(files)) a, b, c, d, e, f = tuple(files) self.assertEqual("a.mkv", a.name) self.assertEqual(100, a.size) self.assertEqual(False, a.is_dir) self.assertEqual("b.rar", b.name) self.assertEqual(200, b.size) self.assertEqual(False, b.is_dir) self.assertEqual("c.rar", c.name) self.assertEqual(300, c.size) self.assertEqual(False, c.is_dir) self.assertEqual("d.lftp.avi", d.name) self.assertEqual(400, d.size) self.assertEqual(False, d.is_dir) self.assertEqual("e", e.name) self.assertEqual(500, e.size) self.assertEqual(True, e.is_dir) self.assertEqual(1, len(e.children)) ea = e.children[0] self.assertEqual("ea.txt", ea.name) self.assertEqual(500, ea.size) self.assertEqual(False, ea.is_dir) self.assertEqual("f.lftp", f.name) self.assertEqual(0, f.size) self.assertEqual(True, f.is_dir) def test_scan_single_lftp_temp_file(self): tempdir = TestSystemScanner.temp_dir # Create: # temp file # non-temp file and # non-temp directory with temp name # non-temp directory with non-temp name temp1 = os.path.join(tempdir, "a.mkv.lftp") with open(temp1, 'wb') as f: f.write(bytearray([0xff] * 100)) nontemp1 = os.path.join(tempdir, "b.rar") with open(nontemp1, 'wb') as f: f.write(bytearray([0xff] * 300)) nontemp2 = os.path.join(tempdir, "c.lftp") os.mkdir(nontemp2) temp2 = os.path.join(nontemp2, "c.txt.lftp") with open(temp2, 'wb') as f: f.write(bytearray([0xff] * 500)) nontemp3 = os.path.join(tempdir, "d") os.mkdir(nontemp3) temp3 = os.path.join(nontemp3, "d.avi.lftp") with open(temp3, 'wb') as f: f.write(bytearray([0xff] * 600)) scanner = SystemScanner(tempdir) # No temp suffix set, must include temp suffix in name param file = scanner.scan_single("a.mkv.lftp") self.assertEqual("a.mkv.lftp", file.name) self.assertEqual(100, file.size) self.assertEqual(False, file.is_dir) file = scanner.scan_single("b.rar") self.assertEqual("b.rar", file.name) self.assertEqual(300, file.size) self.assertEqual(False, file.is_dir) file = scanner.scan_single("c.lftp") self.assertEqual("c.lftp", file.name) self.assertEqual(500, file.size) self.assertEqual(True, file.is_dir) self.assertEqual(1, len(file.children)) child = file.children[0] self.assertEqual("c.txt.lftp", child.name) self.assertEqual(500, child.size) self.assertEqual(False, child.is_dir) file = scanner.scan_single("d") self.assertEqual("d", file.name) self.assertEqual(600, file.size) self.assertEqual(True, file.is_dir) child = file.children[0] self.assertEqual("d.avi.lftp", child.name) self.assertEqual(600, child.size) self.assertEqual(False, child.is_dir) # Temp suffix set, must NOT include temp suffix in name param scanner.set_lftp_temp_suffix(".lftp") file = scanner.scan_single("a.mkv") self.assertEqual("a.mkv", file.name) self.assertEqual(100, file.size) self.assertEqual(False, file.is_dir) file = scanner.scan_single("b.rar") self.assertEqual("b.rar", file.name) self.assertEqual(300, file.size) self.assertEqual(False, file.is_dir) file = scanner.scan_single("c.lftp") self.assertEqual("c.lftp", file.name) self.assertEqual(500, file.size) self.assertEqual(True, file.is_dir) self.assertEqual(1, len(file.children)) child = file.children[0] self.assertEqual("c.txt", child.name) self.assertEqual(500, child.size) self.assertEqual(False, child.is_dir) # also, shouldn't look for directories with temp suffix with self.assertRaises(SystemScannerError) as ctx: scanner.scan_single("c") self.assertTrue("Path does not exist" in str(ctx.exception)) file = scanner.scan_single("d") self.assertEqual("d", file.name) self.assertEqual(600, file.size) self.assertEqual(True, file.is_dir) child = file.children[0] self.assertEqual("d.avi", child.name) self.assertEqual(600, child.size) self.assertEqual(False, child.is_dir) # No file and no temp file with self.assertRaises(SystemScannerError) as ctx: scanner.scan_single("blah") self.assertTrue("Path does not exist" in str(ctx.exception)) def test_files_deleted_while_scanning(self): self.setup_default_tree() scanner = SystemScanner(TestSystemScanner.temp_dir) stop = False # Make and delete files while test runs def monkey_with_files(): orig = os.path.join(TestSystemScanner.temp_dir, "b") dest = os.path.join(TestSystemScanner.temp_dir, "b_copy") while not stop: shutil.copytree(orig, dest) shutil.rmtree(dest) thread = Thread(target=monkey_with_files) thread.start() try: # Scan a bunch of times for i in range(0, 2000): files = scanner.scan() # Must have at least the untouched files self.assertGreaterEqual(len(files), 3) names = set([f.name for f in files]) self.assertIn("a", names) self.assertIn("b", names) self.assertIn("c", names) finally: stop = True thread.join() def test_scan_modified_time(self): self.setup_default_tree() # directory os.utime( os.path.join(TestSystemScanner.temp_dir, "a"), ( datetime.now().timestamp(), datetime(2018, 11, 9, 21, 40, 18).timestamp() ) ) # file os.utime( os.path.join(TestSystemScanner.temp_dir, "c"), ( datetime.now().timestamp(), datetime(2018, 11, 9, 21, 40, 17).timestamp() ) ) scanner = SystemScanner(TestSystemScanner.temp_dir) files = scanner.scan() self.assertEqual(3, len(files)) a, b, c = tuple(files) self.assertEqual(datetime(2018, 11, 9, 21, 40, 18), a.timestamp_modified) self.assertEqual(datetime(2018, 11, 9, 21, 40, 17), c.timestamp_modified) def test_scan_file_with_unicode_chars(self): tempdir = TestSystemScanner.temp_dir # déģķ [dir] # dőÀ× [file, 128 bytes] my_mkdir("déģķ") my_touch(128, "dőÀ") scanner = SystemScanner(tempdir) files = scanner.scan() self.assertEqual(2, len(files)) folder, file = tuple(files) self.assertEqual(0, len(folder.children)) self.assertEqual("déģķ", folder.name) self.assertEqual("dőÀ", file.name) self.assertEqual(128, file.size) def test_scan_file_with_latin_chars(self): tempdir = TestSystemScanner.temp_dir # a\xe9b [dir] # c\xe9d [file, 128 bytes] my_mkdir_latin(b"dir\xe9dir") my_touch_latin(128, b"file\xd9file") scanner = SystemScanner(tempdir) files = scanner.scan() self.assertEqual(2, len(files)) folder, file = tuple(files) self.assertEqual(0, len(folder.children)) self.assertEqual("dir�dir", folder.name) self.assertEqual("file�file", file.name) self.assertEqual(128, file.size) ================================================ FILE: src/python/tests/unittests/test_web/__init__.py ================================================ ================================================ FILE: src/python/tests/unittests/test_web/test_handler/test_stream_log.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest from unittest.mock import patch from logging import LogRecord from web.handler.stream_log import CachedQueueLogHandler def create_log_record(created: float, msg: str) -> LogRecord: record = LogRecord( name=None, level=None, pathname=None, lineno=None, msg=msg, args=None, exc_info=None ) record.created = created return record class TestCachedQueueLogHandler(unittest.TestCase): @patch("web.handler.stream_log.time") def test_caches_new_records(self, mock_time_module): time_func = mock_time_module.time cache = CachedQueueLogHandler(history_size_in_ms=3000) # Set current time at 10000 ms time_func.return_value = 10.0 # Create some records between 7000 ms - 10000 ms record1 = create_log_record(7.5, "record1") record2 = create_log_record(8.5, "record2") record3 = create_log_record(9.5, "record3") # Pass them to cache cache.emit(record1) cache.emit(record2) cache.emit(record3) # Get cached record, all of them should be there actual = cache.get_cached_records() self.assertEqual(3, len(actual)) self.assertEqual("record1", actual[0].msg) self.assertEqual(7.5, actual[0].created) self.assertEqual("record2", actual[1].msg) self.assertEqual(8.5, actual[1].created) self.assertEqual("record3", actual[2].msg) self.assertEqual(9.5, actual[2].created) @patch("web.handler.stream_log.time") def test_prunes_old_records(self, mock_time_module): time_func = mock_time_module.time cache = CachedQueueLogHandler(history_size_in_ms=3000) # Set current time at 10000 ms time_func.return_value = 10.0 # Create some records between 0 ms - 10000 ms record1 = create_log_record(0.5, "record1") record2 = create_log_record(5.5, "record2") record3 = create_log_record(7.5, "record3") record4 = create_log_record(9.5, "record4") # Pass them to cache cache.emit(record1) cache.emit(record2) cache.emit(record3) cache.emit(record4) # Get cached record, only newer than 7000ms should be there actual = cache.get_cached_records() self.assertEqual(2, len(actual)) self.assertEqual("record3", actual[0].msg) self.assertEqual(7.5, actual[0].created) self.assertEqual("record4", actual[1].msg) self.assertEqual(9.5, actual[1].created) @patch("web.handler.stream_log.time") def test_prunes_old_records_at_get_time(self, mock_time_module): time_func = mock_time_module.time cache = CachedQueueLogHandler(history_size_in_ms=3000) # Set current time at 10000 ms time_func.return_value = 10.0 # Create some records between 7000 ms - 10000 ms record1 = create_log_record(7.5, "record1") record2 = create_log_record(8.5, "record2") record3 = create_log_record(9.5, "record3") record4 = create_log_record(10.0, "record4") # Pass them to cache cache.emit(record1) cache.emit(record2) cache.emit(record3) cache.emit(record4) # Now set the current time to 12000 ms time_func.return_value = 12.0 # Get cached record, only newer than 9000ms should be there actual = cache.get_cached_records() self.assertEqual(2, len(actual)) self.assertEqual("record3", actual[0].msg) self.assertEqual(9.5, actual[0].created) self.assertEqual("record4", actual[1].msg) self.assertEqual(10.0, actual[1].created) @patch("web.handler.stream_log.time") def test_cache_can_be_disabled(self, mock_time_module): time_func = mock_time_module.time cache = CachedQueueLogHandler(history_size_in_ms=0) # Set current time at 10000 ms time_func.return_value = 10.0 # Create some records in past and future record1 = create_log_record(7.5, "record1") record2 = create_log_record(10.0, "record2") record3 = create_log_record(11.5, "record3") # Pass them to cache cache.emit(record1) cache.emit(record2) cache.emit(record3) # Get cached record, should return nothing actual = cache.get_cached_records() self.assertEqual(0, len(actual)) ================================================ FILE: src/python/tests/unittests/test_web/test_serialize/__init__.py ================================================ ================================================ FILE: src/python/tests/unittests/test_web/test_serialize/test_serialize.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest from web.serialize import Serialize class DummySerialize(Serialize): def dummy(self): return self._sse_pack(event="event", data="data") def parse_stream(serialized_str: str): parsed = dict() for line in serialized_str.split("\n"): if line: key, value = line.split(":", maxsplit=1) parsed[key.strip()] = value.strip() return parsed ================================================ FILE: src/python/tests/unittests/test_web/test_serialize/test_serialize_auto_queue.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest import json from controller import AutoQueuePattern from web.serialize import SerializeAutoQueue class TestSerializeConfig(unittest.TestCase): def test_is_list(self): patterns = [ AutoQueuePattern(pattern="one"), AutoQueuePattern(pattern="two"), AutoQueuePattern(pattern="three") ] out = SerializeAutoQueue.patterns(patterns) out_list = json.loads(out) self.assertIsInstance(out_list, list) self.assertEqual(3, len(out_list)) def test_patterns(self): patterns = [ AutoQueuePattern(pattern="one"), AutoQueuePattern(pattern="tw o"), AutoQueuePattern(pattern="th'ree"), AutoQueuePattern(pattern="fo\"ur"), AutoQueuePattern(pattern="fi=ve") ] out = SerializeAutoQueue.patterns(patterns) out_list = json.loads(out) self.assertEqual(5, len(out_list)) self.assertEqual([ {"pattern": "one"}, {"pattern": "tw o"}, {"pattern": "th'ree"}, {"pattern": "fo\"ur"}, {"pattern": "fi=ve"}, ], out_list) ================================================ FILE: src/python/tests/unittests/test_web/test_serialize/test_serialize_config.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest import json from common import Config from web.serialize import SerializeConfig class TestSerializeConfig(unittest.TestCase): def test_section_general(self): config = Config() config.general.debug = True out = SerializeConfig.config(config) out_dict = json.loads(out) self.assertIn("general", out_dict) self.assertEqual(True, out_dict["general"]["debug"]) def test_section_lftp(self): config = Config() config.lftp.remote_address = "server.remote.com" config.lftp.remote_username = "user-on-remote-server" config.lftp.remote_port = 3456 config.lftp.remote_path = "/remote/server/path" config.lftp.local_path = "/local/server/path" config.lftp.remote_path_to_scan_script = "/remote/server/path/to/script" config.lftp.num_max_parallel_downloads = 6 config.lftp.num_max_parallel_files_per_download = 7 config.lftp.num_max_connections_per_root_file = 2 config.lftp.num_max_connections_per_dir_file = 3 config.lftp.num_max_total_connections = 4 out = SerializeConfig.config(config) out_dict = json.loads(out) self.assertIn("lftp", out_dict) self.assertEqual("server.remote.com", out_dict["lftp"]["remote_address"]) self.assertEqual("user-on-remote-server", out_dict["lftp"]["remote_username"]) self.assertEqual(3456, out_dict["lftp"]["remote_port"]) self.assertEqual("/remote/server/path", out_dict["lftp"]["remote_path"]) self.assertEqual("/local/server/path", out_dict["lftp"]["local_path"]) self.assertEqual("/remote/server/path/to/script", out_dict["lftp"]["remote_path_to_scan_script"]) self.assertEqual(6, out_dict["lftp"]["num_max_parallel_downloads"]) self.assertEqual(7, out_dict["lftp"]["num_max_parallel_files_per_download"]) self.assertEqual(2, out_dict["lftp"]["num_max_connections_per_root_file"]) self.assertEqual(3, out_dict["lftp"]["num_max_connections_per_dir_file"]) self.assertEqual(4, out_dict["lftp"]["num_max_total_connections"]) def test_section_controller(self): config = Config() config.controller.interval_ms_remote_scan = 1234 config.controller.interval_ms_local_scan = 5678 config.controller.interval_ms_downloading_scan = 9012 out = SerializeConfig.config(config) out_dict = json.loads(out) self.assertIn("controller", out_dict) self.assertEqual(1234, out_dict["controller"]["interval_ms_remote_scan"]) self.assertEqual(5678, out_dict["controller"]["interval_ms_local_scan"]) self.assertEqual(9012, out_dict["controller"]["interval_ms_downloading_scan"]) def test_section_web(self): config = Config() config.web.port = 8080 out = SerializeConfig.config(config) out_dict = json.loads(out) self.assertIn("web", out_dict) self.assertEqual(8080, out_dict["web"]["port"]) def test_section_autoqueue(self): config = Config() config.autoqueue.enabled = True config.autoqueue.patterns_only = False out = SerializeConfig.config(config) out_dict = json.loads(out) self.assertIn("autoqueue", out_dict) self.assertEqual(True, out_dict["autoqueue"]["enabled"]) self.assertEqual(False, out_dict["autoqueue"]["patterns_only"]) ================================================ FILE: src/python/tests/unittests/test_web/test_serialize/test_serialize_log_record.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest import json import logging from .test_serialize import parse_stream from web.serialize import SerializeLogRecord class TestSerializeLogRecord(unittest.TestCase): def test_event_names(self): serialize = SerializeLogRecord() logger = logging.getLogger() record = logger.makeRecord( name=None, level=None, fn=None, lno=None, msg=None, args=None, exc_info=None, func=None, sinfo=None ) out = parse_stream(serialize.record(record)) self.assertEqual("log-record", out["event"]) def test_record_time(self): serialize = SerializeLogRecord() logger = logging.getLogger() record = logger.makeRecord( name=None, level=None, fn=None, lno=None, msg=None, args=None, exc_info=None, func=None, sinfo=None ) out = parse_stream(serialize.record(record)) data = json.loads(out["data"]) self.assertEqual(str(record.created), data["time"]) def test_record_level_name(self): serialize = SerializeLogRecord() logger = logging.getLogger() record = logger.makeRecord( name=None, level=logging.DEBUG, fn=None, lno=None, msg=None, args=None, exc_info=None, func=None, sinfo=None ) out = parse_stream(serialize.record(record)) data = json.loads(out["data"]) self.assertEqual("DEBUG", data["level_name"]) record = logger.makeRecord( name=None, level=logging.INFO, fn=None, lno=None, msg=None, args=None, exc_info=None, func=None, sinfo=None ) out = parse_stream(serialize.record(record)) data = json.loads(out["data"]) self.assertEqual("INFO", data["level_name"]) record = logger.makeRecord( name=None, level=logging.WARNING, fn=None, lno=None, msg=None, args=None, exc_info=None, func=None, sinfo=None ) out = parse_stream(serialize.record(record)) data = json.loads(out["data"]) self.assertEqual("WARNING", data["level_name"]) record = logger.makeRecord( name=None, level=logging.ERROR, fn=None, lno=None, msg=None, args=None, exc_info=None, func=None, sinfo=None ) out = parse_stream(serialize.record(record)) data = json.loads(out["data"]) self.assertEqual("ERROR", data["level_name"]) record = logger.makeRecord( name=None, level=logging.CRITICAL, fn=None, lno=None, msg=None, args=None, exc_info=None, func=None, sinfo=None ) out = parse_stream(serialize.record(record)) data = json.loads(out["data"]) self.assertEqual("CRITICAL", data["level_name"]) def test_record_logger_name(self): serialize = SerializeLogRecord() logger = logging.getLogger() record = logger.makeRecord( name="myloggername", level=None, fn=None, lno=None, msg=None, args=None, exc_info=None, func=None, sinfo=None ) out = parse_stream(serialize.record(record)) data = json.loads(out["data"]) self.assertEqual("myloggername", data["logger_name"]) def test_record_message(self): serialize = SerializeLogRecord() logger = logging.getLogger() record = logger.makeRecord( name=None, level=None, fn=None, lno=None, msg="my logger msg", args=None, exc_info=None, func=None, sinfo=None ) out = parse_stream(serialize.record(record)) data = json.loads(out["data"]) self.assertEqual("my logger msg", data["message"]) def test_record_exception_text(self): serialize = SerializeLogRecord() logger = logging.getLogger() # When there's exc_text already there record = logger.makeRecord( name=None, level=None, fn=None, lno=None, msg=None, args=None, exc_info=None, func=None, sinfo=None ) record.exc_text = "My traceback" out = parse_stream(serialize.record(record)) data = json.loads(out["data"]) self.assertEqual("My traceback", data["exc_tb"]) # When there's exc_info but no exc_text record = logger.makeRecord( name=None, level=None, fn=None, lno=None, msg=None, args=None, exc_info=(None, ValueError(), None), func=None, sinfo=None ) out = parse_stream(serialize.record(record)) data = json.loads(out["data"]) self.assertEqual("ValueError", data["exc_tb"]) # When there's neither record = logger.makeRecord( name=None, level=None, fn=None, lno=None, msg=None, args=None, exc_info=None, func=None, sinfo=None ) out = parse_stream(serialize.record(record)) data = json.loads(out["data"]) self.assertEqual(None, data["exc_tb"]) ================================================ FILE: src/python/tests/unittests/test_web/test_serialize/test_serialize_model.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest import json from datetime import datetime from pytz import timezone from .test_serialize import parse_stream from web.serialize import SerializeModel from model import ModelFile class TestSerializeModel(unittest.TestCase): def test_event_names(self): serialize = SerializeModel() out = parse_stream(serialize.model([])) self.assertEqual("model-init", out["event"]) out = parse_stream( serialize.update_event(SerializeModel.UpdateEvent( SerializeModel.UpdateEvent.Change.ADDED, None, None )) ) self.assertEqual("model-added", out["event"]) out = parse_stream( serialize.update_event(SerializeModel.UpdateEvent( SerializeModel.UpdateEvent.Change.UPDATED, None, None )) ) self.assertEqual("model-updated", out["event"]) out = parse_stream( serialize.update_event(SerializeModel.UpdateEvent( SerializeModel.UpdateEvent.Change.REMOVED, None, None )) ) self.assertEqual("model-removed", out["event"]) def test_model_is_a_list(self): serialize = SerializeModel() files = [] out = parse_stream(serialize.model(files)) data = json.loads(out["data"]) self.assertEqual(list, type(data)) files = [ModelFile("a", True), ModelFile("b", False)] out = parse_stream(serialize.model(files)) data = json.loads(out["data"]) self.assertEqual(list, type(data)) self.assertEqual(2, len(data)) def test_update_event_is_a_dict(self): serialize = SerializeModel() out = parse_stream( serialize.update_event(SerializeModel.UpdateEvent( SerializeModel.UpdateEvent.Change.UPDATED, None, None )) ) data = json.loads(out["data"]) self.assertEqual(dict, type(data)) self.assertEqual(None, data["old_file"]) self.assertEqual(None, data["new_file"]) def test_update_event_files(self): serialize = SerializeModel() a1 = ModelFile("a", False) a1.local_size = 100 a2 = ModelFile("a", False) a2.local_size = 200 out = parse_stream( serialize.update_event(SerializeModel.UpdateEvent( SerializeModel.UpdateEvent.Change.UPDATED, a1, a2 )) ) data = json.loads(out["data"]) self.assertEqual("a", data["old_file"]["name"]) self.assertEqual(100, data["old_file"]["local_size"]) self.assertEqual("a", data["new_file"]["name"]) self.assertEqual(200, data["new_file"]["local_size"]) out = parse_stream( serialize.update_event(SerializeModel.UpdateEvent( SerializeModel.UpdateEvent.Change.ADDED, None, a1 )) ) data = json.loads(out["data"]) self.assertEqual(None, data["old_file"]) self.assertEqual("a", data["new_file"]["name"]) self.assertEqual(100, data["new_file"]["local_size"]) out = parse_stream( serialize.update_event(SerializeModel.UpdateEvent( SerializeModel.UpdateEvent.Change.ADDED, a2, None )) ) data = json.loads(out["data"]) self.assertEqual("a", data["old_file"]["name"]) self.assertEqual(200, data["old_file"]["local_size"]) self.assertEqual(None, data["new_file"]) def test_file_name(self): serialize = SerializeModel() files = [ModelFile("a", True), ModelFile("b", False)] out = parse_stream(serialize.model(files)) data = json.loads(out["data"]) self.assertEqual(2, len(data)) self.assertEqual("a", data[0]["name"]) self.assertEqual("b", data[1]["name"]) def test_file_is_dir(self): serialize = SerializeModel() files = [ModelFile("a", True), ModelFile("b", False)] out = parse_stream(serialize.model(files)) data = json.loads(out["data"]) self.assertEqual(2, len(data)) self.assertEqual(True, data[0]["is_dir"]) self.assertEqual(False, data[1]["is_dir"]) def test_file_state(self): serialize = SerializeModel() a = ModelFile("a", True) a.state = ModelFile.State.DEFAULT b = ModelFile("b", False) b.state = ModelFile.State.DOWNLOADING c = ModelFile("c", True) c.state = ModelFile.State.QUEUED d = ModelFile("d", True) d.state = ModelFile.State.DOWNLOADED e = ModelFile("e", False) e.state = ModelFile.State.DELETED f = ModelFile("f", False) f.state = ModelFile.State.EXTRACTING g = ModelFile("g", False) g.state = ModelFile.State.EXTRACTED files = [a, b, c, d, e, f, g] out = parse_stream(serialize.model(files)) data = json.loads(out["data"]) self.assertEqual(7, len(data)) self.assertEqual("default", data[0]["state"]) self.assertEqual("downloading", data[1]["state"]) self.assertEqual("queued", data[2]["state"]) self.assertEqual("downloaded", data[3]["state"]) self.assertEqual("deleted", data[4]["state"]) self.assertEqual("extracting", data[5]["state"]) self.assertEqual("extracted", data[6]["state"]) def test_remote_size(self): serialize = SerializeModel() a = ModelFile("a", True) a.remote_size = None b = ModelFile("b", False) b.remote_size = 0 c = ModelFile("c", True) c.remote_size = 100 files = [a, b, c] out = parse_stream(serialize.model(files)) data = json.loads(out["data"]) self.assertEqual(3, len(data)) self.assertEqual(None, data[0]["remote_size"]) self.assertEqual(0, data[1]["remote_size"]) self.assertEqual(100, data[2]["remote_size"]) def test_local_size(self): serialize = SerializeModel() a = ModelFile("a", True) a.local_size = None b = ModelFile("b", False) b.local_size = 0 c = ModelFile("c", True) c.local_size = 100 files = [a, b, c] out = parse_stream(serialize.model(files)) data = json.loads(out["data"]) self.assertEqual(3, len(data)) self.assertEqual(None, data[0]["local_size"]) self.assertEqual(0, data[1]["local_size"]) self.assertEqual(100, data[2]["local_size"]) def test_downloading_speed(self): serialize = SerializeModel() a = ModelFile("a", True) a.downloading_speed = None b = ModelFile("b", False) b.downloading_speed = 0 c = ModelFile("c", True) c.downloading_speed = 100 files = [a, b, c] out = parse_stream(serialize.model(files)) data = json.loads(out["data"]) self.assertEqual(3, len(data)) self.assertEqual(None, data[0]["downloading_speed"]) self.assertEqual(0, data[1]["downloading_speed"]) self.assertEqual(100, data[2]["downloading_speed"]) def test_eta(self): serialize = SerializeModel() a = ModelFile("a", True) a.eta = None b = ModelFile("b", False) b.eta = 0 c = ModelFile("c", True) c.eta = 100 files = [a, b, c] out = parse_stream(serialize.model(files)) data = json.loads(out["data"]) self.assertEqual(3, len(data)) self.assertEqual(None, data[0]["eta"]) self.assertEqual(0, data[1]["eta"]) self.assertEqual(100, data[2]["eta"]) def test_file_is_extractable(self): serialize = SerializeModel() a = ModelFile("a", True) a.is_extractable = False b = ModelFile("b", False) b.is_extractable = True files = [a, b] out = parse_stream(serialize.model(files)) data = json.loads(out["data"]) self.assertEqual(2, len(data)) self.assertEqual(False, data[0]["is_extractable"]) self.assertEqual(True, data[1]["is_extractable"]) def test_local_created_timestamp(self): serialize = SerializeModel() a = ModelFile("a", True) b = ModelFile("b", False) b.local_created_timestamp = datetime(2018, 11, 9, 21, 40, 18, tzinfo=timezone('UTC')) files = [a, b] out = parse_stream(serialize.model(files)) data = json.loads(out["data"]) self.assertEqual(2, len(data)) self.assertEqual(None, data[0]["local_created_timestamp"]) self.assertEqual(str(1541799618.0), data[1]["local_created_timestamp"]) def test_local_modified_timestamp(self): serialize = SerializeModel() a = ModelFile("a", True) b = ModelFile("b", False) b.local_modified_timestamp = datetime(2018, 11, 9, 21, 40, 18, tzinfo=timezone('UTC')) files = [a, b] out = parse_stream(serialize.model(files)) data = json.loads(out["data"]) self.assertEqual(2, len(data)) self.assertEqual(None, data[0]["local_modified_timestamp"]) self.assertEqual(str(1541799618.0), data[1]["local_modified_timestamp"]) def test_remote_created_timestamp(self): serialize = SerializeModel() a = ModelFile("a", True) b = ModelFile("b", False) b.remote_created_timestamp = datetime(2018, 11, 9, 21, 40, 18, tzinfo=timezone('UTC')) files = [a, b] out = parse_stream(serialize.model(files)) data = json.loads(out["data"]) self.assertEqual(2, len(data)) self.assertEqual(None, data[0]["remote_created_timestamp"]) self.assertEqual(str(1541799618.0), data[1]["remote_created_timestamp"]) def test_remote_modified_timestamp(self): serialize = SerializeModel() a = ModelFile("a", True) b = ModelFile("b", False) b.remote_modified_timestamp = datetime(2018, 11, 9, 21, 40, 18, tzinfo=timezone('UTC')) files = [a, b] out = parse_stream(serialize.model(files)) data = json.loads(out["data"]) self.assertEqual(2, len(data)) self.assertEqual(None, data[0]["remote_modified_timestamp"]) self.assertEqual(str(1541799618.0), data[1]["remote_modified_timestamp"]) def test_children(self): serialize = SerializeModel() a = ModelFile("a", True) b = ModelFile("b", True) b.add_child(ModelFile("ba", False)) b.add_child(ModelFile("bb", True)) c = ModelFile("c", True) ca = ModelFile("ca", True) ca.add_child(ModelFile("caa", False)) ca.add_child(ModelFile("cab", False)) c.add_child(ca) cb = ModelFile("cb", False) c.add_child(cb) c.eta = 100 files = [a, b, c] out = parse_stream(serialize.model(files)) data = json.loads(out["data"]) self.assertEqual(3, len(data)) self.assertEqual(list, type(data[0]["children"])) self.assertEqual(0, len(data[0]["children"])) self.assertEqual(list, type(data[1]["children"])) self.assertEqual(2, len(data[1]["children"])) self.assertEqual("ba", data[1]["children"][0]["name"]) self.assertEqual(0, len(data[1]["children"][0]["children"])) self.assertEqual("bb", data[1]["children"][1]["name"]) self.assertEqual(0, len(data[1]["children"][1]["children"])) self.assertEqual(list, type(data[2]["children"])) self.assertEqual(2, len(data[2]["children"])) self.assertEqual("ca", data[2]["children"][0]["name"]) self.assertEqual(2, len(data[2]["children"][0]["children"])) self.assertEqual("caa", data[2]["children"][0]["children"][0]["name"]) self.assertEqual(0, len(data[2]["children"][0]["children"][0]["children"])) self.assertEqual("cab", data[2]["children"][0]["children"][1]["name"]) self.assertEqual(0, len(data[2]["children"][0]["children"][1]["children"])) self.assertEqual("cb", data[2]["children"][1]["name"]) self.assertEqual(0, len(data[2]["children"][1]["children"])) def test_full_path(self): serialize = SerializeModel() a = ModelFile("a", True) b = ModelFile("b", True) b.add_child(ModelFile("ba", False)) b.add_child(ModelFile("bb", True)) c = ModelFile("c", True) ca = ModelFile("ca", True) ca.add_child(ModelFile("caa", False)) ca.add_child(ModelFile("cab", False)) c.add_child(ca) cb = ModelFile("cb", False) c.add_child(cb) c.eta = 100 files = [a, b, c] out = parse_stream(serialize.model(files)) data = json.loads(out["data"]) self.assertEqual(3, len(data)) self.assertEqual("a", data[0]["full_path"]) self.assertEqual("b", data[1]["full_path"]) self.assertEqual("b/ba", data[1]["children"][0]["full_path"]) self.assertEqual("b/bb", data[1]["children"][1]["full_path"]) self.assertEqual("c", data[2]["full_path"]) self.assertEqual("c/ca", data[2]["children"][0]["full_path"]) self.assertEqual("c/ca/caa", data[2]["children"][0]["children"][0]["full_path"]) self.assertEqual("c/ca/cab", data[2]["children"][0]["children"][1]["full_path"]) self.assertEqual("c/cb", data[2]["children"][1]["full_path"]) ================================================ FILE: src/python/tests/unittests/test_web/test_serialize/test_serialize_status.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import unittest import json from datetime import datetime from pytz import timezone from .test_serialize import parse_stream from common import Status from web.serialize import SerializeStatus class TestSerializeStatus(unittest.TestCase): def test_event_names(self): serialize = SerializeStatus() status = Status() out = parse_stream(serialize.status(status)) self.assertEqual("status", out["event"]) status.server.up = False status.server.error_msg = "Bad stuff happened" out = parse_stream(serialize.status(status)) self.assertEqual("status", out["event"]) def test_server_status_up(self): serialize = SerializeStatus() status = Status() out = parse_stream(serialize.status(status)) data = json.loads(out["data"]) self.assertEqual(True, data["server"]["up"]) status.server.up = False status.server.error_msg = "Bad stuff happened" out = parse_stream(serialize.status(status)) data = json.loads(out["data"]) self.assertEqual(False, data["server"]["up"]) def test_server_status_error_msg(self): serialize = SerializeStatus() status = Status() out = parse_stream(serialize.status(status)) data = json.loads(out["data"]) self.assertEqual(None, data["server"]["error_msg"]) status.server.up = False status.server.error_msg = "Bad stuff happened" out = parse_stream(serialize.status(status)) data = json.loads(out["data"]) self.assertEqual("Bad stuff happened", data["server"]["error_msg"]) def test_controller_status_latest_local_scan_time(self): serialize = SerializeStatus() status = Status() out = parse_stream(serialize.status(status)) data = json.loads(out["data"]) self.assertIsNone(data["controller"]["latest_local_scan_time"]) timestamp = datetime(2018, 11, 9, 21, 40, 18, tzinfo=timezone('UTC')) status.controller.latest_local_scan_time = timestamp out = parse_stream(serialize.status(status)) data = json.loads(out["data"]) self.assertEqual(str(1541799618.0), data["controller"]["latest_local_scan_time"]) def test_controller_status_latest_remote_scan_time(self): serialize = SerializeStatus() status = Status() out = parse_stream(serialize.status(status)) data = json.loads(out["data"]) self.assertIsNone(data["controller"]["latest_remote_scan_time"]) timestamp = datetime(2018, 11, 9, 21, 40, 18, tzinfo=timezone('UTC')) status.controller.latest_remote_scan_time = timestamp out = parse_stream(serialize.status(status)) data = json.loads(out["data"]) self.assertEqual(str(1541799618.0), data["controller"]["latest_remote_scan_time"]) def test_controller_status_latest_remote_scan_failed(self): serialize = SerializeStatus() status = Status() out = parse_stream(serialize.status(status)) data = json.loads(out["data"]) self.assertIsNone(data["controller"]["latest_remote_scan_failed"]) status.controller.latest_remote_scan_failed = True out = parse_stream(serialize.status(status)) data = json.loads(out["data"]) self.assertEqual(True, data["controller"]["latest_remote_scan_failed"]) def test_controller_status_latest_remote_scan_error(self): serialize = SerializeStatus() status = Status() out = parse_stream(serialize.status(status)) data = json.loads(out["data"]) self.assertIsNone(data["controller"]["latest_remote_scan_error"]) status.controller.latest_remote_scan_error = "remote server went boom" out = parse_stream(serialize.status(status)) data = json.loads(out["data"]) self.assertEqual("remote server went boom", data["controller"]["latest_remote_scan_error"]) ================================================ FILE: src/python/tests/utils.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import os class TestUtils: @staticmethod def chmod_from_to(from_path: str, to_path: str, mode: int): """ Chmod from_path and all its parents up to and including to_path :param from_path: :param to_path: :param mode: :return: """ path = from_path try: os.chmod(path, mode) except PermissionError: pass while path != "/" and path != to_path: path = os.path.dirname(path) try: os.chmod(path, mode) except PermissionError: pass ================================================ FILE: src/python/web/__init__.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from .web_app import WebApp from .web_app_job import WebAppJob from .web_app_builder import WebAppBuilder ================================================ FILE: src/python/web/handler/__init__.py ================================================ ================================================ FILE: src/python/web/handler/auto_queue.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from bottle import HTTPResponse from urllib.parse import unquote from common import overrides from controller import AutoQueuePersist, AutoQueuePattern from ..web_app import IHandler, WebApp from ..serialize import SerializeAutoQueue class AutoQueueHandler(IHandler): def __init__(self, auto_queue_persist: AutoQueuePersist): self.__auto_queue_persist = auto_queue_persist @overrides(IHandler) def add_routes(self, web_app: WebApp): web_app.add_handler("/server/autoqueue/get", self.__handle_get_autoqueue) web_app.add_handler("/server/autoqueue/add/", self.__handle_add_autoqueue) web_app.add_handler("/server/autoqueue/remove/", self.__handle_remove_autoqueue) def __handle_get_autoqueue(self): patterns = list(self.__auto_queue_persist.patterns) patterns.sort(key=lambda p: p.pattern) out_json = SerializeAutoQueue.patterns(patterns) return HTTPResponse(body=out_json) def __handle_add_autoqueue(self, pattern: str): # value is double encoded pattern = unquote(pattern) aqp = AutoQueuePattern(pattern=pattern) if aqp in self.__auto_queue_persist.patterns: return HTTPResponse(body="Auto-queue pattern '{}' already exists.".format(pattern), status=400) else: try: self.__auto_queue_persist.add_pattern(aqp) return HTTPResponse(body="Added auto-queue pattern '{}'.".format(pattern)) except ValueError as e: return HTTPResponse(body=str(e), status=400) def __handle_remove_autoqueue(self, pattern: str): # value is double encoded pattern = unquote(pattern) aqp = AutoQueuePattern(pattern=pattern) if aqp not in self.__auto_queue_persist.patterns: return HTTPResponse(body="Auto-queue pattern '{}' doesn't exist.".format(pattern), status=400) else: self.__auto_queue_persist.remove_pattern(aqp) return HTTPResponse(body="Removed auto-queue pattern '{}'.".format(pattern)) ================================================ FILE: src/python/web/handler/config.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from bottle import HTTPResponse from urllib.parse import unquote from common import overrides, Config, ConfigError from ..web_app import IHandler, WebApp from ..serialize import SerializeConfig class ConfigHandler(IHandler): def __init__(self, config: Config): self.__config = config @overrides(IHandler) def add_routes(self, web_app: WebApp): web_app.add_handler("/server/config/get", self.__handle_get_config) # The regex allows slashes in values web_app.add_handler("/server/config/set/
//", self.__handle_set_config) def __handle_get_config(self): out_json = SerializeConfig.config(self.__config) return HTTPResponse(body=out_json) def __handle_set_config(self, section: str, key: str, value: str): # value is double encoded value = unquote(value) if not self.__config.has_section(section): return HTTPResponse(body="There is no section '{}' in config".format(section), status=400) inner_config = getattr(self.__config, section) if not inner_config.has_property(key): return HTTPResponse(body="Section '{}' in config has no option '{}'".format(section, key), status=400) try: inner_config.set_property(key, value) return HTTPResponse(body="{}.{} set to {}".format(section, key, value)) except ConfigError as e: return HTTPResponse(body=str(e), status=400) ================================================ FILE: src/python/web/handler/controller.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from threading import Event from urllib.parse import unquote from bottle import HTTPResponse from common import overrides from controller import Controller from ..web_app import IHandler, WebApp class WebResponseActionCallback(Controller.Command.ICallback): """ Controller action callback used by model streams to wait for action status. Clients should call wait() method to wait for the status, then query the status from 'success' and 'error' """ def __init__(self): self.__event = Event() self.success = None self.error = None @overrides(Controller.Command.ICallback) def on_failure(self, error: str): self.success = False self.error = error self.__event.set() @overrides(Controller.Command.ICallback) def on_success(self): self.success = True self.__event.set() def wait(self): self.__event.wait() class ControllerHandler(IHandler): def __init__(self, controller: Controller): self.__controller = controller @overrides(IHandler) def add_routes(self, web_app: WebApp): web_app.add_handler("/server/command/queue/", self.__handle_action_queue) web_app.add_handler("/server/command/stop/", self.__handle_action_stop) web_app.add_handler("/server/command/extract/", self.__handle_action_extract) web_app.add_handler("/server/command/delete_local/", self.__handle_action_delete_local) web_app.add_handler("/server/command/delete_remote/", self.__handle_action_delete_remote) def __handle_action_queue(self, file_name: str): """ Request a QUEUE action :param file_name: :return: """ # value is double encoded file_name = unquote(file_name) command = Controller.Command(Controller.Command.Action.QUEUE, file_name) callback = WebResponseActionCallback() command.add_callback(callback) self.__controller.queue_command(command) callback.wait() if callback.success: return HTTPResponse(body="Queued file '{}'".format(file_name)) else: return HTTPResponse(body=callback.error, status=400) def __handle_action_stop(self, file_name: str): """ Request a STOP action :param file_name: :return: """ # value is double encoded file_name = unquote(file_name) command = Controller.Command(Controller.Command.Action.STOP, file_name) callback = WebResponseActionCallback() command.add_callback(callback) self.__controller.queue_command(command) callback.wait() if callback.success: return HTTPResponse(body="Stopped file '{}'".format(file_name)) else: return HTTPResponse(body=callback.error, status=400) def __handle_action_extract(self, file_name: str): """ Request a EXTRACT action :param file_name: :return: """ # value is double encoded file_name = unquote(file_name) command = Controller.Command(Controller.Command.Action.EXTRACT, file_name) callback = WebResponseActionCallback() command.add_callback(callback) self.__controller.queue_command(command) callback.wait() if callback.success: return HTTPResponse(body="Requested extraction for file '{}'".format(file_name)) else: return HTTPResponse(body=callback.error, status=400) def __handle_action_delete_local(self, file_name: str): """ Request a DELETE LOCAL action :param file_name: :return: """ # value is double encoded file_name = unquote(file_name) command = Controller.Command(Controller.Command.Action.DELETE_LOCAL, file_name) callback = WebResponseActionCallback() command.add_callback(callback) self.__controller.queue_command(command) callback.wait() if callback.success: return HTTPResponse(body="Requested local delete for file '{}'".format(file_name)) else: return HTTPResponse(body=callback.error, status=400) def __handle_action_delete_remote(self, file_name: str): """ Request a DELETE REMOTE action :param file_name: :return: """ # value is double encoded file_name = unquote(file_name) command = Controller.Command(Controller.Command.Action.DELETE_REMOTE, file_name) callback = WebResponseActionCallback() command.add_callback(callback) self.__controller.queue_command(command) callback.wait() if callback.success: return HTTPResponse(body="Requested remote delete for file '{}'".format(file_name)) else: return HTTPResponse(body=callback.error, status=400) ================================================ FILE: src/python/web/handler/server.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from bottle import HTTPResponse from common import Context, overrides from ..web_app import IHandler, WebApp class ServerHandler(IHandler): def __init__(self, context: Context): self.logger = context.logger.getChild("ServerActionHandler") self.__request_restart = False @overrides(IHandler) def add_routes(self, web_app: WebApp): web_app.add_handler("/server/command/restart", self.__handle_action_restart) def is_restart_requested(self): """ Returns true is a restart is requested :return: """ return self.__request_restart def __handle_action_restart(self): """ Request a server restart :return: """ self.logger.info("Received a restart action") self.__request_restart = True return HTTPResponse(body="Requested restart") ================================================ FILE: src/python/web/handler/status.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from bottle import HTTPResponse from common import Status, overrides from ..web_app import IHandler, WebApp from ..serialize import SerializeStatusJson class StatusHandler(IHandler): def __init__(self, status: Status): self.__status = status @overrides(IHandler) def add_routes(self, web_app: WebApp): web_app.add_handler("/server/status", self.__handle_get_status) def __handle_get_status(self): out_json = SerializeStatusJson.status(self.__status) return HTTPResponse(body=out_json) ================================================ FILE: src/python/web/handler/stream_log.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import logging from typing import Optional, List import time import copy from threading import Lock from ..web_app import IStreamHandler from ..utils import StreamQueue from ..serialize import SerializeLogRecord from common import overrides class CachedQueueLogHandler(logging.Handler): """ A logging.Handler that caches the past X seconds of logs """ def __init__(self, history_size_in_ms: int): """ Constructs a CachedQueueLogHandler :param history_size_in_ms: history size, set to 0 to disable caching """ super().__init__() self.__history_size_in_ms = history_size_in_ms self.__cached_records = [] self.__cache_lock = Lock() def get_cached_records(self) -> List[logging.LogRecord]: self.__cache_lock.acquire() self.__prune_history() cache = copy.copy(self.__cached_records) self.__cache_lock.release() return cache @overrides(logging.Handler) def emit(self, record: logging.LogRecord): if self.__history_size_in_ms > 0: self.__cache_lock.acquire() self.__cached_records.append(record) self.__prune_history() self.__cache_lock.release() def __prune_history(self): current_time_in_ms = int(time.time()*1000) history_start_time_in_ms = current_time_in_ms - self.__history_size_in_ms # Find the largest index older than history start time prune_index = -1 for i, record in enumerate(self.__cached_records): if 1000.0*record.created < history_start_time_in_ms: prune_index = i else: # assume records are order oldest to newest break if prune_index >= 0: self.__cached_records = self.__cached_records[prune_index+1:] class QueueLogHandler(logging.Handler, StreamQueue[logging.LogRecord]): """ A log handler that stored records in a thread-safe queue """ def __init__(self): logging.Handler.__init__(self) StreamQueue.__init__(self) @overrides(logging.Handler) def emit(self, record): self.put(record) class LogStreamHandler(IStreamHandler): """ Streams logs captured after the stream starts. Also cache a small history of logs and sends them when the stream starts. """ _CACHE_HISTORY_SIZE_IN_MS = 3000 # Cache of logs _cache = None def __init__(self, logger: logging.Logger): self.logger = logger self.handler = QueueLogHandler() self.serialize = SerializeLogRecord() # noinspection PyUnresolvedReferences @classmethod @overrides(IStreamHandler) def register(cls, web_app: "WebApp", **kwargs): # Initialize our cache when we register LogStreamHandler._cache = CachedQueueLogHandler( history_size_in_ms=LogStreamHandler._CACHE_HISTORY_SIZE_IN_MS ) kwargs["logger"].addHandler(LogStreamHandler._cache) super().register(web_app=web_app, **kwargs) @overrides(IStreamHandler) def setup(self): # Send out all the cached records first for record in LogStreamHandler._cache.get_cached_records(): self.handler.emit(record) # Then subscribe the live stream self.logger.addHandler(self.handler) @overrides(IStreamHandler) def get_value(self) -> Optional[str]: record = self.handler.get_next_event() if record is not None: return self.serialize.record(record) else: return None @overrides(IStreamHandler) def cleanup(self): self.logger.removeHandler(self.handler) ================================================ FILE: src/python/web/handler/stream_model.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from typing import Optional from ..web_app import IStreamHandler from ..utils import StreamQueue from ..serialize import SerializeModel from model import IModelListener, ModelFile from common import overrides from controller import Controller class WebResponseModelListener(IModelListener, StreamQueue[SerializeModel.UpdateEvent]): """ Model listener used by streams to listen to model updates One listener should be created for each new request """ def __init__(self): super().__init__() @overrides(IModelListener) def file_added(self, file: ModelFile): self.put(SerializeModel.UpdateEvent(change=SerializeModel.UpdateEvent.Change.ADDED, old_file=None, new_file=file)) @overrides(IModelListener) def file_removed(self, file: ModelFile): self.put(SerializeModel.UpdateEvent(change=SerializeModel.UpdateEvent.Change.REMOVED, old_file=file, new_file=None)) @overrides(IModelListener) def file_updated(self, old_file: ModelFile, new_file: ModelFile): self.put(SerializeModel.UpdateEvent(change=SerializeModel.UpdateEvent.Change.UPDATED, old_file=old_file, new_file=new_file)) class ModelStreamHandler(IStreamHandler): def __init__(self, controller: Controller): self.controller = controller self.serialize = SerializeModel() self.model_listener = WebResponseModelListener() self.initial_model_files = None self.first_run = True @overrides(IStreamHandler) def setup(self): self.initial_model_files = self.controller.get_model_files_and_add_listener(self.model_listener) @overrides(IStreamHandler) def get_value(self) -> Optional[str]: if self.first_run: self.first_run = False return self.serialize.model(self.initial_model_files) else: event = self.model_listener.get_next_event() if event is not None: return self.serialize.update_event(event) else: return None @overrides(IStreamHandler) def cleanup(self): if self.model_listener: self.controller.remove_model_listener(self.model_listener) ================================================ FILE: src/python/web/handler/stream_status.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from typing import Optional from ..web_app import IStreamHandler from ..serialize import SerializeStatus from ..utils import StreamQueue from common import overrides, Status, IStatusListener class StatusListener(IStatusListener, StreamQueue[Status]): """ Status listener used by status streams to listen to status updates """ def __init__(self, status: Status): super().__init__() self.__status = status @overrides(IStatusListener) def notify(self): self.put(self.__status.copy()) class StatusStreamHandler(IStreamHandler): def __init__(self, status: Status): self.status = status self.serialize = SerializeStatus() self.status_listener = StatusListener(status) self.first_run = True @overrides(IStreamHandler) def setup(self): self.status.add_listener(self.status_listener) @overrides(IStreamHandler) def get_value(self) -> Optional[str]: if self.first_run: self.first_run = False status = self.status.copy() return self.serialize.status(status) else: status = self.status_listener.get_next_event() if status is not None: return self.serialize.status(status) else: return None @overrides(IStreamHandler) def cleanup(self): if self.status_listener: self.status.remove_listener(self.status_listener) ================================================ FILE: src/python/web/serialize/__init__.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from .serialize import Serialize from .serialize_model import SerializeModel from .serialize_status import SerializeStatus, SerializeStatusJson from .serialize_config import SerializeConfig from .serialize_auto_queue import SerializeAutoQueue from .serialize_log_record import SerializeLogRecord ================================================ FILE: src/python/web/serialize/serialize.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from abc import ABC class Serialize(ABC): """ Base class for SSE serialization """ # noinspection PyMethodMayBeStatic def _sse_pack(self, event: str, data: str) -> str: """Pack data in SSE format""" buffer = "" buffer += "event: %s\n" % event buffer += "data: %s\n" % data buffer += "\n" return buffer ================================================ FILE: src/python/web/serialize/serialize_auto_queue.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import json from typing import List from controller import AutoQueuePattern class SerializeAutoQueue: __KEY_PATTERN = "pattern" @staticmethod def patterns(patterns: List[AutoQueuePattern]) -> str: patterns_list = [] for pattern in patterns: patterns_list.append({ SerializeAutoQueue.__KEY_PATTERN: pattern.pattern }) return json.dumps(patterns_list) ================================================ FILE: src/python/web/serialize/serialize_config.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import json import collections from common import Config class SerializeConfig: @staticmethod def config(config: Config) -> str: config_dict = config.as_dict() # Make the section names lower case keys = list(config_dict.keys()) config_dict_lowercase = collections.OrderedDict() for key in keys: config_dict_lowercase[key.lower()] = config_dict[key] return json.dumps(config_dict_lowercase) ================================================ FILE: src/python/web/serialize/serialize_log_record.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import json import logging from .serialize import Serialize class SerializeLogRecord(Serialize): """ This class defines the serialization interface between python backend and the EventSource client frontend for the log stream. """ # Event keys __EVENT_RECORD = "log-record" # Data keys __KEY_TIME = "time" __KEY_LEVEL_NAME = "level_name" __KEY_LOGGER_NAME = "logger_name" __KEY_MESSAGE = "message" __KEY_EXCEPTION_TRACEBACK = "exc_tb" def __init__(self): super().__init__() # logging formatter to generate exception traceback self.__log_formatter = logging.Formatter() def record(self, record: logging.LogRecord) -> str: json_dict = dict() json_dict[SerializeLogRecord.__KEY_TIME] = str(record.created) json_dict[SerializeLogRecord.__KEY_LEVEL_NAME] = record.levelname json_dict[SerializeLogRecord.__KEY_LOGGER_NAME] = record.name json_dict[SerializeLogRecord.__KEY_MESSAGE] = record.msg exc_text = None if record.exc_text: exc_text = record.exc_text elif record.exc_info: exc_text = self.__log_formatter.formatException(record.exc_info) json_dict[SerializeLogRecord.__KEY_EXCEPTION_TRACEBACK] = exc_text record_json = json.dumps(json_dict) return self._sse_pack(event=SerializeLogRecord.__EVENT_RECORD, data=record_json) ================================================ FILE: src/python/web/serialize/serialize_model.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from enum import Enum import json from typing import List, Optional from .serialize import Serialize from model import ModelFile class SerializeModel(Serialize): """ This class defines the serialization interface between the python backend and the EventSource client frontend for the model stream. """ class UpdateEvent: class Change(Enum): ADDED = 0 REMOVED = 1 UPDATED = 2 def __init__(self, change: Change, old_file: Optional[ModelFile], new_file: Optional[ModelFile]): self.change = change self.old_file = old_file self.new_file = new_file # Event keys __EVENT_INIT = "model-init" __EVENT_UPDATE = { UpdateEvent.Change.ADDED: "model-added", UpdateEvent.Change.REMOVED: "model-removed", UpdateEvent.Change.UPDATED: "model-updated" } __KEY_UPDATE_OLD_FILE = "old_file" __KEY_UPDATE_NEW_FILE = "new_file" # Model file keys __KEY_FILE_NAME = "name" __KEY_FILE_IS_DIR = "is_dir" __KEY_FILE_STATE = "state" __VALUES_FILE_STATE = { ModelFile.State.DEFAULT: "default", ModelFile.State.QUEUED: "queued", ModelFile.State.DOWNLOADING: "downloading", ModelFile.State.DOWNLOADED: "downloaded", ModelFile.State.DELETED: "deleted", ModelFile.State.EXTRACTING: "extracting", ModelFile.State.EXTRACTED: "extracted" } __KEY_FILE_REMOTE_SIZE = "remote_size" __KEY_FILE_LOCAL_SIZE = "local_size" __KEY_FILE_DOWNLOADING_SPEED = "downloading_speed" __KEY_FILE_ETA = "eta" __KEY_FILE_IS_EXTRACTABLE = "is_extractable" __KEY_FILE_LOCAL_CREATED_TIMESTAMP = "local_created_timestamp" __KEY_FILE_LOCAL_MODIFIED_TIMESTAMP = "local_modified_timestamp" __KEY_FILE_REMOTE_CREATED_TIMESTAMP = "remote_created_timestamp" __KEY_FILE_REMOTE_MODIFIED_TIMESTAMP = "remote_modified_timestamp" __KEY_FILE_FULL_PATH = "full_path" __KEY_FILE_CHILDREN = "children" @staticmethod def __model_file_to_json_dict(model_file: ModelFile) -> dict: json_dict = dict() json_dict[SerializeModel.__KEY_FILE_NAME] = model_file.name json_dict[SerializeModel.__KEY_FILE_IS_DIR] = model_file.is_dir json_dict[SerializeModel.__KEY_FILE_STATE] = SerializeModel.__VALUES_FILE_STATE[model_file.state] json_dict[SerializeModel.__KEY_FILE_REMOTE_SIZE] = model_file.remote_size json_dict[SerializeModel.__KEY_FILE_LOCAL_SIZE] = model_file.local_size json_dict[SerializeModel.__KEY_FILE_DOWNLOADING_SPEED] = model_file.downloading_speed json_dict[SerializeModel.__KEY_FILE_ETA] = model_file.eta json_dict[SerializeModel.__KEY_FILE_IS_EXTRACTABLE] = model_file.is_extractable json_dict[SerializeModel.__KEY_FILE_LOCAL_CREATED_TIMESTAMP] = \ str(model_file.local_created_timestamp.timestamp()) if model_file.local_created_timestamp else None json_dict[SerializeModel.__KEY_FILE_LOCAL_MODIFIED_TIMESTAMP] = \ str(model_file.local_modified_timestamp.timestamp()) if model_file.local_modified_timestamp else None json_dict[SerializeModel.__KEY_FILE_REMOTE_CREATED_TIMESTAMP] = \ str(model_file.remote_created_timestamp.timestamp()) if model_file.remote_created_timestamp else None json_dict[SerializeModel.__KEY_FILE_REMOTE_MODIFIED_TIMESTAMP] = \ str(model_file.remote_modified_timestamp.timestamp()) if model_file.remote_modified_timestamp else None json_dict[SerializeModel.__KEY_FILE_FULL_PATH] = model_file.full_path json_dict[SerializeModel.__KEY_FILE_CHILDREN] = list() for child in model_file.get_children(): json_dict[SerializeModel.__KEY_FILE_CHILDREN].append(SerializeModel.__model_file_to_json_dict(child)) return json_dict def model(self, model_files: List[ModelFile]) -> str: """ Serialize the model :return: """ model_json_list = [SerializeModel.__model_file_to_json_dict(f) for f in model_files] model_json = json.dumps(model_json_list) return self._sse_pack(event=SerializeModel.__EVENT_INIT, data=model_json) def update_event(self, event: UpdateEvent): model_file_json_dict = { SerializeModel.__KEY_UPDATE_OLD_FILE: SerializeModel.__model_file_to_json_dict(event.old_file) if event.old_file else None, SerializeModel.__KEY_UPDATE_NEW_FILE: SerializeModel.__model_file_to_json_dict(event.new_file) if event.new_file else None } model_file_json = json.dumps(model_file_json_dict) return self._sse_pack(event=SerializeModel.__EVENT_UPDATE[event.change], data=model_file_json) ================================================ FILE: src/python/web/serialize/serialize_status.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import json from .serialize import Serialize from common import Status class SerializeStatusJson: # Data keys __KEY_SERVER = "server" __KEY_SERVER_UP = "up" __KEY_SERVER_ERROR_MSG = "error_msg" __KEY_CONTROLLER = "controller" __KEY_CONTROLLER_LATEST_LOCAL_SCAN_TIME = "latest_local_scan_time" __KEY_CONTROLLER_LATEST_REMOTE_SCAN_TIME = "latest_remote_scan_time" __KEY_CONTROLLER_LATEST_REMOTE_SCAN_FAILED = "latest_remote_scan_failed" __KEY_CONTROLLER_LATEST_REMOTE_SCAN_ERROR = "latest_remote_scan_error" @staticmethod def status(status: Status) -> str: json_dict = dict() json_dict[SerializeStatusJson.__KEY_SERVER] = dict() json_dict[SerializeStatusJson.__KEY_SERVER][SerializeStatusJson.__KEY_SERVER_UP] = \ status.server.up json_dict[SerializeStatusJson.__KEY_SERVER][SerializeStatusJson.__KEY_SERVER_ERROR_MSG] = \ status.server.error_msg json_dict[SerializeStatusJson.__KEY_CONTROLLER] = dict() json_dict[SerializeStatusJson.__KEY_CONTROLLER][SerializeStatusJson.__KEY_CONTROLLER_LATEST_LOCAL_SCAN_TIME] = \ str(status.controller.latest_local_scan_time.timestamp()) \ if status.controller.latest_local_scan_time else None json_dict[SerializeStatusJson.__KEY_CONTROLLER][SerializeStatusJson.__KEY_CONTROLLER_LATEST_REMOTE_SCAN_TIME] = \ str(status.controller.latest_remote_scan_time.timestamp()) \ if status.controller.latest_remote_scan_time else None json_dict[SerializeStatusJson.__KEY_CONTROLLER][SerializeStatusJson.__KEY_CONTROLLER_LATEST_REMOTE_SCAN_FAILED] = \ status.controller.latest_remote_scan_failed json_dict[SerializeStatusJson.__KEY_CONTROLLER][SerializeStatusJson.__KEY_CONTROLLER_LATEST_REMOTE_SCAN_ERROR] = \ status.controller.latest_remote_scan_error status_json = json.dumps(json_dict) return status_json class SerializeStatus(Serialize): """ This class defines the serialization interface between python backend and the EventSource client frontend for the status stream. """ # Event keys __EVENT_STATUS = "status" def status(self, status: Status) -> str: status_json = SerializeStatusJson.status(status) return self._sse_pack(event=SerializeStatus.__EVENT_STATUS, data=status_json) ================================================ FILE: src/python/web/utils.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from queue import Queue, Empty from typing import TypeVar, Generic, Optional T = TypeVar('T') class StreamQueue(Generic[T]): """ A queue that transfers events from one thread to another. Useful for web streams that wait for listener events from other threads. The producer thread calls put() to insert events. The consumer stream calls get_next_event() to receive event in its own thread. """ def __init__(self): self.__queue = Queue() def put(self, event: T): self.__queue.put(event) def get_next_event(self) -> Optional[T]: """ Returns the next event if there is one, otherwise returns None :return: """ try: return self.__queue.get(block=False) except Empty: return None ================================================ FILE: src/python/web/web_app.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from typing import Type, Callable, Optional from abc import ABC, abstractmethod import time import bottle from bottle import static_file from common import Context from controller import Controller class IHandler(ABC): """ Abstract class that defines a web handler """ @abstractmethod def add_routes(self, web_app: "WebApp"): """ Add all the handled routes to the given web app :param web_app: :return: """ pass class IStreamHandler(ABC): """ Abstract class that defines a streaming data provider """ @abstractmethod def setup(self): pass @abstractmethod def get_value(self) -> Optional[str]: pass @abstractmethod def cleanup(self): pass @classmethod def register(cls, web_app: "WebApp", **kwargs): """ Register this streaming handler with the web app :param web_app: web_app instance :param kwargs: args for stream handler ctor :return: """ web_app.add_streaming_handler(cls, **kwargs) class WebApp(bottle.Bottle): """ Web app implementation """ _STREAM_POLL_INTERVAL_IN_MS = 100 def __init__(self, context: Context, controller: Controller): super().__init__() self.logger = context.logger.getChild("WebApp") self.__controller = controller self.__html_path = context.args.html_path self.__status = context.status self.logger.info("Html path set to: {}".format(self.__html_path)) self.__stop = False self.__streaming_handlers = [] # list of (handler, kwargs) pairs def add_default_routes(self): """ Add the default routes. This must be called after all the handlers have been added. :return: """ # Streaming route self.get("/server/stream")(self.__web_stream) # Front-end routes self.route("/")(self.__index) self.route("/dashboard")(self.__index) self.route("/settings")(self.__index) self.route("/autoqueue")(self.__index) self.route("/logs")(self.__index) self.route("/about")(self.__index) # For static files self.route("/")(self.__static) def add_handler(self, path: str, handler: Callable): self.get(path)(handler) def add_streaming_handler(self, handler: Type[IStreamHandler], **kwargs): self.__streaming_handlers.append((handler, kwargs)) def process(self): """ Advance the web app state :return: """ pass def stop(self): """ Exit gracefully, kill any connections and clean up any state :return: """ self.__stop = True def __index(self): """ Serves the index.html static file :return: """ return self.__static("index.html") # noinspection PyMethodMayBeStatic def __static(self, file_path: str): """ Serves all the static files :param file_path: :return: """ return static_file(file_path, root=self.__html_path) def __web_stream(self): # Initialize all the handlers handlers = [cls(**kwargs) for (cls, kwargs) in self.__streaming_handlers] try: # Setup the response header bottle.response.content_type = "text/event-stream" bottle.response.cache_control = "no-cache" # Call setup on all handlers for handler in handlers: handler.setup() # Get streaming values until the connection closes while not self.__stop: for handler in handlers: # Process all values from this handler while True: value = handler.get_value() if value: yield value else: break time.sleep(WebApp._STREAM_POLL_INTERVAL_IN_MS / 1000) finally: self.logger.debug("Stream connection stopped by {}".format( "server" if self.__stop else "client" )) # Cleanup all handlers for handler in handlers: handler.cleanup() ================================================ FILE: src/python/web/web_app_builder.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. from common import Context from controller import Controller, AutoQueuePersist from .web_app import WebApp from .handler.stream_model import ModelStreamHandler from .handler.stream_status import StatusStreamHandler from .handler.controller import ControllerHandler from .handler.server import ServerHandler from .handler.config import ConfigHandler from .handler.auto_queue import AutoQueueHandler from .handler.stream_log import LogStreamHandler from .handler.status import StatusHandler class WebAppBuilder: """ Helper class to build WebApp with all the extensions """ def __init__(self, context: Context, controller: Controller, auto_queue_persist: AutoQueuePersist): self.__context = context self.__controller = controller self.controller_handler = ControllerHandler(controller) self.server_handler = ServerHandler(context) self.config_handler = ConfigHandler(context.config) self.auto_queue_handler = AutoQueueHandler(auto_queue_persist) self.status_handler = StatusHandler(context.status) def build(self) -> WebApp: web_app = WebApp(context=self.__context, controller=self.__controller) StatusStreamHandler.register(web_app=web_app, status=self.__context.status) LogStreamHandler.register(web_app=web_app, logger=self.__context.logger) ModelStreamHandler.register(web_app=web_app, controller=self.__controller) self.controller_handler.add_routes(web_app) self.server_handler.add_routes(web_app) self.config_handler.add_routes(web_app) self.auto_queue_handler.add_routes(web_app) self.status_handler.add_routes(web_app) web_app.add_default_routes() return web_app ================================================ FILE: src/python/web/web_app_job.py ================================================ # Copyright 2017, Inderpreet Singh, All rights reserved. import logging from threading import Thread import bottle from paste import httpserver from paste.translogger import TransLogger from .web_app import WebApp from common import overrides, Job, Context class WebAppJob(Job): """ Web interface service :return: """ def __init__(self, context: Context, web_app: WebApp): super().__init__(name=self.__class__.__name__, context=context) self.web_access_logger = context.web_access_logger self.__context = context self.__app = web_app self.__server = None self.__server_thread = None @overrides(Job) def setup(self): # Note: do not use requestlogger.WSGILogger as it breaks SSE self.__server = MyWSGIRefServer(self.web_access_logger, host="0.0.0.0", port=self.__context.config.web.port) self.__server_thread = Thread(target=bottle.run, kwargs={ 'app': self.__app, 'server': self.__server, 'debug': self.__context.args.debug }) self.__server_thread.start() @overrides(Job) def execute(self): self.__app.process() @overrides(Job) def cleanup(self): self.__app.stop() self.__server.stop() self.__server_thread.join() class MyWSGIHandler(httpserver.WSGIHandler): """ This class is overridden to fix a bug in Paste http server """ # noinspection SpellCheckingInspection def wsgi_write_chunk(self, chunk): if type(chunk) is str: chunk = str.encode(chunk) super().wsgi_write_chunk(chunk) class MyWSGIRefServer(bottle.ServerAdapter): """ Extend bottle's default server to support programatic stopping of server Copied from: https://stackoverflow.com/a/16056443 """ quiet = True # disable logging to stdout def __init__(self, logger: logging.Logger, *args, **kwargs): super().__init__(*args, **kwargs) self.logger = logger self.server = None @overrides(bottle.ServerAdapter) def run(self, handler): self.logger.debug("Starting web server") handler = TransLogger(handler, logger=self.logger, setup_console_handler=(not self.quiet)) self.server = httpserver.serve(handler, host=self.host, port=str(self.port), start_loop=False, handler=MyWSGIHandler, **self.options) self.server.serve_forever() def stop(self): self.logger.debug("Stopping web server") self.server.server_close()