From 395f0e2029f4bbd4f719986b2f312e953f9b1e23 Mon Sep 17 00:00:00 2001 From: jason Date: Tue, 17 Mar 2026 19:23:33 -0500 Subject: [PATCH] feat: initial commit from workspace-mcp --- .dockerignore | 42 + .dxtignore | 67 + .env.oauth21 | 62 + .github/FUNDING.yml | 19 + .github/ISSUE_TEMPLATE/bug-report.md | 35 + .github/ISSUE_TEMPLATE/feature_request.md | 20 + .github/dependabot.yml | 11 + .github/pull_request_template.md | 31 + .github/workflows/check-maintainer-edits.yml | 54 + .github/workflows/docker-publish.yml | 66 + .github/workflows/publish-mcp-registry.yml | 106 + .github/workflows/ruff.yml | 45 + .gitignore | 39 + .python-version | 1 + Dockerfile | 45 + LICENSE | 21 + README.md | 1639 ++++++++++++ README_NEW.md | 473 ++++ SECURITY.md | 48 + auth/__init__.py | 1 + auth/auth_info_middleware.py | 378 +++ auth/credential_store.py | 266 ++ auth/external_oauth_provider.py | 188 ++ auth/google_auth.py | 1166 ++++++++ auth/mcp_session_middleware.py | 104 + auth/oauth21_session_store.py | 989 +++++++ auth/oauth_callback_server.py | 287 ++ auth/oauth_config.py | 444 +++ auth/oauth_responses.py | 229 ++ auth/oauth_types.py | 92 + auth/permissions.py | 277 ++ auth/scopes.py | 336 +++ auth/service_decorator.py | 862 ++++++ core/__init__.py | 1 + core/api_enablement.py | 108 + core/attachment_storage.py | 262 ++ core/cli_handler.py | 410 +++ core/comments.py | 305 +++ core/config.py | 37 + core/context.py | 43 + core/log_formatter.py | 207 ++ core/server.py | 620 +++++ core/tool_registry.py | 211 ++ core/tool_tier_loader.py | 196 ++ core/tool_tiers.yaml | 172 ++ core/utils.py | 493 ++++ docker-compose.yml | 16 + fastmcp.json | 21 + fastmcp_server.py | 180 ++ gappsscript/README.md | 514 ++++ gappsscript/TESTING.md | 254 ++ gappsscript/__init__.py | 0 gappsscript/apps_script_tools.py | 1284 +++++++++ gcalendar/__init__.py | 1 + gcalendar/calendar_tools.py | 1346 ++++++++++ gchat/__init__.py | 7 + gchat/chat_tools.py | 583 ++++ gcontacts/__init__.py | 1 + gcontacts/contacts_tools.py | 1052 ++++++++ gdocs/__init__.py | 0 gdocs/docs_helpers.py | 720 +++++ gdocs/docs_markdown.py | 344 +++ gdocs/docs_structure.py | 357 +++ gdocs/docs_tables.py | 464 ++++ gdocs/docs_tools.py | 1918 +++++++++++++ gdocs/managers/__init__.py | 18 + gdocs/managers/batch_operation_manager.py | 534 ++++ gdocs/managers/header_footer_manager.py | 339 +++ gdocs/managers/table_operation_manager.py | 405 +++ gdocs/managers/validation_manager.py | 727 +++++ gdrive/__init__.py | 0 gdrive/drive_helpers.py | 375 +++ gdrive/drive_tools.py | 2383 +++++++++++++++++ gforms/__init__.py | 3 + gforms/forms_tools.py | 487 ++++ glama.json | 4 + gmail/__init__.py | 1 + gmail/gmail_tools.py | 2376 ++++++++++++++++ google_workspace_mcp.dxt | Bin 0 -> 1495398 bytes gsearch/__init__.py | 0 gsearch/search_tools.py | 234 ++ gsheets/__init__.py | 23 + gsheets/sheets_helpers.py | 1050 ++++++++ gsheets/sheets_tools.py | 1205 +++++++++ gslides/__init__.py | 0 gslides/slides_tools.py | 330 +++ gtasks/__init__.py | 5 + gtasks/tasks_tools.py | 951 +++++++ helm-chart/workspace-mcp/Chart.yaml | 19 + helm-chart/workspace-mcp/README.md | 143 + helm-chart/workspace-mcp/templates/NOTES.txt | 55 + .../workspace-mcp/templates/_helpers.tpl | 62 + .../workspace-mcp/templates/configmap.yaml | 12 + .../workspace-mcp/templates/deployment.yaml | 132 + helm-chart/workspace-mcp/templates/hpa.yaml | 32 + .../workspace-mcp/templates/ingress.yaml | 59 + .../templates/poddisruptionbudget.yaml | 18 + .../workspace-mcp/templates/secret.yaml | 21 + .../workspace-mcp/templates/service.yaml | 15 + .../templates/serviceaccount.yaml | 12 + helm-chart/workspace-mcp/values.yaml | 149 ++ main.py | 579 ++++ manifest.json | 191 ++ pyproject.toml | 121 + server.json | 17 + smithery.yaml | 101 + tests/__init__.py | 0 ...test_google_auth_callback_refresh_token.py | 128 + tests/auth/test_google_auth_pkce.py | 118 + .../auth/test_google_auth_prompt_selection.py | 119 + tests/core/__init__.py | 0 tests/core/test_attachment_route.py | 69 + tests/core/test_comments.py | 112 + ...est_well_known_cache_control_middleware.py | 90 + tests/gappsscript/__init__.py | 0 tests/gappsscript/manual_test.py | 373 +++ tests/gappsscript/test_apps_script_tools.py | 432 +++ tests/gchat/__init__.py | 0 tests/gchat/test_chat_tools.py | 419 +++ tests/gcontacts/__init__.py | 1 + tests/gcontacts/test_contacts_tools.py | 339 +++ tests/gdocs/__init__.py | 0 tests/gdocs/test_docs_markdown.py | 455 ++++ tests/gdocs/test_paragraph_style.py | 139 + tests/gdrive/__init__.py | 1 + tests/gdrive/test_create_drive_folder.py | 147 + tests/gdrive/test_drive_tools.py | 970 +++++++ tests/gdrive/test_ssrf_protections.py | 165 ++ tests/gforms/__init__.py | 0 tests/gforms/test_forms_tools.py | 344 +++ tests/gmail/test_attachment_fix.py | 101 + tests/gmail/test_draft_gmail_message.py | 288 ++ tests/gsheets/__init__.py | 0 tests/gsheets/test_format_sheet_range.py | 436 +++ tests/test_main_permissions_tier.py | 60 + tests/test_permissions.py | 201 ++ tests/test_scopes.py | 231 ++ uv.lock | 2195 +++++++++++++++ 138 files changed, 41691 insertions(+) create mode 100644 .dockerignore create mode 100644 .dxtignore create mode 100644 .env.oauth21 create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE/bug-report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/check-maintainer-edits.yml create mode 100644 .github/workflows/docker-publish.yml create mode 100644 .github/workflows/publish-mcp-registry.yml create mode 100644 .github/workflows/ruff.yml create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 README_NEW.md create mode 100644 SECURITY.md create mode 100644 auth/__init__.py create mode 100644 auth/auth_info_middleware.py create mode 100644 auth/credential_store.py create mode 100644 auth/external_oauth_provider.py create mode 100644 auth/google_auth.py create mode 100644 auth/mcp_session_middleware.py create mode 100644 auth/oauth21_session_store.py create mode 100644 auth/oauth_callback_server.py create mode 100644 auth/oauth_config.py create mode 100644 auth/oauth_responses.py create mode 100644 auth/oauth_types.py create mode 100644 auth/permissions.py create mode 100644 auth/scopes.py create mode 100644 auth/service_decorator.py create mode 100644 core/__init__.py create mode 100644 core/api_enablement.py create mode 100644 core/attachment_storage.py create mode 100644 core/cli_handler.py create mode 100644 core/comments.py create mode 100644 core/config.py create mode 100644 core/context.py create mode 100644 core/log_formatter.py create mode 100644 core/server.py create mode 100644 core/tool_registry.py create mode 100644 core/tool_tier_loader.py create mode 100644 core/tool_tiers.yaml create mode 100644 core/utils.py create mode 100644 docker-compose.yml create mode 100644 fastmcp.json create mode 100644 fastmcp_server.py create mode 100644 gappsscript/README.md create mode 100644 gappsscript/TESTING.md create mode 100644 gappsscript/__init__.py create mode 100644 gappsscript/apps_script_tools.py create mode 100644 gcalendar/__init__.py create mode 100644 gcalendar/calendar_tools.py create mode 100644 gchat/__init__.py create mode 100644 gchat/chat_tools.py create mode 100644 gcontacts/__init__.py create mode 100644 gcontacts/contacts_tools.py create mode 100644 gdocs/__init__.py create mode 100644 gdocs/docs_helpers.py create mode 100644 gdocs/docs_markdown.py create mode 100644 gdocs/docs_structure.py create mode 100644 gdocs/docs_tables.py create mode 100644 gdocs/docs_tools.py create mode 100644 gdocs/managers/__init__.py create mode 100644 gdocs/managers/batch_operation_manager.py create mode 100644 gdocs/managers/header_footer_manager.py create mode 100644 gdocs/managers/table_operation_manager.py create mode 100644 gdocs/managers/validation_manager.py create mode 100644 gdrive/__init__.py create mode 100644 gdrive/drive_helpers.py create mode 100644 gdrive/drive_tools.py create mode 100644 gforms/__init__.py create mode 100644 gforms/forms_tools.py create mode 100644 glama.json create mode 100644 gmail/__init__.py create mode 100644 gmail/gmail_tools.py create mode 100644 google_workspace_mcp.dxt create mode 100644 gsearch/__init__.py create mode 100644 gsearch/search_tools.py create mode 100644 gsheets/__init__.py create mode 100644 gsheets/sheets_helpers.py create mode 100644 gsheets/sheets_tools.py create mode 100644 gslides/__init__.py create mode 100644 gslides/slides_tools.py create mode 100644 gtasks/__init__.py create mode 100644 gtasks/tasks_tools.py create mode 100644 helm-chart/workspace-mcp/Chart.yaml create mode 100644 helm-chart/workspace-mcp/README.md create mode 100644 helm-chart/workspace-mcp/templates/NOTES.txt create mode 100644 helm-chart/workspace-mcp/templates/_helpers.tpl create mode 100644 helm-chart/workspace-mcp/templates/configmap.yaml create mode 100644 helm-chart/workspace-mcp/templates/deployment.yaml create mode 100644 helm-chart/workspace-mcp/templates/hpa.yaml create mode 100644 helm-chart/workspace-mcp/templates/ingress.yaml create mode 100644 helm-chart/workspace-mcp/templates/poddisruptionbudget.yaml create mode 100644 helm-chart/workspace-mcp/templates/secret.yaml create mode 100644 helm-chart/workspace-mcp/templates/service.yaml create mode 100644 helm-chart/workspace-mcp/templates/serviceaccount.yaml create mode 100644 helm-chart/workspace-mcp/values.yaml create mode 100644 main.py create mode 100644 manifest.json create mode 100644 pyproject.toml create mode 100644 server.json create mode 100644 smithery.yaml create mode 100644 tests/__init__.py create mode 100644 tests/auth/test_google_auth_callback_refresh_token.py create mode 100644 tests/auth/test_google_auth_pkce.py create mode 100644 tests/auth/test_google_auth_prompt_selection.py create mode 100644 tests/core/__init__.py create mode 100644 tests/core/test_attachment_route.py create mode 100644 tests/core/test_comments.py create mode 100644 tests/core/test_well_known_cache_control_middleware.py create mode 100644 tests/gappsscript/__init__.py create mode 100644 tests/gappsscript/manual_test.py create mode 100644 tests/gappsscript/test_apps_script_tools.py create mode 100644 tests/gchat/__init__.py create mode 100644 tests/gchat/test_chat_tools.py create mode 100644 tests/gcontacts/__init__.py create mode 100644 tests/gcontacts/test_contacts_tools.py create mode 100644 tests/gdocs/__init__.py create mode 100644 tests/gdocs/test_docs_markdown.py create mode 100644 tests/gdocs/test_paragraph_style.py create mode 100644 tests/gdrive/__init__.py create mode 100644 tests/gdrive/test_create_drive_folder.py create mode 100644 tests/gdrive/test_drive_tools.py create mode 100644 tests/gdrive/test_ssrf_protections.py create mode 100644 tests/gforms/__init__.py create mode 100644 tests/gforms/test_forms_tools.py create mode 100644 tests/gmail/test_attachment_fix.py create mode 100644 tests/gmail/test_draft_gmail_message.py create mode 100644 tests/gsheets/__init__.py create mode 100644 tests/gsheets/test_format_sheet_range.py create mode 100644 tests/test_main_permissions_tier.py create mode 100644 tests/test_permissions.py create mode 100644 tests/test_scopes.py create mode 100644 uv.lock diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..250863f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,42 @@ +# Git and version control +.git +.gitignore +gitdiff.txt + +# Documentation and notes +*.md +AUTHENTICATION_REFACTOR_PROPOSAL.md +leverage_fastmcp_responses.md + +# Test files and coverage +tests/ +htmlcov/ +.coverage +pytest_out.txt + +# Build artifacts +build/ +dist/ +*.egg-info/ + +# Development files +mcp_server_debug.log +.credentials/ + +# Cache and temporary files +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +.pytest_cache/ + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/.dxtignore b/.dxtignore new file mode 100644 index 0000000..92c2268 --- /dev/null +++ b/.dxtignore @@ -0,0 +1,67 @@ +# ============================================================================= +# .dxtignore — defense-in-depth denylist for dxt pack +# +# IMPORTANT: Always use dxt-safe-pack.sh instead of bare `dxt pack`. +# The script guarantees only git-tracked files are packaged. +# This file exists as a safety net in case someone runs `dxt pack` directly. +# ============================================================================= + +# ---- Caches ---------------------------------------------------------------- +.mypy_cache +__pycache__ +*.py[cod] +*.so +.pytest_cache +.ruff_cache + +# ---- Build / packaging ----------------------------------------------------- +*.egg-info +build/ +dist/ + +# ---- Environments & tooling ------------------------------------------------ +.env +.venv +venv/ +.idea/ +.vscode/ +.claude/ +.serena/ +node_modules/ + +# ---- macOS ----------------------------------------------------------------- +.DS_Store + +# ---- Secrets & credentials — CRITICAL -------------------------------------- +client_secret.json +.credentials +credentials.json +token.pickle +*_token +*_secret +.mcpregistry_* +*.key +*.pem +*.p12 +*.crt +*.der + +# ---- Test & debug ----------------------------------------------------------- +.coverage +pytest_out.txt +mcp_server_debug.log +diff_output.txt + +# ---- Temp & editor files ---------------------------------------------------- +*.tmp +*.log +*.pid +*.swp +*.swo +*~ + +# ---- Development artifacts not for distribution ----------------------------- +scripts/ +.beads +.github/ +tests/ diff --git a/.env.oauth21 b/.env.oauth21 new file mode 100644 index 0000000..a7d4927 --- /dev/null +++ b/.env.oauth21 @@ -0,0 +1,62 @@ +# OAuth 2.1 Configuration Example +# Copy this to .env and update with your Google OAuth credentials + +# Required: Google OAuth 2.0 Client Credentials +# Note: OAuth 2.1 will automatically use GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET +# if OAUTH2_CLIENT_ID and OAUTH2_CLIENT_SECRET are not set + +GOOGLE_OAUTH_CLIENT_ID="your-google-client-id" +GOOGLE_OAUTH_CLIENT_SECRET="your-google-client-secret" + +# Development Settings (set to true for localhost testing) +OAUTH2_ALLOW_INSECURE_TRANSPORT=false +OAUTH2_ENABLE_DEBUG=false + +# Legacy Compatibility (recommended during migration) +OAUTH2_ENABLE_LEGACY_AUTH=true + +# --------------------------------------------------------------------------- +# FastMCP OAuth Proxy Storage Backends (OAuth 2.1) +# +# Storage backend for OAuth proxy state. Options: memory, disk, valkey +# Default: FastMCP's built-in default (disk on Mac/Windows, memory on Linux) +# +# WORKSPACE_MCP_OAUTH_PROXY_STORAGE_BACKEND=memory|disk|valkey +# +# --------------------------------------------------------------------------- +# Memory Storage (default on Linux) +# - Fast, no persistence, data lost on restart +# - Best for: development, testing, stateless deployments +# +# WORKSPACE_MCP_OAUTH_PROXY_STORAGE_BACKEND=memory +# +# --------------------------------------------------------------------------- +# Disk Storage (default on Mac/Windows) +# - Persists across restarts, single-server only +# - Best for: single-server production, persistent caching +# +# WORKSPACE_MCP_OAUTH_PROXY_STORAGE_BACKEND=disk +# WORKSPACE_MCP_OAUTH_PROXY_DISK_DIRECTORY=~/.fastmcp/oauth-proxy +# +# --------------------------------------------------------------------------- +# Valkey/Redis Storage +# - Distributed, multi-server support +# - Best for: production, multi-server deployments, cloud native +# +# WORKSPACE_MCP_OAUTH_PROXY_STORAGE_BACKEND=valkey +# WORKSPACE_MCP_OAUTH_PROXY_VALKEY_HOST=localhost +# WORKSPACE_MCP_OAUTH_PROXY_VALKEY_PORT=6379 +# WORKSPACE_MCP_OAUTH_PROXY_VALKEY_USE_TLS=false +# WORKSPACE_MCP_OAUTH_PROXY_VALKEY_DB=0 +# WORKSPACE_MCP_OAUTH_PROXY_VALKEY_USERNAME= +# WORKSPACE_MCP_OAUTH_PROXY_VALKEY_PASSWORD= +# WORKSPACE_MCP_OAUTH_PROXY_VALKEY_REQUEST_TIMEOUT_MS=5000 +# WORKSPACE_MCP_OAUTH_PROXY_VALKEY_CONNECTION_TIMEOUT_MS=10000 +# +# --------------------------------------------------------------------------- +# Encryption: +# - Disk and Valkey storage are encrypted with Fernet. +# - Key derived from FASTMCP_SERVER_AUTH_GOOGLE_JWT_SIGNING_KEY if set, +# otherwise from GOOGLE_OAUTH_CLIENT_SECRET. +# - For stable decryption across client-secret rotations, set +# FASTMCP_SERVER_AUTH_GOOGLE_JWT_SIGNING_KEY explicitly. diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..a558a66 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,19 @@ +# .github/FUNDING.yml +github: taylorwilsdon + +# --- Optional platforms (one value per platform) --- +# patreon: REPLACE_ME +# open_collective: REPLACE_ME +# ko_fi: REPLACE_ME +# liberapay: REPLACE_ME +# issuehunt: REPLACE_ME +# polar: REPLACE_ME +# buy_me_a_coffee: REPLACE_ME +# thanks_dev: u/gh/REPLACE_ME_GITHUB_USERNAME + +# Tidelift uses platform/package (npm, pypi, rubygems, maven, packagist, nuget) +# tidelift: pypi/REPLACE_ME_PACKAGE_NAME + +# Up to 4 custom URLs (wrap in quotes if they contain :) +# Good pattern: link to a SUPPORT.md that describes how to sponsor, or your donation page. +# custom: ["https://REPLACE_ME_DOMAIN/sponsor", "https://github.com/REPLACE_ME_OWNER/REPLACE_ME_REPO/blob/main/SUPPORT.md"] diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 0000000..77bc1f0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,35 @@ +--- +name: Bug Report +about: Create a report to help us improve Google Workspace MCP +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**Startup Logs** +Include the startup output including everything from the Active Configuration section to "Uvicorn running" + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Platform (please complete the following information):** + - OS: [e.g. macOS, Ubuntu, Windows] +- Container: [if applicable, e.g. Docker) + - Version [e.g. v1.2.0] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5990d9c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..dc68863 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,31 @@ +## Description +Brief description of the changes in this PR. + +## Type of Change +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update + +## Testing +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes +- [ ] I have tested this change manually + +## Checklist +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my own code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] My changes generate no new warnings +- [ ] **I have enabled "Allow edits from maintainers" for this pull request** + +## Additional Notes +Add any other context about the pull request here. + +--- + +**⚠️ IMPORTANT:** This repository requires that you enable "Allow edits from maintainers" when creating your pull request. This allows maintainers to make small fixes and improvements directly to your branch, speeding up the review process. + +To enable this setting: +1. When creating the PR, check the "Allow edits from maintainers" checkbox +2. If you've already created the PR, you can enable this in the PR sidebar under "Allow edits from maintainers" \ No newline at end of file diff --git a/.github/workflows/check-maintainer-edits.yml b/.github/workflows/check-maintainer-edits.yml new file mode 100644 index 0000000..525c530 --- /dev/null +++ b/.github/workflows/check-maintainer-edits.yml @@ -0,0 +1,54 @@ +name: Check Maintainer Edits Enabled + +on: + pull_request: + types: [opened, synchronize, reopened, edited] + +permissions: + pull-requests: read + issues: write + +jobs: + check-maintainer-edits: + runs-on: ubuntu-latest + if: github.event.pull_request.head.repo.fork == true || github.event.pull_request.head.repo.full_name != github.repository + + steps: + - name: Check if maintainer edits are enabled + uses: actions/github-script@v7 + with: + script: | + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number + }); + + if (!pr.maintainer_can_modify) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: '⚠️ **Maintainer edits not enabled**\n\n' + + 'This repository requires that you enable "Allow edits from maintainers" for your pull request. This allows maintainers to make small fixes and improvements directly to your branch, which speeds up the review process.\n\n' + + '**To enable this setting:**\n' + + '1. Go to your pull request page\n' + + '2. In the right sidebar, look for "Allow edits from maintainers"\n' + + '3. Check the checkbox to enable it\n\n' + + 'Once you\'ve enabled this setting, this check will automatically pass. Thank you! 🙏' + }); + + core.setFailed('Maintainer edits must be enabled for this pull request'); + } else { + console.log('✅ Maintainer edits are enabled'); + } + + check-maintainer-edits-internal: + runs-on: ubuntu-latest + if: github.event.pull_request.head.repo.fork == false && github.event.pull_request.head.repo.full_name == github.repository + + steps: + - name: Skip check for internal PRs + run: | + echo "✅ Skipping maintainer edits check for internal pull request" + echo "This check only applies to external contributors and forks" \ No newline at end of file diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..b457075 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,66 @@ +name: Docker Build and Push to GHCR + +on: + push: + branches: + - main + tags: + - 'v*.*.*' + pull_request: + branches: + - main + workflow_dispatch: + +permissions: {} + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha,prefix=sha- + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/publish-mcp-registry.yml b/.github/workflows/publish-mcp-registry.yml new file mode 100644 index 0000000..c70f5d2 --- /dev/null +++ b/.github/workflows/publish-mcp-registry.yml @@ -0,0 +1,106 @@ +name: Publish PyPI + MCP Registry + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: {} + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Resolve version from tag + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" + + - name: Verify tag matches pyproject version + run: | + PYPROJECT_VERSION="$(python - <<'PY' + import tomllib + with open("pyproject.toml", "rb") as f: + data = tomllib.load(f) + print(data["project"]["version"]) + PY + )" + if [ "$PYPROJECT_VERSION" != "$VERSION" ]; then + echo "Tag version ($VERSION) does not match pyproject version ($PYPROJECT_VERSION)." + exit 1 + fi + + - name: Sync server.json version with release + run: | + tmp="$(mktemp)" + jq --arg version "$VERSION" ' + .version = $version + | .packages = ( + (.packages // []) + | map( + if ((.registryType // .registry_type // "") == "pypi") + then .version = $version + else . + end + ) + ) + ' server.json > "$tmp" + mv "$tmp" server.json + + - name: Validate server.json against schema + run: | + python -m pip install --upgrade pip + python -m pip install jsonschema requests + python - <<'PY' + import json + import requests + from jsonschema import Draft202012Validator + + with open("server.json", "r", encoding="utf-8") as f: + instance = json.load(f) + + schema_url = instance["$schema"] + schema = requests.get(schema_url, timeout=30).json() + + Draft202012Validator.check_schema(schema) + Draft202012Validator(schema).validate(instance) + print("server.json schema validation passed") + PY + + - name: Build distribution + run: | + python -m pip install build + python -m build + + - name: Check package metadata + run: | + python -m pip install twine + twine check dist/* + + - name: Publish package to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + skip-existing: true + + - name: Install mcp-publisher + run: | + OS="$(uname -s | tr '[:upper:]' '[:lower:]')" + ARCH="$(uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/')" + curl -fsSL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_${OS}_${ARCH}.tar.gz" | tar xz mcp-publisher + chmod +x mcp-publisher + + - name: Login to MCP Registry with GitHub OIDC + run: ./mcp-publisher login github-oidc + + - name: Publish server to MCP Registry + run: ./mcp-publisher publish diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml new file mode 100644 index 0000000..d4e7cb2 --- /dev/null +++ b/.github/workflows/ruff.yml @@ -0,0 +1,45 @@ +name: Ruff + +on: + pull_request: + branches: [ main ] + push: + branches: [ main ] + +permissions: + contents: write + +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.ref || github.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + token: ${{ secrets.GITHUB_TOKEN }} + - uses: actions/setup-python@v6 + with: + python-version: '3.11' + - name: Install uv + uses: astral-sh/setup-uv@v7 + - name: Install dependencies + run: uv sync + - name: Auto-fix ruff lint and format + if: github.event_name == 'pull_request' + run: | + uv run ruff check --fix + uv run ruff format + - name: Commit and push fixes + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + run: | + git diff --quiet && exit 0 + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "style: auto-fix ruff lint and format" + git push + - name: Validate + run: | + uv run ruff check + uv run ruff format --check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ee5566 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# ---- Python artefacts -------------------------------------------------- +__pycache__/ +*.py[cod] +*.so +.mcp.json +claude.md +.beads/* +.beads/issues.jsonl + +# ---- Packaging --------------------------------------------------------- +*.egg-info/ +build/ +dist/ + +# ---- Environments & tooling ------------------------------------------- +.env +.venv/ +venv/ +.idea/ +.vscode/ + +# ---- macOS clutter ----------------------------------------------------- +.DS_Store + +# ---- Secrets ----------------------------------------------------------- +client_secret.json + +# ---- Logs -------------------------------------------------------------- +mcp_server_debug.log + +# ---- Local development files ------------------------------------------- +/.credentials +/.claude +.serena/ +Caddyfile +ecosystem.config.cjs + +# ---- Agent instructions (not for distribution) ------------------------- +.github/instructions/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..59a1fe4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Install uv for faster dependency management +RUN pip install --no-cache-dir uv + +COPY . . + +# Install Python dependencies using uv sync +RUN uv sync --frozen --no-dev --extra disk + +# Create non-root user for security +RUN useradd --create-home --shell /bin/bash app \ + && chown -R app:app /app + +# Give read and write access to the store_creds volume +RUN mkdir -p /app/store_creds \ + && chown -R app:app /app/store_creds \ + && chmod 755 /app/store_creds + +USER app + +# Expose port (use default of 8000 if PORT not set) +EXPOSE 8000 +# Expose additional port if PORT environment variable is set to a different value +ARG PORT +EXPOSE ${PORT:-8000} + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD sh -c 'curl -f http://localhost:${PORT:-8000}/health || exit 1' + +# Set environment variables for Python startup args +ENV TOOL_TIER="" +ENV TOOLS="" + +# Use entrypoint for the base command and CMD for args +ENTRYPOINT ["/bin/sh", "-c"] +CMD ["uv run main.py --transport streamable-http ${TOOL_TIER:+--tool-tier \"$TOOL_TIER\"} ${TOOLS:+--tools $TOOLS}"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bc5b15a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Taylor Wilsdon + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..32a12a8 --- /dev/null +++ b/README.md @@ -0,0 +1,1639 @@ + + +
+ +# Google Workspace MCP Server + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Python 3.10+](https://img.shields.io/badge/Python-3.10%2B-blue.svg)](https://www.python.org/downloads/) +[![PyPI](https://img.shields.io/pypi/v/workspace-mcp.svg)](https://pypi.org/project/workspace-mcp/) +[![PyPI Downloads](https://static.pepy.tech/personalized-badge/workspace-mcp?period=total&units=INTERNATIONAL_SYSTEM&left_color=BLACK&right_color=BLUE&left_text=downloads)](https://pepy.tech/projects/workspace-mcp) +[![Website](https://img.shields.io/badge/Website-workspacemcp.com-green.svg)](https://workspacemcp.com) + +*Full natural language control over Google Calendar, Drive, Gmail, Docs, Sheets, Slides, Forms, Tasks, Contacts, and Chat through all MCP clients, AI assistants and developer tools. Includes a full featured CLI for use with tools like Claude Code and Codex!* + +**The most feature-complete Google Workspace MCP server**, with Remote OAuth2.1 multi-user support and 1-click Claude installation. With native OAuth 2.1, stateless mode and external auth server support, it's the only Workspace MCP you can host for your whole organization centrally & securely! + +###### Support for all free Google accounts (Gmail, Docs, Drive etc) & Google Workspace plans (Starter, Standard, Plus, Enterprise, Non Profit) with expanded app options like Chat & Spaces.

Interested in a private, managed cloud instance? [That can be arranged.](https://workspacemcp.com/workspace-mcp-cloud) + + +
+ +
+ + + +
+ +--- + + +**See it in action:** +
+ +
+ +--- + +### A quick plug for AI-Enhanced Docs +
+But why? + +**This README was written with AI assistance, and here's why that matters** +> +> As a solo dev building open source tools, comprehensive documentation often wouldn't happen without AI help. Using agentic dev tools like **Roo** & **Claude Code** that understand the entire codebase, AI doesn't just regurgitate generic content - it extracts real implementation details and creates accurate, specific documentation. +> +> In this case, Sonnet 4 took a pass & a human (me) verified them 2/16/26. +
+ +## Overview + +A production-ready MCP server that integrates all major Google Workspace services with AI assistants. It supports both single-user operation and multi-user authentication via OAuth 2.1, making it a powerful backend for custom applications. Built with FastMCP for optimal performance, featuring advanced authentication handling, service caching, and streamlined development patterns. + +**Simplified Setup**: Now uses Google Desktop OAuth clients - no redirect URIs or port configuration needed! + + +## Features + + + + + + +
+ +**@ Gmail** • ** Drive** • ** Calendar** ** Docs** +- Complete Gmail management, end-to-end coverage +- Full calendar management with advanced features +- File operations with Office format support +- Document creation, editing & comments +- Deep, exhaustive support for fine-grained editing + +--- + +** Forms** • **@ Chat** • ** Sheets** • ** Slides** +- Form creation, publish settings & response management +- Space management & messaging capabilities +- Spreadsheet operations with flexible cell management +- Presentation creation, updates & content manipulation + +--- + +** Apps Script** +- Automate cross-application workflows with custom code +- Execute existing business logic and custom functions +- Manage script projects, deployments & versions +- Debug and modify Apps Script code programmatically +- Bridge Google Workspace services through automation + + + +** Authentication & Security** +- Advanced OAuth 2.0 & OAuth 2.1 support +- Automatic token refresh & session management +- Transport-aware callback handling +- Multi-user bearer token authentication +- Innovative CORS proxy architecture + +--- + +** Tasks** • **👤 Contacts** • ** Custom Search** +- Task & task list management with hierarchy +- Contact management via People API with groups +- Programmable Search Engine (PSE) integration + +
+ +--- + +## Quick Start + +
+Quick Reference Card - Essential commands & configs at a glance + + + +
+ +**Credentials** +```bash +export GOOGLE_OAUTH_CLIENT_ID="..." +export GOOGLE_OAUTH_CLIENT_SECRET="..." +``` +[Full setup →](#credential-configuration) + + + +**Launch Commands** +```bash +uvx workspace-mcp --tool-tier core +uv run main.py --tools gmail drive +``` +[More options →](#start-the-server) + + + +**Tool Tiers** +- `core` - Essential tools +- `extended` - Core + extras +- `complete` - Everything +[Details →](#tool-tiers) + +
+ +
+ + + +#### Required Configuration +
+Environment Variables ← Click to configure in Claude Desktop + + + +
+ +**Required** +| Variable | Purpose | +|----------|---------| +| `GOOGLE_OAUTH_CLIENT_ID` | OAuth client ID from Google Cloud | +| `GOOGLE_OAUTH_CLIENT_SECRET` | OAuth client secret | +| `OAUTHLIB_INSECURE_TRANSPORT=1` | Development only (allows `http://` redirect) | + + + +**Optional** +| Variable | Purpose | +|----------|---------| +| `USER_GOOGLE_EMAIL` | Default email for single-user auth | +| `GOOGLE_PSE_API_KEY` | API key for Custom Search | +| `GOOGLE_PSE_ENGINE_ID` | Search Engine ID for Custom Search | +| `MCP_ENABLE_OAUTH21` | Set to `true` for OAuth 2.1 support | +| `EXTERNAL_OAUTH21_PROVIDER` | Set to `true` for external OAuth flow with bearer tokens (requires OAuth 2.1) | +| `WORKSPACE_MCP_STATELESS_MODE` | Set to `true` for stateless operation (requires OAuth 2.1) | + +
+ +Claude Desktop stores these securely in the OS keychain; set them once in the extension pane. +
+ +--- + +### One-Click Claude Desktop Install (Claude Desktop Only, Stdio, Single User) + +1. **Download:** Grab the latest `google_workspace_mcp.dxt` from the “Releases” page +2. **Install:** Double-click the file – Claude Desktop opens and prompts you to **Install** +3. **Configure:** In Claude Desktop → **Settings → Extensions → Google Workspace MCP**, paste your Google OAuth credentials +4. **Use it:** Start a new Claude chat and call any Google Workspace tool + +> +**Why DXT?** +> Desktop Extensions (`.dxt`) bundle the server, dependencies, and manifest so users go from download → working MCP in **one click** – no terminal, no JSON editing, no version conflicts. + +
+ +
+ +--- + +### Prerequisites + +- **Python 3.10+** +- **[uvx](https://github.com/astral-sh/uv)** (for instant installation) or [uv](https://github.com/astral-sh/uv) (for development) +- **Google Cloud Project** with OAuth 2.0 credentials + +### Configuration + +
+Google Cloud Setup ← OAuth 2.0 credentials & API enablement + + + + + + + + + + +
+ +**1. Create Project** +```text +console.cloud.google.com + +→ Create new project +→ Note project name +``` +[Open Console →](https://console.cloud.google.com/) + + + +**2. OAuth Credentials** +```text +APIs & Services → Credentials +→ Create Credentials +→ OAuth Client ID +→ Desktop Application +``` +Download & save credentials + + + +**3. Enable APIs** +```text +APIs & Services → Library + +Search & enable: +Calendar, Drive, Gmail, +Docs, Sheets, Slides, +Forms, Tasks, People, +Chat, Search +``` +See quick links below + +
+ +
+OAuth Credential Setup Guide ← Step-by-step instructions + +**Complete Setup Process:** + +1. **Create OAuth 2.0 Credentials** - Visit [Google Cloud Console](https://console.cloud.google.com/) + - Create a new project (or use existing) + - Navigate to **APIs & Services → Credentials** + - Click **Create Credentials → OAuth Client ID** + - Choose **Desktop Application** as the application type (no redirect URIs needed!) + - Download credentials and note the Client ID and Client Secret + +2. **Enable Required APIs** - In **APIs & Services → Library** + - Search for and enable each required API + - Or use the quick links below for one-click enabling + +3. **Configure Environment** - Set your credentials: + ```bash + export GOOGLE_OAUTH_CLIENT_ID="your-client-id" + export GOOGLE_OAUTH_CLIENT_SECRET="your-secret" + ``` + +[Full Documentation →](https://developers.google.com/workspace/guides/auth-overview) + +
+ +
+ +
+ Quick API Enable Links ← One-click enable each Google API + You can enable each one by clicking the links below (make sure you're logged into the Google Cloud Console and have the correct project selected): + +* [Enable Google Calendar API](https://console.cloud.google.com/flows/enableapi?apiid=calendar-json.googleapis.com) +* [Enable Google Drive API](https://console.cloud.google.com/flows/enableapi?apiid=drive.googleapis.com) +* [Enable Gmail API](https://console.cloud.google.com/flows/enableapi?apiid=gmail.googleapis.com) +* [Enable Google Docs API](https://console.cloud.google.com/flows/enableapi?apiid=docs.googleapis.com) +* [Enable Google Sheets API](https://console.cloud.google.com/flows/enableapi?apiid=sheets.googleapis.com) +* [Enable Google Slides API](https://console.cloud.google.com/flows/enableapi?apiid=slides.googleapis.com) +* [Enable Google Forms API](https://console.cloud.google.com/flows/enableapi?apiid=forms.googleapis.com) +* [Enable Google Tasks API](https://console.cloud.google.com/flows/enableapi?apiid=tasks.googleapis.com) +* [Enable Google Chat API](https://console.cloud.google.com/flows/enableapi?apiid=chat.googleapis.com) +* [Enable Google People API](https://console.cloud.google.com/flows/enableapi?apiid=people.googleapis.com) +* [Enable Google Custom Search API](https://console.cloud.google.com/flows/enableapi?apiid=customsearch.googleapis.com) +* [Enable Google Apps Script API](https://console.cloud.google.com/flows/enableapi?apiid=script.googleapis.com) + +
+ +
+ +1.1. **Credentials**: See [Credential Configuration](#credential-configuration) for detailed setup options + +2. **Environment Configuration**: + +
+Environment Variables ← Configure your runtime environment + + + + + + + +
+ +**◆ Development Mode** +```bash +export OAUTHLIB_INSECURE_TRANSPORT=1 +``` +Allows HTTP redirect URIs + + + +**@ Default User** +```bash +export USER_GOOGLE_EMAIL=\ + your.email@gmail.com +``` +Single-user authentication + + + +**◆ Custom Search** +```bash +export GOOGLE_PSE_API_KEY=xxx +export GOOGLE_PSE_ENGINE_ID=yyy +``` +Optional: Search API setup + +
+ +
+ +3. **Server Configuration**: + +
+Server Settings ← Customize ports, URIs & proxies + + + + + + + +
+ +**◆ Base Configuration** +```bash +export WORKSPACE_MCP_BASE_URI= + http://localhost +export WORKSPACE_MCP_PORT=8000 +export WORKSPACE_MCP_HOST=0.0.0.0 # Use 127.0.0.1 for localhost-only +``` +Server URL & port settings + + + +**↻ Proxy Support** +```bash +export MCP_ENABLE_OAUTH21= + true +``` +Leverage multi-user OAuth2.1 clients + + + +**@ Default Email** +```bash +export USER_GOOGLE_EMAIL=\ + your.email@gmail.com +``` +Skip email in auth flows in single user mode + +
+ +
+Configuration Details ← Learn more about each setting + +| Variable | Description | Default | +|----------|-------------|---------| +| `WORKSPACE_MCP_BASE_URI` | Base server URI (no port) | `http://localhost` | +| `WORKSPACE_MCP_PORT` | Server listening port | `8000` | +| `WORKSPACE_MCP_HOST` | Server bind host | `0.0.0.0` | +| `WORKSPACE_EXTERNAL_URL` | External URL for reverse proxy setups | None | +| `WORKSPACE_ATTACHMENT_DIR` | Directory for downloaded attachments | `~/.workspace-mcp/attachments/` | +| `GOOGLE_OAUTH_REDIRECT_URI` | Override OAuth callback URL | Auto-constructed | +| `USER_GOOGLE_EMAIL` | Default auth email | None | + +
+ +
+ +### Google Custom Search Setup + +
+Custom Search Configuration ← Enable web search capabilities + + + + + + + + + + +
+ +**1. Create Search Engine** +```text +programmablesearchengine.google.com +/controlpanel/create + +→ Configure sites or entire web +→ Note your Engine ID (cx) +``` +[Open Control Panel →](https://programmablesearchengine.google.com/controlpanel/create) + + + +**2. Get API Key** +```text +developers.google.com +/custom-search/v1/overview + +→ Create/select project +→ Enable Custom Search API +→ Create credentials (API Key) +``` +[Get API Key →](https://developers.google.com/custom-search/v1/overview) + + + +**3. Set Variables** +```bash +export GOOGLE_PSE_API_KEY=\ + "your-api-key" +export GOOGLE_PSE_ENGINE_ID=\ + "your-engine-id" +``` +Configure in environment + +
+ +
+Quick Setup Guide ← Step-by-step instructions + +**Complete Setup Process:** + +1. **Create Search Engine** - Visit the [Control Panel](https://programmablesearchengine.google.com/controlpanel/create) + - Choose "Search the entire web" or specify sites + - Copy the Search Engine ID (looks like: `017643444788157684527:6ivsjbpxpqw`) + +2. **Enable API & Get Key** - Visit [Google Developers Console](https://console.cloud.google.com/) + - Enable "Custom Search API" in your project + - Create credentials → API Key + - Restrict key to Custom Search API (recommended) + +3. **Configure Environment** - Add to your shell or `.env`: + ```bash + export GOOGLE_PSE_API_KEY="AIzaSy..." + export GOOGLE_PSE_ENGINE_ID="01764344478..." + ``` + +≡ [Full Documentation →](https://developers.google.com/custom-search/v1/overview) + +
+ +
+ +
+ +### Start the Server + +> **📌 Transport Mode Guidance**: Use **streamable HTTP mode** (`--transport streamable-http`) for all modern MCP clients including Claude Code, VS Code MCP, and MCP Inspector. Stdio mode is only for clients with incomplete MCP specification support. + +
+Launch Commands ← Choose your startup mode + + + + + + + + + + +
+ +**▶ Legacy Mode** +```bash +uv run main.py +``` +⚠️ Stdio mode (incomplete MCP clients only) + + + +**◆ HTTP Mode (Recommended)** +```bash +uv run main.py \ + --transport streamable-http +``` +✅ Full MCP spec compliance & OAuth 2.1 + + + +**@ Single User** +```bash +uv run main.py \ + --single-user +``` +Simplified authentication +⚠️ Cannot be used with OAuth 2.1 mode + +
+ +
+Advanced Options ← Tool selection, tiers & Docker + +**▶ Selective Tool Loading** +```bash +# Load specific services only +uv run main.py --tools gmail drive calendar +uv run main.py --tools sheets docs + +# Combine with other flags +uv run main.py --single-user --tools gmail +``` + + +**🔒 Read-Only Mode** +```bash +# Requests only read-only scopes & disables write tools +uv run main.py --read-only + +# Combine with specific tools or tiers +uv run main.py --tools gmail drive --read-only +uv run main.py --tool-tier core --read-only +``` +Read-only mode provides secure, restricted access by: +- Requesting only `*.readonly` OAuth scopes (e.g., `gmail.readonly`, `drive.readonly`) +- Automatically filtering out tools that require write permissions at startup +- Allowing read operations: list, get, search, and export across all services + +**🔐 Granular Permissions** +```bash +# Per-service permission levels +uv run main.py --permissions gmail:organize drive:readonly + +# Combine permissions with tier filtering +uv run main.py --permissions gmail:send drive:full --tool-tier core +``` +Granular permissions mode provides service-by-service scope control: +- Format: `service:level` (one entry per service) +- Gmail levels: `readonly`, `organize`, `drafts`, `send`, `full` (cumulative) +- Tasks levels: `readonly`, `manage`, `full` (cumulative; `manage` allows create/update/move but denies `delete` and `clear_completed`) +- Other services currently support: `readonly`, `full` +- `--permissions` and `--read-only` are mutually exclusive +- `--permissions` cannot be combined with `--tools`; enabled services are determined by the `--permissions` entries (optionally filtered by `--tool-tier`) +- With `--tool-tier`, only tier-matched tools are enabled and only services that have tools in the selected tier are imported + +**★ Tool Tiers** +```bash +uv run main.py --tool-tier core # ● Essential tools only +uv run main.py --tool-tier extended # ◐ Core + additional +uv run main.py --tool-tier complete # ○ All available tools +``` + +**◆ Docker Deployment** +```bash +docker build -t workspace-mcp . +docker run -p 8000:8000 -v $(pwd):/app \ + workspace-mcp --transport streamable-http + +# With tool selection via environment variables +docker run -e TOOL_TIER=core workspace-mcp +docker run -e TOOLS="gmail drive calendar" workspace-mcp +``` + +**Available Services**: `gmail` • `drive` • `calendar` • `docs` • `sheets` • `forms` • `tasks` • `contacts` • `chat` • `search` + +
+ +
+ +
+ +### CLI Mode + +The server supports a CLI mode for direct tool invocation without running the full MCP server. This is ideal for scripting, automation, and use by coding agents (Codex, Claude Code). + +
+CLI Commands ← Direct tool execution from command line + + + + + + + + + + +
+ +**▶ List Tools** +```bash +workspace-mcp --cli +workspace-mcp --cli list +workspace-mcp --cli list --json +``` +View all available tools + + + +**◆ Tool Help** +```bash +workspace-mcp --cli search_gmail_messages --help +``` +Show parameters and documentation + +
+ +**▶ Run with Arguments** +```bash +workspace-mcp --cli search_gmail_messages \ + --args '{"query": "is:unread"}' +``` +Execute tool with inline JSON + + + +**◆ Pipe from Stdin** +```bash +echo '{"query": "is:unread"}' | \ + workspace-mcp --cli search_gmail_messages +``` +Pass arguments via stdin + +
+ +
+CLI Usage Details ← Complete reference + +**Command Structure:** +```bash +workspace-mcp --cli [command] [options] +``` + +**Commands:** +| Command | Description | +|---------|-------------| +| `list` (default) | List all available tools | +| `` | Execute the specified tool | +| ` --help` | Show detailed help for a tool | + +**Options:** +| Option | Description | +|--------|-------------| +| `--args`, `-a` | JSON string with tool arguments | +| `--json`, `-j` | Output in JSON format (for `list` command) | +| `--help`, `-h` | Show help for a tool | + +**Examples:** +```bash +# List all Gmail tools +workspace-mcp --cli list | grep gmail + +# Search for unread emails +workspace-mcp --cli search_gmail_messages --args '{"query": "is:unread", "max_results": 5}' + +# Get calendar events for today +workspace-mcp --cli get_events --args '{"calendar_id": "primary", "time_min": "2024-01-15T00:00:00Z"}' + +# Create a Drive file from a URL +workspace-mcp --cli create_drive_file --args '{"name": "doc.pdf", "source_url": "https://example.com/file.pdf"}' + +# Combine with jq for processing +workspace-mcp --cli list --json | jq '.tools[] | select(.name | contains("gmail"))' +``` + +**Notes:** +- CLI mode uses OAuth 2.0 (same credentials as server mode) +- Authentication flows work the same way - browser opens for first-time auth +- Results are printed to stdout; errors go to stderr +- Exit code 0 on success, 1 on error + +
+ +
+ +### Tool Tiers + +The server organizes tools into **three progressive tiers** for simplified deployment. Choose a tier that matches your usage needs and API quota requirements. + + + + + + +
+ +#### Available Tiers + +** Core** (`--tool-tier core`) +Essential tools for everyday tasks. Perfect for light usage with minimal API quotas. Includes search, read, create, and basic modify operations across all services. + +** Extended** (`--tool-tier extended`) +Core functionality plus management tools. Adds labels, folders, batch operations, and advanced search. Ideal for regular usage with moderate API needs. + +** Complete** (`--tool-tier complete`) +Full API access including comments, headers/footers, publishing settings, and administrative functions. For power users needing maximum functionality. + + + +#### Important Notes + + **Start with `core`** and upgrade as needed + **Tiers are cumulative** – each includes all previous + **Mix and match** with `--tools` for specific services + **Configuration** in `core/tool_tiers.yaml` + **Authentication** included in all tiers + +
+ +#### Usage Examples + +```bash +# Basic tier selection +uv run main.py --tool-tier core # Start with essential tools only +uv run main.py --tool-tier extended # Expand to include management features +uv run main.py --tool-tier complete # Enable all available functionality + +# Selective service loading with tiers +uv run main.py --tools gmail drive --tool-tier core # Core tools for specific services +uv run main.py --tools gmail --tool-tier extended # Extended Gmail functionality only +uv run main.py --tools docs sheets --tool-tier complete # Full access to Docs and Sheets + +# Combine tier selection with granular permission levels +uv run main.py --permissions gmail:organize drive:full --tool-tier core +``` + +## 📋 Credential Configuration + +
+🔑 OAuth Credentials Setup ← Essential for all installations + + + + + + + + + + +
+ +**🚀 Environment Variables** +```bash +export GOOGLE_OAUTH_CLIENT_ID=\ + "your-client-id" +export GOOGLE_OAUTH_CLIENT_SECRET=\ + "your-secret" +``` +Best for production + + + +**📁 File-based** +```bash +# Download & place in project root +client_secret.json + +# Or specify custom path +export GOOGLE_CLIENT_SECRET_PATH=\ + /path/to/secret.json +``` +Traditional method + + + +**⚡ .env File** +```bash +cp .env.oauth21 .env +# Edit .env with credentials +``` +Best for development + +
+ +
+📖 Credential Loading Details ← Understanding priority & best practices + +**Loading Priority** +1. Environment variables (`export VAR=value`) +2. `.env` file in project root (warning - if you run via `uvx` rather than `uv run` from the repo directory, you are spawning a standalone process not associated with your clone of the repo and it will not find your .env file without specifying it directly) +3. `client_secret.json` via `GOOGLE_CLIENT_SECRET_PATH` +4. Default `client_secret.json` in project root + +**Why Environment Variables?** +- ✅ **Docker/K8s ready** - Native container support +- ✅ **Cloud platforms** - Heroku, Railway, Vercel +- ✅ **CI/CD pipelines** - GitHub Actions, Jenkins +- ✅ **No secrets in git** - Keep credentials secure +- ✅ **Easy rotation** - Update without code changes + +
+ +
+ +
+ +--- + +## 🧰 Available Tools + +> **Note**: All tools support automatic authentication via `@require_google_service()` decorators with 30-minute service caching. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +### 📅 **Google Calendar** [`calendar_tools.py`](gcalendar/calendar_tools.py) + +| Tool | Tier | Description | +|------|------|-------------| +| `list_calendars` | **Core** | List accessible calendars | +| `get_events` | **Core** | Retrieve events with time range filtering | +| `manage_event` | **Core** | Create, update, or delete calendar events | + + + +### 📁 **Google Drive** [`drive_tools.py`](gdrive/drive_tools.py) + +| Tool | Tier | Description | +|------|------|-------------| +| `search_drive_files` | **Core** | Search files with query syntax | +| `get_drive_file_content` | **Core** | Read file content (Office formats) | +| `get_drive_file_download_url` | **Core** | Download Drive files to local disk | +| `create_drive_file` | **Core** | Create files or fetch from URLs | +| `create_drive_folder` | **Core** | Create empty folders in Drive or shared drives | +| `import_to_google_doc` | **Core** | Import files (MD, DOCX, HTML, etc.) as Google Docs | +| `get_drive_shareable_link` | **Core** | Get shareable links for a file | +| `list_drive_items` | Extended | List folder contents | +| `copy_drive_file` | Extended | Copy existing files (templates) with optional renaming | +| `update_drive_file` | Extended | Update file metadata, move between folders | +| `manage_drive_access` | Extended | Grant, update, revoke permissions, and transfer ownership | +| `set_drive_file_permissions` | Extended | Set link sharing and file-level sharing settings | +| `get_drive_file_permissions` | Complete | Get detailed file permissions | +| `check_drive_file_public_access` | Complete | Check public sharing status | + +
+ +### 📧 **Gmail** [`gmail_tools.py`](gmail/gmail_tools.py) + +| Tool | Tier | Description | +|------|------|-------------| +| `search_gmail_messages` | **Core** | Search with Gmail operators | +| `get_gmail_message_content` | **Core** | Retrieve message content | +| `get_gmail_messages_content_batch` | **Core** | Batch retrieve message content | +| `send_gmail_message` | **Core** | Send emails | +| `get_gmail_thread_content` | Extended | Get full thread content | +| `modify_gmail_message_labels` | Extended | Modify message labels | +| `list_gmail_labels` | Extended | List available labels | +| `list_gmail_filters` | Extended | List Gmail filters | +| `manage_gmail_label` | Extended | Create/update/delete labels | +| `manage_gmail_filter` | Extended | Create or delete Gmail filters | +| `draft_gmail_message` | Extended | Create drafts | +| `get_gmail_threads_content_batch` | Complete | Batch retrieve thread content | +| `batch_modify_gmail_message_labels` | Complete | Batch modify labels | +| `start_google_auth` | Complete | Legacy OAuth 2.0 auth (disabled when OAuth 2.1 is enabled) | + +
+📎 Email Attachments ← Send emails with files + +Both `send_gmail_message` and `draft_gmail_message` support attachments via two methods: + +**Option 1: File Path** (local server only) +```python +attachments=[{"path": "/path/to/report.pdf"}] +``` +Reads file from disk, auto-detects MIME type. Optional `filename` override. + +**Option 2: Base64 Content** (works everywhere) +```python +attachments=[{ + "filename": "report.pdf", + "content": "JVBERi0xLjQK...", # base64-encoded + "mime_type": "application/pdf" # optional +}] +``` + +**⚠️ Centrally Hosted Servers**: When the MCP server runs remotely (cloud, shared instance), it cannot access your local filesystem. Use **Option 2** with base64-encoded content. Your MCP client must encode files before sending. + +
+ +
+📥 Downloaded Attachment Storage ← Where downloaded files are saved + +When downloading Gmail attachments (`get_gmail_attachment_content`) or Drive files (`get_drive_file_download_url`), files are saved to a persistent local directory rather than a temporary folder in the working directory. + +**Default location:** `~/.workspace-mcp/attachments/` + +Files are saved with their original filename plus a short UUID suffix for uniqueness (e.g., `invoice_a1b2c3d4.pdf`). In **stdio mode**, the tool returns the absolute file path for direct filesystem access. In **HTTP mode**, it returns a download URL via the `/attachments/{file_id}` endpoint. + +To customize the storage directory: +```bash +export WORKSPACE_ATTACHMENT_DIR="/path/to/custom/dir" +``` + +Saved files expire after 1 hour and are cleaned up automatically. + +
+ +
+ +### 📝 **Google Docs** [`docs_tools.py`](gdocs/docs_tools.py) + +| Tool | Tier | Description | +|------|------|-------------| +| `get_doc_content` | **Core** | Extract document text | +| `create_doc` | **Core** | Create new documents | +| `modify_doc_text` | **Core** | Modify document text (formatting + links) | +| `search_docs` | Extended | Find documents by name | +| `find_and_replace_doc` | Extended | Find and replace text | +| `list_docs_in_folder` | Extended | List docs in folder | +| `insert_doc_elements` | Extended | Add tables, lists, page breaks | +| `update_paragraph_style` | Extended | Apply heading styles, lists (bulleted/numbered with nesting), and paragraph formatting | +| `get_doc_as_markdown` | Extended | Export document as formatted Markdown with optional comments | +| `insert_doc_image` | Complete | Insert images from Drive/URLs | +| `update_doc_headers_footers` | Complete | Modify headers and footers | +| `batch_update_doc` | Complete | Execute multiple operations | +| `inspect_doc_structure` | Complete | Analyze document structure | +| `export_doc_to_pdf` | Extended | Export document to PDF | +| `create_table_with_data` | Complete | Create data tables | +| `debug_table_structure` | Complete | Debug table issues | +| `list_document_comments` | Complete | List all document comments | +| `manage_document_comment` | Complete | Create, reply to, or resolve comments | + +
+ +### 📊 **Google Sheets** [`sheets_tools.py`](gsheets/sheets_tools.py) + +| Tool | Tier | Description | +|------|------|-------------| +| `read_sheet_values` | **Core** | Read cell ranges | +| `modify_sheet_values` | **Core** | Write/update/clear cells | +| `create_spreadsheet` | **Core** | Create new spreadsheets | +| `list_spreadsheets` | Extended | List accessible spreadsheets | +| `get_spreadsheet_info` | Extended | Get spreadsheet metadata | +| `format_sheet_range` | Extended | Apply colors, number formats, text wrapping, alignment, bold/italic, font size | +| `create_sheet` | Complete | Add sheets to existing files | +| `list_spreadsheet_comments` | Complete | List all spreadsheet comments | +| `manage_spreadsheet_comment` | Complete | Create, reply to, or resolve comments | +| `manage_conditional_formatting` | Complete | Add, update, or delete conditional formatting rules | + + + +### 🖼️ **Google Slides** [`slides_tools.py`](gslides/slides_tools.py) + +| Tool | Tier | Description | +|------|------|-------------| +| `create_presentation` | **Core** | Create new presentations | +| `get_presentation` | **Core** | Retrieve presentation details | +| `batch_update_presentation` | Extended | Apply multiple updates | +| `get_page` | Extended | Get specific slide information | +| `get_page_thumbnail` | Extended | Generate slide thumbnails | +| `list_presentation_comments` | Complete | List all presentation comments | +| `manage_presentation_comment` | Complete | Create, reply to, or resolve comments | + +
+ +### 📝 **Google Forms** [`forms_tools.py`](gforms/forms_tools.py) + +| Tool | Tier | Description | +|------|------|-------------| +| `create_form` | **Core** | Create new forms | +| `get_form` | **Core** | Retrieve form details & URLs | +| `set_publish_settings` | Complete | Configure form settings | +| `get_form_response` | Complete | Get individual responses | +| `list_form_responses` | Extended | List all responses with pagination | +| `batch_update_form` | Complete | Apply batch updates (questions, settings) | + + + +### ✓ **Google Tasks** [`tasks_tools.py`](gtasks/tasks_tools.py) + +| Tool | Tier | Description | +|------|------|-------------| +| `list_tasks` | **Core** | List tasks with filtering | +| `get_task` | **Core** | Retrieve task details | +| `manage_task` | **Core** | Create, update, delete, or move tasks | +| `list_task_lists` | Complete | List task lists | +| `get_task_list` | Complete | Get task list details | +| `manage_task_list` | Complete | Create, update, delete task lists, or clear completed tasks | + +
+ +### 👤 **Google Contacts** [`contacts_tools.py`](gcontacts/contacts_tools.py) + +| Tool | Tier | Description | +|------|------|-------------| +| `search_contacts` | **Core** | Search contacts by name, email, phone | +| `get_contact` | **Core** | Retrieve detailed contact info | +| `list_contacts` | **Core** | List contacts with pagination | +| `manage_contact` | **Core** | Create, update, or delete contacts | +| `list_contact_groups` | Extended | List contact groups/labels | +| `get_contact_group` | Extended | Get group details with members | +| `manage_contacts_batch` | Complete | Batch create, update, or delete contacts | +| `manage_contact_group` | Complete | Create, update, delete groups, or modify membership | + +
+ +### 💬 **Google Chat** [`chat_tools.py`](gchat/chat_tools.py) + +| Tool | Tier | Description | +|------|------|-------------| +| `list_spaces` | Extended | List chat spaces/rooms | +| `get_messages` | **Core** | Retrieve space messages | +| `send_message` | **Core** | Send messages to spaces | +| `search_messages` | **Core** | Search across chat history | +| `create_reaction` | **Core** | Add emoji reaction to a message | +| `download_chat_attachment` | Extended | Download attachment from a chat message | + + + +### 🔍 **Google Custom Search** [`search_tools.py`](gsearch/search_tools.py) + +| Tool | Tier | Description | +|------|------|-------------| +| `search_custom` | **Core** | Perform web searches (supports site restrictions via sites parameter) | +| `get_search_engine_info` | Complete | Retrieve search engine metadata | + +
+ +### **Google Apps Script** [`apps_script_tools.py`](gappsscript/apps_script_tools.py) + +| Tool | Tier | Description | +|------|------|-------------| +| `list_script_projects` | **Core** | List accessible Apps Script projects | +| `get_script_project` | **Core** | Get complete project with all files | +| `get_script_content` | **Core** | Retrieve specific file content | +| `create_script_project` | **Core** | Create new standalone or bound project | +| `update_script_content` | **Core** | Update or create script files | +| `run_script_function` | **Core** | Execute function with parameters | +| `list_deployments` | Extended | List all project deployments | +| `manage_deployment` | Extended | Create, update, or delete script deployments | +| `list_script_processes` | Extended | View recent executions and status | + +
+ + +**Tool Tier Legend:** +- **Core**: Essential tools for basic functionality • Minimal API usage • Getting started +- **Extended**: Core tools + additional features • Regular usage • Expanded capabilities +- **Complete**: All available tools including advanced features • Power users • Full API access + +--- + +### Connect to Claude Desktop + +The server supports two transport modes: + +#### Stdio Mode (Legacy - For Clients with Incomplete MCP Support) + +> **⚠️ Important**: Stdio mode is a **legacy fallback** for clients that don't properly implement the MCP specification with OAuth 2.1 and streamable HTTP support. **Claude Code and other modern MCP clients should use streamable HTTP mode** (`--transport streamable-http`) for proper OAuth flow and multi-user support. + +In general, you should use the one-click DXT installer package for Claude Desktop. +If you are unable to for some reason, you can configure it manually via `claude_desktop_config.json` + +**Manual Claude Configuration (Alternative)** + +
+📝 Claude Desktop JSON Config ← Click for manual setup instructions + +1. Open Claude Desktop Settings → Developer → Edit Config + - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` + - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` + +2. Add the server configuration: +```json +{ + "mcpServers": { + "google_workspace": { + "command": "uvx", + "args": ["workspace-mcp"], + "env": { + "GOOGLE_OAUTH_CLIENT_ID": "your-client-id", + "GOOGLE_OAUTH_CLIENT_SECRET": "your-secret", + "OAUTHLIB_INSECURE_TRANSPORT": "1" + } + } + } +} +``` +
+ +### Connect to LM Studio + +Add a new MCP server in LM Studio (Settings → MCP Servers) using the same JSON format: + +```json +{ + "mcpServers": { + "google_workspace": { + "command": "uvx", + "args": ["workspace-mcp"], + "env": { + "GOOGLE_OAUTH_CLIENT_ID": "your-client-id", + "GOOGLE_OAUTH_CLIENT_SECRET": "your-secret", + "OAUTHLIB_INSECURE_TRANSPORT": "1", + } + } + } +} +``` + + +### 2. Advanced / Cross-Platform Installation + +If you’re developing, deploying to servers, or using another MCP-capable client, keep reading. + +#### Instant CLI (uvx) + +
+Quick Start with uvx ← No installation required! + +```bash +# Requires Python 3.10+ and uvx +# First, set credentials (see Credential Configuration above) +uvx workspace-mcp --tool-tier core # or --tools gmail drive calendar +``` + +> **Note**: Configure [OAuth credentials](#credential-configuration) before running. Supports environment variables, `.env` file, or `client_secret.json`. + +
+ +### Local Development Setup + +
+🛠️ Developer Workflow ← Install deps, lint, and test + +```bash +# Install everything needed for linting, tests, and release tooling +uv sync --group dev + +# Run the same linter that git hooks invoke automatically +uv run ruff check . + +# Execute the full test suite (async fixtures require pytest-asyncio) +uv run pytest +``` + +- `uv sync --group test` installs only the testing stack if you need a slimmer environment. +- `uv run main.py --transport streamable-http` launches the server with your checked-out code for manual verification. +- Ruff is part of the `dev` group because pre-push hooks call `ruff check` automatically—run it locally before committing to avoid hook failures. + +
+ +### OAuth 2.1 Support (Multi-User Bearer Token Authentication) + +The server includes OAuth 2.1 support for bearer token authentication, enabling multi-user session management. **OAuth 2.1 automatically reuses your existing `GOOGLE_OAUTH_CLIENT_ID` and `GOOGLE_OAUTH_CLIENT_SECRET` credentials** - no additional configuration needed! + +**When to use OAuth 2.1:** +- Multiple users accessing the same MCP server instance +- Need for bearer token authentication instead of passing user emails +- Building web applications or APIs on top of the MCP server +- Production environments requiring secure session management +- Browser-based clients requiring CORS support + +**⚠️ Important: OAuth 2.1 and Single-User Mode are mutually exclusive** + +OAuth 2.1 mode (`MCP_ENABLE_OAUTH21=true`) cannot be used together with the `--single-user` flag: +- **Single-user mode**: For legacy clients that pass user emails in tool calls +- **OAuth 2.1 mode**: For modern multi-user scenarios with bearer token authentication + +Choose one authentication method - using both will result in a startup error. + +**Enabling OAuth 2.1:** +To enable OAuth 2.1, set the `MCP_ENABLE_OAUTH21` environment variable to `true`. + +```bash +# OAuth 2.1 requires HTTP transport mode +export MCP_ENABLE_OAUTH21=true +uv run main.py --transport streamable-http +``` + +If `MCP_ENABLE_OAUTH21` is not set to `true`, the server will use legacy authentication, which is suitable for clients that do not support OAuth 2.1. + +
+🔐 How the FastMCP GoogleProvider handles OAuth ← Advanced OAuth 2.1 details + +FastMCP ships a native `GoogleProvider` that we now rely on directly. It solves the two tricky parts of using Google OAuth with MCP clients: + +1. **Dynamic Client Registration**: Google still doesn't support OAuth 2.1 DCR, but the FastMCP provider exposes the full DCR surface and forwards registrations to Google using your fixed credentials. MCP clients register as usual and the provider hands them your Google client ID/secret under the hood. + +2. **CORS & Browser Compatibility**: The provider includes an OAuth proxy that serves all discovery, authorization, and token endpoints with proper CORS headers. We no longer maintain custom `/oauth2/*` routes—the provider handles the upstream exchanges securely and advertises the correct metadata to clients. + +The result is a leaner server that still enables any OAuth 2.1 compliant client (including browser-based ones) to authenticate through Google without bespoke code. + +
+ +### Stateless Mode (Container-Friendly) + +The server supports a stateless mode designed for containerized environments where file system writes should be avoided: + +**Enabling Stateless Mode:** +```bash +# Stateless mode requires OAuth 2.1 to be enabled +export MCP_ENABLE_OAUTH21=true +export WORKSPACE_MCP_STATELESS_MODE=true +uv run main.py --transport streamable-http +``` + +**Key Features:** +- **No file system writes**: Credentials are never written to disk +- **No debug logs**: File-based logging is completely disabled +- **Memory-only sessions**: All tokens stored in memory via OAuth 2.1 session store +- **Container-ready**: Perfect for Docker, Kubernetes, and serverless deployments +- **Token per request**: Each request must include a valid Bearer token + +**Requirements:** +- Must be used with `MCP_ENABLE_OAUTH21=true` +- Incompatible with single-user mode +- Clients must handle OAuth flow and send valid tokens with each request + +This mode is ideal for: +- Cloud deployments where persistent storage is unavailable +- Multi-tenant environments requiring strict isolation +- Containerized applications with read-only filesystems +- Serverless functions and ephemeral compute environments + +**MCP Inspector**: No additional configuration needed with desktop OAuth client. + +**Claude Code**: No additional configuration needed with desktop OAuth client. + +### OAuth Proxy Storage Backends + +The server supports pluggable storage backends for OAuth proxy state management via FastMCP 2.13.0+. Choose a backend based on your deployment needs. + +**Available Backends:** + +| Backend | Best For | Persistence | Multi-Server | +|---------|----------|-------------|--------------| +| Memory | Development, testing | ❌ | ❌ | +| Disk | Single-server production | ✅ | ❌ | +| Valkey/Redis | Distributed production | ✅ | ✅ | + +**Configuration:** + +```bash +# Memory storage (fast, no persistence) +export WORKSPACE_MCP_OAUTH_PROXY_STORAGE_BACKEND=memory + +# Disk storage (persists across restarts) +export WORKSPACE_MCP_OAUTH_PROXY_STORAGE_BACKEND=disk +export WORKSPACE_MCP_OAUTH_PROXY_DISK_DIRECTORY=~/.fastmcp/oauth-proxy + +# Valkey/Redis storage (distributed, multi-server) +export WORKSPACE_MCP_OAUTH_PROXY_STORAGE_BACKEND=valkey +export WORKSPACE_MCP_OAUTH_PROXY_VALKEY_HOST=redis.example.com +export WORKSPACE_MCP_OAUTH_PROXY_VALKEY_PORT=6379 +``` + +> Disk support requires `workspace-mcp[disk]` (or `py-key-value-aio[disk]`) when installing from source. +> The official Docker image includes the `disk` extra by default. +> Valkey support is optional. Install `workspace-mcp[valkey]` (or `py-key-value-aio[valkey]`) only if you enable the Valkey backend. +> Windows: building `valkey-glide` from source requires MSVC C++ build tools with C11 support. If you see `aws-lc-sys` C11 errors, set `CFLAGS=/std:c11`. + +
+🔐 Valkey/Redis Configuration Options + +| Variable | Default | Description | +|----------|---------|-------------| +| `WORKSPACE_MCP_OAUTH_PROXY_VALKEY_HOST` | localhost | Valkey/Redis host | +| `WORKSPACE_MCP_OAUTH_PROXY_VALKEY_PORT` | 6379 | Port (6380 auto-enables TLS) | +| `WORKSPACE_MCP_OAUTH_PROXY_VALKEY_DB` | 0 | Database number | +| `WORKSPACE_MCP_OAUTH_PROXY_VALKEY_USE_TLS` | auto | Enable TLS (auto if port 6380) | +| `WORKSPACE_MCP_OAUTH_PROXY_VALKEY_USERNAME` | - | Authentication username | +| `WORKSPACE_MCP_OAUTH_PROXY_VALKEY_PASSWORD` | - | Authentication password | +| `WORKSPACE_MCP_OAUTH_PROXY_VALKEY_REQUEST_TIMEOUT_MS` | 5000 | Request timeout for remote hosts | +| `WORKSPACE_MCP_OAUTH_PROXY_VALKEY_CONNECTION_TIMEOUT_MS` | 10000 | Connection timeout for remote hosts | + +**Encryption:** Disk and Valkey storage are encrypted with Fernet. The encryption key is derived from `FASTMCP_SERVER_AUTH_GOOGLE_JWT_SIGNING_KEY` if set, otherwise from `GOOGLE_OAUTH_CLIENT_SECRET`. + +
+ +### External OAuth 2.1 Provider Mode + +The server supports an external OAuth 2.1 provider mode for scenarios where authentication is handled by an external system. In this mode, the MCP server does not manage the OAuth flow itself but expects valid bearer tokens in the Authorization header of tool calls. + +**Enabling External OAuth 2.1 Provider Mode:** +```bash +# External OAuth provider mode requires OAuth 2.1 to be enabled +export MCP_ENABLE_OAUTH21=true +export EXTERNAL_OAUTH21_PROVIDER=true +uv run main.py --transport streamable-http +``` + +**How It Works:** +- **Protocol-level auth disabled**: MCP handshake (`initialize`) and `tools/list` do not require authentication +- **Tool-level auth required**: All tool calls must include `Authorization: Bearer ` header +- **External OAuth flow**: Your external system handles the OAuth flow and obtains Google access tokens +- **Token validation**: Server validates bearer tokens via Google's tokeninfo API +- **Multi-user support**: Each request is authenticated independently based on its bearer token + +**Key Features:** +- **No local OAuth flow**: Server does not provide OAuth callback endpoints or manage OAuth state +- **Bearer token only**: All authentication via Authorization headers +- **Stateless by design**: Works seamlessly with `WORKSPACE_MCP_STATELESS_MODE=true` +- **External identity providers**: Integrate with your existing authentication infrastructure +- **Tool discovery**: Clients can list available tools without authentication + +**Requirements:** +- Must be used with `MCP_ENABLE_OAUTH21=true` +- OAuth credentials still required for token validation (`GOOGLE_OAUTH_CLIENT_ID`, `GOOGLE_OAUTH_CLIENT_SECRET`) +- External system must obtain valid Google OAuth access tokens (ya29.*) +- Each tool call request must include valid bearer token + +**Use Cases:** +- Integrating with existing authentication systems +- Custom OAuth flows managed by your application +- API gateways that handle authentication upstream +- Multi-tenant SaaS applications with centralized auth +- Mobile or web apps with their own OAuth implementation + + +### VS Code MCP Client Support + +> **✅ Recommended**: VS Code MCP extension properly supports the full MCP specification. **Always use HTTP transport mode** for proper OAuth 2.1 authentication. + +
+🆚 VS Code Configuration ← Setup for VS Code MCP extension + +```json +{ + "servers": { + "google-workspace": { + "url": "http://localhost:8000/mcp/", + "type": "http" + } + } +} +``` + +*Note: Make sure to start the server with `--transport streamable-http` when using VS Code MCP.* +
+ +### Claude Code MCP Client Support + +> **✅ Recommended**: Claude Code is a modern MCP client that properly supports the full MCP specification. **Always use HTTP transport mode** with Claude Code for proper OAuth 2.1 authentication and multi-user support. + +
+🆚 Claude Code Configuration ← Setup for Claude Code MCP support + +```bash +# Start the server in HTTP mode first +uv run main.py --transport streamable-http + +# Then add to Claude Code +claude mcp add --transport http workspace-mcp http://localhost:8000/mcp +``` +
+ +#### Reverse Proxy Setup + +If you're running the MCP server behind a reverse proxy (nginx, Apache, Cloudflare, etc.), you have two configuration options: + +**Problem**: When behind a reverse proxy, the server constructs OAuth URLs using internal ports (e.g., `http://localhost:8000`) but external clients need the public URL (e.g., `https://your-domain.com`). + +**Solution 1**: Set `WORKSPACE_EXTERNAL_URL` for all OAuth endpoints: +```bash +# This configures all OAuth endpoints to use your external URL +export WORKSPACE_EXTERNAL_URL="https://your-domain.com" +``` + +**Solution 2**: Set `GOOGLE_OAUTH_REDIRECT_URI` for just the callback: +```bash +# This only overrides the OAuth callback URL +export GOOGLE_OAUTH_REDIRECT_URI="https://your-domain.com/oauth2callback" +``` + +You also have options for: +| `OAUTH_CUSTOM_REDIRECT_URIS` *(optional)* | Comma-separated list of additional redirect URIs | +| `OAUTH_ALLOWED_ORIGINS` *(optional)* | Comma-separated list of additional CORS origins | + +**Important**: +- Use `WORKSPACE_EXTERNAL_URL` when all OAuth endpoints should use the external URL (recommended for reverse proxy setups) +- Use `GOOGLE_OAUTH_REDIRECT_URI` when you only need to override the callback URL +- The redirect URI must exactly match what's configured in your Google Cloud Console +- Your reverse proxy must forward OAuth-related requests (`/oauth2callback`, `/oauth2/*`, `/.well-known/*`) to the MCP server + +
+🚀 Advanced uvx Commands ← More startup options + +```bash +# Configure credentials first (see Credential Configuration section) + +# Start with specific tools only +uvx workspace-mcp --tools gmail drive calendar tasks + +# Start with tool tiers (recommended for most users) +uvx workspace-mcp --tool-tier core # Essential tools +uvx workspace-mcp --tool-tier extended # Core + additional features +uvx workspace-mcp --tool-tier complete # All tools + +# Start in HTTP mode for debugging +uvx workspace-mcp --transport streamable-http +``` +
+ +*Requires Python 3.10+ and [uvx](https://github.com/astral-sh/uv). The package is available on [PyPI](https://pypi.org/project/workspace-mcp).* + +### Development Installation + +For development or customization: + +```bash +git clone https://github.com/taylorwilsdon/google_workspace_mcp.git +cd google_workspace_mcp +uv run main.py +``` + +**Development Installation (For Contributors)**: + +
+🔧 Developer Setup JSON ← For contributors & customization + +```json +{ + "mcpServers": { + "google_workspace": { + "command": "uv", + "args": [ + "run", + "--directory", + "/path/to/repo/google_workspace_mcp", + "main.py" + ], + "env": { + "GOOGLE_OAUTH_CLIENT_ID": "your-client-id", + "GOOGLE_OAUTH_CLIENT_SECRET": "your-secret", + "OAUTHLIB_INSECURE_TRANSPORT": "1" + } + } + } +} +``` +
+ +#### HTTP Mode (For debugging or web interfaces) +If you need to use HTTP mode with Claude Desktop: + +```json +{ + "mcpServers": { + "google_workspace": { + "command": "npx", + "args": ["mcp-remote", "http://localhost:8000/mcp"] + } + } +} +``` + +*Note: Make sure to start the server with `--transport streamable-http` when using HTTP mode.* + +### First-Time Authentication + +The server uses **Google Desktop OAuth** for simplified authentication: + +- **No redirect URIs needed**: Desktop OAuth clients handle authentication without complex callback URLs +- **Automatic flow**: The server manages the entire OAuth process transparently +- **Transport-agnostic**: Works seamlessly in both stdio and HTTP modes + +When calling a tool: +1. Server returns authorization URL +2. Open URL in browser and authorize +3. Google provides an authorization code +4. Paste the code when prompted (or it's handled automatically) +5. Server completes authentication and retries your request + +--- + +## ◆ Development + +### Project Structure + +``` +google_workspace_mcp/ +├── auth/ # Authentication system with decorators +├── core/ # MCP server and utilities +├── g{service}/ # Service-specific tools +├── main.py # Server entry point +├── client_secret.json # OAuth credentials (not committed) +└── pyproject.toml # Dependencies +``` + +### Adding New Tools + +```python +from auth.service_decorator import require_google_service + +@require_google_service("drive", "drive_read") # Service + scope group +async def your_new_tool(service, param1: str, param2: int = 10): + """Tool description""" + # service is automatically injected and cached + result = service.files().list().execute() + return result # Return native Python objects +``` + +### Architecture Highlights + +- **Service Caching**: 30-minute TTL reduces authentication overhead +- **Scope Management**: Centralized in `SCOPE_GROUPS` for easy maintenance +- **Error Handling**: Native exceptions instead of manual error construction +- **Multi-Service Support**: `@require_multiple_services()` for complex tools + +### Credential Store System + +The server includes an abstract credential store API and a default backend for managing Google OAuth +credentials with support for multiple storage backends: + +**Features:** +- **Abstract Interface**: `CredentialStore` base class defines standard operations (get, store, delete, list users) +- **Local File Storage**: `LocalDirectoryCredentialStore` implementation stores credentials as JSON files +- **Configurable Storage**: Environment variable `GOOGLE_MCP_CREDENTIALS_DIR` sets storage location +- **Multi-User Support**: Store and manage credentials for multiple Google accounts +- **Automatic Directory Creation**: Storage directory is created automatically if it doesn't exist + +**Configuration:** +```bash +# Optional: Set custom credentials directory +export GOOGLE_MCP_CREDENTIALS_DIR="/path/to/credentials" + +# Default locations (if GOOGLE_MCP_CREDENTIALS_DIR not set): +# - ~/.google_workspace_mcp/credentials (if home directory accessible) +# - ./.credentials (fallback) +``` + +**Usage Example:** +```python +from auth.credential_store import get_credential_store + +# Get the global credential store instance +store = get_credential_store() + +# Store credentials for a user +store.store_credential("user@example.com", credentials) + +# Retrieve credentials +creds = store.get_credential("user@example.com") + +# List all users with stored credentials +users = store.list_users() +``` + +The credential store automatically handles credential serialization, expiry parsing, and provides error handling for storage operations. + +--- + +## ⊠ Security + +- **Credentials**: Never commit `.env`, `client_secret.json` or the `.credentials/` directory to source control! +- **OAuth Callback**: Uses `http://localhost:8000/oauth2callback` for development (requires `OAUTHLIB_INSECURE_TRANSPORT=1`) +- **Transport-Aware Callbacks**: Stdio mode starts a minimal HTTP server only for OAuth, ensuring callbacks work in all modes +- **Production**: Use HTTPS & OAuth 2.1 and configure accordingly +- **Scope Minimization**: Tools request only necessary permissions +- **Local File Access Control**: Tools that read local files (e.g., attachments, `file://` uploads) are restricted to the user's home directory by default. Override this with the `ALLOWED_FILE_DIRS` environment variable: + ```bash + # Colon-separated list of directories (semicolon on Windows) from which local file reads are permitted + export ALLOWED_FILE_DIRS="/home/user/documents:/data/shared" + ``` + Regardless of the allowlist, access to sensitive paths (`.env`, `.ssh/`, `.aws/`, `/etc/shadow`, credential files, etc.) is always blocked. + +--- + + +--- + +## ≡ License + +MIT License - see `LICENSE` file for details. + +--- + +Validations: +[![MCP Badge](https://lobehub.com/badge/mcp/taylorwilsdon-google_workspace_mcp)](https://lobehub.com/mcp/taylorwilsdon-google_workspace_mcp) + +[![Verified on MseeP](https://mseep.ai/badge.svg)](https://mseep.ai/app/eebbc4a6-0f8c-41b2-ace8-038e5516dba0) + + +
+Batch Emails +
diff --git a/README_NEW.md b/README_NEW.md new file mode 100644 index 0000000..9e01ba0 --- /dev/null +++ b/README_NEW.md @@ -0,0 +1,473 @@ +
+ +# Google Workspace MCP Server + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Python 3.10+](https://img.shields.io/badge/Python-3.10%2B-blue.svg)](https://www.python.org/downloads/) +[![PyPI](https://img.shields.io/pypi/v/workspace-mcp.svg)](https://pypi.org/project/workspace-mcp/) + +**Complete Google Workspace control through natural language.** Gmail, Calendar, Drive, Docs, Sheets, Slides, Forms, Tasks, Chat, Apps Script, and Custom Search—all via MCP. + +[Quick Start](#-quick-start) • [Tools Reference](#-tools-reference) • [Configuration](#-configuration) • [OAuth Setup](#-oauth-setup) + +
+ +--- + +## ⚡ Quick Start + +### One-Click Install (Claude Desktop) + +1. Download `google_workspace_mcp.dxt` from [Releases](https://github.com/taylorwilsdon/google_workspace_mcp/releases) +2. Double-click → Claude Desktop installs automatically +3. Add your Google OAuth credentials in Settings → Extensions + +### CLI Install + +```bash +# Instant run (no install) +uvx workspace-mcp + +# With specific tools only +uvx workspace-mcp --tools gmail drive calendar + +# With tool tier +uvx workspace-mcp --tool-tier core +``` + +### Environment Variables + +```bash +export GOOGLE_OAUTH_CLIENT_ID="your-client-id" +export GOOGLE_OAUTH_CLIENT_SECRET="your-client-secret" +export OAUTHLIB_INSECURE_TRANSPORT=1 # Development only +``` + +--- + +## 🛠 Tools Reference + +### Gmail (10 tools) + +| Tool | Tier | Description | +|------|------|-------------| +| `search_gmail_messages` | Core | Search with Gmail operators, returns message/thread IDs with web links | +| `get_gmail_message_content` | Core | Get full message: subject, sender, body, attachments | +| `get_gmail_messages_content_batch` | Core | Batch retrieve up to 25 messages | +| `send_gmail_message` | Core | Send emails with HTML support, CC/BCC, threading | +| `get_gmail_thread_content` | Extended | Get complete conversation thread | +| `draft_gmail_message` | Extended | Create drafts with threading support | +| `list_gmail_labels` | Extended | List all system and user labels | +| `manage_gmail_label` | Extended | Create, update, delete labels | +| `modify_gmail_message_labels` | Extended | Add/remove labels (archive, trash, etc.) | +| `manage_gmail_filter` | Extended | Create or delete Gmail filters | +| `get_gmail_threads_content_batch` | Complete | Batch retrieve threads | +| `batch_modify_gmail_message_labels` | Complete | Bulk label operations | + +**Also includes:** `get_gmail_attachment_content`, `list_gmail_filters` + +### Google Drive (10 tools) + +| Tool | Tier | Description | +|------|------|-------------| +| `search_drive_files` | Core | Search files with Drive query syntax or free text | +| `get_drive_file_content` | Core | Read content from Docs, Sheets, Office files (.docx, .xlsx, .pptx) | +| `get_drive_file_download_url` | Core | Download Drive files to local disk | +| `create_drive_file` | Core | Create files from content or URL (supports file://, http://, https://) | +| `create_drive_folder` | Core | Create empty folders in Drive or shared drives | +| `import_to_google_doc` | Core | Import files (MD, DOCX, HTML, etc.) as Google Docs | +| `get_drive_shareable_link` | Core | Get shareable links for a file | +| `list_drive_items` | Extended | List folder contents with shared drive support | +| `copy_drive_file` | Extended | Copy existing files (templates) with optional renaming | +| `update_drive_file` | Extended | Update metadata, move between folders, star, trash | +| `manage_drive_access` | Extended | Grant, update, revoke permissions, and transfer ownership | +| `set_drive_file_permissions` | Extended | Set link sharing and file-level sharing settings | +| `get_drive_file_permissions` | Complete | Get detailed file permissions | +| `check_drive_file_public_access` | Complete | Verify public link sharing for Docs image insertion | + +### Google Calendar (3 tools) + +| Tool | Tier | Description | +|------|------|-------------| +| `list_calendars` | Core | List all accessible calendars | +| `get_events` | Core | Query events by time range, search, or specific ID | +| `manage_event` | Core | Create, update, or delete calendar events | + +**Event features:** Timezone support, transparency (busy/free), visibility settings, up to 5 custom reminders, Google Meet integration, attendees, attachments + +### Google Docs (14 tools) + +| Tool | Tier | Description | +|------|------|-------------| +| `get_doc_content` | Core | Extract text from Docs or .docx files (supports tabs) | +| `create_doc` | Core | Create new documents with optional initial content | +| `modify_doc_text` | Core | Insert, replace, format text (bold, italic, colors, fonts, links) | +| `search_docs` | Extended | Find documents by name | +| `find_and_replace_doc` | Extended | Global find/replace with case matching | +| `list_docs_in_folder` | Extended | List Docs in a specific folder | +| `insert_doc_elements` | Extended | Add tables, lists, page breaks | +| `update_paragraph_style` | Extended | Apply heading styles, lists (bulleted/numbered with nesting), and paragraph formatting | +| `get_doc_as_markdown` | Extended | Export document as formatted Markdown with optional comments | +| `export_doc_to_pdf` | Extended | Export to PDF and save to Drive | +| `insert_doc_image` | Complete | Insert images from Drive or URLs | +| `update_doc_headers_footers` | Complete | Modify headers/footers | +| `batch_update_doc` | Complete | Execute multiple operations atomically | +| `inspect_doc_structure` | Complete | Analyze document structure for safe insertion points | +| `create_table_with_data` | Complete | Create and populate tables in one operation | +| `debug_table_structure` | Complete | Debug table cell positions and content | +| `list_document_comments` | Complete | List all document comments | +| `manage_document_comment` | Complete | Create, reply to, or resolve comments | + +### Google Sheets (9 tools) + +| Tool | Tier | Description | +|------|------|-------------| +| `read_sheet_values` | Core | Read cell ranges with formatted output | +| `modify_sheet_values` | Core | Write, update, or clear cell values | +| `create_spreadsheet` | Core | Create new spreadsheets with multiple sheets | +| `list_spreadsheets` | Extended | List accessible spreadsheets | +| `get_spreadsheet_info` | Extended | Get metadata, sheets, conditional formats | +| `format_sheet_range` | Extended | Apply colors, number formats, text wrapping, alignment, bold/italic, font size | +| `create_sheet` | Complete | Add sheets to existing spreadsheets | +| `list_spreadsheet_comments` | Complete | List all spreadsheet comments | +| `manage_spreadsheet_comment` | Complete | Create, reply to, or resolve comments | +| `manage_conditional_formatting` | Complete | Add, update, or delete conditional formatting rules | + +### Google Slides (7 tools) + +| Tool | Tier | Description | +|------|------|-------------| +| `create_presentation` | Core | Create new presentations | +| `get_presentation` | Core | Get presentation details with slide text extraction | +| `batch_update_presentation` | Extended | Apply multiple updates (create slides, shapes, etc.) | +| `get_page` | Extended | Get specific slide details and elements | +| `get_page_thumbnail` | Extended | Generate PNG thumbnails | +| `list_presentation_comments` | Complete | List all presentation comments | +| `manage_presentation_comment` | Complete | Create, reply to, or resolve comments | + +### Google Forms (6 tools) + +| Tool | Tier | Description | +|------|------|-------------| +| `create_form` | Core | Create forms with title and description | +| `get_form` | Core | Get form details, questions, and URLs | +| `list_form_responses` | Extended | List responses with pagination | +| `set_publish_settings` | Complete | Configure template and authentication settings | +| `get_form_response` | Complete | Get individual response details | +| `batch_update_form` | Complete | Execute batch updates to forms (questions, items, settings) | + +### Google Tasks (5 tools) + +| Tool | Tier | Description | +|------|------|-------------| +| `list_tasks` | Core | List tasks with filtering, subtask hierarchy preserved | +| `get_task` | Core | Get task details | +| `manage_task` | Core | Create, update, delete, or move tasks | +| `list_task_lists` | Complete | List all task lists | +| `get_task_list` | Complete | Get task list details | +| `manage_task_list` | Complete | Create, update, delete task lists, or clear completed tasks | + +### Google Apps Script (9 tools) + +| Tool | Tier | Description | +|------|------|-------------| +| `list_script_projects` | Core | List accessible Apps Script projects | +| `get_script_project` | Core | Get complete project with all files | +| `get_script_content` | Core | Retrieve specific file content | +| `create_script_project` | Core | Create new standalone or bound project | +| `update_script_content` | Core | Update or create script files | +| `run_script_function` | Core | Execute function with parameters | +| `list_deployments` | Extended | List all project deployments | +| `manage_deployment` | Extended | Create, update, or delete script deployments | +| `list_script_processes` | Extended | View recent executions and status | + +**Enables:** Cross-app automation, persistent workflows, custom business logic execution, script development and debugging + +**Note:** Trigger management is not currently supported via MCP tools. + +### Google Contacts (7 tools) + +| Tool | Tier | Description | +|------|------|-------------| +| `search_contacts` | Core | Search contacts by name, email, phone | +| `get_contact` | Core | Retrieve detailed contact info | +| `list_contacts` | Core | List contacts with pagination | +| `manage_contact` | Core | Create, update, or delete contacts | +| `list_contact_groups` | Extended | List contact groups/labels | +| `get_contact_group` | Extended | Get group details with members | +| `manage_contacts_batch` | Complete | Batch create, update, or delete contacts | +| `manage_contact_group` | Complete | Create, update, delete groups, or modify membership | + +### Google Chat (4 tools) + +| Tool | Tier | Description | +|------|------|-------------| +| `get_messages` | Core | Retrieve messages from a space | +| `send_message` | Core | Send messages with optional threading | +| `search_messages` | Core | Search across chat history | +| `list_spaces` | Extended | List rooms and DMs | + +### Google Custom Search (2 tools) + +| Tool | Tier | Description | +|------|------|-------------| +| `search_custom` | Core | Web search with filters (date, file type, language, safe search, site restrictions via sites parameter) | +| `get_search_engine_info` | Complete | Get search engine metadata | + +**Requires:** `GOOGLE_PSE_API_KEY` and `GOOGLE_PSE_ENGINE_ID` environment variables + +--- + +## 📊 Tool Tiers + +Choose a tier based on your needs: + +| Tier | Tools | Use Case | +|------|-------|----------| +| **Core** | ~30 | Essential operations: search, read, create, send | +| **Extended** | ~50 | Core + management: labels, folders, batch ops | +| **Complete** | 111 | Full API: comments, headers, admin functions | + +```bash +uvx workspace-mcp --tool-tier core # Start minimal +uvx workspace-mcp --tool-tier extended # Add management +uvx workspace-mcp --tool-tier complete # Everything +``` + +Mix tiers with specific services: +```bash +uvx workspace-mcp --tools gmail drive --tool-tier extended +``` + +--- + +## ⚙ Configuration + +### Required + +| Variable | Description | +|----------|-------------| +| `GOOGLE_OAUTH_CLIENT_ID` | OAuth client ID from Google Cloud | +| `GOOGLE_OAUTH_CLIENT_SECRET` | OAuth client secret | + +### Optional + +| Variable | Description | +|----------|-------------| +| `USER_GOOGLE_EMAIL` | Default email for single-user mode | +| `GOOGLE_PSE_API_KEY` | Custom Search API key | +| `GOOGLE_PSE_ENGINE_ID` | Programmable Search Engine ID | +| `MCP_ENABLE_OAUTH21` | Enable OAuth 2.1 multi-user support | +| `WORKSPACE_MCP_STATELESS_MODE` | No file writes (container-friendly) | +| `EXTERNAL_OAUTH21_PROVIDER` | External OAuth flow with bearer tokens | +| `WORKSPACE_MCP_BASE_URI` | Server base URL (default: `http://localhost`) | +| `WORKSPACE_MCP_PORT` | Server port (default: `8000`) | +| `WORKSPACE_EXTERNAL_URL` | External URL for reverse proxy setups | +| `GOOGLE_MCP_CREDENTIALS_DIR` | Custom credentials storage path | + +--- + +## 🔐 OAuth Setup + +### 1. Create Google Cloud Project + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Create a new project +3. Navigate to **APIs & Services → Credentials** +4. Click **Create Credentials → OAuth Client ID** +5. Select **Desktop Application** +6. Download credentials + +### 2. Enable APIs + +Click to enable each API: + +- [Calendar](https://console.cloud.google.com/flows/enableapi?apiid=calendar-json.googleapis.com) +- [Drive](https://console.cloud.google.com/flows/enableapi?apiid=drive.googleapis.com) +- [Gmail](https://console.cloud.google.com/flows/enableapi?apiid=gmail.googleapis.com) +- [Docs](https://console.cloud.google.com/flows/enableapi?apiid=docs.googleapis.com) +- [Sheets](https://console.cloud.google.com/flows/enableapi?apiid=sheets.googleapis.com) +- [Slides](https://console.cloud.google.com/flows/enableapi?apiid=slides.googleapis.com) +- [Forms](https://console.cloud.google.com/flows/enableapi?apiid=forms.googleapis.com) +- [Tasks](https://console.cloud.google.com/flows/enableapi?apiid=tasks.googleapis.com) +- [Chat](https://console.cloud.google.com/flows/enableapi?apiid=chat.googleapis.com) +- [Custom Search](https://console.cloud.google.com/flows/enableapi?apiid=customsearch.googleapis.com) + +### 3. First Authentication + +When you first call a tool: +1. Server returns an authorization URL +2. Open URL in browser, authorize access +3. Paste the authorization code when prompted +4. Credentials are cached for future use + +--- + +## 🚀 Transport Modes + +### Stdio (Default) + +Best for Claude Desktop and local MCP clients: + +```bash +uvx workspace-mcp +``` + +### HTTP (Streamable) + +For web interfaces, debugging, or multi-client setups: + +```bash +uvx workspace-mcp --transport streamable-http +``` + +Access at `http://localhost:8000/mcp/` + +### Docker + +```bash +docker build -t workspace-mcp . +docker run -p 8000:8000 \ + -e GOOGLE_OAUTH_CLIENT_ID="..." \ + -e GOOGLE_OAUTH_CLIENT_SECRET="..." \ + workspace-mcp --transport streamable-http +``` + +--- + +## 🔧 Client Configuration + +### Claude Desktop + +```json +{ + "mcpServers": { + "google_workspace": { + "command": "uvx", + "args": ["workspace-mcp", "--tool-tier", "core"], + "env": { + "GOOGLE_OAUTH_CLIENT_ID": "your-client-id", + "GOOGLE_OAUTH_CLIENT_SECRET": "your-secret", + "OAUTHLIB_INSECURE_TRANSPORT": "1" + } + } + } +} +``` + +### LM Studio + +```json +{ + "mcpServers": { + "google_workspace": { + "command": "uvx", + "args": ["workspace-mcp"], + "env": { + "GOOGLE_OAUTH_CLIENT_ID": "your-client-id", + "GOOGLE_OAUTH_CLIENT_SECRET": "your-secret", + "OAUTHLIB_INSECURE_TRANSPORT": "1", + "USER_GOOGLE_EMAIL": "you@example.com" + } + } + } +} +``` + +### VS Code + +```json +{ + "servers": { + "google-workspace": { + "url": "http://localhost:8000/mcp/", + "type": "http" + } + } +} +``` + +### Claude Code + +```bash +claude mcp add --transport http workspace-mcp http://localhost:8000/mcp +``` + +--- + +## 🏗 Architecture + +``` +google_workspace_mcp/ +├── auth/ # OAuth 2.0/2.1, credential storage, decorators +├── core/ # MCP server, tool registry, utilities +├── gcalendar/ # Calendar tools +├── gchat/ # Chat tools +├── gdocs/ # Docs tools + managers (tables, headers, batch) +├── gdrive/ # Drive tools + helpers +├── gforms/ # Forms tools +├── gmail/ # Gmail tools +├── gsearch/ # Custom Search tools +├── gsheets/ # Sheets tools + helpers +├── gslides/ # Slides tools +├── gtasks/ # Tasks tools +└── main.py # Entry point +``` + +### Key Patterns + +**Service Decorator:** All tools use `@require_google_service()` for automatic authentication with 30-minute service caching. + +```python +@server.tool() +@require_google_service("gmail", "gmail_read") +async def search_gmail_messages(service, user_google_email: str, query: str): + # service is injected automatically + ... +``` + +**Multi-Service Tools:** Some tools need multiple APIs: + +```python +@require_multiple_services([ + {"service_type": "drive", "scopes": "drive_read", "param_name": "drive_service"}, + {"service_type": "docs", "scopes": "docs_read", "param_name": "docs_service"}, +]) +async def get_doc_content(drive_service, docs_service, ...): + ... +``` + +--- + +## 🧪 Development + +```bash +git clone https://github.com/taylorwilsdon/google_workspace_mcp.git +cd google_workspace_mcp + +# Install with dev dependencies +uv sync --group dev + +# Run locally +uv run main.py + +# Run tests +uv run pytest + +# Lint +uv run ruff check . +``` + +--- + +## 📄 License + +MIT License - see [LICENSE](LICENSE) for details. + +--- + +
+ +**[Documentation](https://workspacemcp.com)** • **[Issues](https://github.com/taylorwilsdon/google_workspace_mcp/issues)** • **[PyPI](https://pypi.org/project/workspace-mcp/)** + +
diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5fb6f32 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,48 @@ +# Security Policy + +## Reporting Security Issues + +**Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.** + +Instead, please email us at **taylor@workspacemcp.com** + +Please include as much of the following information as you can to help us better understand and resolve the issue: + +- The type of issue (e.g., authentication bypass, credential exposure, command injection, etc.) +- Full paths of source file(s) related to the manifestation of the issue +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- Step-by-step instructions to reproduce the issue +- Proof-of-concept or exploit code (if possible) +- Impact of the issue, including how an attacker might exploit the issue + +This information will help us triage your report more quickly. + +## Supported Versions + +We release patches for security vulnerabilities. Which versions are eligible for receiving such patches depends on the CVSS v3.0 Rating: + +| Version | Supported | +| ------- | ------------------ | +| 1.4.x | :white_check_mark: | +| < 1.4 | :x: | + +## Security Considerations + +When using this MCP server, please ensure: + +1. Store Google OAuth credentials securely +2. Never commit credentials to version control +3. Use environment variables for sensitive configuration +4. Regularly rotate OAuth refresh tokens +5. Limit OAuth scopes to only what's necessary + +For more information on securing your use of the project, see https://workspacemcp.com/privacy + +## Preferred Languages + +We prefer all communications to be in English. + +## Policy + +We follow the principle of responsible disclosure. We will make every effort to address security issues in a timely manner and will coordinate with reporters to understand and resolve issues before public disclosure. \ No newline at end of file diff --git a/auth/__init__.py b/auth/__init__.py new file mode 100644 index 0000000..2762636 --- /dev/null +++ b/auth/__init__.py @@ -0,0 +1 @@ +# Make the auth directory a Python package diff --git a/auth/auth_info_middleware.py b/auth/auth_info_middleware.py new file mode 100644 index 0000000..af11993 --- /dev/null +++ b/auth/auth_info_middleware.py @@ -0,0 +1,378 @@ +""" +Authentication middleware to populate context state with user information +""" + +import logging +import time + +from fastmcp.server.middleware import Middleware, MiddlewareContext +from fastmcp.server.dependencies import get_access_token +from fastmcp.server.dependencies import get_http_headers + +from auth.external_oauth_provider import get_session_time +from auth.oauth21_session_store import ensure_session_from_access_token +from auth.oauth_types import WorkspaceAccessToken + +# Configure logging +logger = logging.getLogger(__name__) + + +class AuthInfoMiddleware(Middleware): + """ + Middleware to extract authentication information from JWT tokens + and populate the FastMCP context state for use in tools and prompts. + """ + + def __init__(self): + super().__init__() + self.auth_provider_type = "GoogleProvider" + + async def _process_request_for_auth(self, context: MiddlewareContext): + """Helper to extract, verify, and store auth info from a request.""" + if not context.fastmcp_context: + logger.warning("No fastmcp_context available") + return + + authenticated_user = None + auth_via = None + + # First check if FastMCP has already validated an access token + try: + access_token = get_access_token() + if access_token: + logger.info("[AuthInfoMiddleware] FastMCP access_token found") + user_email = getattr(access_token, "email", None) + if not user_email and hasattr(access_token, "claims"): + user_email = access_token.claims.get("email") + + if user_email: + logger.info( + f"✓ Using FastMCP validated token for user: {user_email}" + ) + await context.fastmcp_context.set_state( + "authenticated_user_email", user_email + ) + await context.fastmcp_context.set_state( + "authenticated_via", "fastmcp_oauth" + ) + await context.fastmcp_context.set_state( + "access_token", access_token, serializable=False + ) + authenticated_user = user_email + auth_via = "fastmcp_oauth" + else: + logger.warning( + f"FastMCP access_token found but no email. Type: {type(access_token).__name__}" + ) + except Exception as e: + logger.debug(f"Could not get FastMCP access_token: {e}") + + # Try to get the HTTP request to extract Authorization header + if not authenticated_user: + try: + # Use the new FastMCP method to get HTTP headers + headers = get_http_headers() + logger.info( + f"[AuthInfoMiddleware] get_http_headers() returned: {headers is not None}, keys: {list(headers.keys()) if headers else 'None'}" + ) + if headers: + logger.debug("Processing HTTP headers for authentication") + + # Get the Authorization header + auth_header = headers.get("authorization", "") + if auth_header.startswith("Bearer "): + token_str = auth_header[7:] # Remove "Bearer " prefix + logger.info("Found Bearer token in request") + + # For Google OAuth tokens (ya29.*), we need to verify them differently + if token_str.startswith("ya29."): + logger.debug("Detected Google OAuth access token format") + + # Verify the token to get user info + from core.server import get_auth_provider + + auth_provider = get_auth_provider() + + if auth_provider: + try: + # Verify the token + verified_auth = await auth_provider.verify_token( + token_str + ) + if verified_auth: + # Extract user email from verified token + user_email = getattr( + verified_auth, "email", None + ) + if not user_email and hasattr( + verified_auth, "claims" + ): + user_email = verified_auth.claims.get( + "email" + ) + + if isinstance( + verified_auth, WorkspaceAccessToken + ): + # ExternalOAuthProvider returns a fully-formed WorkspaceAccessToken + access_token = verified_auth + else: + # Standard GoogleProvider returns a base AccessToken; + # wrap it in WorkspaceAccessToken for identical downstream handling + verified_expires = getattr( + verified_auth, "expires_at", None + ) + access_token = WorkspaceAccessToken( + token=token_str, + client_id=getattr( + verified_auth, "client_id", None + ) + or "google", + scopes=getattr( + verified_auth, "scopes", [] + ) + or [], + session_id=f"google_oauth_{token_str[:8]}", + expires_at=verified_expires + if verified_expires is not None + else int(time.time()) + + get_session_time(), + claims=getattr( + verified_auth, "claims", {} + ) + or {}, + sub=getattr(verified_auth, "sub", None) + or user_email, + email=user_email, + ) + + # Store in context state - this is the authoritative authentication state + await context.fastmcp_context.set_state( + "access_token", + access_token, + serializable=False, + ) + mcp_session_id = getattr( + context.fastmcp_context, "session_id", None + ) + ensure_session_from_access_token( + access_token, + user_email, + mcp_session_id, + ) + await context.fastmcp_context.set_state( + "auth_provider_type", + self.auth_provider_type, + ) + await context.fastmcp_context.set_state( + "token_type", "google_oauth" + ) + await context.fastmcp_context.set_state( + "user_email", user_email + ) + await context.fastmcp_context.set_state( + "username", user_email + ) + # Set the definitive authentication state + await context.fastmcp_context.set_state( + "authenticated_user_email", user_email + ) + await context.fastmcp_context.set_state( + "authenticated_via", "bearer_token" + ) + authenticated_user = user_email + auth_via = "bearer_token" + else: + logger.error( + "Failed to verify Google OAuth token" + ) + except Exception as e: + logger.error( + f"Error verifying Google OAuth token: {e}" + ) + else: + logger.warning( + "No auth provider available to verify Google token" + ) + + else: + # Non-Google JWT tokens require verification + # SECURITY: Never set authenticated_user_email from unverified tokens + logger.debug( + "Unverified JWT token rejected - only verified tokens accepted" + ) + else: + logger.debug("No Bearer token in Authorization header") + else: + logger.debug( + "No HTTP headers available (might be using stdio transport)" + ) + except Exception as e: + logger.debug(f"Could not get HTTP request: {e}") + + # After trying HTTP headers, check for other authentication methods + # This consolidates all authentication logic in the middleware + if not authenticated_user: + logger.debug( + "No authentication found via bearer token, checking other methods" + ) + + # Check transport mode + from core.config import get_transport_mode + + transport_mode = get_transport_mode() + + if transport_mode == "stdio": + # In stdio mode, check if there's a session with credentials + # This is ONLY safe in stdio mode because it's single-user + logger.debug("Checking for stdio mode authentication") + + # Get the requested user from the context if available + requested_user = None + if hasattr(context, "request") and hasattr(context.request, "params"): + requested_user = context.request.params.get("user_google_email") + elif hasattr(context, "arguments"): + # FastMCP may store arguments differently + requested_user = context.arguments.get("user_google_email") + + if requested_user: + try: + from auth.oauth21_session_store import get_oauth21_session_store + + store = get_oauth21_session_store() + + # Check if user has a recent session + if store.has_session(requested_user): + logger.debug( + f"Using recent stdio session for {requested_user}" + ) + # In stdio mode, we can trust the user has authenticated recently + await context.fastmcp_context.set_state( + "authenticated_user_email", requested_user + ) + await context.fastmcp_context.set_state( + "authenticated_via", "stdio_session" + ) + await context.fastmcp_context.set_state( + "auth_provider_type", "oauth21_stdio" + ) + authenticated_user = requested_user + auth_via = "stdio_session" + except Exception as e: + logger.debug(f"Error checking stdio session: {e}") + + # If no requested user was provided but exactly one session exists, assume it in stdio mode + if not authenticated_user: + try: + from auth.oauth21_session_store import get_oauth21_session_store + + store = get_oauth21_session_store() + single_user = store.get_single_user_email() + if single_user: + logger.debug( + f"Defaulting to single stdio OAuth session for {single_user}" + ) + await context.fastmcp_context.set_state( + "authenticated_user_email", single_user + ) + await context.fastmcp_context.set_state( + "authenticated_via", "stdio_single_session" + ) + await context.fastmcp_context.set_state( + "auth_provider_type", "oauth21_stdio" + ) + await context.fastmcp_context.set_state( + "user_email", single_user + ) + await context.fastmcp_context.set_state( + "username", single_user + ) + authenticated_user = single_user + auth_via = "stdio_single_session" + except Exception as e: + logger.debug( + f"Error determining stdio single-user session: {e}" + ) + + # Check for MCP session binding + if not authenticated_user and hasattr( + context.fastmcp_context, "session_id" + ): + mcp_session_id = context.fastmcp_context.session_id + if mcp_session_id: + try: + from auth.oauth21_session_store import get_oauth21_session_store + + store = get_oauth21_session_store() + + # Check if this MCP session is bound to a user + bound_user = store.get_user_by_mcp_session(mcp_session_id) + if bound_user: + logger.debug(f"MCP session bound to {bound_user}") + await context.fastmcp_context.set_state( + "authenticated_user_email", bound_user + ) + await context.fastmcp_context.set_state( + "authenticated_via", "mcp_session_binding" + ) + await context.fastmcp_context.set_state( + "auth_provider_type", "oauth21_session" + ) + authenticated_user = bound_user + auth_via = "mcp_session_binding" + except Exception as e: + logger.debug(f"Error checking MCP session binding: {e}") + + # Single exit point with logging + if authenticated_user: + logger.info(f"✓ Authenticated via {auth_via}: {authenticated_user}") + auth_email = await context.fastmcp_context.get_state( + "authenticated_user_email" + ) + logger.debug( + f"Context state after auth: authenticated_user_email={auth_email}" + ) + + async def on_call_tool(self, context: MiddlewareContext, call_next): + """Extract auth info from token and set in context state""" + logger.debug("Processing tool call authentication") + + try: + await self._process_request_for_auth(context) + + logger.debug("Passing to next handler") + result = await call_next(context) + logger.debug("Handler completed") + return result + + except Exception as e: + # Check if this is an authentication error - don't log traceback for these + if "GoogleAuthenticationError" in str( + type(e) + ) or "Access denied: Cannot retrieve credentials" in str(e): + logger.info(f"Authentication check failed: {e}") + else: + logger.error(f"Error in on_call_tool middleware: {e}", exc_info=True) + raise + + async def on_get_prompt(self, context: MiddlewareContext, call_next): + """Extract auth info for prompt requests too""" + logger.debug("Processing prompt authentication") + + try: + await self._process_request_for_auth(context) + + logger.debug("Passing prompt to next handler") + result = await call_next(context) + logger.debug("Prompt handler completed") + return result + + except Exception as e: + # Check if this is an authentication error - don't log traceback for these + if "GoogleAuthenticationError" in str( + type(e) + ) or "Access denied: Cannot retrieve credentials" in str(e): + logger.info(f"Authentication check failed in prompt: {e}") + else: + logger.error(f"Error in on_get_prompt middleware: {e}", exc_info=True) + raise diff --git a/auth/credential_store.py b/auth/credential_store.py new file mode 100644 index 0000000..9dff429 --- /dev/null +++ b/auth/credential_store.py @@ -0,0 +1,266 @@ +""" +Credential Store API for Google Workspace MCP + +This module provides a standardized interface for credential storage and retrieval, +supporting multiple backends configurable via environment variables. +""" + +import os +import json +import logging +from abc import ABC, abstractmethod +from typing import Optional, List +from datetime import datetime +from google.oauth2.credentials import Credentials + +logger = logging.getLogger(__name__) + + +class CredentialStore(ABC): + """Abstract base class for credential storage.""" + + @abstractmethod + def get_credential(self, user_email: str) -> Optional[Credentials]: + """ + Get credentials for a user by email. + + Args: + user_email: User's email address + + Returns: + Google Credentials object or None if not found + """ + pass + + @abstractmethod + def store_credential(self, user_email: str, credentials: Credentials) -> bool: + """ + Store credentials for a user. + + Args: + user_email: User's email address + credentials: Google Credentials object to store + + Returns: + True if successfully stored, False otherwise + """ + pass + + @abstractmethod + def delete_credential(self, user_email: str) -> bool: + """ + Delete credentials for a user. + + Args: + user_email: User's email address + + Returns: + True if successfully deleted, False otherwise + """ + pass + + @abstractmethod + def list_users(self) -> List[str]: + """ + List all users with stored credentials. + + Returns: + List of user email addresses + """ + pass + + +class LocalDirectoryCredentialStore(CredentialStore): + """Credential store that uses local JSON files for storage.""" + + def __init__(self, base_dir: Optional[str] = None): + """ + Initialize the local JSON credential store. + + Args: + base_dir: Base directory for credential files. If None, uses the directory + configured by environment variables in this order: + 1. WORKSPACE_MCP_CREDENTIALS_DIR (preferred) + 2. GOOGLE_MCP_CREDENTIALS_DIR (backward compatibility) + 3. ~/.google_workspace_mcp/credentials (default) + """ + if base_dir is None: + # Check WORKSPACE_MCP_CREDENTIALS_DIR first (preferred) + workspace_creds_dir = os.getenv("WORKSPACE_MCP_CREDENTIALS_DIR") + google_creds_dir = os.getenv("GOOGLE_MCP_CREDENTIALS_DIR") + + if workspace_creds_dir: + base_dir = os.path.expanduser(workspace_creds_dir) + logger.info( + f"Using credentials directory from WORKSPACE_MCP_CREDENTIALS_DIR: {base_dir}" + ) + # Fall back to GOOGLE_MCP_CREDENTIALS_DIR for backward compatibility + elif google_creds_dir: + base_dir = os.path.expanduser(google_creds_dir) + logger.info( + f"Using credentials directory from GOOGLE_MCP_CREDENTIALS_DIR: {base_dir}" + ) + else: + home_dir = os.path.expanduser("~") + if home_dir and home_dir != "~": + base_dir = os.path.join( + home_dir, ".google_workspace_mcp", "credentials" + ) + else: + base_dir = os.path.join(os.getcwd(), ".credentials") + logger.info(f"Using default credentials directory: {base_dir}") + + self.base_dir = base_dir + logger.info( + f"LocalDirectoryCredentialStore initialized with base_dir: {base_dir}" + ) + + def _get_credential_path(self, user_email: str) -> str: + """Get the file path for a user's credentials.""" + if not os.path.exists(self.base_dir): + os.makedirs(self.base_dir) + logger.info(f"Created credentials directory: {self.base_dir}") + return os.path.join(self.base_dir, f"{user_email}.json") + + def get_credential(self, user_email: str) -> Optional[Credentials]: + """Get credentials from local JSON file.""" + creds_path = self._get_credential_path(user_email) + + if not os.path.exists(creds_path): + logger.debug(f"No credential file found for {user_email} at {creds_path}") + return None + + try: + with open(creds_path, "r") as f: + creds_data = json.load(f) + + # Parse expiry if present + expiry = None + if creds_data.get("expiry"): + try: + expiry = datetime.fromisoformat(creds_data["expiry"]) + # Ensure timezone-naive datetime for Google auth library compatibility + if expiry.tzinfo is not None: + expiry = expiry.replace(tzinfo=None) + except (ValueError, TypeError) as e: + logger.warning(f"Could not parse expiry time for {user_email}: {e}") + + credentials = Credentials( + token=creds_data.get("token"), + refresh_token=creds_data.get("refresh_token"), + token_uri=creds_data.get("token_uri"), + client_id=creds_data.get("client_id"), + client_secret=creds_data.get("client_secret"), + scopes=creds_data.get("scopes"), + expiry=expiry, + ) + + logger.debug(f"Loaded credentials for {user_email} from {creds_path}") + return credentials + + except (IOError, json.JSONDecodeError, KeyError) as e: + logger.error( + f"Error loading credentials for {user_email} from {creds_path}: {e}" + ) + return None + + def store_credential(self, user_email: str, credentials: Credentials) -> bool: + """Store credentials to local JSON file.""" + creds_path = self._get_credential_path(user_email) + + creds_data = { + "token": credentials.token, + "refresh_token": credentials.refresh_token, + "token_uri": credentials.token_uri, + "client_id": credentials.client_id, + "client_secret": credentials.client_secret, + "scopes": credentials.scopes, + "expiry": credentials.expiry.isoformat() if credentials.expiry else None, + } + + try: + with open(creds_path, "w") as f: + json.dump(creds_data, f, indent=2) + logger.info(f"Stored credentials for {user_email} to {creds_path}") + return True + except IOError as e: + logger.error( + f"Error storing credentials for {user_email} to {creds_path}: {e}" + ) + return False + + def delete_credential(self, user_email: str) -> bool: + """Delete credential file for a user.""" + creds_path = self._get_credential_path(user_email) + + try: + if os.path.exists(creds_path): + os.remove(creds_path) + logger.info(f"Deleted credentials for {user_email} from {creds_path}") + return True + else: + logger.debug( + f"No credential file to delete for {user_email} at {creds_path}" + ) + return True # Consider it a success if file doesn't exist + except IOError as e: + logger.error( + f"Error deleting credentials for {user_email} from {creds_path}: {e}" + ) + return False + + def list_users(self) -> List[str]: + """List all users with credential files.""" + if not os.path.exists(self.base_dir): + return [] + + users = [] + non_credential_files = {"oauth_states"} + try: + for filename in os.listdir(self.base_dir): + if filename.endswith(".json"): + user_email = filename[:-5] # Remove .json extension + if user_email in non_credential_files or "@" not in user_email: + continue + users.append(user_email) + logger.debug( + f"Found {len(users)} users with credentials in {self.base_dir}" + ) + except OSError as e: + logger.error(f"Error listing credential files in {self.base_dir}: {e}") + + return sorted(users) + + +# Global credential store instance +_credential_store: Optional[CredentialStore] = None + + +def get_credential_store() -> CredentialStore: + """ + Get the global credential store instance. + + Returns: + Configured credential store instance + """ + global _credential_store + + if _credential_store is None: + # always use LocalJsonCredentialStore as the default + # Future enhancement: support other backends via environment variables + _credential_store = LocalDirectoryCredentialStore() + logger.info(f"Initialized credential store: {type(_credential_store).__name__}") + + return _credential_store + + +def set_credential_store(store: CredentialStore): + """ + Set the global credential store instance. + + Args: + store: Credential store instance to use + """ + global _credential_store + _credential_store = store + logger.info(f"Set credential store: {type(store).__name__}") diff --git a/auth/external_oauth_provider.py b/auth/external_oauth_provider.py new file mode 100644 index 0000000..c4103ed --- /dev/null +++ b/auth/external_oauth_provider.py @@ -0,0 +1,188 @@ +""" +External OAuth Provider for Google Workspace MCP + +Extends FastMCP's GoogleProvider to support external OAuth flows where +access tokens (ya29.*) are issued by external systems and need validation. + +This provider acts as a Resource Server only - it validates tokens issued by +Google's Authorization Server but does not issue tokens itself. +""" + +import functools +import logging +import os +import time +from typing import Optional + +from starlette.routing import Route +from fastmcp.server.auth.providers.google import GoogleProvider +from fastmcp.server.auth import AccessToken +from google.oauth2.credentials import Credentials + +from auth.oauth_types import WorkspaceAccessToken + +logger = logging.getLogger(__name__) + +# Google's OAuth 2.0 Authorization Server +GOOGLE_ISSUER_URL = "https://accounts.google.com" + +# Configurable session time in seconds (default: 1 hour, max: 24 hours) +_DEFAULT_SESSION_TIME = 3600 +_MAX_SESSION_TIME = 86400 + + +@functools.lru_cache(maxsize=1) +def get_session_time() -> int: + """Parse SESSION_TIME from environment with fallback, min/max clamp. + + Result is cached; changes require a server restart. + """ + raw = os.getenv("SESSION_TIME", "") + if not raw: + return _DEFAULT_SESSION_TIME + try: + value = int(raw) + except ValueError: + logger.warning( + "Invalid SESSION_TIME=%r, falling back to %d", raw, _DEFAULT_SESSION_TIME + ) + return _DEFAULT_SESSION_TIME + clamped = max(1, min(value, _MAX_SESSION_TIME)) + if clamped != value: + logger.warning( + "SESSION_TIME=%d clamped to %d (allowed range: 1–%d)", + value, + clamped, + _MAX_SESSION_TIME, + ) + return clamped + + +class ExternalOAuthProvider(GoogleProvider): + """ + Extended GoogleProvider that supports validating external Google OAuth access tokens. + + This provider handles ya29.* access tokens by calling Google's userinfo API, + while maintaining compatibility with standard JWT ID tokens. + + Unlike the standard GoogleProvider, this acts as a Resource Server only: + - Does NOT create /authorize, /token, /register endpoints + - Only advertises Google's authorization server in metadata + - Only validates tokens, does not issue them + """ + + def __init__( + self, + client_id: str, + client_secret: str, + resource_server_url: Optional[str] = None, + **kwargs, + ): + """Initialize and store client credentials for token validation.""" + self._resource_server_url = resource_server_url + super().__init__(client_id=client_id, client_secret=client_secret, **kwargs) + # Store credentials as they're not exposed by parent class + self._client_id = client_id + self._client_secret = client_secret + # Store as string - Pydantic validates it when passed to models + self.resource_server_url = self._resource_server_url + + async def verify_token(self, token: str) -> Optional[AccessToken]: + """ + Verify a token - supports both JWT ID tokens and ya29.* access tokens. + + For ya29.* access tokens (issued externally), validates by calling + Google's userinfo API. For JWT tokens, delegates to parent class. + + Args: + token: Token string to verify (JWT or ya29.* access token) + + Returns: + AccessToken object if valid, None otherwise + """ + # For ya29.* access tokens, validate using Google's userinfo API + if token.startswith("ya29."): + logger.debug("Validating external Google OAuth access token") + + try: + from auth.google_auth import get_user_info + + # Create minimal Credentials object for userinfo API call + credentials = Credentials( + token=token, + token_uri="https://oauth2.googleapis.com/token", + client_id=self._client_id, + client_secret=self._client_secret, + ) + + # Validate token by calling userinfo API + user_info = get_user_info(credentials, skip_valid_check=True) + + if user_info and user_info.get("email"): + session_time = get_session_time() + # Token is valid - create AccessToken object + logger.info( + f"Validated external access token for: {user_info['email']}" + ) + + scope_list = list(getattr(self, "required_scopes", []) or []) + access_token = WorkspaceAccessToken( + token=token, + scopes=scope_list, + expires_at=int(time.time()) + session_time, + claims={ + "email": user_info["email"], + "sub": user_info.get("id"), + }, + client_id=self._client_id, + email=user_info["email"], + sub=user_info.get("id"), + ) + return access_token + else: + logger.error("Could not get user info from access token") + return None + + except Exception as e: + logger.error(f"Error validating external access token: {e}") + return None + + # For JWT tokens, use parent class implementation + return await super().verify_token(token) + + def get_routes(self, **kwargs) -> list[Route]: + """ + Get OAuth routes for external provider mode. + + Returns only protected resource metadata routes that point to Google + as the authorization server. Does not create authorization server routes + (/authorize, /token, etc.) since tokens are issued by Google directly. + + Args: + **kwargs: Additional arguments passed by FastMCP (e.g., mcp_path) + + Returns: + List of routes - only protected resource metadata + """ + from mcp.server.auth.routes import create_protected_resource_routes + + if not self.resource_server_url: + logger.warning( + "ExternalOAuthProvider: resource_server_url not set, no routes created" + ) + return [] + + # Create protected resource routes that point to Google as the authorization server + # Pass strings directly - Pydantic validates them during model construction + protected_routes = create_protected_resource_routes( + resource_url=self.resource_server_url, + authorization_servers=[GOOGLE_ISSUER_URL], + scopes_supported=self.required_scopes, + resource_name="Google Workspace MCP", + resource_documentation=None, + ) + + logger.info( + f"ExternalOAuthProvider: Created protected resource routes pointing to {GOOGLE_ISSUER_URL}" + ) + return protected_routes diff --git a/auth/google_auth.py b/auth/google_auth.py new file mode 100644 index 0000000..fe70499 --- /dev/null +++ b/auth/google_auth.py @@ -0,0 +1,1166 @@ +# auth/google_auth.py + +import asyncio +import json +import jwt +import logging +import os + +from typing import List, Optional, Tuple, Dict, Any +from urllib.parse import parse_qs, urlparse + +from google.oauth2.credentials import Credentials +from google_auth_oauthlib.flow import Flow +from google.auth.transport.requests import Request +from google.auth.exceptions import RefreshError +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError +from auth.scopes import SCOPES, get_current_scopes, has_required_scopes # noqa +from auth.oauth21_session_store import get_oauth21_session_store +from auth.credential_store import get_credential_store +from auth.oauth_config import get_oauth_config, is_stateless_mode +from core.config import ( + get_transport_mode, + get_oauth_redirect_uri, +) +from core.context import get_fastmcp_session_id + +# Try to import FastMCP dependencies (may not be available in all environments) +try: + from fastmcp.server.dependencies import get_context as get_fastmcp_context +except ImportError: + get_fastmcp_context = None + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +# Constants +def get_default_credentials_dir(): + """Get the default credentials directory path, preferring user-specific locations. + + Environment variable priority: + 1. WORKSPACE_MCP_CREDENTIALS_DIR (preferred) + 2. GOOGLE_MCP_CREDENTIALS_DIR (backward compatibility) + 3. ~/.google_workspace_mcp/credentials (default) + """ + # Check WORKSPACE_MCP_CREDENTIALS_DIR first (preferred) + workspace_creds_dir = os.getenv("WORKSPACE_MCP_CREDENTIALS_DIR") + if workspace_creds_dir: + expanded = os.path.expanduser(workspace_creds_dir) + logger.info( + f"Using credentials directory from WORKSPACE_MCP_CREDENTIALS_DIR: {expanded}" + ) + return expanded + + # Fall back to GOOGLE_MCP_CREDENTIALS_DIR for backward compatibility + google_creds_dir = os.getenv("GOOGLE_MCP_CREDENTIALS_DIR") + if google_creds_dir: + expanded = os.path.expanduser(google_creds_dir) + logger.info( + f"Using credentials directory from GOOGLE_MCP_CREDENTIALS_DIR: {expanded}" + ) + return expanded + + # Use user home directory for credentials storage + home_dir = os.path.expanduser("~") + if home_dir and home_dir != "~": # Valid home directory found + return os.path.join(home_dir, ".google_workspace_mcp", "credentials") + + # Fallback to current working directory if home directory is not accessible + return os.path.join(os.getcwd(), ".credentials") + + +DEFAULT_CREDENTIALS_DIR = get_default_credentials_dir() + +# Session credentials now handled by OAuth21SessionStore - no local cache needed +# Centralized Client Secrets Path Logic +_client_secrets_env = os.getenv("GOOGLE_CLIENT_SECRET_PATH") or os.getenv( + "GOOGLE_CLIENT_SECRETS" +) +if _client_secrets_env: + CONFIG_CLIENT_SECRETS_PATH = _client_secrets_env +else: + # Assumes this file is in auth/ and client_secret.json is in the root + CONFIG_CLIENT_SECRETS_PATH = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "client_secret.json", + ) + +# --- Helper Functions --- + + +def _find_any_credentials( + base_dir: str = DEFAULT_CREDENTIALS_DIR, +) -> tuple[Optional[Credentials], Optional[str]]: + """ + Find and load any valid credentials from the credentials directory. + Used in single-user mode to bypass session-to-OAuth mapping. + + Returns: + Tuple of (Credentials, user_email) or (None, None) if none exist. + Returns the user email to enable saving refreshed credentials. + """ + try: + store = get_credential_store() + users = store.list_users() + if not users: + logger.info( + "[single-user] No users found with credentials via credential store" + ) + return None, None + + # Return credentials for the first user found + first_user = users[0] + credentials = store.get_credential(first_user) + if credentials: + logger.info( + f"[single-user] Found credentials for {first_user} via credential store" + ) + return credentials, first_user + else: + logger.warning( + f"[single-user] Could not load credentials for {first_user} via credential store" + ) + + except Exception as e: + logger.error( + f"[single-user] Error finding credentials via credential store: {e}" + ) + + logger.info("[single-user] No valid credentials found via credential store") + return None, None + + +def save_credentials_to_session(session_id: str, credentials: Credentials): + """Saves user credentials using OAuth21SessionStore.""" + # Get user email from credentials if possible + user_email = None + if credentials and credentials.id_token: + try: + decoded_token = jwt.decode( + credentials.id_token, options={"verify_signature": False} + ) + user_email = decoded_token.get("email") + except Exception as e: + logger.debug(f"Could not decode id_token to get email: {e}") + + if user_email: + store = get_oauth21_session_store() + store.store_session( + user_email=user_email, + access_token=credentials.token, + refresh_token=credentials.refresh_token, + token_uri=credentials.token_uri, + client_id=credentials.client_id, + client_secret=credentials.client_secret, + scopes=credentials.scopes, + expiry=credentials.expiry, + mcp_session_id=session_id, + ) + logger.debug( + f"Credentials saved to OAuth21SessionStore for session_id: {session_id}, user: {user_email}" + ) + else: + logger.warning( + f"Could not save credentials to session store - no user email found for session: {session_id}" + ) + + +def load_credentials_from_session(session_id: str) -> Optional[Credentials]: + """Loads user credentials from OAuth21SessionStore.""" + store = get_oauth21_session_store() + credentials = store.get_credentials_by_mcp_session(session_id) + if credentials: + logger.debug( + f"Credentials loaded from OAuth21SessionStore for session_id: {session_id}" + ) + else: + logger.debug( + f"No credentials found in OAuth21SessionStore for session_id: {session_id}" + ) + return credentials + + +def load_client_secrets_from_env() -> Optional[Dict[str, Any]]: + """ + Loads the client secrets from environment variables. + + Environment variables used: + - GOOGLE_OAUTH_CLIENT_ID: OAuth 2.0 client ID + - GOOGLE_OAUTH_CLIENT_SECRET: OAuth 2.0 client secret + - GOOGLE_OAUTH_REDIRECT_URI: (optional) OAuth redirect URI + + Returns: + Client secrets configuration dict compatible with Google OAuth library, + or None if required environment variables are not set. + """ + client_id = os.getenv("GOOGLE_OAUTH_CLIENT_ID") + client_secret = os.getenv("GOOGLE_OAUTH_CLIENT_SECRET") + redirect_uri = os.getenv("GOOGLE_OAUTH_REDIRECT_URI") + + if client_id and client_secret: + # Create config structure that matches Google client secrets format + web_config = { + "client_id": client_id, + "client_secret": client_secret, + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + } + + # Add redirect_uri if provided via environment variable + if redirect_uri: + web_config["redirect_uris"] = [redirect_uri] + + # Return the full config structure expected by Google OAuth library + config = {"web": web_config} + + logger.info("Loaded OAuth client credentials from environment variables") + return config + + logger.debug("OAuth client credentials not found in environment variables") + return None + + +def load_client_secrets(client_secrets_path: str) -> Dict[str, Any]: + """ + Loads the client secrets from environment variables (preferred) or from the client secrets file. + + Priority order: + 1. Environment variables (GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET) + 2. File-based credentials at the specified path + + Args: + client_secrets_path: Path to the client secrets JSON file (used as fallback) + + Returns: + Client secrets configuration dict + + Raises: + ValueError: If client secrets file has invalid format + IOError: If file cannot be read and no environment variables are set + """ + # First, try to load from environment variables + env_config = load_client_secrets_from_env() + if env_config: + # Extract the "web" config from the environment structure + return env_config["web"] + + # Fall back to loading from file + try: + with open(client_secrets_path, "r") as f: + client_config = json.load(f) + # The file usually contains a top-level key like "web" or "installed" + if "web" in client_config: + logger.info( + f"Loaded OAuth client credentials from file: {client_secrets_path}" + ) + return client_config["web"] + elif "installed" in client_config: + logger.info( + f"Loaded OAuth client credentials from file: {client_secrets_path}" + ) + return client_config["installed"] + else: + logger.error( + f"Client secrets file {client_secrets_path} has unexpected format." + ) + raise ValueError("Invalid client secrets file format") + except (IOError, json.JSONDecodeError) as e: + logger.error(f"Error loading client secrets file {client_secrets_path}: {e}") + raise + + +def check_client_secrets() -> Optional[str]: + """ + Checks for the presence of OAuth client secrets, either as environment + variables or as a file. + + Returns: + An error message string if secrets are not found, otherwise None. + """ + env_config = load_client_secrets_from_env() + if not env_config and not os.path.exists(CONFIG_CLIENT_SECRETS_PATH): + logger.error( + f"OAuth client credentials not found. No environment variables set and no file at {CONFIG_CLIENT_SECRETS_PATH}" + ) + return f"OAuth client credentials not found. Please set GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET environment variables or provide a client secrets file at {CONFIG_CLIENT_SECRETS_PATH}." + return None + + +def create_oauth_flow( + scopes: List[str], + redirect_uri: str, + state: Optional[str] = None, + code_verifier: Optional[str] = None, + autogenerate_code_verifier: bool = True, +) -> Flow: + """Creates an OAuth flow using environment variables or client secrets file.""" + flow_kwargs = { + "scopes": scopes, + "redirect_uri": redirect_uri, + "state": state, + } + if code_verifier: + flow_kwargs["code_verifier"] = code_verifier + # Preserve the original verifier when re-creating the flow in callback. + flow_kwargs["autogenerate_code_verifier"] = False + else: + # Generate PKCE code verifier for the initial auth flow. + # google-auth-oauthlib's from_client_* helpers pass + # autogenerate_code_verifier=None unless explicitly provided, which + # prevents Flow from generating and storing a code_verifier. + flow_kwargs["autogenerate_code_verifier"] = autogenerate_code_verifier + + # Try environment variables first + env_config = load_client_secrets_from_env() + if env_config: + # Use client config directly + flow = Flow.from_client_config(env_config, **flow_kwargs) + logger.debug("Created OAuth flow from environment variables") + return flow + + # Fall back to file-based config + if not os.path.exists(CONFIG_CLIENT_SECRETS_PATH): + raise FileNotFoundError( + f"OAuth client secrets file not found at {CONFIG_CLIENT_SECRETS_PATH} and no environment variables set" + ) + + flow = Flow.from_client_secrets_file( + CONFIG_CLIENT_SECRETS_PATH, + **flow_kwargs, + ) + logger.debug( + f"Created OAuth flow from client secrets file: {CONFIG_CLIENT_SECRETS_PATH}" + ) + return flow + + +def _determine_oauth_prompt( + user_google_email: Optional[str], + required_scopes: List[str], + session_id: Optional[str] = None, +) -> str: + """ + Determine which OAuth prompt to use for a new authorization URL. + + Uses `select_account` for re-auth when existing credentials already cover + required scopes. Uses `consent` for first-time auth and scope expansion. + """ + normalized_email = ( + user_google_email.strip() + if user_google_email + and user_google_email.strip() + and user_google_email.lower() != "default" + else None + ) + + # If no explicit email was provided, attempt to resolve it from session mapping. + if not normalized_email and session_id: + try: + session_user = get_oauth21_session_store().get_user_by_mcp_session( + session_id + ) + if session_user: + normalized_email = session_user + except Exception as e: + logger.debug(f"Could not resolve user from session for prompt choice: {e}") + + if not normalized_email: + logger.info( + "[start_auth_flow] Using prompt='consent' (no known user email for re-auth detection)." + ) + return "consent" + + existing_credentials: Optional[Credentials] = None + + # Prefer credentials bound to the current session when available. + if session_id: + try: + session_store = get_oauth21_session_store() + mapped_user = session_store.get_user_by_mcp_session(session_id) + if mapped_user == normalized_email: + existing_credentials = session_store.get_credentials_by_mcp_session( + session_id + ) + except Exception as e: + logger.debug( + f"Could not read OAuth 2.1 session store for prompt choice: {e}" + ) + + # Fall back to credential file store in stateful mode. + if not existing_credentials and not is_stateless_mode(): + try: + existing_credentials = get_credential_store().get_credential( + normalized_email + ) + except Exception as e: + logger.debug(f"Could not read credential store for prompt choice: {e}") + + if not existing_credentials: + logger.info( + f"[start_auth_flow] Using prompt='consent' (no existing credentials for {normalized_email})." + ) + return "consent" + + if has_required_scopes(existing_credentials.scopes, required_scopes): + logger.info( + f"[start_auth_flow] Using prompt='select_account' for re-auth of {normalized_email}." + ) + return "select_account" + + logger.info( + f"[start_auth_flow] Using prompt='consent' (existing credentials for {normalized_email} are missing required scopes)." + ) + return "consent" + + +# --- Core OAuth Logic --- + + +async def start_auth_flow( + user_google_email: Optional[str], + service_name: str, # e.g., "Google Calendar", "Gmail" for user messages + redirect_uri: str, # Added redirect_uri as a required parameter +) -> str: + """ + Initiates the Google OAuth flow and returns an actionable message for the user. + + Args: + user_google_email: The user's specified Google email, if provided. + service_name: The name of the Google service requiring auth (for user messages). + redirect_uri: The URI Google will redirect to after authorization. + + Returns: + A formatted string containing guidance for the LLM/user. + + Raises: + Exception: If the OAuth flow cannot be initiated. + """ + initial_email_provided = bool( + user_google_email + and user_google_email.strip() + and user_google_email.lower() != "default" + ) + user_display_name = ( + f"{service_name} for '{user_google_email}'" + if initial_email_provided + else service_name + ) + + logger.info( + f"[start_auth_flow] Initiating auth for {user_display_name} with scopes for enabled tools." + ) + + # Note: Caller should ensure OAuth callback is available before calling this function + + try: + if "OAUTHLIB_INSECURE_TRANSPORT" not in os.environ and ( + "localhost" in redirect_uri or "127.0.0.1" in redirect_uri + ): # Use passed redirect_uri + logger.warning( + "OAUTHLIB_INSECURE_TRANSPORT not set. Setting it for localhost/local development." + ) + os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" + + oauth_state = os.urandom(16).hex() + current_scopes = get_current_scopes() + + flow = create_oauth_flow( + scopes=current_scopes, # Use scopes for enabled tools only + redirect_uri=redirect_uri, # Use passed redirect_uri + state=oauth_state, + ) + + session_id = None + try: + session_id = get_fastmcp_session_id() + except Exception as e: + logger.debug( + f"Could not retrieve FastMCP session ID for state binding: {e}" + ) + + prompt_type = _determine_oauth_prompt( + user_google_email=user_google_email, + required_scopes=current_scopes, + session_id=session_id, + ) + auth_url, _ = flow.authorization_url(access_type="offline", prompt=prompt_type) + + store = get_oauth21_session_store() + store.store_oauth_state( + oauth_state, + session_id=session_id, + code_verifier=flow.code_verifier, + ) + + logger.info( + f"Auth flow started for {user_display_name}. Advise user to visit: {auth_url}" + ) + + message_lines = [ + f"**ACTION REQUIRED: Google Authentication Needed for {user_display_name}**\n", + f"To proceed, the user must authorize this application for {service_name} access using all required permissions.", + "**LLM, please present this exact authorization URL to the user as a clickable hyperlink:**", + f"Authorization URL: {auth_url}", + f"Markdown for hyperlink: [Click here to authorize {service_name} access]({auth_url})\n", + "**LLM, after presenting the link, instruct the user as follows:**", + "1. Click the link and complete the authorization in their browser.", + ] + session_info_for_llm = "" + + if not initial_email_provided: + message_lines.extend( + [ + f"2. After successful authorization{session_info_for_llm}, the browser page will display the authenticated email address.", + " **LLM: Instruct the user to provide you with this email address.**", + "3. Once you have the email, **retry their original command, ensuring you include this `user_google_email`.**", + ] + ) + else: + message_lines.append( + f"2. After successful authorization{session_info_for_llm}, **retry their original command**." + ) + + message_lines.append( + f"\nThe application will use the new credentials. If '{user_google_email}' was provided, it must match the authenticated account." + ) + return "\n".join(message_lines) + + except FileNotFoundError as e: + error_text = f"OAuth client credentials not found: {e}. Please either:\n1. Set environment variables: GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET\n2. Ensure '{CONFIG_CLIENT_SECRETS_PATH}' file exists" + logger.error(error_text, exc_info=True) + raise Exception(error_text) + except Exception as e: + error_text = f"Could not initiate authentication for {user_display_name} due to an unexpected error: {str(e)}" + logger.error( + f"Failed to start the OAuth flow for {user_display_name}: {e}", + exc_info=True, + ) + raise Exception(error_text) + + +def handle_auth_callback( + scopes: List[str], + authorization_response: str, + redirect_uri: str, + credentials_base_dir: str = DEFAULT_CREDENTIALS_DIR, + session_id: Optional[str] = None, + client_secrets_path: Optional[ + str + ] = None, # Deprecated: kept for backward compatibility +) -> Tuple[str, Credentials]: + """ + Handles the callback from Google, exchanges the code for credentials, + fetches user info, determines user_google_email, saves credentials (file & session), + and returns them. + + Args: + scopes: List of OAuth scopes requested. + authorization_response: The full callback URL from Google. + redirect_uri: The redirect URI. + credentials_base_dir: Base directory for credential files. + session_id: Optional MCP session ID to associate with the credentials. + client_secrets_path: (Deprecated) Path to client secrets file. Ignored if environment variables are set. + + Returns: + A tuple containing the user_google_email and the obtained Credentials object. + + Raises: + ValueError: If the state is missing or doesn't match. + FlowExchangeError: If the code exchange fails. + HttpError: If fetching user info fails. + """ + try: + # Log deprecation warning if old parameter is used + if client_secrets_path: + logger.warning( + "The 'client_secrets_path' parameter is deprecated. Use GOOGLE_OAUTH_CLIENT_ID and GOOGLE_OAUTH_CLIENT_SECRET environment variables instead." + ) + + # Allow HTTP for localhost in development + if "OAUTHLIB_INSECURE_TRANSPORT" not in os.environ: + logger.warning( + "OAUTHLIB_INSECURE_TRANSPORT not set. Setting it for localhost development." + ) + os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" + + # Allow partial scope grants without raising an exception. + # When users decline some scopes on Google's consent screen, + # oauthlib raises because the granted scopes differ from requested. + if "OAUTHLIB_RELAX_TOKEN_SCOPE" not in os.environ: + os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1" + + store = get_oauth21_session_store() + parsed_response = urlparse(authorization_response) + state_values = parse_qs(parsed_response.query).get("state") + state = state_values[0] if state_values else None + + state_info = store.validate_and_consume_oauth_state( + state, session_id=session_id + ) + logger.debug( + "Validated OAuth callback state %s for session %s", + (state[:8] if state else ""), + state_info.get("session_id") or "", + ) + + flow = create_oauth_flow( + scopes=scopes, + redirect_uri=redirect_uri, + state=state, + code_verifier=state_info.get("code_verifier"), + autogenerate_code_verifier=False, + ) + + # Exchange the authorization code for credentials + # Note: fetch_token will use the redirect_uri configured in the flow + flow.fetch_token(authorization_response=authorization_response) + credentials = flow.credentials + logger.info("Successfully exchanged authorization code for tokens.") + + # Handle partial OAuth grants: if the user declined some scopes on + # Google's consent screen, credentials.granted_scopes contains only + # what was actually authorized. Store those instead of the inflated + # requested scopes so that refresh() sends the correct scope set. + granted = getattr(credentials, "granted_scopes", None) + if granted and set(granted) != set(credentials.scopes or []): + logger.warning( + "Partial OAuth grant detected. Requested: %s, Granted: %s", + credentials.scopes, + granted, + ) + credentials = Credentials( + token=credentials.token, + refresh_token=credentials.refresh_token, + id_token=getattr(credentials, "id_token", None), + token_uri=credentials.token_uri, + client_id=credentials.client_id, + client_secret=credentials.client_secret, + scopes=list(granted), + expiry=credentials.expiry, + quota_project_id=getattr(credentials, "quota_project_id", None), + ) + + # Get user info to determine user_id (using email here) + user_info = get_user_info(credentials) + if not user_info or "email" not in user_info: + logger.error("Could not retrieve user email from Google.") + raise ValueError("Failed to get user email for identification.") + + user_google_email = user_info["email"] + logger.info(f"Identified user_google_email: {user_google_email}") + + credential_store = get_credential_store() + if not credentials.refresh_token: + fallback_refresh_token = None + + if session_id: + try: + session_credentials = store.get_credentials_by_mcp_session( + session_id + ) + if session_credentials and session_credentials.refresh_token: + fallback_refresh_token = session_credentials.refresh_token + logger.info( + "OAuth callback response omitted refresh token; preserving existing refresh token from session store." + ) + except Exception as e: + logger.debug( + f"Could not check session store for existing refresh token: {e}" + ) + + if not fallback_refresh_token and not is_stateless_mode(): + try: + existing_credentials = credential_store.get_credential( + user_google_email + ) + if existing_credentials and existing_credentials.refresh_token: + fallback_refresh_token = existing_credentials.refresh_token + logger.info( + "OAuth callback response omitted refresh token; preserving existing refresh token from credential store." + ) + except Exception as e: + logger.debug( + f"Could not check credential store for existing refresh token: {e}" + ) + + if fallback_refresh_token: + credentials = Credentials( + token=credentials.token, + refresh_token=fallback_refresh_token, + id_token=getattr(credentials, "id_token", None), + token_uri=credentials.token_uri, + client_id=credentials.client_id, + client_secret=credentials.client_secret, + scopes=credentials.scopes, + expiry=credentials.expiry, + quota_project_id=getattr(credentials, "quota_project_id", None), + ) + else: + logger.warning( + "OAuth callback did not include a refresh token and no previous refresh token was available to preserve." + ) + + # Save the credentials + credential_store.store_credential(user_google_email, credentials) + + # Always save to OAuth21SessionStore for centralized management + store.store_session( + user_email=user_google_email, + access_token=credentials.token, + refresh_token=credentials.refresh_token, + token_uri=credentials.token_uri, + client_id=credentials.client_id, + client_secret=credentials.client_secret, + scopes=credentials.scopes, + expiry=credentials.expiry, + mcp_session_id=session_id, + issuer="https://accounts.google.com", # Add issuer for Google tokens + ) + + # If session_id is provided, also save to session cache for compatibility + if session_id: + save_credentials_to_session(session_id, credentials) + + return user_google_email, credentials + + except Exception as e: # Catch specific exceptions like FlowExchangeError if needed + logger.error(f"Error handling auth callback: {e}") + raise # Re-raise for the caller + + +def get_credentials( + user_google_email: Optional[str], # Can be None if relying on session_id + required_scopes: List[str], + client_secrets_path: Optional[str] = None, + credentials_base_dir: str = DEFAULT_CREDENTIALS_DIR, + session_id: Optional[str] = None, +) -> Optional[Credentials]: + """ + Retrieves stored credentials, prioritizing OAuth 2.1 store, then session, then file. Refreshes if necessary. + If credentials are loaded from file and a session_id is present, they are cached in the session. + In single-user mode, bypasses session mapping and uses any available credentials. + + Args: + user_google_email: Optional user's Google email. + required_scopes: List of scopes the credentials must have. + client_secrets_path: Optional path to client secrets (legacy; refresh uses embedded client info). + credentials_base_dir: Base directory for credential files. + session_id: Optional MCP session ID. + + Returns: + Valid Credentials object or None. + """ + skip_session_cache = False + # First, try OAuth 2.1 session store if we have a session_id (FastMCP session) + if session_id: + try: + store = get_oauth21_session_store() + + session_user = store.get_user_by_mcp_session(session_id) + if user_google_email and session_user and session_user != user_google_email: + logger.info( + f"[get_credentials] Session user {session_user} doesn't match requested {user_google_email}; " + "skipping session store" + ) + skip_session_cache = True + else: + # Try to get credentials by MCP session + credentials = store.get_credentials_by_mcp_session(session_id) + if credentials: + logger.info( + f"[get_credentials] Found OAuth 2.1 credentials for MCP session {session_id}" + ) + + # Refresh invalid credentials before checking scopes + if (not credentials.valid) and credentials.refresh_token: + try: + credentials.refresh(Request()) + logger.info( + f"[get_credentials] Refreshed OAuth 2.1 credentials for session {session_id}" + ) + # Update stored credentials + user_email = store.get_user_by_mcp_session(session_id) + if user_email: + store.store_session( + user_email=user_email, + access_token=credentials.token, + refresh_token=credentials.refresh_token, + token_uri=credentials.token_uri, + client_id=credentials.client_id, + client_secret=credentials.client_secret, + scopes=credentials.scopes, + expiry=credentials.expiry, + mcp_session_id=session_id, + issuer="https://accounts.google.com", + ) + # Persist to file so rotated refresh tokens survive restarts + if not is_stateless_mode(): + try: + credential_store = get_credential_store() + credential_store.store_credential( + user_email, credentials + ) + except Exception as persist_error: + logger.warning( + f"[get_credentials] Failed to persist refreshed OAuth 2.1 credentials for user {user_email}: {persist_error}" + ) + except Exception as e: + logger.error( + f"[get_credentials] Failed to refresh OAuth 2.1 credentials: {e}" + ) + return None + + # Check scopes after refresh so stale metadata doesn't block valid tokens + if not has_required_scopes(credentials.scopes, required_scopes): + logger.warning( + f"[get_credentials] OAuth 2.1 credentials lack required scopes. Need: {required_scopes}, Have: {credentials.scopes}" + ) + return None + + if credentials.valid: + return credentials + + return None + except ImportError: + pass # OAuth 2.1 store not available + except Exception as e: + logger.debug(f"[get_credentials] Error checking OAuth 2.1 store: {e}") + + # Check for single-user mode + if os.getenv("MCP_SINGLE_USER_MODE") == "1": + logger.info( + "[get_credentials] Single-user mode: bypassing session mapping, finding any credentials" + ) + credentials, found_user_email = _find_any_credentials(credentials_base_dir) + if not credentials: + logger.info( + f"[get_credentials] Single-user mode: No credentials found in {credentials_base_dir}" + ) + return None + + # Use the email from the credential file if not provided + # This ensures we can save refreshed credentials even when the token is expired + if not user_google_email and found_user_email: + user_google_email = found_user_email + logger.debug( + f"[get_credentials] Single-user mode: using email {user_google_email} from credential file" + ) + else: + credentials: Optional[Credentials] = None + + # Session ID should be provided by the caller + if not session_id: + logger.debug("[get_credentials] No session_id provided") + + logger.debug( + f"[get_credentials] Called for user_google_email: '{user_google_email}', session_id: '{session_id}', required_scopes: {required_scopes}" + ) + + if session_id and not skip_session_cache: + credentials = load_credentials_from_session(session_id) + if credentials: + logger.debug( + f"[get_credentials] Loaded credentials from session for session_id '{session_id}'." + ) + + if not credentials and user_google_email: + if not is_stateless_mode(): + logger.debug( + f"[get_credentials] No session credentials, trying credential store for user_google_email '{user_google_email}'." + ) + store = get_credential_store() + credentials = store.get_credential(user_google_email) + else: + logger.debug( + f"[get_credentials] No session credentials, skipping file store in stateless mode for user_google_email '{user_google_email}'." + ) + + if credentials and session_id: + logger.debug( + f"[get_credentials] Loaded from file for user '{user_google_email}', caching to session '{session_id}'." + ) + if not skip_session_cache: + save_credentials_to_session( + session_id, credentials + ) # Cache for current session + + if not credentials: + logger.info( + f"[get_credentials] No credentials found for user '{user_google_email}' or session '{session_id}'." + ) + return None + + logger.debug( + f"[get_credentials] Credentials found. Scopes: {credentials.scopes}, Valid: {credentials.valid}, Expired: {credentials.expired}" + ) + + # Attempt refresh before checking scopes — the scope check validates against + # credentials.scopes which is set at authorization time and not updated by the + # google-auth library on refresh. Checking scopes first would block a valid + # refresh attempt when stored scope metadata is stale. + if credentials.valid: + logger.debug( + f"[get_credentials] Credentials are valid. User: '{user_google_email}', Session: '{session_id}'" + ) + elif credentials.refresh_token: + logger.info( + f"[get_credentials] Credentials not valid. Attempting refresh. User: '{user_google_email}', Session: '{session_id}'" + ) + try: + logger.debug( + "[get_credentials] Refreshing token using embedded client credentials" + ) + credentials.refresh(Request()) + logger.info( + f"[get_credentials] Credentials refreshed successfully. User: '{user_google_email}', Session: '{session_id}'" + ) + + # Save refreshed credentials (skip file save in stateless mode) + if user_google_email: # Always save to credential store if email is known + if not is_stateless_mode(): + credential_store = get_credential_store() + credential_store.store_credential(user_google_email, credentials) + else: + logger.info( + f"Skipping credential file save in stateless mode for {user_google_email}" + ) + + # Also update OAuth21SessionStore + store = get_oauth21_session_store() + store.store_session( + user_email=user_google_email, + access_token=credentials.token, + refresh_token=credentials.refresh_token, + token_uri=credentials.token_uri, + client_id=credentials.client_id, + client_secret=credentials.client_secret, + scopes=credentials.scopes, + expiry=credentials.expiry, + mcp_session_id=session_id, + issuer="https://accounts.google.com", # Add issuer for Google tokens + ) + + if session_id: # Update session cache if it was the source or is active + save_credentials_to_session(session_id, credentials) + except RefreshError as e: + logger.warning( + f"[get_credentials] RefreshError - token expired/revoked: {e}. User: '{user_google_email}', Session: '{session_id}'" + ) + # For RefreshError, we should return None to trigger reauthentication + return None + except Exception as e: + logger.error( + f"[get_credentials] Error refreshing credentials: {e}. User: '{user_google_email}', Session: '{session_id}'", + exc_info=True, + ) + return None # Failed to refresh + else: + logger.warning( + f"[get_credentials] Credentials invalid/cannot refresh. Valid: {credentials.valid}, Refresh Token: {credentials.refresh_token is not None}. User: '{user_google_email}', Session: '{session_id}'" + ) + return None + + # Check scopes after refresh so stale scope metadata doesn't block valid tokens. + # Uses hierarchy-aware check (e.g. gmail.modify satisfies gmail.readonly). + if not has_required_scopes(credentials.scopes, required_scopes): + logger.warning( + f"[get_credentials] Credentials lack required scopes. Need: {required_scopes}, Have: {credentials.scopes}. User: '{user_google_email}', Session: '{session_id}'" + ) + return None # Re-authentication needed for scopes + + logger.debug( + f"[get_credentials] Credentials have sufficient scopes. User: '{user_google_email}', Session: '{session_id}'" + ) + return credentials + + +def get_user_info( + credentials: Credentials, *, skip_valid_check: bool = False +) -> Optional[Dict[str, Any]]: + """Fetches basic user profile information (requires userinfo.email scope).""" + if not credentials: + logger.error("Cannot get user info: Missing credentials.") + return None + if not skip_valid_check and not credentials.valid: + logger.error("Cannot get user info: Invalid credentials.") + return None + service = None + try: + # Using googleapiclient discovery to get user info + # Requires 'google-api-python-client' library + service = build("oauth2", "v2", credentials=credentials) + user_info = service.userinfo().get().execute() + logger.info(f"Successfully fetched user info: {user_info.get('email')}") + return user_info + except HttpError as e: + logger.error(f"HttpError fetching user info: {e.status_code} {e.reason}") + # Handle specific errors, e.g., 401 Unauthorized might mean token issue + return None + except Exception as e: + logger.error(f"Unexpected error fetching user info: {e}") + return None + finally: + if service: + service.close() + + +# --- Centralized Google Service Authentication --- + + +class GoogleAuthenticationError(Exception): + """Exception raised when Google authentication is required or fails.""" + + def __init__(self, message: str, auth_url: Optional[str] = None): + super().__init__(message) + self.auth_url = auth_url + + +async def get_authenticated_google_service( + service_name: str, # "gmail", "calendar", "drive", "docs" + version: str, # "v1", "v3" + tool_name: str, # For logging/debugging + user_google_email: str, # Required - no more Optional + required_scopes: List[str], + session_id: Optional[str] = None, # Session context for logging +) -> tuple[Any, str]: + """ + Centralized Google service authentication for all MCP tools. + Returns (service, user_email) on success or raises GoogleAuthenticationError. + + Args: + service_name: The Google service name ("gmail", "calendar", "drive", "docs") + version: The API version ("v1", "v3", etc.) + tool_name: The name of the calling tool (for logging/debugging) + user_google_email: The user's Google email address (required) + required_scopes: List of required OAuth scopes + + Returns: + tuple[service, user_email] on success + + Raises: + GoogleAuthenticationError: When authentication is required or fails + """ + + # Try to get FastMCP session ID if not provided + if not session_id: + try: + # First try context variable (works in async context) + session_id = get_fastmcp_session_id() + if session_id: + logger.debug( + f"[{tool_name}] Got FastMCP session ID from context: {session_id}" + ) + else: + logger.debug( + f"[{tool_name}] Context variable returned None/empty session ID" + ) + except Exception as e: + logger.debug( + f"[{tool_name}] Could not get FastMCP session from context: {e}" + ) + + # Fallback to direct FastMCP context if context variable not set + if not session_id and get_fastmcp_context: + try: + fastmcp_ctx = get_fastmcp_context() + if fastmcp_ctx and hasattr(fastmcp_ctx, "session_id"): + session_id = fastmcp_ctx.session_id + logger.debug( + f"[{tool_name}] Got FastMCP session ID directly: {session_id}" + ) + else: + logger.debug( + f"[{tool_name}] FastMCP context exists but no session_id attribute" + ) + except Exception as e: + logger.debug( + f"[{tool_name}] Could not get FastMCP context directly: {e}" + ) + + # Final fallback: log if we still don't have session_id + if not session_id: + logger.warning( + f"[{tool_name}] Unable to obtain FastMCP session ID from any source" + ) + + logger.info( + f"[{tool_name}] Attempting to get authenticated {service_name} service. Email: '{user_google_email}', Session: '{session_id}'" + ) + + # Validate email format + if not user_google_email or "@" not in user_google_email: + error_msg = f"Authentication required for {tool_name}. No valid 'user_google_email' provided. Please provide a valid Google email address." + logger.info(f"[{tool_name}] {error_msg}") + raise GoogleAuthenticationError(error_msg) + + credentials = await asyncio.to_thread( + get_credentials, + user_google_email=user_google_email, + required_scopes=required_scopes, + client_secrets_path=CONFIG_CLIENT_SECRETS_PATH, + session_id=session_id, # Pass through session context + ) + + if not credentials or not credentials.valid: + logger.warning( + f"[{tool_name}] No valid credentials. Email: '{user_google_email}'." + ) + logger.info( + f"[{tool_name}] Valid email '{user_google_email}' provided, initiating auth flow." + ) + + # Ensure OAuth callback is available + from auth.oauth_callback_server import ensure_oauth_callback_available + + redirect_uri = get_oauth_redirect_uri() + config = get_oauth_config() + success, error_msg = ensure_oauth_callback_available( + get_transport_mode(), config.port, config.base_uri + ) + if not success: + error_detail = f" ({error_msg})" if error_msg else "" + raise GoogleAuthenticationError( + f"Cannot initiate OAuth flow - callback server unavailable{error_detail}" + ) + + # Generate auth URL and raise exception with it + auth_response = await start_auth_flow( + user_google_email=user_google_email, + service_name=f"Google {service_name.title()}", + redirect_uri=redirect_uri, + ) + + # Extract the auth URL from the response and raise with it + raise GoogleAuthenticationError(auth_response) + + try: + service = build(service_name, version, credentials=credentials) + log_user_email = user_google_email + + # Try to get email from credentials if needed for validation + if credentials and credentials.id_token: + try: + # Decode without verification (just to get email for logging) + decoded_token = jwt.decode( + credentials.id_token, options={"verify_signature": False} + ) + token_email = decoded_token.get("email") + if token_email: + log_user_email = token_email + logger.info(f"[{tool_name}] Token email: {token_email}") + except Exception as e: + logger.debug(f"[{tool_name}] Could not decode id_token: {e}") + + logger.info( + f"[{tool_name}] Successfully authenticated {service_name} service for user: {log_user_email}" + ) + return service, log_user_email + + except Exception as e: + error_msg = f"[{tool_name}] Failed to build {service_name} service: {str(e)}" + logger.error(error_msg, exc_info=True) + raise GoogleAuthenticationError(error_msg) diff --git a/auth/mcp_session_middleware.py b/auth/mcp_session_middleware.py new file mode 100644 index 0000000..1e84308 --- /dev/null +++ b/auth/mcp_session_middleware.py @@ -0,0 +1,104 @@ +""" +MCP Session Middleware + +This middleware intercepts MCP requests and sets the session context +for use by tool functions. +""" + +import logging +from typing import Callable, Any + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request + +from auth.oauth21_session_store import ( + SessionContext, + SessionContextManager, + extract_session_from_headers, +) +# OAuth 2.1 is now handled by FastMCP auth + +logger = logging.getLogger(__name__) + + +class MCPSessionMiddleware(BaseHTTPMiddleware): + """ + Middleware that extracts session information from requests and makes it + available to MCP tool functions via context variables. + """ + + async def dispatch(self, request: Request, call_next: Callable) -> Any: + """Process request and set session context.""" + + logger.debug( + f"MCPSessionMiddleware processing request: {request.method} {request.url.path}" + ) + + # Skip non-MCP paths + if not request.url.path.startswith("/mcp"): + logger.debug(f"Skipping non-MCP path: {request.url.path}") + return await call_next(request) + + session_context = None + + try: + # Extract session information + headers = dict(request.headers) + session_id = extract_session_from_headers(headers) + + # Try to get OAuth 2.1 auth context from FastMCP + auth_context = None + user_email = None + mcp_session_id = None + # Check for FastMCP auth context + if hasattr(request.state, "auth"): + auth_context = request.state.auth + # Extract user email from auth claims if available + if hasattr(auth_context, "claims") and auth_context.claims: + user_email = auth_context.claims.get("email") + + # Check for FastMCP session ID (from streamable HTTP transport) + if hasattr(request.state, "session_id"): + mcp_session_id = request.state.session_id + logger.debug(f"Found FastMCP session ID: {mcp_session_id}") + + # SECURITY: Do not decode JWT without verification + # User email must come from verified sources only (FastMCP auth context) + + # Build session context + if session_id or auth_context or user_email or mcp_session_id: + # Create session ID hierarchy: explicit session_id > Google user session > FastMCP session + effective_session_id = session_id + if not effective_session_id and user_email: + effective_session_id = f"google_{user_email}" + elif not effective_session_id and mcp_session_id: + effective_session_id = mcp_session_id + + session_context = SessionContext( + session_id=effective_session_id, + user_id=user_email + or (auth_context.user_id if auth_context else None), + auth_context=auth_context, + request=request, + metadata={ + "path": request.url.path, + "method": request.method, + "user_email": user_email, + "mcp_session_id": mcp_session_id, + }, + ) + + logger.debug( + f"MCP request with session: session_id={session_context.session_id}, " + f"user_id={session_context.user_id}, path={request.url.path}" + ) + + # Process request with session context + with SessionContextManager(session_context): + response = await call_next(request) + return response + + except Exception as e: + logger.error(f"Error in MCP session middleware: {e}") + # Continue without session context + return await call_next(request) diff --git a/auth/oauth21_session_store.py b/auth/oauth21_session_store.py new file mode 100644 index 0000000..f659de2 --- /dev/null +++ b/auth/oauth21_session_store.py @@ -0,0 +1,989 @@ +""" +OAuth 2.1 Session Store for Google Services + +This module provides a global store for OAuth 2.1 authenticated sessions +that can be accessed by Google service decorators. It also includes +session context management and credential conversion functionality. +""" + +import contextvars +import logging +from typing import Dict, Optional, Any, Tuple +from threading import RLock +from datetime import datetime, timedelta, timezone +from dataclasses import dataclass + +from fastmcp.server.auth import AccessToken +from google.oauth2.credentials import Credentials +from auth.oauth_config import is_external_oauth21_provider + +logger = logging.getLogger(__name__) + + +def _normalize_expiry_to_naive_utc(expiry: Optional[Any]) -> Optional[datetime]: + """ + Convert expiry values to timezone-naive UTC datetimes for google-auth compatibility. + + Naive datetime inputs are assumed to already represent UTC and are returned unchanged so that + google-auth Credentials receive naive UTC datetimes for expiry comparison. + """ + if expiry is None: + return None + + if isinstance(expiry, datetime): + if expiry.tzinfo is not None: + try: + return expiry.astimezone(timezone.utc).replace(tzinfo=None) + except Exception: # pragma: no cover - defensive + logger.debug( + "Failed to normalize aware expiry; returning without tzinfo" + ) + return expiry.replace(tzinfo=None) + return expiry # Already naive; assumed to represent UTC + + if isinstance(expiry, str): + try: + parsed = datetime.fromisoformat(expiry.replace("Z", "+00:00")) + except ValueError: + logger.debug("Failed to parse expiry string '%s'", expiry) + return None + return _normalize_expiry_to_naive_utc(parsed) + + logger.debug("Unsupported expiry type '%s' (%s)", expiry, type(expiry)) + return None + + +# Context variable to store the current session information +_current_session_context: contextvars.ContextVar[Optional["SessionContext"]] = ( + contextvars.ContextVar("current_session_context", default=None) +) + + +@dataclass +class SessionContext: + """Container for session-related information.""" + + session_id: Optional[str] = None + user_id: Optional[str] = None + auth_context: Optional[Any] = None + request: Optional[Any] = None + metadata: Dict[str, Any] = None + issuer: Optional[str] = None + + def __post_init__(self): + if self.metadata is None: + self.metadata = {} + + +def set_session_context(context: Optional[SessionContext]): + """ + Set the current session context. + + Args: + context: The session context to set + """ + _current_session_context.set(context) + if context: + logger.debug( + f"Set session context: session_id={context.session_id}, user_id={context.user_id}" + ) + else: + logger.debug("Cleared session context") + + +def get_session_context() -> Optional[SessionContext]: + """ + Get the current session context. + + Returns: + The current session context or None + """ + return _current_session_context.get() + + +def clear_session_context(): + """Clear the current session context.""" + set_session_context(None) + + +class SessionContextManager: + """ + Context manager for temporarily setting session context. + + Usage: + with SessionContextManager(session_context): + # Code that needs access to session context + pass + """ + + def __init__(self, context: Optional[SessionContext]): + self.context = context + self.token = None + + def __enter__(self): + """Set the session context.""" + self.token = _current_session_context.set(self.context) + return self.context + + def __exit__(self, exc_type, exc_val, exc_tb): + """Reset the session context.""" + if self.token: + _current_session_context.reset(self.token) + + +def extract_session_from_headers(headers: Dict[str, str]) -> Optional[str]: + """ + Extract session ID from request headers. + + Args: + headers: Request headers + + Returns: + Session ID if found + """ + # Try different header names + session_id = headers.get("mcp-session-id") or headers.get("Mcp-Session-Id") + if session_id: + return session_id + + session_id = headers.get("x-session-id") or headers.get("X-Session-ID") + if session_id: + return session_id + + # Try Authorization header for Bearer token + auth_header = headers.get("authorization") or headers.get("Authorization") + if auth_header and auth_header.lower().startswith("bearer "): + token = auth_header[7:] # Remove "Bearer " prefix + # Intentionally ignore empty tokens - "Bearer " with no token should not + # create a session context (avoids hash collisions on empty string) + if token: + # Use thread-safe lookup to find session by access token + store = get_oauth21_session_store() + session_id = store.find_session_id_for_access_token(token) + if session_id: + return session_id + + # If no session found, create a temporary session ID from token hash + # This allows header-based authentication to work with session context + import hashlib + + token_hash = hashlib.sha256(token.encode()).hexdigest()[:8] + return f"bearer_token_{token_hash}" + + return None + + +# ============================================================================= +# OAuth21SessionStore - Main Session Management +# ============================================================================= + + +class OAuth21SessionStore: + """ + Global store for OAuth 2.1 authenticated sessions. + + This store maintains a mapping of user emails to their OAuth 2.1 + authenticated credentials, allowing Google services to access them. + It also maintains a mapping from FastMCP session IDs to user emails. + + Security: Sessions are bound to specific users and can only access + their own credentials. + """ + + def __init__(self): + self._sessions: Dict[str, Dict[str, Any]] = {} + self._mcp_session_mapping: Dict[ + str, str + ] = {} # Maps FastMCP session ID -> user email + self._session_auth_binding: Dict[ + str, str + ] = {} # Maps session ID -> authenticated user email (immutable) + self._oauth_states: Dict[str, Dict[str, Any]] = {} + self._lock = RLock() + + def _cleanup_expired_oauth_states_locked(self): + """Remove expired OAuth state entries. Caller must hold lock.""" + now = datetime.now(timezone.utc) + expired_states = [ + state + for state, data in self._oauth_states.items() + if data.get("expires_at") and data["expires_at"] <= now + ] + for state in expired_states: + del self._oauth_states[state] + logger.debug( + "Removed expired OAuth state: %s", + state[:8] if len(state) > 8 else state, + ) + + def store_oauth_state( + self, + state: str, + session_id: Optional[str] = None, + expires_in_seconds: int = 600, + code_verifier: Optional[str] = None, + ) -> None: + """Persist an OAuth state value for later validation.""" + if not state: + raise ValueError("OAuth state must be provided") + if expires_in_seconds < 0: + raise ValueError("expires_in_seconds must be non-negative") + + with self._lock: + self._cleanup_expired_oauth_states_locked() + now = datetime.now(timezone.utc) + expiry = now + timedelta(seconds=expires_in_seconds) + self._oauth_states[state] = { + "session_id": session_id, + "expires_at": expiry, + "created_at": now, + "code_verifier": code_verifier, + } + logger.debug( + "Stored OAuth state %s (expires at %s)", + state[:8] if len(state) > 8 else state, + expiry.isoformat(), + ) + + def validate_and_consume_oauth_state( + self, + state: str, + session_id: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Validate that a state value exists and consume it. + + Args: + state: The OAuth state returned by Google. + session_id: Optional session identifier that initiated the flow. + + Returns: + Metadata associated with the state. + + Raises: + ValueError: If the state is missing, expired, or does not match the session. + """ + if not state: + raise ValueError("Missing OAuth state parameter") + + with self._lock: + self._cleanup_expired_oauth_states_locked() + state_info = self._oauth_states.get(state) + + if not state_info: + logger.error( + "SECURITY: OAuth callback received unknown or expired state" + ) + raise ValueError("Invalid or expired OAuth state parameter") + + bound_session = state_info.get("session_id") + if bound_session and session_id and bound_session != session_id: + # Consume the state to prevent replay attempts + del self._oauth_states[state] + logger.error( + "SECURITY: OAuth state session mismatch (expected %s, got %s)", + bound_session, + session_id, + ) + raise ValueError("OAuth state does not match the initiating session") + + # State is valid – consume it to prevent reuse + del self._oauth_states[state] + logger.debug( + "Validated OAuth state %s", + state[:8] if len(state) > 8 else state, + ) + return state_info + + def store_session( + self, + user_email: str, + access_token: str, + refresh_token: Optional[str] = None, + token_uri: str = "https://oauth2.googleapis.com/token", + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + scopes: Optional[list] = None, + expiry: Optional[Any] = None, + session_id: Optional[str] = None, + mcp_session_id: Optional[str] = None, + issuer: Optional[str] = None, + ): + """ + Store OAuth 2.1 session information. + + Args: + user_email: User's email address + access_token: OAuth 2.1 access token + refresh_token: OAuth 2.1 refresh token + token_uri: Token endpoint URI + client_id: OAuth client ID + client_secret: OAuth client secret + scopes: List of granted scopes + expiry: Token expiry time + session_id: OAuth 2.1 session ID + mcp_session_id: FastMCP session ID to map to this user + issuer: Token issuer (e.g., "https://accounts.google.com") + """ + with self._lock: + normalized_expiry = _normalize_expiry_to_naive_utc(expiry) + + # Clean up previous session mappings for this user before storing new one + old_session = self._sessions.get(user_email) + if old_session: + old_mcp_session_id = old_session.get("mcp_session_id") + old_session_id = old_session.get("session_id") + # Remove old MCP session mapping if it differs from new one + if old_mcp_session_id and old_mcp_session_id != mcp_session_id: + if old_mcp_session_id in self._mcp_session_mapping: + del self._mcp_session_mapping[old_mcp_session_id] + logger.debug( + f"Removed stale MCP session mapping: {old_mcp_session_id}" + ) + if old_mcp_session_id in self._session_auth_binding: + del self._session_auth_binding[old_mcp_session_id] + logger.debug( + f"Removed stale auth binding: {old_mcp_session_id}" + ) + # Remove old OAuth session binding if it differs from new one + if old_session_id and old_session_id != session_id: + if old_session_id in self._session_auth_binding: + del self._session_auth_binding[old_session_id] + logger.debug( + f"Removed stale OAuth session binding: {old_session_id}" + ) + + session_info = { + "access_token": access_token, + "refresh_token": refresh_token, + "token_uri": token_uri, + "client_id": client_id, + "client_secret": client_secret, + "scopes": scopes or [], + "expiry": normalized_expiry, + "session_id": session_id, + "mcp_session_id": mcp_session_id, + "issuer": issuer, + } + + self._sessions[user_email] = session_info + + # Store MCP session mapping if provided + if mcp_session_id: + # Create immutable session binding (first binding wins, cannot be changed) + if mcp_session_id not in self._session_auth_binding: + self._session_auth_binding[mcp_session_id] = user_email + logger.info( + f"Created immutable session binding: {mcp_session_id} -> {user_email}" + ) + elif self._session_auth_binding[mcp_session_id] != user_email: + # Security: Attempt to bind session to different user + logger.error( + f"SECURITY: Attempt to rebind session {mcp_session_id} from {self._session_auth_binding[mcp_session_id]} to {user_email}" + ) + raise ValueError( + f"Session {mcp_session_id} is already bound to a different user" + ) + + self._mcp_session_mapping[mcp_session_id] = user_email + logger.info( + f"Stored OAuth 2.1 session for {user_email} (session_id: {session_id}, mcp_session_id: {mcp_session_id})" + ) + else: + logger.info( + f"Stored OAuth 2.1 session for {user_email} (session_id: {session_id})" + ) + + # Also create binding for the OAuth session ID + if session_id and session_id not in self._session_auth_binding: + self._session_auth_binding[session_id] = user_email + + def get_credentials(self, user_email: str) -> Optional[Credentials]: + """ + Get Google credentials for a user from OAuth 2.1 session. + + Args: + user_email: User's email address + + Returns: + Google Credentials object or None + """ + with self._lock: + session_info = self._sessions.get(user_email) + if not session_info: + logger.debug(f"No OAuth 2.1 session found for {user_email}") + return None + + try: + # Create Google credentials from session info + credentials = Credentials( + token=session_info["access_token"], + refresh_token=session_info.get("refresh_token"), + token_uri=session_info["token_uri"], + client_id=session_info.get("client_id"), + client_secret=session_info.get("client_secret"), + scopes=session_info.get("scopes", []), + expiry=session_info.get("expiry"), + ) + + logger.debug(f"Retrieved OAuth 2.1 credentials for {user_email}") + return credentials + + except Exception as e: + logger.error(f"Failed to create credentials for {user_email}: {e}") + return None + + def get_credentials_by_mcp_session( + self, mcp_session_id: str + ) -> Optional[Credentials]: + """ + Get Google credentials using FastMCP session ID. + + Args: + mcp_session_id: FastMCP session ID + + Returns: + Google Credentials object or None + """ + with self._lock: + # Look up user email from MCP session mapping + user_email = self._mcp_session_mapping.get(mcp_session_id) + if not user_email: + logger.debug(f"No user mapping found for MCP session {mcp_session_id}") + return None + + logger.debug(f"Found user {user_email} for MCP session {mcp_session_id}") + return self.get_credentials(user_email) + + def get_credentials_with_validation( + self, + requested_user_email: str, + session_id: Optional[str] = None, + auth_token_email: Optional[str] = None, + allow_recent_auth: bool = False, + ) -> Optional[Credentials]: + """ + Get Google credentials with session validation. + + This method ensures that a session can only access credentials for its + authenticated user, preventing cross-account access. + + Args: + requested_user_email: The email of the user whose credentials are requested + session_id: The current session ID (MCP or OAuth session) + auth_token_email: Email from the verified auth token (if available) + + Returns: + Google Credentials object if validation passes, None otherwise + """ + with self._lock: + # Priority 1: Check auth token email (most secure, from verified JWT) + if auth_token_email: + if auth_token_email != requested_user_email: + logger.error( + f"SECURITY VIOLATION: Token for {auth_token_email} attempted to access " + f"credentials for {requested_user_email}" + ) + return None + # Token email matches, allow access + return self.get_credentials(requested_user_email) + + # Priority 2: Check session binding + if session_id: + bound_user = self._session_auth_binding.get(session_id) + if bound_user: + if bound_user != requested_user_email: + logger.error( + f"SECURITY VIOLATION: Session {session_id} (bound to {bound_user}) " + f"attempted to access credentials for {requested_user_email}" + ) + return None + # Session binding matches, allow access + return self.get_credentials(requested_user_email) + + # Check if this is an MCP session + mcp_user = self._mcp_session_mapping.get(session_id) + if mcp_user: + if mcp_user != requested_user_email: + logger.error( + f"SECURITY VIOLATION: MCP session {session_id} (user {mcp_user}) " + f"attempted to access credentials for {requested_user_email}" + ) + return None + # MCP session matches, allow access + return self.get_credentials(requested_user_email) + + # Special case: Allow access if user has recently authenticated (for clients that don't send tokens) + # CRITICAL SECURITY: This is ONLY allowed in stdio mode, NEVER in OAuth 2.1 mode + if allow_recent_auth and requested_user_email in self._sessions: + # Check transport mode to ensure this is only used in stdio + try: + from core.config import get_transport_mode + + transport_mode = get_transport_mode() + if transport_mode != "stdio": + logger.error( + f"SECURITY: Attempted to use allow_recent_auth in {transport_mode} mode. " + f"This is only allowed in stdio mode!" + ) + return None + except Exception as e: + logger.error(f"Failed to check transport mode: {e}") + return None + + logger.info( + f"Allowing credential access for {requested_user_email} based on recent authentication " + f"(stdio mode only - client not sending bearer token)" + ) + return self.get_credentials(requested_user_email) + + # No session or token info available - deny access for security + logger.warning( + f"Credential access denied for {requested_user_email}: No valid session or token" + ) + return None + + def get_user_by_mcp_session(self, mcp_session_id: str) -> Optional[str]: + """ + Get user email by FastMCP session ID. + + Args: + mcp_session_id: FastMCP session ID + + Returns: + User email or None + """ + with self._lock: + return self._mcp_session_mapping.get(mcp_session_id) + + def get_session_info(self, user_email: str) -> Optional[Dict[str, Any]]: + """ + Get complete session information including issuer. + + Args: + user_email: User's email address + + Returns: + Session information dictionary or None + """ + with self._lock: + return self._sessions.get(user_email) + + def remove_session(self, user_email: str): + """Remove session for a user.""" + with self._lock: + if user_email in self._sessions: + # Get session IDs to clean up mappings + session_info = self._sessions.get(user_email, {}) + mcp_session_id = session_info.get("mcp_session_id") + session_id = session_info.get("session_id") + + # Remove from sessions + del self._sessions[user_email] + + # Remove from MCP mapping if exists + if mcp_session_id and mcp_session_id in self._mcp_session_mapping: + del self._mcp_session_mapping[mcp_session_id] + # Also remove from auth binding + if mcp_session_id in self._session_auth_binding: + del self._session_auth_binding[mcp_session_id] + logger.info( + f"Removed OAuth 2.1 session for {user_email} and MCP mapping for {mcp_session_id}" + ) + + # Remove OAuth session binding if exists + if session_id and session_id in self._session_auth_binding: + del self._session_auth_binding[session_id] + + if not mcp_session_id: + logger.info(f"Removed OAuth 2.1 session for {user_email}") + + # Clean up any orphaned mappings that may have accumulated + self._cleanup_orphaned_mappings_locked() + + def has_session(self, user_email: str) -> bool: + """Check if a user has an active session.""" + with self._lock: + return user_email in self._sessions + + def has_mcp_session(self, mcp_session_id: str) -> bool: + """Check if an MCP session has an associated user session.""" + with self._lock: + return mcp_session_id in self._mcp_session_mapping + + def get_single_user_email(self) -> Optional[str]: + """Return the sole authenticated user email when exactly one session exists.""" + with self._lock: + if len(self._sessions) == 1: + return next(iter(self._sessions)) + return None + + def get_stats(self) -> Dict[str, Any]: + """Get store statistics.""" + with self._lock: + return { + "total_sessions": len(self._sessions), + "users": list(self._sessions.keys()), + "mcp_session_mappings": len(self._mcp_session_mapping), + "mcp_sessions": list(self._mcp_session_mapping.keys()), + } + + def find_session_id_for_access_token(self, token: str) -> Optional[str]: + """ + Thread-safe lookup of session ID by access token. + + Args: + token: The access token to search for + + Returns: + Session ID if found, None otherwise + """ + with self._lock: + for user_email, session_info in self._sessions.items(): + if session_info.get("access_token") == token: + return session_info.get("session_id") or f"bearer_{user_email}" + return None + + def _cleanup_orphaned_mappings_locked(self) -> int: + """Remove orphaned mappings. Caller must hold lock.""" + # Collect valid session IDs and mcp_session_ids from active sessions + valid_session_ids = set() + valid_mcp_session_ids = set() + for session_info in self._sessions.values(): + if session_info.get("session_id"): + valid_session_ids.add(session_info["session_id"]) + if session_info.get("mcp_session_id"): + valid_mcp_session_ids.add(session_info["mcp_session_id"]) + + removed = 0 + + # Remove orphaned MCP session mappings + orphaned_mcp = [ + sid for sid in self._mcp_session_mapping if sid not in valid_mcp_session_ids + ] + for sid in orphaned_mcp: + del self._mcp_session_mapping[sid] + removed += 1 + logger.debug(f"Removed orphaned MCP session mapping: {sid}") + + # Remove orphaned auth bindings + valid_bindings = valid_session_ids | valid_mcp_session_ids + orphaned_bindings = [ + sid for sid in self._session_auth_binding if sid not in valid_bindings + ] + for sid in orphaned_bindings: + del self._session_auth_binding[sid] + removed += 1 + logger.debug(f"Removed orphaned auth binding: {sid}") + + if removed > 0: + logger.info(f"Cleaned up {removed} orphaned session mappings/bindings") + + return removed + + def cleanup_orphaned_mappings(self) -> int: + """ + Remove orphaned entries from mcp_session_mapping and session_auth_binding. + + Returns: + Number of orphaned entries removed + """ + with self._lock: + return self._cleanup_orphaned_mappings_locked() + + +# Global instance +_global_store = OAuth21SessionStore() + + +def get_oauth21_session_store() -> OAuth21SessionStore: + """Get the global OAuth 2.1 session store.""" + return _global_store + + +# ============================================================================= +# Google Credentials Bridge (absorbed from oauth21_google_bridge.py) +# ============================================================================= + +# Global auth provider instance (set during server initialization) +_auth_provider = None + + +def set_auth_provider(provider): + """Set the global auth provider instance.""" + global _auth_provider + _auth_provider = provider + logger.debug("OAuth 2.1 session store configured") + + +def get_auth_provider(): + """Get the global auth provider instance.""" + return _auth_provider + + +def _resolve_client_credentials() -> Tuple[Optional[str], Optional[str]]: + """Resolve OAuth client credentials from the active provider or configuration.""" + client_id: Optional[str] = None + client_secret: Optional[str] = None + + if _auth_provider: + client_id = getattr(_auth_provider, "_upstream_client_id", None) + secret_obj = getattr(_auth_provider, "_upstream_client_secret", None) + if secret_obj is not None: + if hasattr(secret_obj, "get_secret_value"): + try: + client_secret = secret_obj.get_secret_value() # type: ignore[call-arg] + except Exception as exc: # pragma: no cover - defensive + logger.debug( + f"Failed to resolve client secret from provider: {exc}" + ) + elif isinstance(secret_obj, str): + client_secret = secret_obj + + if not client_id or not client_secret: + try: + from auth.oauth_config import get_oauth_config + + cfg = get_oauth_config() + client_id = client_id or cfg.client_id + client_secret = client_secret or cfg.client_secret + except Exception as exc: # pragma: no cover - defensive + logger.debug(f"Failed to resolve client credentials from config: {exc}") + + return client_id, client_secret + + +def _build_credentials_from_provider( + access_token: AccessToken, +) -> Optional[Credentials]: + """Construct Google credentials from the provider cache.""" + if not _auth_provider: + return None + + access_entry = getattr(_auth_provider, "_access_tokens", {}).get(access_token.token) + if not access_entry: + access_entry = access_token + + client_id, client_secret = _resolve_client_credentials() + + refresh_token_value = getattr(_auth_provider, "_access_to_refresh", {}).get( + access_token.token + ) + refresh_token_obj = None + if refresh_token_value: + refresh_token_obj = getattr(_auth_provider, "_refresh_tokens", {}).get( + refresh_token_value + ) + + expiry = None + expires_at = getattr(access_entry, "expires_at", None) + if expires_at: + try: + expiry_candidate = datetime.fromtimestamp(expires_at, tz=timezone.utc) + expiry = _normalize_expiry_to_naive_utc(expiry_candidate) + except Exception: # pragma: no cover - defensive + expiry = None + + scopes = getattr(access_entry, "scopes", None) + + return Credentials( + token=access_token.token, + refresh_token=refresh_token_obj.token if refresh_token_obj else None, + token_uri="https://oauth2.googleapis.com/token", + client_id=client_id, + client_secret=client_secret, + scopes=scopes, + expiry=expiry, + ) + + +def ensure_session_from_access_token( + access_token: AccessToken, + user_email: Optional[str], + mcp_session_id: Optional[str] = None, +) -> Optional[Credentials]: + """Ensure credentials derived from an access token are cached and returned.""" + + if not access_token: + return None + + email = user_email + if not email and getattr(access_token, "claims", None): + email = access_token.claims.get("email") + + credentials = _build_credentials_from_provider(access_token) + store_expiry: Optional[datetime] = None + + if credentials is None: + client_id, client_secret = _resolve_client_credentials() + expiry = None + expires_at = getattr(access_token, "expires_at", None) + if expires_at: + try: + expiry = datetime.fromtimestamp(expires_at, tz=timezone.utc) + except Exception: # pragma: no cover - defensive + expiry = None + + normalized_expiry = _normalize_expiry_to_naive_utc(expiry) + credentials = Credentials( + token=access_token.token, + refresh_token=None, + token_uri="https://oauth2.googleapis.com/token", + client_id=client_id, + client_secret=client_secret, + scopes=getattr(access_token, "scopes", None), + expiry=normalized_expiry, + ) + store_expiry = expiry + else: + store_expiry = credentials.expiry + + # Skip session storage for external OAuth 2.1 to prevent memory leak from ephemeral tokens + if email and not is_external_oauth21_provider(): + try: + store = get_oauth21_session_store() + store.store_session( + user_email=email, + access_token=credentials.token, + refresh_token=credentials.refresh_token, + token_uri=credentials.token_uri, + client_id=credentials.client_id, + client_secret=credentials.client_secret, + scopes=credentials.scopes, + expiry=store_expiry, + session_id=f"google_{email}", + mcp_session_id=mcp_session_id, + issuer="https://accounts.google.com", + ) + except Exception as exc: # pragma: no cover - defensive + logger.debug(f"Failed to cache credentials for {email}: {exc}") + + return credentials + + +def get_credentials_from_token( + access_token: str, user_email: Optional[str] = None +) -> Optional[Credentials]: + """ + Convert a bearer token to Google credentials. + + Args: + access_token: The bearer token + user_email: Optional user email for session lookup + + Returns: + Google Credentials object or None + """ + try: + store = get_oauth21_session_store() + + # If we have user_email, try to get credentials from store + if user_email: + credentials = store.get_credentials(user_email) + if credentials and credentials.token == access_token: + logger.debug(f"Found matching credentials from store for {user_email}") + return credentials + + # If the FastMCP provider is managing tokens, sync from provider storage + if _auth_provider: + access_record = getattr(_auth_provider, "_access_tokens", {}).get( + access_token + ) + if access_record: + logger.debug("Building credentials from FastMCP provider cache") + return ensure_session_from_access_token(access_record, user_email) + + # Otherwise, create minimal credentials with just the access token + # Assume token is valid for 1 hour (typical for Google tokens) + expiry = _normalize_expiry_to_naive_utc( + datetime.now(timezone.utc) + timedelta(hours=1) + ) + client_id, client_secret = _resolve_client_credentials() + + credentials = Credentials( + token=access_token, + refresh_token=None, + token_uri="https://oauth2.googleapis.com/token", + client_id=client_id, + client_secret=client_secret, + scopes=None, + expiry=expiry, + ) + + logger.debug("Created fallback Google credentials from bearer token") + return credentials + + except Exception as e: + logger.error(f"Failed to create Google credentials from token: {e}") + return None + + +def store_token_session( + token_response: dict, user_email: str, mcp_session_id: Optional[str] = None +) -> str: + """ + Store a token response in the session store. + + Args: + token_response: OAuth token response from Google + user_email: User's email address + mcp_session_id: Optional FastMCP session ID to map to this user + + Returns: + Session ID + """ + if not _auth_provider: + logger.error("Auth provider not configured") + return "" + + try: + # Try to get FastMCP session ID from context if not provided + if not mcp_session_id: + try: + from core.context import get_fastmcp_session_id + + mcp_session_id = get_fastmcp_session_id() + if mcp_session_id: + logger.debug( + f"Got FastMCP session ID from context: {mcp_session_id}" + ) + except Exception as e: + logger.debug(f"Could not get FastMCP session from context: {e}") + + # Store session in OAuth21SessionStore + store = get_oauth21_session_store() + + session_id = f"google_{user_email}" + client_id, client_secret = _resolve_client_credentials() + scopes = token_response.get("scope", "") + scopes_list = scopes.split() if scopes else None + expiry = datetime.now(timezone.utc) + timedelta( + seconds=token_response.get("expires_in", 3600) + ) + + store.store_session( + user_email=user_email, + access_token=token_response.get("access_token"), + refresh_token=token_response.get("refresh_token"), + token_uri="https://oauth2.googleapis.com/token", + client_id=client_id, + client_secret=client_secret, + scopes=scopes_list, + expiry=expiry, + session_id=session_id, + mcp_session_id=mcp_session_id, + issuer="https://accounts.google.com", + ) + + if mcp_session_id: + logger.info( + f"Stored token session for {user_email} with MCP session {mcp_session_id}" + ) + else: + logger.info(f"Stored token session for {user_email}") + + return session_id + + except Exception as e: + logger.error(f"Failed to store token session: {e}") + return "" diff --git a/auth/oauth_callback_server.py b/auth/oauth_callback_server.py new file mode 100644 index 0000000..a009d4c --- /dev/null +++ b/auth/oauth_callback_server.py @@ -0,0 +1,287 @@ +""" +Transport-aware OAuth callback handling. + +In streamable-http mode: Uses the existing FastAPI server +In stdio mode: Starts a minimal HTTP server just for OAuth callbacks +""" + +import asyncio +import logging +import threading +import time +import socket +import uvicorn + +from fastapi import FastAPI, Request +from fastapi.responses import FileResponse, JSONResponse +from typing import Optional +from urllib.parse import urlparse + +from auth.scopes import SCOPES, get_current_scopes # noqa +from auth.oauth_responses import ( + create_error_response, + create_success_response, + create_server_error_response, +) +from auth.google_auth import handle_auth_callback, check_client_secrets +from auth.oauth_config import get_oauth_redirect_uri + +logger = logging.getLogger(__name__) + + +class MinimalOAuthServer: + """ + Minimal HTTP server for OAuth callbacks in stdio mode. + Only starts when needed and uses the same port (8000) as streamable-http mode. + """ + + def __init__(self, port: int = 8000, base_uri: str = "http://localhost"): + self.port = port + self.base_uri = base_uri + self.app = FastAPI() + self.server = None + self.server_thread = None + self.is_running = False + + # Setup the callback route + self._setup_callback_route() + # Setup attachment serving route + self._setup_attachment_route() + + def _setup_callback_route(self): + """Setup the OAuth callback route.""" + + @self.app.get("/oauth2callback") + async def oauth_callback(request: Request): + """Handle OAuth callback - same logic as in core/server.py""" + code = request.query_params.get("code") + error = request.query_params.get("error") + + if error: + error_message = ( + f"Authentication failed: Google returned an error: {error}." + ) + logger.error(error_message) + return create_error_response(error_message) + + if not code: + error_message = ( + "Authentication failed: No authorization code received from Google." + ) + logger.error(error_message) + return create_error_response(error_message) + + try: + # Check if we have credentials available (environment variables or file) + error_message = check_client_secrets() + if error_message: + return create_server_error_response(error_message) + + logger.info( + "OAuth callback: Received authorization code. Attempting to exchange for tokens." + ) + + # Session ID tracking removed - not needed + + # Exchange code for credentials + redirect_uri = get_oauth_redirect_uri() + verified_user_id, credentials = handle_auth_callback( + scopes=get_current_scopes(), + authorization_response=str(request.url), + redirect_uri=redirect_uri, + session_id=None, + ) + + logger.info( + f"OAuth callback: Successfully authenticated user: {verified_user_id}." + ) + + # Return success page using shared template + return create_success_response(verified_user_id) + + except Exception as e: + error_message_detail = f"Error processing OAuth callback: {str(e)}" + logger.error(error_message_detail, exc_info=True) + return create_server_error_response(str(e)) + + def _setup_attachment_route(self): + """Setup the attachment serving route.""" + from core.attachment_storage import get_attachment_storage + + @self.app.get("/attachments/{file_id}") + async def serve_attachment(file_id: str, request: Request): + """Serve a stored attachment file.""" + storage = get_attachment_storage() + metadata = storage.get_attachment_metadata(file_id) + + if not metadata: + return JSONResponse( + {"error": "Attachment not found or expired"}, status_code=404 + ) + + file_path = storage.get_attachment_path(file_id) + if not file_path: + return JSONResponse( + {"error": "Attachment file not found"}, status_code=404 + ) + + return FileResponse( + path=str(file_path), + filename=metadata["filename"], + media_type=metadata["mime_type"], + ) + + def start(self) -> tuple[bool, str]: + """ + Start the minimal OAuth server. + + Returns: + Tuple of (success: bool, error_message: str) + """ + if self.is_running: + logger.info("Minimal OAuth server is already running") + return True, "" + + # Check if port is available + # Extract hostname from base_uri (e.g., "http://localhost" -> "localhost") + try: + parsed_uri = urlparse(self.base_uri) + hostname = parsed_uri.hostname or "localhost" + except Exception: + hostname = "localhost" + + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind((hostname, self.port)) + except OSError: + error_msg = f"Port {self.port} is already in use on {hostname}. Cannot start minimal OAuth server." + logger.error(error_msg) + return False, error_msg + + def run_server(): + """Run the server in a separate thread.""" + try: + config = uvicorn.Config( + self.app, + host=hostname, + port=self.port, + log_level="warning", + access_log=False, + ) + self.server = uvicorn.Server(config) + asyncio.run(self.server.serve()) + + except Exception as e: + logger.error(f"Minimal OAuth server error: {e}", exc_info=True) + self.is_running = False + + # Start server in background thread + self.server_thread = threading.Thread(target=run_server, daemon=True) + self.server_thread.start() + + # Wait for server to start + max_wait = 3.0 + start_time = time.time() + while time.time() - start_time < max_wait: + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + result = s.connect_ex((hostname, self.port)) + if result == 0: + self.is_running = True + logger.info( + f"Minimal OAuth server started on {hostname}:{self.port}" + ) + return True, "" + except Exception: + pass + time.sleep(0.1) + + error_msg = f"Failed to start minimal OAuth server on {hostname}:{self.port} - server did not respond within {max_wait}s" + logger.error(error_msg) + return False, error_msg + + def stop(self): + """Stop the minimal OAuth server.""" + if not self.is_running: + return + + try: + if self.server: + if hasattr(self.server, "should_exit"): + self.server.should_exit = True + + if self.server_thread and self.server_thread.is_alive(): + self.server_thread.join(timeout=3.0) + + self.is_running = False + logger.info("Minimal OAuth server stopped") + + except Exception as e: + logger.error(f"Error stopping minimal OAuth server: {e}", exc_info=True) + + +# Global instance for stdio mode +_minimal_oauth_server: Optional[MinimalOAuthServer] = None + + +def ensure_oauth_callback_available( + transport_mode: str = "stdio", port: int = 8000, base_uri: str = "http://localhost" +) -> tuple[bool, str]: + """ + Ensure OAuth callback endpoint is available for the given transport mode. + + For streamable-http: Assumes the main server is already running + For stdio: Starts a minimal server if needed + + Args: + transport_mode: "stdio" or "streamable-http" + port: Port number (default 8000) + base_uri: Base URI (default "http://localhost") + + Returns: + Tuple of (success: bool, error_message: str) + """ + global _minimal_oauth_server + + if transport_mode == "streamable-http": + # In streamable-http mode, the main FastAPI server should handle callbacks + logger.debug( + "Using existing FastAPI server for OAuth callbacks (streamable-http mode)" + ) + return True, "" + + elif transport_mode == "stdio": + # In stdio mode, start minimal server if not already running + if _minimal_oauth_server is None: + logger.info(f"Creating minimal OAuth server instance for {base_uri}:{port}") + _minimal_oauth_server = MinimalOAuthServer(port, base_uri) + + if not _minimal_oauth_server.is_running: + logger.info("Starting minimal OAuth server for stdio mode") + success, error_msg = _minimal_oauth_server.start() + if success: + logger.info( + f"Minimal OAuth server successfully started on {base_uri}:{port}" + ) + return True, "" + else: + logger.error( + f"Failed to start minimal OAuth server on {base_uri}:{port}: {error_msg}" + ) + return False, error_msg + else: + logger.info("Minimal OAuth server is already running") + return True, "" + + else: + error_msg = f"Unknown transport mode: {transport_mode}" + logger.error(error_msg) + return False, error_msg + + +def cleanup_oauth_callback_server(): + """Clean up the minimal OAuth server if it was started.""" + global _minimal_oauth_server + if _minimal_oauth_server: + _minimal_oauth_server.stop() + _minimal_oauth_server = None diff --git a/auth/oauth_config.py b/auth/oauth_config.py new file mode 100644 index 0000000..f4e23b4 --- /dev/null +++ b/auth/oauth_config.py @@ -0,0 +1,444 @@ +""" +OAuth Configuration Management + +This module centralizes OAuth-related configuration to eliminate hardcoded values +scattered throughout the codebase. It provides environment variable support and +sensible defaults for all OAuth-related settings. + +Supports both OAuth 2.0 and OAuth 2.1 with automatic client capability detection. +""" + +import os +from threading import RLock +from urllib.parse import urlparse +from typing import List, Optional, Dict, Any + + +class OAuthConfig: + """ + Centralized OAuth configuration management. + + This class eliminates the hardcoded configuration anti-pattern identified + in the challenge review by providing a single source of truth for all + OAuth-related configuration values. + """ + + def __init__(self): + # Base server configuration + self.base_uri = os.getenv("WORKSPACE_MCP_BASE_URI", "http://localhost") + self.port = int(os.getenv("PORT", os.getenv("WORKSPACE_MCP_PORT", "8000"))) + self.base_url = f"{self.base_uri}:{self.port}" + + # External URL for reverse proxy scenarios + self.external_url = os.getenv("WORKSPACE_EXTERNAL_URL") + + # OAuth client configuration + self.client_id = os.getenv("GOOGLE_OAUTH_CLIENT_ID") + self.client_secret = os.getenv("GOOGLE_OAUTH_CLIENT_SECRET") + + # OAuth 2.1 configuration + self.oauth21_enabled = ( + os.getenv("MCP_ENABLE_OAUTH21", "false").lower() == "true" + ) + self.pkce_required = self.oauth21_enabled # PKCE is mandatory in OAuth 2.1 + self.supported_code_challenge_methods = ( + ["S256", "plain"] if not self.oauth21_enabled else ["S256"] + ) + + # External OAuth 2.1 provider configuration + self.external_oauth21_provider = ( + os.getenv("EXTERNAL_OAUTH21_PROVIDER", "false").lower() == "true" + ) + if self.external_oauth21_provider and not self.oauth21_enabled: + raise ValueError( + "EXTERNAL_OAUTH21_PROVIDER requires MCP_ENABLE_OAUTH21=true" + ) + + # Stateless mode configuration + self.stateless_mode = ( + os.getenv("WORKSPACE_MCP_STATELESS_MODE", "false").lower() == "true" + ) + if self.stateless_mode and not self.oauth21_enabled: + raise ValueError( + "WORKSPACE_MCP_STATELESS_MODE requires MCP_ENABLE_OAUTH21=true" + ) + + # Transport mode (will be set at runtime) + self._transport_mode = "stdio" # Default + + # Redirect URI configuration + self.redirect_uri = self._get_redirect_uri() + self.redirect_path = self._get_redirect_path(self.redirect_uri) + + # Ensure FastMCP's Google provider picks up our existing configuration + self._apply_fastmcp_google_env() + + def _get_redirect_uri(self) -> str: + """ + Get the OAuth redirect URI, supporting reverse proxy configurations. + + Returns: + The configured redirect URI + """ + explicit_uri = os.getenv("GOOGLE_OAUTH_REDIRECT_URI") + if explicit_uri: + return explicit_uri + return f"{self.base_url}/oauth2callback" + + @staticmethod + def _get_redirect_path(uri: str) -> str: + """Extract the redirect path from a full redirect URI.""" + parsed = urlparse(uri) + if parsed.scheme or parsed.netloc: + path = parsed.path or "/oauth2callback" + else: + # If the value was already a path, ensure it starts with '/' + path = uri if uri.startswith("/") else f"/{uri}" + return path or "/oauth2callback" + + def _apply_fastmcp_google_env(self) -> None: + """Mirror legacy GOOGLE_* env vars into FastMCP Google provider settings.""" + if not self.client_id: + return + + def _set_if_absent(key: str, value: Optional[str]) -> None: + if value and key not in os.environ: + os.environ[key] = value + + # Don't set FASTMCP_SERVER_AUTH if using external OAuth provider + # (external OAuth means protocol-level auth is disabled, only tool-level auth) + if not self.external_oauth21_provider: + _set_if_absent( + "FASTMCP_SERVER_AUTH", + "fastmcp.server.auth.providers.google.GoogleProvider" + if self.oauth21_enabled + else None, + ) + + _set_if_absent("FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID", self.client_id) + _set_if_absent("FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET", self.client_secret) + _set_if_absent("FASTMCP_SERVER_AUTH_GOOGLE_BASE_URL", self.get_oauth_base_url()) + _set_if_absent("FASTMCP_SERVER_AUTH_GOOGLE_REDIRECT_PATH", self.redirect_path) + + def get_redirect_uris(self) -> List[str]: + """ + Get all valid OAuth redirect URIs. + + Returns: + List of all supported redirect URIs + """ + uris = [] + + # Primary redirect URI + uris.append(self.redirect_uri) + + # Custom redirect URIs from environment + custom_uris = os.getenv("OAUTH_CUSTOM_REDIRECT_URIS") + if custom_uris: + uris.extend([uri.strip() for uri in custom_uris.split(",")]) + + # Remove duplicates while preserving order + return list(dict.fromkeys(uris)) + + def get_allowed_origins(self) -> List[str]: + """ + Get allowed CORS origins for OAuth endpoints. + + Returns: + List of allowed origins for CORS + """ + origins = [] + + # Server's own origin + origins.append(self.base_url) + + # VS Code and development origins + origins.extend( + [ + "vscode-webview://", + "https://vscode.dev", + "https://github.dev", + ] + ) + + # Custom origins from environment + custom_origins = os.getenv("OAUTH_ALLOWED_ORIGINS") + if custom_origins: + origins.extend([origin.strip() for origin in custom_origins.split(",")]) + + return list(dict.fromkeys(origins)) + + def is_configured(self) -> bool: + """ + Check if OAuth is properly configured. + + Returns: + True if OAuth client credentials are available + """ + return bool(self.client_id and self.client_secret) + + def get_oauth_base_url(self) -> str: + """ + Get OAuth base URL for constructing OAuth endpoints. + + Uses WORKSPACE_EXTERNAL_URL if set (for reverse proxy scenarios), + otherwise falls back to constructed base_url with port. + + Returns: + Base URL for OAuth endpoints + """ + if self.external_url: + return self.external_url + return self.base_url + + def validate_redirect_uri(self, uri: str) -> bool: + """ + Validate if a redirect URI is allowed. + + Args: + uri: The redirect URI to validate + + Returns: + True if the URI is allowed, False otherwise + """ + allowed_uris = self.get_redirect_uris() + return uri in allowed_uris + + def get_environment_summary(self) -> dict: + """ + Get a summary of the current OAuth configuration. + + Returns: + Dictionary with configuration summary (excluding secrets) + """ + return { + "base_url": self.base_url, + "external_url": self.external_url, + "effective_oauth_url": self.get_oauth_base_url(), + "redirect_uri": self.redirect_uri, + "redirect_path": self.redirect_path, + "client_configured": bool(self.client_id), + "oauth21_enabled": self.oauth21_enabled, + "external_oauth21_provider": self.external_oauth21_provider, + "pkce_required": self.pkce_required, + "transport_mode": self._transport_mode, + "total_redirect_uris": len(self.get_redirect_uris()), + "total_allowed_origins": len(self.get_allowed_origins()), + } + + def set_transport_mode(self, mode: str) -> None: + """ + Set the current transport mode for OAuth callback handling. + + Args: + mode: Transport mode ("stdio", "streamable-http", etc.) + """ + self._transport_mode = mode + + def get_transport_mode(self) -> str: + """ + Get the current transport mode. + + Returns: + Current transport mode + """ + return self._transport_mode + + def is_oauth21_enabled(self) -> bool: + """ + Check if OAuth 2.1 mode is enabled. + + Returns: + True if OAuth 2.1 is enabled + """ + return self.oauth21_enabled + + def is_external_oauth21_provider(self) -> bool: + """ + Check if external OAuth 2.1 provider mode is enabled. + + When enabled, the server expects external OAuth flow with bearer tokens + in Authorization headers for tool calls. Protocol-level auth is disabled. + + Returns: + True if external OAuth 2.1 provider is enabled + """ + return self.external_oauth21_provider + + def detect_oauth_version(self, request_params: Dict[str, Any]) -> str: + """ + Detect OAuth version based on request parameters. + + This method implements a conservative detection strategy: + - Only returns "oauth21" when we have clear indicators + - Defaults to "oauth20" for backward compatibility + - Respects the global oauth21_enabled flag + + Args: + request_params: Request parameters from authorization or token request + + Returns: + "oauth21" or "oauth20" based on detection + """ + # If OAuth 2.1 is not enabled globally, always return OAuth 2.0 + if not self.oauth21_enabled: + return "oauth20" + + # Use the structured type for cleaner detection logic + from auth.oauth_types import OAuthVersionDetectionParams + + params = OAuthVersionDetectionParams.from_request(request_params) + + # Clear OAuth 2.1 indicator: PKCE is present + if params.has_pkce: + return "oauth21" + + # Additional detection: Check if we have an active OAuth 2.1 session + # This is important for tool calls where PKCE params aren't available + authenticated_user = request_params.get("authenticated_user") + if authenticated_user: + try: + from auth.oauth21_session_store import get_oauth21_session_store + + store = get_oauth21_session_store() + if store.has_session(authenticated_user): + return "oauth21" + except (ImportError, AttributeError, RuntimeError): + pass # Fall back to OAuth 2.0 if session check fails + + # For public clients in OAuth 2.1 mode, we require PKCE + # But since they didn't send PKCE, fall back to OAuth 2.0 + # This ensures backward compatibility + + # Default to OAuth 2.0 for maximum compatibility + return "oauth20" + + def get_authorization_server_metadata( + self, scopes: Optional[List[str]] = None + ) -> Dict[str, Any]: + """ + Get OAuth authorization server metadata per RFC 8414. + + Args: + scopes: Optional list of supported scopes to include in metadata + + Returns: + Authorization server metadata dictionary + """ + oauth_base = self.get_oauth_base_url() + metadata = { + "issuer": "https://accounts.google.com", + "authorization_endpoint": f"{oauth_base}/oauth2/authorize", + "token_endpoint": f"{oauth_base}/oauth2/token", + "registration_endpoint": f"{oauth_base}/oauth2/register", + "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs", + "userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo", + "response_types_supported": ["code", "token"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "token_endpoint_auth_methods_supported": [ + "client_secret_post", + "client_secret_basic", + ], + "code_challenge_methods_supported": self.supported_code_challenge_methods, + } + + # Include scopes if provided + if scopes is not None: + metadata["scopes_supported"] = scopes + + # Add OAuth 2.1 specific metadata + if self.oauth21_enabled: + metadata["pkce_required"] = True + # OAuth 2.1 deprecates implicit flow + metadata["response_types_supported"] = ["code"] + # OAuth 2.1 requires exact redirect URI matching + metadata["require_exact_redirect_uri"] = True + + return metadata + + +# Global configuration instance with thread-safe access +_oauth_config = None +_oauth_config_lock = RLock() + + +def get_oauth_config() -> OAuthConfig: + """ + Get the global OAuth configuration instance. + + Thread-safe singleton accessor. + + Returns: + The singleton OAuth configuration instance + """ + global _oauth_config + with _oauth_config_lock: + if _oauth_config is None: + _oauth_config = OAuthConfig() + return _oauth_config + + +def reload_oauth_config() -> OAuthConfig: + """ + Reload the OAuth configuration from environment variables. + + Thread-safe reload that prevents races with concurrent access. + + Returns: + The reloaded OAuth configuration instance + """ + global _oauth_config + with _oauth_config_lock: + _oauth_config = OAuthConfig() + return _oauth_config + + +# Convenience functions for backward compatibility +def get_oauth_base_url() -> str: + """Get OAuth base URL.""" + return get_oauth_config().get_oauth_base_url() + + +def get_redirect_uris() -> List[str]: + """Get all valid OAuth redirect URIs.""" + return get_oauth_config().get_redirect_uris() + + +def get_allowed_origins() -> List[str]: + """Get allowed CORS origins.""" + return get_oauth_config().get_allowed_origins() + + +def is_oauth_configured() -> bool: + """Check if OAuth is properly configured.""" + return get_oauth_config().is_configured() + + +def set_transport_mode(mode: str) -> None: + """Set the current transport mode.""" + get_oauth_config().set_transport_mode(mode) + + +def get_transport_mode() -> str: + """Get the current transport mode.""" + return get_oauth_config().get_transport_mode() + + +def is_oauth21_enabled() -> bool: + """Check if OAuth 2.1 is enabled.""" + return get_oauth_config().is_oauth21_enabled() + + +def get_oauth_redirect_uri() -> str: + """Get the primary OAuth redirect URI.""" + return get_oauth_config().redirect_uri + + +def is_stateless_mode() -> bool: + """Check if stateless mode is enabled.""" + return get_oauth_config().stateless_mode + + +def is_external_oauth21_provider() -> bool: + """Check if external OAuth 2.1 provider mode is enabled.""" + return get_oauth_config().is_external_oauth21_provider() diff --git a/auth/oauth_responses.py b/auth/oauth_responses.py new file mode 100644 index 0000000..5c2a0a9 --- /dev/null +++ b/auth/oauth_responses.py @@ -0,0 +1,229 @@ +""" +Shared OAuth callback response templates. + +Provides reusable HTML response templates for OAuth authentication flows +to eliminate duplication between server.py and oauth_callback_server.py. +""" + +from fastapi.responses import HTMLResponse +from typing import Optional + + +def create_error_response(error_message: str, status_code: int = 400) -> HTMLResponse: + """ + Create a standardized error response for OAuth failures. + + Args: + error_message: The error message to display + status_code: HTTP status code (default 400) + + Returns: + HTMLResponse with error page + """ + content = f""" + + Authentication Error + +

Authentication Error

+

{error_message}

+

Please ensure you grant the requested permissions. You can close this tab and try again.

+ + + """ + return HTMLResponse(content=content, status_code=status_code) + + +def create_success_response(verified_user_id: Optional[str] = None) -> HTMLResponse: + """ + Create a standardized success response for OAuth authentication. + + Args: + verified_user_id: The authenticated user's email (optional) + + Returns: + HTMLResponse with success page + """ + # Handle the case where no user ID is provided + user_display = verified_user_id if verified_user_id else "Google User" + + content = f""" + + Authentication Successful + + + + +
+
+

Authentication Successful

+
+ You've been authenticated as {user_display} +
+
+ Your credentials have been securely saved. You can now close this tab and retry your original command. +
+ +
This tab will close automatically in 10 seconds
+
+ +""" + return HTMLResponse(content=content) + + +def create_server_error_response(error_detail: str) -> HTMLResponse: + """ + Create a standardized server error response for OAuth processing failures. + + Args: + error_detail: The detailed error message + + Returns: + HTMLResponse with server error page + """ + content = f""" + + Authentication Processing Error + +

Authentication Processing Error

+

An unexpected error occurred while processing your authentication: {error_detail}

+

Please try again. You can close this tab.

+ + + """ + return HTMLResponse(content=content, status_code=500) diff --git a/auth/oauth_types.py b/auth/oauth_types.py new file mode 100644 index 0000000..e353e33 --- /dev/null +++ b/auth/oauth_types.py @@ -0,0 +1,92 @@ +""" +Type definitions for OAuth authentication. + +This module provides structured types for OAuth-related parameters, +improving code maintainability and type safety. +""" + +from dataclasses import dataclass +from typing import Optional, List, Dict, Any + +from fastmcp.server.auth import AccessToken + + +class WorkspaceAccessToken(AccessToken): + """AccessToken extended with workspace-specific fields.""" + + session_id: Optional[str] = None + sub: Optional[str] = None + email: Optional[str] = None + + +@dataclass +class OAuth21ServiceRequest: + """ + Encapsulates parameters for OAuth 2.1 service authentication requests. + + This parameter object pattern reduces function complexity and makes + it easier to extend authentication parameters in the future. + """ + + service_name: str + version: str + tool_name: str + user_google_email: str + required_scopes: List[str] + session_id: Optional[str] = None + auth_token_email: Optional[str] = None + allow_recent_auth: bool = False + context: Optional[Dict[str, Any]] = None + + def to_legacy_params(self) -> dict: + """Convert to legacy parameter format for backward compatibility.""" + return { + "service_name": self.service_name, + "version": self.version, + "tool_name": self.tool_name, + "user_google_email": self.user_google_email, + "required_scopes": self.required_scopes, + } + + +@dataclass +class OAuthVersionDetectionParams: + """ + Parameters used for OAuth version detection. + + Encapsulates the various signals we use to determine + whether a client supports OAuth 2.1 or needs OAuth 2.0. + """ + + client_id: Optional[str] = None + client_secret: Optional[str] = None + code_challenge: Optional[str] = None + code_challenge_method: Optional[str] = None + code_verifier: Optional[str] = None + authenticated_user: Optional[str] = None + session_id: Optional[str] = None + + @classmethod + def from_request( + cls, request_params: Dict[str, Any] + ) -> "OAuthVersionDetectionParams": + """Create from raw request parameters.""" + return cls( + client_id=request_params.get("client_id"), + client_secret=request_params.get("client_secret"), + code_challenge=request_params.get("code_challenge"), + code_challenge_method=request_params.get("code_challenge_method"), + code_verifier=request_params.get("code_verifier"), + authenticated_user=request_params.get("authenticated_user"), + session_id=request_params.get("session_id"), + ) + + @property + def has_pkce(self) -> bool: + """Check if PKCE parameters are present.""" + return bool(self.code_challenge or self.code_verifier) + + @property + def is_public_client(self) -> bool: + """Check if this appears to be a public client (no secret).""" + return bool(self.client_id and not self.client_secret) diff --git a/auth/permissions.py b/auth/permissions.py new file mode 100644 index 0000000..547f3d5 --- /dev/null +++ b/auth/permissions.py @@ -0,0 +1,277 @@ +""" +Granular per-service permission levels. + +Each service has named permission levels (cumulative), mapping to a list of +OAuth scopes. The levels for a service are ordered from least to most +permissive — requesting level N implicitly includes all scopes from levels < N. + +Usage: + --permissions gmail:organize drive:readonly + +Gmail levels: readonly, organize, drafts, send, full +Tasks levels: readonly, manage, full +Other services: readonly, full (extensible by adding entries to SERVICE_PERMISSION_LEVELS) +""" + +import logging +from typing import Dict, FrozenSet, List, Optional, Tuple + +from auth.scopes import ( + GMAIL_READONLY_SCOPE, + GMAIL_LABELS_SCOPE, + GMAIL_MODIFY_SCOPE, + GMAIL_COMPOSE_SCOPE, + GMAIL_SEND_SCOPE, + GMAIL_SETTINGS_BASIC_SCOPE, + DRIVE_READONLY_SCOPE, + DRIVE_FILE_SCOPE, + DRIVE_SCOPE, + CALENDAR_READONLY_SCOPE, + CALENDAR_EVENTS_SCOPE, + CALENDAR_SCOPE, + DOCS_READONLY_SCOPE, + DOCS_WRITE_SCOPE, + SHEETS_READONLY_SCOPE, + SHEETS_WRITE_SCOPE, + CHAT_READONLY_SCOPE, + CHAT_WRITE_SCOPE, + CHAT_SPACES_SCOPE, + CHAT_SPACES_READONLY_SCOPE, + FORMS_BODY_SCOPE, + FORMS_BODY_READONLY_SCOPE, + FORMS_RESPONSES_READONLY_SCOPE, + SLIDES_SCOPE, + SLIDES_READONLY_SCOPE, + TASKS_SCOPE, + TASKS_READONLY_SCOPE, + CONTACTS_SCOPE, + CONTACTS_READONLY_SCOPE, + CUSTOM_SEARCH_SCOPE, + SCRIPT_PROJECTS_SCOPE, + SCRIPT_PROJECTS_READONLY_SCOPE, + SCRIPT_DEPLOYMENTS_SCOPE, + SCRIPT_DEPLOYMENTS_READONLY_SCOPE, + SCRIPT_PROCESSES_READONLY_SCOPE, + SCRIPT_METRICS_SCOPE, +) + +logger = logging.getLogger(__name__) + +# Ordered permission levels per service. +# Each entry is (level_name, [additional_scopes_at_this_level]). +# Scopes are CUMULATIVE: level N includes all scopes from levels 0..N. +SERVICE_PERMISSION_LEVELS: Dict[str, List[Tuple[str, List[str]]]] = { + "gmail": [ + ("readonly", [GMAIL_READONLY_SCOPE]), + ("organize", [GMAIL_LABELS_SCOPE, GMAIL_MODIFY_SCOPE]), + ("drafts", [GMAIL_COMPOSE_SCOPE]), + ("send", [GMAIL_SEND_SCOPE]), + ("full", [GMAIL_SETTINGS_BASIC_SCOPE]), + ], + "drive": [ + ("readonly", [DRIVE_READONLY_SCOPE]), + ("full", [DRIVE_SCOPE, DRIVE_FILE_SCOPE]), + ], + "calendar": [ + ("readonly", [CALENDAR_READONLY_SCOPE]), + ("full", [CALENDAR_SCOPE, CALENDAR_EVENTS_SCOPE]), + ], + "docs": [ + ("readonly", [DOCS_READONLY_SCOPE, DRIVE_READONLY_SCOPE]), + ("full", [DOCS_WRITE_SCOPE, DRIVE_READONLY_SCOPE, DRIVE_FILE_SCOPE]), + ], + "sheets": [ + ("readonly", [SHEETS_READONLY_SCOPE, DRIVE_READONLY_SCOPE]), + ("full", [SHEETS_WRITE_SCOPE, DRIVE_READONLY_SCOPE]), + ], + "chat": [ + ("readonly", [CHAT_READONLY_SCOPE, CHAT_SPACES_READONLY_SCOPE]), + ("full", [CHAT_WRITE_SCOPE, CHAT_SPACES_SCOPE]), + ], + "forms": [ + ("readonly", [FORMS_BODY_READONLY_SCOPE, FORMS_RESPONSES_READONLY_SCOPE]), + ("full", [FORMS_BODY_SCOPE, FORMS_RESPONSES_READONLY_SCOPE]), + ], + "slides": [ + ("readonly", [SLIDES_READONLY_SCOPE]), + ("full", [SLIDES_SCOPE]), + ], + "tasks": [ + ("readonly", [TASKS_READONLY_SCOPE]), + ("manage", [TASKS_SCOPE]), + ("full", []), + ], + "contacts": [ + ("readonly", [CONTACTS_READONLY_SCOPE]), + ("full", [CONTACTS_SCOPE]), + ], + "search": [ + ("readonly", [CUSTOM_SEARCH_SCOPE]), + ("full", [CUSTOM_SEARCH_SCOPE]), + ], + "appscript": [ + ( + "readonly", + [ + SCRIPT_PROJECTS_READONLY_SCOPE, + SCRIPT_DEPLOYMENTS_READONLY_SCOPE, + SCRIPT_PROCESSES_READONLY_SCOPE, + SCRIPT_METRICS_SCOPE, + DRIVE_READONLY_SCOPE, + ], + ), + ( + "full", + [ + SCRIPT_PROJECTS_SCOPE, + SCRIPT_DEPLOYMENTS_SCOPE, + SCRIPT_PROCESSES_READONLY_SCOPE, + SCRIPT_METRICS_SCOPE, + DRIVE_FILE_SCOPE, + ], + ), + ], +} + +# Actions denied at specific permission levels. +# Maps service -> level -> frozenset of denied action names. +# Levels not listed here (or services without entries) deny nothing. +SERVICE_DENIED_ACTIONS: Dict[str, Dict[str, FrozenSet[str]]] = { + "tasks": { + "manage": frozenset({"delete", "clear_completed"}), + }, +} + + +def is_action_denied(service: str, action: str) -> bool: + """Check whether *action* is denied for *service* under current permissions. + + Returns ``False`` when granular permissions mode is not active, when the + service has no permission entry, or when the configured level does not + deny the action. + """ + if _PERMISSIONS is None: + return False + level = _PERMISSIONS.get(service) + if level is None: + return False + denied = SERVICE_DENIED_ACTIONS.get(service, {}).get(level, frozenset()) + return action in denied + + +# Module-level state: parsed --permissions config +# Dict mapping service_name -> level_name, e.g. {"gmail": "organize"} +_PERMISSIONS: Optional[Dict[str, str]] = None + + +def set_permissions(permissions: Optional[Dict[str, str]]) -> None: + """Set granular permissions from parsed --permissions argument.""" + global _PERMISSIONS + _PERMISSIONS = permissions + if permissions is not None: + logger.info("Granular permissions set: %s", permissions) + + +def get_permissions() -> Optional[Dict[str, str]]: + """Return current permissions dict, or None if not using granular mode.""" + return _PERMISSIONS + + +def is_permissions_mode() -> bool: + """Check if granular permissions mode is active.""" + return _PERMISSIONS is not None + + +def get_scopes_for_permission(service: str, level: str) -> List[str]: + """ + Get cumulative scopes for a service at a given permission level. + + Returns all scopes up to and including the named level. + Raises ValueError if service or level is unknown. + """ + levels = SERVICE_PERMISSION_LEVELS.get(service) + if levels is None: + raise ValueError(f"Unknown service: '{service}'") + + cumulative: List[str] = [] + found = False + for level_name, level_scopes in levels: + cumulative.extend(level_scopes) + if level_name == level: + found = True + break + + if not found: + valid = [name for name, _ in levels] + raise ValueError( + f"Unknown permission level '{level}' for service '{service}'. " + f"Valid levels: {valid}" + ) + + return sorted(set(cumulative)) + + +def get_all_permission_scopes() -> List[str]: + """ + Get the combined scopes for all services at their configured permission levels. + + Only meaningful when is_permissions_mode() is True. + """ + if _PERMISSIONS is None: + return [] + + all_scopes: set = set() + for service, level in _PERMISSIONS.items(): + all_scopes.update(get_scopes_for_permission(service, level)) + return list(all_scopes) + + +def get_allowed_scopes_set() -> Optional[set]: + """ + Get the set of allowed scopes under permissions mode (for tool filtering). + + Returns None if permissions mode is not active. + """ + if _PERMISSIONS is None: + return None + return set(get_all_permission_scopes()) + + +def get_valid_levels(service: str) -> List[str]: + """Get valid permission level names for a service.""" + levels = SERVICE_PERMISSION_LEVELS.get(service) + if levels is None: + return [] + return [name for name, _ in levels] + + +def parse_permissions_arg(permissions_list: List[str]) -> Dict[str, str]: + """ + Parse --permissions arguments like ["gmail:organize", "drive:full"]. + + Returns dict mapping service -> level. + Raises ValueError on parse errors (unknown service, invalid level, bad format). + """ + result: Dict[str, str] = {} + for entry in permissions_list: + if ":" not in entry: + raise ValueError( + f"Invalid permission format: '{entry}'. " + f"Expected 'service:level' (e.g., 'gmail:organize', 'drive:readonly')" + ) + service, level = entry.split(":", 1) + if service in result: + raise ValueError(f"Duplicate service in permissions: '{service}'") + if service not in SERVICE_PERMISSION_LEVELS: + raise ValueError( + f"Unknown service: '{service}'. " + f"Valid services: {sorted(SERVICE_PERMISSION_LEVELS.keys())}" + ) + valid = get_valid_levels(service) + if level not in valid: + raise ValueError( + f"Unknown level '{level}' for service '{service}'. " + f"Valid levels: {valid}" + ) + result[service] = level + return result diff --git a/auth/scopes.py b/auth/scopes.py new file mode 100644 index 0000000..aa610ac --- /dev/null +++ b/auth/scopes.py @@ -0,0 +1,336 @@ +""" +Google Workspace OAuth Scopes + +This module centralizes OAuth scope definitions for Google Workspace integration. +Separated from service_decorator.py to avoid circular imports. +""" + +import logging + +logger = logging.getLogger(__name__) + +# Global variable to store enabled tools (set by main.py) +_ENABLED_TOOLS = None + +# Individual OAuth Scope Constants +USERINFO_EMAIL_SCOPE = "https://www.googleapis.com/auth/userinfo.email" +USERINFO_PROFILE_SCOPE = "https://www.googleapis.com/auth/userinfo.profile" +OPENID_SCOPE = "openid" +CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar" +CALENDAR_READONLY_SCOPE = "https://www.googleapis.com/auth/calendar.readonly" +CALENDAR_EVENTS_SCOPE = "https://www.googleapis.com/auth/calendar.events" + +# Google Drive scopes +DRIVE_SCOPE = "https://www.googleapis.com/auth/drive" +DRIVE_READONLY_SCOPE = "https://www.googleapis.com/auth/drive.readonly" +DRIVE_FILE_SCOPE = "https://www.googleapis.com/auth/drive.file" + +# Google Docs scopes +DOCS_READONLY_SCOPE = "https://www.googleapis.com/auth/documents.readonly" +DOCS_WRITE_SCOPE = "https://www.googleapis.com/auth/documents" + +# Gmail API scopes +GMAIL_READONLY_SCOPE = "https://www.googleapis.com/auth/gmail.readonly" +GMAIL_SEND_SCOPE = "https://www.googleapis.com/auth/gmail.send" +GMAIL_COMPOSE_SCOPE = "https://www.googleapis.com/auth/gmail.compose" +GMAIL_MODIFY_SCOPE = "https://www.googleapis.com/auth/gmail.modify" +GMAIL_LABELS_SCOPE = "https://www.googleapis.com/auth/gmail.labels" +GMAIL_SETTINGS_BASIC_SCOPE = "https://www.googleapis.com/auth/gmail.settings.basic" + +# Google Chat API scopes +CHAT_READONLY_SCOPE = "https://www.googleapis.com/auth/chat.messages.readonly" +CHAT_WRITE_SCOPE = "https://www.googleapis.com/auth/chat.messages" +CHAT_SPACES_SCOPE = "https://www.googleapis.com/auth/chat.spaces" +CHAT_SPACES_READONLY_SCOPE = "https://www.googleapis.com/auth/chat.spaces.readonly" + +# Google Sheets API scopes +SHEETS_READONLY_SCOPE = "https://www.googleapis.com/auth/spreadsheets.readonly" +SHEETS_WRITE_SCOPE = "https://www.googleapis.com/auth/spreadsheets" + +# Google Forms API scopes +FORMS_BODY_SCOPE = "https://www.googleapis.com/auth/forms.body" +FORMS_BODY_READONLY_SCOPE = "https://www.googleapis.com/auth/forms.body.readonly" +FORMS_RESPONSES_READONLY_SCOPE = ( + "https://www.googleapis.com/auth/forms.responses.readonly" +) + +# Google Slides API scopes +SLIDES_SCOPE = "https://www.googleapis.com/auth/presentations" +SLIDES_READONLY_SCOPE = "https://www.googleapis.com/auth/presentations.readonly" + +# Google Tasks API scopes +TASKS_SCOPE = "https://www.googleapis.com/auth/tasks" +TASKS_READONLY_SCOPE = "https://www.googleapis.com/auth/tasks.readonly" + +# Google Contacts (People API) scopes +CONTACTS_SCOPE = "https://www.googleapis.com/auth/contacts" +CONTACTS_READONLY_SCOPE = "https://www.googleapis.com/auth/contacts.readonly" + +# Google Custom Search API scope +CUSTOM_SEARCH_SCOPE = "https://www.googleapis.com/auth/cse" + +# Google Apps Script API scopes +SCRIPT_PROJECTS_SCOPE = "https://www.googleapis.com/auth/script.projects" +SCRIPT_PROJECTS_READONLY_SCOPE = ( + "https://www.googleapis.com/auth/script.projects.readonly" +) +SCRIPT_DEPLOYMENTS_SCOPE = "https://www.googleapis.com/auth/script.deployments" +SCRIPT_DEPLOYMENTS_READONLY_SCOPE = ( + "https://www.googleapis.com/auth/script.deployments.readonly" +) +SCRIPT_PROCESSES_READONLY_SCOPE = "https://www.googleapis.com/auth/script.processes" +SCRIPT_METRICS_SCOPE = "https://www.googleapis.com/auth/script.metrics" + +# Google scope hierarchy: broader scopes that implicitly cover narrower ones. +# See https://developers.google.com/gmail/api/auth/scopes, +# https://developers.google.com/drive/api/guides/api-specific-auth, etc. +SCOPE_HIERARCHY = { + GMAIL_MODIFY_SCOPE: { + GMAIL_READONLY_SCOPE, + GMAIL_SEND_SCOPE, + GMAIL_COMPOSE_SCOPE, + GMAIL_LABELS_SCOPE, + }, + DRIVE_SCOPE: {DRIVE_READONLY_SCOPE, DRIVE_FILE_SCOPE}, + CALENDAR_SCOPE: {CALENDAR_READONLY_SCOPE, CALENDAR_EVENTS_SCOPE}, + DOCS_WRITE_SCOPE: {DOCS_READONLY_SCOPE}, + SHEETS_WRITE_SCOPE: {SHEETS_READONLY_SCOPE}, + SLIDES_SCOPE: {SLIDES_READONLY_SCOPE}, + TASKS_SCOPE: {TASKS_READONLY_SCOPE}, + CONTACTS_SCOPE: {CONTACTS_READONLY_SCOPE}, + CHAT_WRITE_SCOPE: {CHAT_READONLY_SCOPE}, + CHAT_SPACES_SCOPE: {CHAT_SPACES_READONLY_SCOPE}, + FORMS_BODY_SCOPE: {FORMS_BODY_READONLY_SCOPE}, + SCRIPT_PROJECTS_SCOPE: {SCRIPT_PROJECTS_READONLY_SCOPE}, + SCRIPT_DEPLOYMENTS_SCOPE: {SCRIPT_DEPLOYMENTS_READONLY_SCOPE}, +} + + +def has_required_scopes(available_scopes, required_scopes): + """ + Check if available scopes satisfy all required scopes, accounting for + Google's scope hierarchy (e.g., gmail.modify covers gmail.readonly). + + Args: + available_scopes: Scopes the credentials have (set, list, or frozenset). + required_scopes: Scopes that are required (set, list, or frozenset). + + Returns: + True if all required scopes are satisfied. + """ + available = set(available_scopes or []) + required = set(required_scopes or []) + # Expand available scopes with implied narrower scopes + expanded = set(available) + for broad_scope, covered in SCOPE_HIERARCHY.items(): + if broad_scope in available: + expanded.update(covered) + return all(scope in expanded for scope in required) + + +# Base OAuth scopes required for user identification +BASE_SCOPES = [USERINFO_EMAIL_SCOPE, USERINFO_PROFILE_SCOPE, OPENID_SCOPE] + +# Service-specific scope groups +DOCS_SCOPES = [ + DOCS_READONLY_SCOPE, + DOCS_WRITE_SCOPE, + DRIVE_READONLY_SCOPE, + DRIVE_FILE_SCOPE, +] + +CALENDAR_SCOPES = [CALENDAR_SCOPE, CALENDAR_READONLY_SCOPE, CALENDAR_EVENTS_SCOPE] + +DRIVE_SCOPES = [DRIVE_SCOPE, DRIVE_READONLY_SCOPE, DRIVE_FILE_SCOPE] + +GMAIL_SCOPES = [ + GMAIL_READONLY_SCOPE, + GMAIL_SEND_SCOPE, + GMAIL_COMPOSE_SCOPE, + GMAIL_MODIFY_SCOPE, + GMAIL_LABELS_SCOPE, + GMAIL_SETTINGS_BASIC_SCOPE, +] + +CHAT_SCOPES = [ + CHAT_READONLY_SCOPE, + CHAT_WRITE_SCOPE, + CHAT_SPACES_SCOPE, + CHAT_SPACES_READONLY_SCOPE, +] + +SHEETS_SCOPES = [SHEETS_READONLY_SCOPE, SHEETS_WRITE_SCOPE, DRIVE_READONLY_SCOPE] + +FORMS_SCOPES = [ + FORMS_BODY_SCOPE, + FORMS_BODY_READONLY_SCOPE, + FORMS_RESPONSES_READONLY_SCOPE, +] + +SLIDES_SCOPES = [SLIDES_SCOPE, SLIDES_READONLY_SCOPE] + +TASKS_SCOPES = [TASKS_SCOPE, TASKS_READONLY_SCOPE] + +CONTACTS_SCOPES = [CONTACTS_SCOPE, CONTACTS_READONLY_SCOPE] + +CUSTOM_SEARCH_SCOPES = [CUSTOM_SEARCH_SCOPE] + +SCRIPT_SCOPES = [ + SCRIPT_PROJECTS_SCOPE, + SCRIPT_PROJECTS_READONLY_SCOPE, + SCRIPT_DEPLOYMENTS_SCOPE, + SCRIPT_DEPLOYMENTS_READONLY_SCOPE, + SCRIPT_PROCESSES_READONLY_SCOPE, # Required for list_script_processes + SCRIPT_METRICS_SCOPE, # Required for get_script_metrics + DRIVE_FILE_SCOPE, # Required for list/delete script projects (uses Drive API) +] + +# Tool-to-scopes mapping +TOOL_SCOPES_MAP = { + "gmail": GMAIL_SCOPES, + "drive": DRIVE_SCOPES, + "calendar": CALENDAR_SCOPES, + "docs": DOCS_SCOPES, + "sheets": SHEETS_SCOPES, + "chat": CHAT_SCOPES, + "forms": FORMS_SCOPES, + "slides": SLIDES_SCOPES, + "tasks": TASKS_SCOPES, + "contacts": CONTACTS_SCOPES, + "search": CUSTOM_SEARCH_SCOPES, + "appscript": SCRIPT_SCOPES, +} + +# Tool-to-read-only-scopes mapping +TOOL_READONLY_SCOPES_MAP = { + "gmail": [GMAIL_READONLY_SCOPE], + "drive": [DRIVE_READONLY_SCOPE], + "calendar": [CALENDAR_READONLY_SCOPE], + "docs": [DOCS_READONLY_SCOPE, DRIVE_READONLY_SCOPE], + "sheets": [SHEETS_READONLY_SCOPE, DRIVE_READONLY_SCOPE], + "chat": [CHAT_READONLY_SCOPE, CHAT_SPACES_READONLY_SCOPE], + "forms": [FORMS_BODY_READONLY_SCOPE, FORMS_RESPONSES_READONLY_SCOPE], + "slides": [SLIDES_READONLY_SCOPE], + "tasks": [TASKS_READONLY_SCOPE], + "contacts": [CONTACTS_READONLY_SCOPE], + "search": CUSTOM_SEARCH_SCOPES, + "appscript": [ + SCRIPT_PROJECTS_READONLY_SCOPE, + SCRIPT_DEPLOYMENTS_READONLY_SCOPE, + SCRIPT_PROCESSES_READONLY_SCOPE, + SCRIPT_METRICS_SCOPE, + DRIVE_READONLY_SCOPE, + ], +} + + +def set_enabled_tools(enabled_tools): + """ + Set the globally enabled tools list. + + Args: + enabled_tools: List of enabled tool names. + """ + global _ENABLED_TOOLS + _ENABLED_TOOLS = enabled_tools + logger.info(f"Enabled tools set for scope management: {enabled_tools}") + + +# Global variable to store read-only mode (set by main.py) +_READ_ONLY_MODE = False + + +def set_read_only(enabled: bool): + """ + Set the global read-only mode. + + Args: + enabled: Boolean indicating if read-only mode should be enabled. + """ + global _READ_ONLY_MODE + _READ_ONLY_MODE = enabled + logger.info(f"Read-only mode set to: {enabled}") + + +def is_read_only_mode() -> bool: + """Check if read-only mode is enabled.""" + return _READ_ONLY_MODE + + +def get_all_read_only_scopes() -> list[str]: + """Get all possible read-only scopes across all tools.""" + all_scopes = set(BASE_SCOPES) + for scopes in TOOL_READONLY_SCOPES_MAP.values(): + all_scopes.update(scopes) + return list(all_scopes) + + +def get_current_scopes(): + """ + Returns scopes for currently enabled tools. + Uses globally set enabled tools or all tools if not set. + + .. deprecated:: + This function is a thin wrapper around get_scopes_for_tools() and exists + for backwards compatibility. Prefer using get_scopes_for_tools() directly + for new code, which allows explicit control over the tool list parameter. + + Returns: + List of unique scopes for the enabled tools plus base scopes. + """ + return get_scopes_for_tools(_ENABLED_TOOLS) + + +def get_scopes_for_tools(enabled_tools=None): + """ + Returns scopes for enabled tools only. + + Args: + enabled_tools: List of enabled tool names. If None, returns all scopes. + + Returns: + List of unique scopes for the enabled tools plus base scopes. + """ + # Granular permissions mode overrides both full and read-only scope maps. + # Lazy import with guard to avoid circular dependency during module init + # (SCOPES = get_scopes_for_tools() runs at import time before auth.permissions + # is fully loaded, but permissions mode is never active at that point). + try: + from auth.permissions import is_permissions_mode, get_all_permission_scopes + + if is_permissions_mode(): + scopes = BASE_SCOPES.copy() + scopes.extend(get_all_permission_scopes()) + logger.debug( + "Generated scopes from granular permissions: %d unique scopes", + len(set(scopes)), + ) + return list(set(scopes)) + except ImportError: + pass + + if enabled_tools is None: + # Default behavior - return all scopes + enabled_tools = TOOL_SCOPES_MAP.keys() + + # Start with base scopes (always required) + scopes = BASE_SCOPES.copy() + + # Determine which map to use based on read-only mode + scope_map = TOOL_READONLY_SCOPES_MAP if _READ_ONLY_MODE else TOOL_SCOPES_MAP + mode_str = "read-only" if _READ_ONLY_MODE else "full" + + # Add scopes for each enabled tool + for tool in enabled_tools: + if tool in scope_map: + scopes.extend(scope_map[tool]) + + logger.debug( + f"Generated {mode_str} scopes for tools {list(enabled_tools)}: {len(set(scopes))} unique scopes" + ) + # Return unique scopes + return list(set(scopes)) + + +# Combined scopes for all supported Google Workspace operations (backwards compatibility) +SCOPES = get_scopes_for_tools() diff --git a/auth/service_decorator.py b/auth/service_decorator.py new file mode 100644 index 0000000..a045f84 --- /dev/null +++ b/auth/service_decorator.py @@ -0,0 +1,862 @@ +import inspect +import logging + +import re +from functools import wraps +from typing import Dict, List, Optional, Any, Callable, Union, Tuple +from contextlib import ExitStack + +from google.auth.exceptions import RefreshError +from googleapiclient.discovery import build +from fastmcp.server.dependencies import get_access_token, get_context +from auth.google_auth import get_authenticated_google_service, GoogleAuthenticationError +from auth.oauth21_session_store import ( + get_auth_provider, + get_oauth21_session_store, + ensure_session_from_access_token, +) +from auth.oauth_config import ( + is_oauth21_enabled, + get_oauth_config, + is_external_oauth21_provider, +) +from core.context import set_fastmcp_session_id +from auth.scopes import ( + GMAIL_READONLY_SCOPE, + GMAIL_SEND_SCOPE, + GMAIL_COMPOSE_SCOPE, + GMAIL_MODIFY_SCOPE, + GMAIL_LABELS_SCOPE, + GMAIL_SETTINGS_BASIC_SCOPE, + DRIVE_SCOPE, + DRIVE_READONLY_SCOPE, + DRIVE_FILE_SCOPE, + DOCS_READONLY_SCOPE, + DOCS_WRITE_SCOPE, + CALENDAR_READONLY_SCOPE, + CALENDAR_EVENTS_SCOPE, + SHEETS_READONLY_SCOPE, + SHEETS_WRITE_SCOPE, + CHAT_READONLY_SCOPE, + CHAT_WRITE_SCOPE, + CHAT_SPACES_SCOPE, + CHAT_SPACES_READONLY_SCOPE, + FORMS_BODY_SCOPE, + FORMS_BODY_READONLY_SCOPE, + FORMS_RESPONSES_READONLY_SCOPE, + SLIDES_SCOPE, + SLIDES_READONLY_SCOPE, + TASKS_SCOPE, + TASKS_READONLY_SCOPE, + CONTACTS_SCOPE, + CONTACTS_READONLY_SCOPE, + CUSTOM_SEARCH_SCOPE, + SCRIPT_PROJECTS_SCOPE, + SCRIPT_PROJECTS_READONLY_SCOPE, + SCRIPT_DEPLOYMENTS_SCOPE, + SCRIPT_DEPLOYMENTS_READONLY_SCOPE, + has_required_scopes, +) + +logger = logging.getLogger(__name__) + + +# Authentication helper functions +async def _get_auth_context( + tool_name: str, +) -> Tuple[Optional[str], Optional[str], Optional[str]]: + """ + Get authentication context from FastMCP. + + Returns: + Tuple of (authenticated_user, auth_method, mcp_session_id) + """ + try: + ctx = get_context() + if not ctx: + return None, None, None + + authenticated_user = await ctx.get_state("authenticated_user_email") + auth_method = await ctx.get_state("authenticated_via") + mcp_session_id = ctx.session_id if hasattr(ctx, "session_id") else None + + if mcp_session_id: + set_fastmcp_session_id(mcp_session_id) + + logger.info( + f"[{tool_name}] Auth from middleware: authenticated_user={authenticated_user}, auth_method={auth_method}, session_id={mcp_session_id}" + ) + return authenticated_user, auth_method, mcp_session_id + + except Exception as e: + logger.debug(f"[{tool_name}] Could not get FastMCP context: {e}") + return None, None, None + + +def _detect_oauth_version( + authenticated_user: Optional[str], mcp_session_id: Optional[str], tool_name: str +) -> bool: + """ + Detect whether to use OAuth 2.1 based on configuration and context. + + Returns: + True if OAuth 2.1 should be used, False otherwise + """ + if not is_oauth21_enabled(): + return False + + # When OAuth 2.1 is enabled globally, ALWAYS use OAuth 2.1 for authenticated users + if authenticated_user: + logger.info( + f"[{tool_name}] OAuth 2.1 mode: Using OAuth 2.1 for authenticated user '{authenticated_user}'" + ) + return True + + # If FastMCP protocol-level auth is enabled, a validated access token should + # be available even if middleware state wasn't populated. + try: + if get_access_token() is not None: + logger.info( + f"[{tool_name}] OAuth 2.1 mode: Using OAuth 2.1 based on validated access token" + ) + return True + except Exception as e: + logger.debug( + f"[{tool_name}] Could not inspect access token for OAuth mode: {e}" + ) + + # Only use version detection for unauthenticated requests + config = get_oauth_config() + request_params = {} + if mcp_session_id: + request_params["session_id"] = mcp_session_id + + oauth_version = config.detect_oauth_version(request_params) + use_oauth21 = oauth_version == "oauth21" + logger.info( + f"[{tool_name}] OAuth version detected: {oauth_version}, will use OAuth 2.1: {use_oauth21}" + ) + return use_oauth21 + + +def _update_email_in_args(args: tuple, index: int, new_email: str) -> tuple: + """Update email at specific index in args tuple.""" + if index < len(args): + args_list = list(args) + args_list[index] = new_email + return tuple(args_list) + return args + + +def _override_oauth21_user_email( + use_oauth21: bool, + authenticated_user: Optional[str], + current_user_email: str, + args: tuple, + kwargs: dict, + param_names: List[str], + tool_name: str, + service_type: str = "", +) -> Tuple[str, tuple]: + """ + Override user_google_email with authenticated user when using OAuth 2.1. + + Returns: + Tuple of (updated_user_email, updated_args) + """ + if not ( + use_oauth21 and authenticated_user and current_user_email != authenticated_user + ): + return current_user_email, args + + service_suffix = f" for service '{service_type}'" if service_type else "" + logger.info( + f"[{tool_name}] OAuth 2.1: Overriding user_google_email from '{current_user_email}' to authenticated user '{authenticated_user}'{service_suffix}" + ) + + # Update in kwargs if present + if "user_google_email" in kwargs: + kwargs["user_google_email"] = authenticated_user + + # Update in args if user_google_email is passed positionally + try: + user_email_index = param_names.index("user_google_email") + args = _update_email_in_args(args, user_email_index, authenticated_user) + except ValueError: + pass # user_google_email not in positional parameters + + return authenticated_user, args + + +async def _authenticate_service( + use_oauth21: bool, + service_name: str, + service_version: str, + tool_name: str, + user_google_email: str, + resolved_scopes: List[str], + mcp_session_id: Optional[str], + authenticated_user: Optional[str], +) -> Tuple[Any, str]: + """ + Authenticate and get Google service using appropriate OAuth version. + + Returns: + Tuple of (service, actual_user_email) + """ + if use_oauth21: + logger.debug(f"[{tool_name}] Using OAuth 2.1 flow") + return await get_authenticated_google_service_oauth21( + service_name=service_name, + version=service_version, + tool_name=tool_name, + user_google_email=user_google_email, + required_scopes=resolved_scopes, + session_id=mcp_session_id, + auth_token_email=authenticated_user, + allow_recent_auth=False, + ) + else: + logger.debug(f"[{tool_name}] Using legacy OAuth 2.0 flow") + return await get_authenticated_google_service( + service_name=service_name, + version=service_version, + tool_name=tool_name, + user_google_email=user_google_email, + required_scopes=resolved_scopes, + session_id=mcp_session_id, + ) + + +async def get_authenticated_google_service_oauth21( + service_name: str, + version: str, + tool_name: str, + user_google_email: str, + required_scopes: List[str], + session_id: Optional[str] = None, + auth_token_email: Optional[str] = None, + allow_recent_auth: bool = False, +) -> tuple[Any, str]: + """ + OAuth 2.1 authentication using the session store with security validation. + """ + provider = get_auth_provider() + access_token = get_access_token() + + if provider and access_token: + token_email = None + if getattr(access_token, "claims", None): + token_email = access_token.claims.get("email") + + resolved_email = token_email or auth_token_email or user_google_email + if not resolved_email: + raise GoogleAuthenticationError( + "Authenticated user email could not be determined from access token." + ) + + if auth_token_email and token_email and token_email != auth_token_email: + raise GoogleAuthenticationError( + "Access token email does not match authenticated session context." + ) + + if token_email and user_google_email and token_email != user_google_email: + raise GoogleAuthenticationError( + f"Authenticated account {token_email} does not match requested user {user_google_email}." + ) + + credentials = ensure_session_from_access_token( + access_token, resolved_email, session_id + ) + if not credentials: + raise GoogleAuthenticationError( + "Unable to build Google credentials from authenticated access token." + ) + + scopes_available = set(credentials.scopes or []) + if not scopes_available and getattr(access_token, "scopes", None): + scopes_available = set(access_token.scopes) + + if not has_required_scopes(scopes_available, required_scopes): + raise GoogleAuthenticationError( + f"OAuth credentials lack required scopes. Need: {required_scopes}, Have: {sorted(scopes_available)}" + ) + + service = build(service_name, version, credentials=credentials) + logger.info(f"[{tool_name}] Authenticated {service_name} for {resolved_email}") + return service, resolved_email + + store = get_oauth21_session_store() + + # Use the validation method to ensure session can only access its own credentials + credentials = store.get_credentials_with_validation( + requested_user_email=user_google_email, + session_id=session_id, + auth_token_email=auth_token_email, + allow_recent_auth=allow_recent_auth, + ) + + if not credentials: + raise GoogleAuthenticationError( + f"Access denied: Cannot retrieve credentials for {user_google_email}. " + f"You can only access credentials for your authenticated account." + ) + + if not credentials.scopes: + scopes_available = set(required_scopes) + else: + scopes_available = set(credentials.scopes) + + if not has_required_scopes(scopes_available, required_scopes): + raise GoogleAuthenticationError( + f"OAuth 2.1 credentials lack required scopes. Need: {required_scopes}, Have: {sorted(scopes_available)}" + ) + + service = build(service_name, version, credentials=credentials) + logger.info(f"[{tool_name}] Authenticated {service_name} for {user_google_email}") + + return service, user_google_email + + +def _extract_oauth21_user_email( + authenticated_user: Optional[str], func_name: str +) -> str: + """ + Extract user email for OAuth 2.1 mode. + + Args: + authenticated_user: The authenticated user from context + func_name: Name of the function being decorated (for error messages) + + Returns: + User email string + + Raises: + Exception: If no authenticated user found in OAuth 2.1 mode + """ + if not authenticated_user: + raise Exception( + f"OAuth 2.1 mode requires an authenticated user for {func_name}, but none was found." + ) + return authenticated_user + + +def _extract_oauth20_user_email( + args: tuple, kwargs: dict, wrapper_sig: inspect.Signature +) -> str: + """ + Extract user email for OAuth 2.0 mode from function arguments. + + Args: + args: Positional arguments passed to wrapper + kwargs: Keyword arguments passed to wrapper + wrapper_sig: Function signature for parameter binding + + Returns: + User email string + + Raises: + Exception: If user_google_email parameter not found + """ + bound_args = wrapper_sig.bind(*args, **kwargs) + bound_args.apply_defaults() + + user_google_email = bound_args.arguments.get("user_google_email") + if not user_google_email: + raise Exception("'user_google_email' parameter is required but was not found.") + return user_google_email + + +def _remove_user_email_arg_from_docstring(docstring: str) -> str: + """ + Remove user_google_email parameter documentation from docstring. + + Args: + docstring: The original function docstring + + Returns: + Modified docstring with user_google_email parameter removed + """ + if not docstring: + return docstring + + # Pattern to match user_google_email parameter documentation + # Handles various formats like: + # - user_google_email (str): The user's Google email address. Required. + # - user_google_email: Description + # - user_google_email (str) - Description + patterns = [ + r"^\s*user_google_email\s*\([^)]*\)\s*:\s*[^\n]*\.?\s*(?:Required\.?)?\s*\n", + r"^\s*user_google_email\s*:\s*[^\n]*\n", + r"^\s*user_google_email\s*\([^)]*\)\s*-\s*[^\n]*\n", + ] + + modified_docstring = docstring + for pattern in patterns: + modified_docstring = re.sub(pattern, "", modified_docstring, flags=re.MULTILINE) + + # Clean up any sequence of 3 or more newlines that might have been created + modified_docstring = re.sub(r"\n{3,}", "\n\n", modified_docstring) + return modified_docstring + + +# Service configuration mapping +SERVICE_CONFIGS = { + "gmail": {"service": "gmail", "version": "v1"}, + "drive": {"service": "drive", "version": "v3"}, + "calendar": {"service": "calendar", "version": "v3"}, + "docs": {"service": "docs", "version": "v1"}, + "sheets": {"service": "sheets", "version": "v4"}, + "chat": {"service": "chat", "version": "v1"}, + "forms": {"service": "forms", "version": "v1"}, + "slides": {"service": "slides", "version": "v1"}, + "tasks": {"service": "tasks", "version": "v1"}, + "people": {"service": "people", "version": "v1"}, + "customsearch": {"service": "customsearch", "version": "v1"}, + "script": {"service": "script", "version": "v1"}, +} + + +# Scope group definitions for easy reference +SCOPE_GROUPS = { + # Gmail scopes + "gmail_read": GMAIL_READONLY_SCOPE, + "gmail_send": GMAIL_SEND_SCOPE, + "gmail_compose": GMAIL_COMPOSE_SCOPE, + "gmail_modify": GMAIL_MODIFY_SCOPE, + "gmail_labels": GMAIL_LABELS_SCOPE, + "gmail_settings_basic": GMAIL_SETTINGS_BASIC_SCOPE, + # Drive scopes + "drive": DRIVE_SCOPE, + "drive_read": DRIVE_READONLY_SCOPE, + "drive_file": DRIVE_FILE_SCOPE, + # Docs scopes + "docs_read": DOCS_READONLY_SCOPE, + "docs_write": DOCS_WRITE_SCOPE, + # Calendar scopes + "calendar_read": CALENDAR_READONLY_SCOPE, + "calendar_events": CALENDAR_EVENTS_SCOPE, + # Sheets scopes + "sheets_read": SHEETS_READONLY_SCOPE, + "sheets_write": SHEETS_WRITE_SCOPE, + # Chat scopes + "chat_read": CHAT_READONLY_SCOPE, + "chat_write": CHAT_WRITE_SCOPE, + "chat_spaces": CHAT_SPACES_SCOPE, + "chat_spaces_readonly": CHAT_SPACES_READONLY_SCOPE, + # Forms scopes + "forms": FORMS_BODY_SCOPE, + "forms_read": FORMS_BODY_READONLY_SCOPE, + "forms_responses_read": FORMS_RESPONSES_READONLY_SCOPE, + # Slides scopes + "slides": SLIDES_SCOPE, + "slides_read": SLIDES_READONLY_SCOPE, + # Tasks scopes + "tasks": TASKS_SCOPE, + "tasks_read": TASKS_READONLY_SCOPE, + # Contacts scopes + "contacts": CONTACTS_SCOPE, + "contacts_read": CONTACTS_READONLY_SCOPE, + # Custom Search scope + "customsearch": CUSTOM_SEARCH_SCOPE, + # Apps Script scopes + "script_readonly": SCRIPT_PROJECTS_READONLY_SCOPE, + "script_projects": SCRIPT_PROJECTS_SCOPE, + "script_deployments": SCRIPT_DEPLOYMENTS_SCOPE, + "script_deployments_readonly": SCRIPT_DEPLOYMENTS_READONLY_SCOPE, +} + + +def _resolve_scopes(scopes: Union[str, List[str]]) -> List[str]: + """Resolve scope names to actual scope URLs.""" + if isinstance(scopes, str): + if scopes in SCOPE_GROUPS: + return [SCOPE_GROUPS[scopes]] + else: + return [scopes] + + resolved = [] + for scope in scopes: + if scope in SCOPE_GROUPS: + resolved.append(SCOPE_GROUPS[scope]) + else: + resolved.append(scope) + return resolved + + +def _handle_token_refresh_error( + error: RefreshError, user_email: str, service_name: str +) -> str: + """ + Handle token refresh errors gracefully, particularly expired/revoked tokens. + + Args: + error: The RefreshError that occurred + user_email: User's email address + service_name: Name of the Google service + + Returns: + A user-friendly error message with instructions for reauthentication + """ + error_str = str(error) + + if ( + "invalid_grant" in error_str.lower() + or "expired or revoked" in error_str.lower() + ): + logger.warning( + f"Token expired or revoked for user {user_email} accessing {service_name}" + ) + + service_display_name = f"Google {service_name.title()}" + if is_oauth21_enabled(): + if is_external_oauth21_provider(): + oauth21_step = ( + "Provide a valid OAuth 2.1 bearer token in the Authorization header" + ) + else: + oauth21_step = "Sign in through your MCP client's OAuth 2.1 flow" + + return ( + f"**Authentication Required: Token Expired/Revoked for {service_display_name}**\n\n" + f"Your Google authentication token for {user_email} has expired or been revoked. " + f"This commonly happens when:\n" + f"- The token has been unused for an extended period\n" + f"- You've changed your Google account password\n" + f"- You've revoked access to the application\n\n" + f"**To resolve this, please:**\n" + f"1. {oauth21_step}\n" + f"2. Retry your original command\n\n" + f"The application will automatically use the new credentials once authentication is complete." + ) + + return ( + f"**Authentication Required: Token Expired/Revoked for {service_display_name}**\n\n" + f"Your Google authentication token for {user_email} has expired or been revoked. " + f"This commonly happens when:\n" + f"- The token has been unused for an extended period\n" + f"- You've changed your Google account password\n" + f"- You've revoked access to the application\n\n" + f"**To resolve this, please:**\n" + f"1. Run `start_google_auth` with your email ({user_email}) and service_name='{service_display_name}'\n" + f"2. Complete the authentication flow in your browser\n" + f"3. Retry your original command\n\n" + f"The application will automatically use the new credentials once authentication is complete." + ) + else: + # Handle other types of refresh errors + logger.error(f"Unexpected refresh error for user {user_email}: {error}") + if is_oauth21_enabled(): + if is_external_oauth21_provider(): + return ( + f"Authentication error occurred for {user_email}. " + "Please provide a valid OAuth 2.1 bearer token and retry." + ) + return ( + f"Authentication error occurred for {user_email}. " + "Please sign in via your MCP client's OAuth 2.1 flow and retry." + ) + return ( + f"Authentication error occurred for {user_email}. " + f"Please try running `start_google_auth` with your email and the appropriate service name to reauthenticate." + ) + + +def require_google_service( + service_type: str, + scopes: Union[str, List[str]], + version: Optional[str] = None, +): + """ + Decorator that automatically handles Google service authentication and injection. + + Args: + service_type: Type of Google service ("gmail", "drive", "calendar", etc.) + scopes: Required scopes (can be scope group names or actual URLs) + version: Service version (defaults to standard version for service type) + + Usage: + @require_google_service("gmail", "gmail_read") + async def search_messages(service, user_google_email: str, query: str): + # service parameter is automatically injected + # Original authentication logic is handled automatically + """ + + def decorator(func: Callable) -> Callable: + original_sig = inspect.signature(func) + params = list(original_sig.parameters.values()) + + # The decorated function must have 'service' as its first parameter. + if not params or params[0].name != "service": + raise TypeError( + f"Function '{func.__name__}' decorated with @require_google_service " + "must have 'service' as its first parameter." + ) + + # Create a new signature for the wrapper that excludes the 'service' parameter. + # In OAuth 2.1 mode, also exclude 'user_google_email' since it's automatically determined. + if is_oauth21_enabled(): + # Remove both 'service' and 'user_google_email' parameters + filtered_params = [p for p in params[1:] if p.name != "user_google_email"] + wrapper_sig = original_sig.replace(parameters=filtered_params) + else: + # Only remove 'service' parameter for OAuth 2.0 mode + wrapper_sig = original_sig.replace(parameters=params[1:]) + + @wraps(func) + async def wrapper(*args, **kwargs): + # Note: `args` and `kwargs` are now the arguments for the *wrapper*, + # which does not include 'service'. + + # Get authentication context early to determine OAuth mode + authenticated_user, auth_method, mcp_session_id = await _get_auth_context( + func.__name__ + ) + + # Extract user_google_email based on OAuth mode + if is_oauth21_enabled(): + user_google_email = _extract_oauth21_user_email( + authenticated_user, func.__name__ + ) + else: + user_google_email = _extract_oauth20_user_email( + args, kwargs, wrapper_sig + ) + + # Get service configuration from the decorator's arguments + if service_type not in SERVICE_CONFIGS: + raise Exception(f"Unknown service type: {service_type}") + + config = SERVICE_CONFIGS[service_type] + service_name = config["service"] + service_version = version or config["version"] + + # Resolve scopes + resolved_scopes = _resolve_scopes(scopes) + + try: + tool_name = func.__name__ + + # Log authentication status + logger.debug( + f"[{tool_name}] Auth: {authenticated_user or 'none'} via {auth_method or 'none'} (session: {mcp_session_id[:8] if mcp_session_id else 'none'})" + ) + + # Detect OAuth version + use_oauth21 = _detect_oauth_version( + authenticated_user, mcp_session_id, tool_name + ) + + # In OAuth 2.1 mode, user_google_email is already set to authenticated_user + # In OAuth 2.0 mode, we may need to override it + if not is_oauth21_enabled(): + wrapper_params = list(wrapper_sig.parameters.keys()) + user_google_email, args = _override_oauth21_user_email( + use_oauth21, + authenticated_user, + user_google_email, + args, + kwargs, + wrapper_params, + tool_name, + ) + + # Authenticate service + service, actual_user_email = await _authenticate_service( + use_oauth21, + service_name, + service_version, + tool_name, + user_google_email, + resolved_scopes, + mcp_session_id, + authenticated_user, + ) + except GoogleAuthenticationError as e: + logger.error( + f"[{tool_name}] GoogleAuthenticationError during authentication. " + f"Method={auth_method or 'none'}, User={authenticated_user or 'none'}, " + f"Service={service_name} v{service_version}, MCPSessionID={mcp_session_id or 'none'}: {e}" + ) + # Re-raise the original error without wrapping it + raise + + try: + # In OAuth 2.1 mode, we need to add user_google_email to kwargs since it was removed from signature + if is_oauth21_enabled(): + kwargs["user_google_email"] = user_google_email + + # Prepend the fetched service object to the original arguments + return await func(service, *args, **kwargs) + except RefreshError as e: + error_message = _handle_token_refresh_error( + e, actual_user_email, service_name + ) + raise GoogleAuthenticationError(error_message) + finally: + if service: + service.close() + + # Set the wrapper's signature to the one without 'service' + wrapper.__signature__ = wrapper_sig + + # Conditionally modify docstring to remove user_google_email parameter documentation + if is_oauth21_enabled(): + logger.debug( + "OAuth 2.1 mode enabled, removing user_google_email from docstring" + ) + if func.__doc__: + wrapper.__doc__ = _remove_user_email_arg_from_docstring(func.__doc__) + + # Attach required scopes to the wrapper for tool filtering + wrapper._required_google_scopes = _resolve_scopes(scopes) + + return wrapper + + return decorator + + +def require_multiple_services(service_configs: List[Dict[str, Any]]): + """ + Decorator for functions that need multiple Google services. + + Args: + service_configs: List of service configurations, each containing: + - service_type: Type of service + - scopes: Required scopes + - param_name: Name to inject service as (e.g., 'drive_service', 'docs_service') + - version: Optional version override + + Usage: + @require_multiple_services([ + {"service_type": "drive", "scopes": "drive_read", "param_name": "drive_service"}, + {"service_type": "docs", "scopes": "docs_read", "param_name": "docs_service"} + ]) + async def get_doc_with_metadata(drive_service, docs_service, user_google_email: str, doc_id: str): + # Both services are automatically injected + """ + + def decorator(func: Callable) -> Callable: + original_sig = inspect.signature(func) + + service_param_names = {config["param_name"] for config in service_configs} + params = list(original_sig.parameters.values()) + + # Remove injected service params from the wrapper signature; drop user_google_email only for OAuth 2.1. + filtered_params = [p for p in params if p.name not in service_param_names] + if is_oauth21_enabled(): + filtered_params = [ + p for p in filtered_params if p.name != "user_google_email" + ] + + wrapper_sig = original_sig.replace(parameters=filtered_params) + wrapper_param_names = [p.name for p in filtered_params] + + @wraps(func) + async def wrapper(*args, **kwargs): + # Get authentication context early + tool_name = func.__name__ + authenticated_user, _, mcp_session_id = await _get_auth_context(tool_name) + + # Extract user_google_email based on OAuth mode + if is_oauth21_enabled(): + user_google_email = _extract_oauth21_user_email( + authenticated_user, tool_name + ) + else: + user_google_email = _extract_oauth20_user_email( + args, kwargs, wrapper_sig + ) + + # Authenticate all services + with ExitStack() as stack: + for config in service_configs: + service_type = config["service_type"] + scopes = config["scopes"] + param_name = config["param_name"] + version = config.get("version") + + if service_type not in SERVICE_CONFIGS: + raise Exception(f"Unknown service type: {service_type}") + + service_config = SERVICE_CONFIGS[service_type] + service_name = service_config["service"] + service_version = version or service_config["version"] + resolved_scopes = _resolve_scopes(scopes) + + try: + # Detect OAuth version (simplified for multiple services) + use_oauth21 = ( + is_oauth21_enabled() and authenticated_user is not None + ) + + # In OAuth 2.0 mode, we may need to override user_google_email + if not is_oauth21_enabled(): + user_google_email, args = _override_oauth21_user_email( + use_oauth21, + authenticated_user, + user_google_email, + args, + kwargs, + wrapper_param_names, + tool_name, + service_type, + ) + + # Authenticate service + service, _ = await _authenticate_service( + use_oauth21, + service_name, + service_version, + tool_name, + user_google_email, + resolved_scopes, + mcp_session_id, + authenticated_user, + ) + + # Inject service with specified parameter name + kwargs[param_name] = service + stack.callback(service.close) + + except GoogleAuthenticationError as e: + logger.error( + f"[{tool_name}] GoogleAuthenticationError for service '{service_type}' (user: {user_google_email}): {e}" + ) + # Re-raise the original error without wrapping it + raise + + # Call the original function with refresh error handling + try: + # In OAuth 2.1 mode, we need to add user_google_email to kwargs since it was removed from signature + if is_oauth21_enabled(): + kwargs["user_google_email"] = user_google_email + + return await func(*args, **kwargs) + except RefreshError as e: + # Handle token refresh errors gracefully + error_message = _handle_token_refresh_error( + e, user_google_email, "Multiple Services" + ) + raise GoogleAuthenticationError(error_message) + + # Set the wrapper's signature + wrapper.__signature__ = wrapper_sig + + # Conditionally modify docstring to remove user_google_email parameter documentation + if is_oauth21_enabled(): + logger.debug( + "OAuth 2.1 mode enabled, removing user_google_email from docstring" + ) + if func.__doc__: + wrapper.__doc__ = _remove_user_email_arg_from_docstring(func.__doc__) + + # Attach all required scopes to the wrapper for tool filtering + all_scopes = [] + for config in service_configs: + all_scopes.extend(_resolve_scopes(config["scopes"])) + wrapper._required_google_scopes = all_scopes + + return wrapper + + return decorator diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..b320b74 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1 @@ +# Make the core directory a Python package diff --git a/core/api_enablement.py b/core/api_enablement.py new file mode 100644 index 0000000..e5f493d --- /dev/null +++ b/core/api_enablement.py @@ -0,0 +1,108 @@ +import re +from typing import Dict, Optional, Tuple + + +API_ENABLEMENT_LINKS: Dict[str, str] = { + "calendar-json.googleapis.com": "https://console.cloud.google.com/flows/enableapi?apiid=calendar-json.googleapis.com", + "drive.googleapis.com": "https://console.cloud.google.com/flows/enableapi?apiid=drive.googleapis.com", + "gmail.googleapis.com": "https://console.cloud.google.com/flows/enableapi?apiid=gmail.googleapis.com", + "docs.googleapis.com": "https://console.cloud.google.com/flows/enableapi?apiid=docs.googleapis.com", + "sheets.googleapis.com": "https://console.cloud.google.com/flows/enableapi?apiid=sheets.googleapis.com", + "slides.googleapis.com": "https://console.cloud.google.com/flows/enableapi?apiid=slides.googleapis.com", + "forms.googleapis.com": "https://console.cloud.google.com/flows/enableapi?apiid=forms.googleapis.com", + "tasks.googleapis.com": "https://console.cloud.google.com/flows/enableapi?apiid=tasks.googleapis.com", + "chat.googleapis.com": "https://console.cloud.google.com/flows/enableapi?apiid=chat.googleapis.com", + "customsearch.googleapis.com": "https://console.cloud.google.com/flows/enableapi?apiid=customsearch.googleapis.com", +} + + +SERVICE_NAME_TO_API: Dict[str, str] = { + "Google Calendar": "calendar-json.googleapis.com", + "Google Drive": "drive.googleapis.com", + "Gmail": "gmail.googleapis.com", + "Google Docs": "docs.googleapis.com", + "Google Sheets": "sheets.googleapis.com", + "Google Slides": "slides.googleapis.com", + "Google Forms": "forms.googleapis.com", + "Google Tasks": "tasks.googleapis.com", + "Google Chat": "chat.googleapis.com", + "Google Custom Search": "customsearch.googleapis.com", +} + + +INTERNAL_SERVICE_TO_API: Dict[str, str] = { + "calendar": "calendar-json.googleapis.com", + "drive": "drive.googleapis.com", + "gmail": "gmail.googleapis.com", + "docs": "docs.googleapis.com", + "sheets": "sheets.googleapis.com", + "slides": "slides.googleapis.com", + "forms": "forms.googleapis.com", + "tasks": "tasks.googleapis.com", + "chat": "chat.googleapis.com", + "customsearch": "customsearch.googleapis.com", + "search": "customsearch.googleapis.com", +} + + +def extract_api_info_from_error( + error_details: str, +) -> Tuple[Optional[str], Optional[str]]: + """ + Extract API service and project ID from error details. + + Returns: + Tuple of (api_service, project_id) or (None, None) if not found + """ + api_pattern = r"https://console\.developers\.google\.com/apis/api/([^/]+)/overview" + project_pattern = r"project[=\s]+([a-zA-Z0-9-]+)" + + api_match = re.search(api_pattern, error_details) + project_match = re.search(project_pattern, error_details) + + api_service = api_match.group(1) if api_match else None + project_id = project_match.group(1) if project_match else None + + return api_service, project_id + + +def get_api_enablement_message( + error_details: str, service_type: Optional[str] = None +) -> str: + """ + Generate a helpful error message with direct API enablement link. + + Args: + error_details: The error details string from the HttpError + service_type: Optional service type (e.g., "calendar", "gmail", or "Google Calendar") + + Returns: + Formatted error message with enablement link + """ + api_service, project_id = extract_api_info_from_error(error_details) + + if not api_service and service_type: + # Check internal service names first (e.g., "calendar", "gmail") + api_service = INTERNAL_SERVICE_TO_API.get(service_type) + if not api_service: + # Check display names (e.g., "Google Calendar") + api_service = SERVICE_NAME_TO_API.get(service_type) + + if api_service and api_service in API_ENABLEMENT_LINKS: + enable_link = API_ENABLEMENT_LINKS[api_service] + service_display_name = next( + (name for name, api in SERVICE_NAME_TO_API.items() if api == api_service), + api_service, + ) + + message = ( + f"{service_display_name} API is not enabled for your project" + f"{f' ({project_id})' if project_id else ''}.\n\n" + f"Enable it here: {enable_link}\n\n" + f"After enabling, wait 1-2 minutes for the change to propagate, then try again." + f"IMPORTANT - LLM: share the link provided as a clickable hyperlink and instruct the user to enable the required API." + ) + + return message + + return "" diff --git a/core/attachment_storage.py b/core/attachment_storage.py new file mode 100644 index 0000000..6448574 --- /dev/null +++ b/core/attachment_storage.py @@ -0,0 +1,262 @@ +""" +Temporary attachment storage for Gmail attachments. + +Stores attachments to local disk and returns file paths for direct access. +Files are automatically cleaned up after expiration (default 1 hour). +""" + +import base64 +import logging +import os +import uuid +from pathlib import Path +from typing import NamedTuple, Optional, Dict +from datetime import datetime, timedelta + +logger = logging.getLogger(__name__) + +# Default expiration: 1 hour +DEFAULT_EXPIRATION_SECONDS = 3600 + +# Storage directory - configurable via WORKSPACE_ATTACHMENT_DIR env var +# Uses absolute path to avoid creating tmp/ in arbitrary working directories (see #327) +_default_dir = str(Path.home() / ".workspace-mcp" / "attachments") +STORAGE_DIR = ( + Path(os.getenv("WORKSPACE_ATTACHMENT_DIR", _default_dir)).expanduser().resolve() +) + + +def _ensure_storage_dir() -> None: + """Create the storage directory on first use, not at import time.""" + STORAGE_DIR.mkdir(parents=True, exist_ok=True, mode=0o700) + + +class SavedAttachment(NamedTuple): + """Result of saving an attachment: provides both the UUID and the absolute file path.""" + + file_id: str + path: str + + +class AttachmentStorage: + """Manages temporary storage of email attachments.""" + + def __init__(self, expiration_seconds: int = DEFAULT_EXPIRATION_SECONDS): + self.expiration_seconds = expiration_seconds + self._metadata: Dict[str, Dict] = {} + + def save_attachment( + self, + base64_data: str, + filename: Optional[str] = None, + mime_type: Optional[str] = None, + ) -> SavedAttachment: + """ + Save an attachment to local disk. + + Args: + base64_data: Base64-encoded attachment data + filename: Original filename (optional) + mime_type: MIME type (optional) + + Returns: + SavedAttachment with file_id (UUID) and path (absolute file path) + """ + _ensure_storage_dir() + + # Generate unique file ID for metadata tracking + file_id = str(uuid.uuid4()) + + # Decode base64 data + try: + file_bytes = base64.urlsafe_b64decode(base64_data) + except Exception as e: + logger.error(f"Failed to decode base64 attachment data: {e}") + raise ValueError(f"Invalid base64 data: {e}") + + # Determine file extension from filename or mime type + extension = "" + if filename: + extension = Path(filename).suffix + elif mime_type: + # Basic mime type to extension mapping + mime_to_ext = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "application/pdf": ".pdf", + "application/zip": ".zip", + "text/plain": ".txt", + "text/html": ".html", + } + extension = mime_to_ext.get(mime_type, "") + + # Use original filename if available, with UUID suffix for uniqueness + if filename: + stem = Path(filename).stem + ext = Path(filename).suffix + save_name = f"{stem}_{file_id[:8]}{ext}" + else: + save_name = f"{file_id}{extension}" + + # Save file with restrictive permissions (sensitive email/drive content) + file_path = STORAGE_DIR / save_name + try: + fd = os.open( + file_path, + os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_BINARY", 0), + 0o600, + ) + try: + total_written = 0 + data_len = len(file_bytes) + while total_written < data_len: + written = os.write(fd, file_bytes[total_written:]) + if written == 0: + raise OSError( + "os.write returned 0 bytes; could not write attachment data" + ) + total_written += written + finally: + os.close(fd) + logger.info( + f"Saved attachment file_id={file_id} filename={filename or save_name} " + f"({len(file_bytes)} bytes) to {file_path}" + ) + except Exception as e: + logger.error( + f"Failed to save attachment file_id={file_id} " + f"filename={filename or save_name} to {file_path}: {e}" + ) + raise + + # Store metadata + expires_at = datetime.now() + timedelta(seconds=self.expiration_seconds) + self._metadata[file_id] = { + "file_path": str(file_path), + "filename": filename or f"attachment{extension}", + "mime_type": mime_type or "application/octet-stream", + "size": len(file_bytes), + "created_at": datetime.now(), + "expires_at": expires_at, + } + + return SavedAttachment(file_id=file_id, path=str(file_path)) + + def get_attachment_path(self, file_id: str) -> Optional[Path]: + """ + Get the file path for an attachment ID. + + Args: + file_id: Unique file ID + + Returns: + Path object if file exists and not expired, None otherwise + """ + if file_id not in self._metadata: + logger.warning(f"Attachment {file_id} not found in metadata") + return None + + metadata = self._metadata[file_id] + file_path = Path(metadata["file_path"]) + + # Check if expired + if datetime.now() > metadata["expires_at"]: + logger.info(f"Attachment {file_id} has expired, cleaning up") + self._cleanup_file(file_id) + return None + + # Check if file exists + if not file_path.exists(): + logger.warning(f"Attachment file {file_path} does not exist") + del self._metadata[file_id] + return None + + return file_path + + def get_attachment_metadata(self, file_id: str) -> Optional[Dict]: + """ + Get metadata for an attachment. + + Args: + file_id: Unique file ID + + Returns: + Metadata dict if exists and not expired, None otherwise + """ + if file_id not in self._metadata: + return None + + metadata = self._metadata[file_id].copy() + + # Check if expired + if datetime.now() > metadata["expires_at"]: + self._cleanup_file(file_id) + return None + + return metadata + + def _cleanup_file(self, file_id: str) -> None: + """Remove file and metadata.""" + if file_id in self._metadata: + file_path = Path(self._metadata[file_id]["file_path"]) + try: + if file_path.exists(): + file_path.unlink() + logger.debug(f"Deleted expired attachment file: {file_path}") + except Exception as e: + logger.warning(f"Failed to delete attachment file {file_path}: {e}") + del self._metadata[file_id] + + def cleanup_expired(self) -> int: + """ + Clean up expired attachments. + + Returns: + Number of files cleaned up + """ + now = datetime.now() + expired_ids = [ + file_id + for file_id, metadata in self._metadata.items() + if now > metadata["expires_at"] + ] + + for file_id in expired_ids: + self._cleanup_file(file_id) + + return len(expired_ids) + + +# Global instance +_attachment_storage: Optional[AttachmentStorage] = None + + +def get_attachment_storage() -> AttachmentStorage: + """Get the global attachment storage instance.""" + global _attachment_storage + if _attachment_storage is None: + _attachment_storage = AttachmentStorage() + return _attachment_storage + + +def get_attachment_url(file_id: str) -> str: + """ + Generate a URL for accessing an attachment. + + Args: + file_id: Unique file ID + + Returns: + Full URL to access the attachment + """ + from core.config import WORKSPACE_MCP_PORT, WORKSPACE_MCP_BASE_URI + + # Use external URL if set (for reverse proxy scenarios) + external_url = os.getenv("WORKSPACE_EXTERNAL_URL") + if external_url: + base_url = external_url.rstrip("/") + else: + base_url = f"{WORKSPACE_MCP_BASE_URI}:{WORKSPACE_MCP_PORT}" + + return f"{base_url}/attachments/{file_id}" diff --git a/core/cli_handler.py b/core/cli_handler.py new file mode 100644 index 0000000..3627463 --- /dev/null +++ b/core/cli_handler.py @@ -0,0 +1,410 @@ +""" +CLI Handler for Google Workspace MCP + +This module provides a command-line interface mode for directly invoking +MCP tools without running the full server. Designed for use by coding agents +(Codex, Claude Code) and command-line users. + +Usage: + workspace-mcp --cli # List available tools + workspace-mcp --cli list # List available tools + workspace-mcp --cli # Run tool (reads JSON args from stdin) + workspace-mcp --cli --args '{"key": "value"}' # Run with inline args + workspace-mcp --cli --help # Show tool details +""" + +import asyncio +import json +import logging +import sys +from typing import Any, Dict, List, Optional + +from auth.oauth_config import set_transport_mode +from core.tool_registry import get_tool_components + +logger = logging.getLogger(__name__) + + +def get_registered_tools(server) -> Dict[str, Any]: + """ + Get all registered tools from the FastMCP server. + + Args: + server: The FastMCP server instance + + Returns: + Dictionary mapping tool names to their metadata + """ + tools = {} + + for name, tool in get_tool_components(server).items(): + tools[name] = { + "name": name, + "description": getattr(tool, "description", None) + or _extract_docstring(tool), + "parameters": _extract_parameters(tool), + "tool_obj": tool, + } + + return tools + + +def _extract_docstring(tool) -> Optional[str]: + """Extract the first meaningful line of a tool's docstring as its description.""" + fn = getattr(tool, "fn", None) or tool + if fn and fn.__doc__: + # Get first non-empty line that's not just "Args:" etc. + for line in fn.__doc__.strip().split("\n"): + line = line.strip() + # Skip empty lines and common section headers + if line and not line.startswith( + ("Args:", "Returns:", "Raises:", "Example", "Note:") + ): + return line + return None + + +def _extract_parameters(tool) -> Dict[str, Any]: + """Extract parameter information from a tool.""" + params = {} + + # Try to get parameters from the tool's schema + if hasattr(tool, "parameters"): + schema = tool.parameters + if isinstance(schema, dict): + props = schema.get("properties", {}) + required = set(schema.get("required", [])) + for name, prop in props.items(): + params[name] = { + "type": prop.get("type", "any"), + "description": prop.get("description", ""), + "required": name in required, + "default": prop.get("default"), + } + + return params + + +def list_tools(server, output_format: str = "text") -> str: + """ + List all available tools. + + Args: + server: The FastMCP server instance + output_format: Output format ("text" or "json") + + Returns: + Formatted string listing all tools + """ + tools = get_registered_tools(server) + + if output_format == "json": + # Return JSON format for programmatic use + tool_list = [] + for name, info in sorted(tools.items()): + tool_list.append( + { + "name": name, + "description": info["description"], + "parameters": info["parameters"], + } + ) + return json.dumps({"tools": tool_list}, indent=2) + + # Text format for human reading + lines = [ + f"Available tools ({len(tools)}):", + "", + ] + + # Group tools by service + services = {} + for name, info in tools.items(): + # Extract service prefix from tool name + prefix = name.split("_")[0] if "_" in name else "other" + if prefix not in services: + services[prefix] = [] + services[prefix].append((name, info)) + + for service in sorted(services.keys()): + lines.append(f" {service.upper()}:") + for name, info in sorted(services[service]): + desc = info["description"] or "(no description)" + # Get first line only and truncate + first_line = desc.split("\n")[0].strip() + if len(first_line) > 70: + first_line = first_line[:67] + "..." + lines.append(f" {name}") + lines.append(f" {first_line}") + lines.append("") + + lines.append("Use --cli --help for detailed tool information") + lines.append("Use --cli --args '{...}' to run a tool") + + return "\n".join(lines) + + +def show_tool_help(server, tool_name: str) -> str: + """ + Show detailed help for a specific tool. + + Args: + server: The FastMCP server instance + tool_name: Name of the tool + + Returns: + Formatted help string for the tool + """ + tools = get_registered_tools(server) + + if tool_name not in tools: + available = ", ".join(sorted(tools.keys())[:10]) + return f"Error: Tool '{tool_name}' not found.\n\nAvailable tools include: {available}..." + + tool_info = tools[tool_name] + tool_obj = tool_info["tool_obj"] + + # Get full docstring + fn = getattr(tool_obj, "fn", None) or tool_obj + docstring = fn.__doc__ if fn and fn.__doc__ else "(no documentation)" + + lines = [ + f"Tool: {tool_name}", + "=" * (len(tool_name) + 6), + "", + docstring, + "", + "Parameters:", + ] + + params = tool_info["parameters"] + if params: + for name, param_info in params.items(): + req = "(required)" if param_info.get("required") else "(optional)" + param_type = param_info.get("type", "any") + desc = param_info.get("description", "") + default = param_info.get("default") + + lines.append(f" {name}: {param_type} {req}") + if desc: + lines.append(f" {desc}") + if default is not None: + lines.append(f" Default: {default}") + else: + lines.append(" (no parameters)") + + lines.extend( + [ + "", + "Example usage:", + f' workspace-mcp --cli {tool_name} --args \'{{"param": "value"}}\'', + "", + "Or pipe JSON from stdin:", + f' echo \'{{"param": "value"}}\' | workspace-mcp --cli {tool_name}', + ] + ) + + return "\n".join(lines) + + +async def run_tool(server, tool_name: str, args: Dict[str, Any]) -> str: + """ + Execute a tool with the provided arguments. + + Args: + server: The FastMCP server instance + tool_name: Name of the tool to execute + args: Dictionary of arguments to pass to the tool + + Returns: + Tool result as a string + """ + tools = get_registered_tools(server) + + if tool_name not in tools: + raise ValueError(f"Tool '{tool_name}' not found") + + tool_info = tools[tool_name] + tool_obj = tool_info["tool_obj"] + + # Get the actual function to call + fn = getattr(tool_obj, "fn", None) + if fn is None: + raise ValueError(f"Tool '{tool_name}' has no callable function") + + call_args = dict(args) + + try: + logger.debug( + f"[CLI] Executing tool: {tool_name} with args: {list(call_args.keys())}" + ) + + # Call the tool function + if asyncio.iscoroutinefunction(fn): + result = await fn(**call_args) + else: + result = fn(**call_args) + + # Convert result to string if needed + if isinstance(result, str): + return result + else: + return json.dumps(result, indent=2, default=str) + + except TypeError as e: + # Provide helpful error for missing/invalid arguments + error_msg = str(e) + params = tool_info["parameters"] + required = [n for n, p in params.items() if p.get("required")] + + return ( + f"Error calling {tool_name}: {error_msg}\n\n" + f"Required parameters: {required}\n" + f"Provided parameters: {list(call_args.keys())}" + ) + except Exception as e: + logger.error(f"[CLI] Error executing {tool_name}: {e}", exc_info=True) + return f"Error: {type(e).__name__}: {e}" + + +def parse_cli_args(args: List[str]) -> Dict[str, Any]: + """ + Parse CLI arguments for tool execution. + + Args: + args: List of arguments after --cli + + Returns: + Dictionary with parsed values: + - command: "list", "help", or "run" + - tool_name: Name of tool (if applicable) + - tool_args: Arguments for the tool (if applicable) + - output_format: "text" or "json" + """ + result = { + "command": "list", + "tool_name": None, + "tool_args": {}, + "output_format": "text", + } + + if not args: + return result + + i = 0 + while i < len(args): + arg = args[i] + + if arg in ("list", "-l", "--list"): + result["command"] = "list" + i += 1 + elif arg in ("--json", "-j"): + result["output_format"] = "json" + i += 1 + elif arg in ("help", "--help", "-h"): + # Help command - if tool_name already set, show help for that tool + if result["tool_name"]: + result["command"] = "help" + else: + # Check if next arg is a tool name + if i + 1 < len(args) and not args[i + 1].startswith("-"): + result["tool_name"] = args[i + 1] + result["command"] = "help" + i += 1 + else: + # No tool specified, show general help + result["command"] = "list" + i += 1 + elif arg in ("--args", "-a") and i + 1 < len(args): + # Parse inline JSON arguments + json_str = args[i + 1] + try: + result["tool_args"] = json.loads(json_str) + except json.JSONDecodeError as e: + # Provide helpful debug info + raise ValueError( + f"Invalid JSON in --args: {e}\n" + f"Received: {repr(json_str)}\n" + f"Tip: Try using stdin instead: echo '' | workspace-mcp --cli " + ) + i += 2 + elif not arg.startswith("-") and not result["tool_name"]: + # First non-flag argument is the tool name + result["tool_name"] = arg + result["command"] = "run" + i += 1 + else: + i += 1 + + return result + + +def read_stdin_args() -> Dict[str, Any]: + """ + Read JSON arguments from stdin if available. + + Returns: + Dictionary of arguments or empty dict if stdin is a TTY or no data is provided. + """ + if sys.stdin.isatty(): + logger.debug("[CLI] stdin is a TTY; no JSON args will be read from stdin") + return {} + + try: + stdin_data = sys.stdin.read().strip() + if stdin_data: + return json.loads(stdin_data) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid JSON from stdin: {e}") + + return {} + + +async def handle_cli_mode(server, cli_args: List[str]) -> int: + """ + Main entry point for CLI mode. + + Args: + server: The FastMCP server instance + cli_args: Arguments passed after --cli + + Returns: + Exit code (0 for success, 1 for error) + """ + # Set transport mode to "stdio" so OAuth callback server starts when needed + # This is required for authentication flow when no cached credentials exist + set_transport_mode("stdio") + + try: + parsed = parse_cli_args(cli_args) + + if parsed["command"] == "list": + output = list_tools(server, parsed["output_format"]) + print(output) + return 0 + + if parsed["command"] == "help": + output = show_tool_help(server, parsed["tool_name"]) + print(output) + return 0 + + if parsed["command"] == "run": + # Merge stdin args with inline args (inline takes precedence) + args = read_stdin_args() + args.update(parsed["tool_args"]) + + result = await run_tool(server, parsed["tool_name"], args) + print(result) + return 0 + + # Unknown command + print(f"Unknown command: {parsed['command']}") + return 1 + + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + except Exception as e: + logger.error(f"[CLI] Unexpected error: {e}", exc_info=True) + print(f"Error: {e}", file=sys.stderr) + return 1 diff --git a/core/comments.py b/core/comments.py new file mode 100644 index 0000000..844bdda --- /dev/null +++ b/core/comments.py @@ -0,0 +1,305 @@ +""" +Core Comments Module + +This module provides reusable comment management functions for Google Workspace applications. +All Google Workspace apps (Docs, Sheets, Slides) use the Drive API for comment operations. +""" + +import logging +import asyncio +from typing import Optional + +from auth.service_decorator import require_google_service +from core.server import server +from core.utils import handle_http_errors + +logger = logging.getLogger(__name__) + + +async def _manage_comment_dispatch( + service, + app_name: str, + file_id: str, + action: str, + comment_content: Optional[str] = None, + comment_id: Optional[str] = None, +) -> str: + """Route comment management actions to the appropriate implementation.""" + action_lower = action.lower().strip() + if action_lower == "create": + if not comment_content: + raise ValueError("comment_content is required for create action") + return await _create_comment_impl(service, app_name, file_id, comment_content) + elif action_lower == "reply": + if not comment_id or not comment_content: + raise ValueError( + "comment_id and comment_content are required for reply action" + ) + return await _reply_to_comment_impl( + service, app_name, file_id, comment_id, comment_content + ) + elif action_lower == "resolve": + if not comment_id: + raise ValueError("comment_id is required for resolve action") + return await _resolve_comment_impl(service, app_name, file_id, comment_id) + else: + raise ValueError( + f"Invalid action '{action_lower}'. Must be 'create', 'reply', or 'resolve'." + ) + + +def create_comment_tools(app_name: str, file_id_param: str): + """ + Factory function to create comment management tools for a specific Google Workspace app. + + Args: + app_name: Name of the app (e.g., "document", "spreadsheet", "presentation") + file_id_param: Parameter name for the file ID (e.g., "document_id", "spreadsheet_id", "presentation_id") + + Returns: + Dict containing the comment management functions with unique names + """ + + # --- Consolidated tools --- + list_func_name = f"list_{app_name}_comments" + manage_func_name = f"manage_{app_name}_comment" + + if file_id_param == "document_id": + + @require_google_service("drive", "drive_read") + @handle_http_errors(list_func_name, service_type="drive") + async def list_comments( + service, user_google_email: str, document_id: str + ) -> str: + """List all comments from a Google Document.""" + return await _read_comments_impl(service, app_name, document_id) + + @require_google_service("drive", "drive_file") + @handle_http_errors(manage_func_name, service_type="drive") + async def manage_comment( + service, + user_google_email: str, + document_id: str, + action: str, + comment_content: Optional[str] = None, + comment_id: Optional[str] = None, + ) -> str: + """Manage comments on a Google Document. + + Actions: + - create: Create a new comment. Requires comment_content. + - reply: Reply to a comment. Requires comment_id and comment_content. + - resolve: Resolve a comment. Requires comment_id. + """ + return await _manage_comment_dispatch( + service, app_name, document_id, action, comment_content, comment_id + ) + + elif file_id_param == "spreadsheet_id": + + @require_google_service("drive", "drive_read") + @handle_http_errors(list_func_name, service_type="drive") + async def list_comments( + service, user_google_email: str, spreadsheet_id: str + ) -> str: + """List all comments from a Google Spreadsheet.""" + return await _read_comments_impl(service, app_name, spreadsheet_id) + + @require_google_service("drive", "drive_file") + @handle_http_errors(manage_func_name, service_type="drive") + async def manage_comment( + service, + user_google_email: str, + spreadsheet_id: str, + action: str, + comment_content: Optional[str] = None, + comment_id: Optional[str] = None, + ) -> str: + """Manage comments on a Google Spreadsheet. + + Actions: + - create: Create a new comment. Requires comment_content. + - reply: Reply to a comment. Requires comment_id and comment_content. + - resolve: Resolve a comment. Requires comment_id. + """ + return await _manage_comment_dispatch( + service, app_name, spreadsheet_id, action, comment_content, comment_id + ) + + elif file_id_param == "presentation_id": + + @require_google_service("drive", "drive_read") + @handle_http_errors(list_func_name, service_type="drive") + async def list_comments( + service, user_google_email: str, presentation_id: str + ) -> str: + """List all comments from a Google Presentation.""" + return await _read_comments_impl(service, app_name, presentation_id) + + @require_google_service("drive", "drive_file") + @handle_http_errors(manage_func_name, service_type="drive") + async def manage_comment( + service, + user_google_email: str, + presentation_id: str, + action: str, + comment_content: Optional[str] = None, + comment_id: Optional[str] = None, + ) -> str: + """Manage comments on a Google Presentation. + + Actions: + - create: Create a new comment. Requires comment_content. + - reply: Reply to a comment. Requires comment_id and comment_content. + - resolve: Resolve a comment. Requires comment_id. + """ + return await _manage_comment_dispatch( + service, app_name, presentation_id, action, comment_content, comment_id + ) + + list_comments.__name__ = list_func_name + manage_comment.__name__ = manage_func_name + server.tool()(list_comments) + server.tool()(manage_comment) + + return { + "list_comments": list_comments, + "manage_comment": manage_comment, + } + + +async def _read_comments_impl(service, app_name: str, file_id: str) -> str: + """Implementation for reading comments from any Google Workspace file.""" + logger.info(f"[read_{app_name}_comments] Reading comments for {app_name} {file_id}") + + response = await asyncio.to_thread( + service.comments() + .list( + fileId=file_id, + fields="comments(id,content,author,createdTime,modifiedTime,resolved,quotedFileContent,replies(content,author,id,createdTime,modifiedTime))", + ) + .execute + ) + + comments = response.get("comments", []) + + if not comments: + return f"No comments found in {app_name} {file_id}" + + output = [f"Found {len(comments)} comments in {app_name} {file_id}:\\n"] + + for comment in comments: + author = comment.get("author", {}).get("displayName", "Unknown") + content = comment.get("content", "") + created = comment.get("createdTime", "") + resolved = comment.get("resolved", False) + comment_id = comment.get("id", "") + status = " [RESOLVED]" if resolved else "" + + quoted_text = comment.get("quotedFileContent", {}).get("value", "") + + output.append(f"Comment ID: {comment_id}") + output.append(f"Author: {author}") + output.append(f"Created: {created}{status}") + if quoted_text: + output.append(f"Quoted text: {quoted_text}") + output.append(f"Content: {content}") + + # Add replies if any + replies = comment.get("replies", []) + if replies: + output.append(f" Replies ({len(replies)}):") + for reply in replies: + reply_author = reply.get("author", {}).get("displayName", "Unknown") + reply_content = reply.get("content", "") + reply_created = reply.get("createdTime", "") + reply_id = reply.get("id", "") + output.append(f" Reply ID: {reply_id}") + output.append(f" Author: {reply_author}") + output.append(f" Created: {reply_created}") + output.append(f" Content: {reply_content}") + + output.append("") # Empty line between comments + + return "\\n".join(output) + + +async def _create_comment_impl( + service, app_name: str, file_id: str, comment_content: str +) -> str: + """Implementation for creating a comment on any Google Workspace file.""" + logger.info(f"[create_{app_name}_comment] Creating comment in {app_name} {file_id}") + + body = {"content": comment_content} + + comment = await asyncio.to_thread( + service.comments() + .create( + fileId=file_id, + body=body, + fields="id,content,author,createdTime,modifiedTime", + ) + .execute + ) + + comment_id = comment.get("id", "") + author = comment.get("author", {}).get("displayName", "Unknown") + created = comment.get("createdTime", "") + + return f"Comment created successfully!\\nComment ID: {comment_id}\\nAuthor: {author}\\nCreated: {created}\\nContent: {comment_content}" + + +async def _reply_to_comment_impl( + service, app_name: str, file_id: str, comment_id: str, reply_content: str +) -> str: + """Implementation for replying to a comment on any Google Workspace file.""" + logger.info( + f"[reply_to_{app_name}_comment] Replying to comment {comment_id} in {app_name} {file_id}" + ) + + body = {"content": reply_content} + + reply = await asyncio.to_thread( + service.replies() + .create( + fileId=file_id, + commentId=comment_id, + body=body, + fields="id,content,author,createdTime,modifiedTime", + ) + .execute + ) + + reply_id = reply.get("id", "") + author = reply.get("author", {}).get("displayName", "Unknown") + created = reply.get("createdTime", "") + + return f"Reply posted successfully!\\nReply ID: {reply_id}\\nAuthor: {author}\\nCreated: {created}\\nContent: {reply_content}" + + +async def _resolve_comment_impl( + service, app_name: str, file_id: str, comment_id: str +) -> str: + """Implementation for resolving a comment on any Google Workspace file.""" + logger.info( + f"[resolve_{app_name}_comment] Resolving comment {comment_id} in {app_name} {file_id}" + ) + + body = {"content": "This comment has been resolved.", "action": "resolve"} + + reply = await asyncio.to_thread( + service.replies() + .create( + fileId=file_id, + commentId=comment_id, + body=body, + fields="id,content,author,createdTime,modifiedTime", + ) + .execute + ) + + reply_id = reply.get("id", "") + author = reply.get("author", {}).get("displayName", "Unknown") + created = reply.get("createdTime", "") + + return f"Comment {comment_id} has been resolved successfully.\\nResolve reply ID: {reply_id}\\nAuthor: {author}\\nCreated: {created}" diff --git a/core/config.py b/core/config.py new file mode 100644 index 0000000..e7b8aaa --- /dev/null +++ b/core/config.py @@ -0,0 +1,37 @@ +""" +Shared configuration for Google Workspace MCP server. +This module holds configuration values that need to be shared across modules +to avoid circular imports. + +NOTE: OAuth configuration has been moved to auth.oauth_config for centralization. +This module now imports from there for backward compatibility. +""" + +import os +from auth.oauth_config import ( + get_oauth_base_url, + get_oauth_redirect_uri, + set_transport_mode, + get_transport_mode, + is_oauth21_enabled, +) + +# Server configuration +WORKSPACE_MCP_PORT = int(os.getenv("PORT", os.getenv("WORKSPACE_MCP_PORT", 8000))) +WORKSPACE_MCP_BASE_URI = os.getenv("WORKSPACE_MCP_BASE_URI", "http://localhost") + +# Disable USER_GOOGLE_EMAIL in OAuth 2.1 multi-user mode +USER_GOOGLE_EMAIL = ( + None if is_oauth21_enabled() else os.getenv("USER_GOOGLE_EMAIL", None) +) + +# Re-export OAuth functions for backward compatibility +__all__ = [ + "WORKSPACE_MCP_PORT", + "WORKSPACE_MCP_BASE_URI", + "USER_GOOGLE_EMAIL", + "get_oauth_base_url", + "get_oauth_redirect_uri", + "set_transport_mode", + "get_transport_mode", +] diff --git a/core/context.py b/core/context.py new file mode 100644 index 0000000..f0780f3 --- /dev/null +++ b/core/context.py @@ -0,0 +1,43 @@ +# core/context.py +import contextvars +from typing import Optional + +# Context variable to hold injected credentials for the life of a single request. +_injected_oauth_credentials = contextvars.ContextVar( + "injected_oauth_credentials", default=None +) + +# Context variable to hold FastMCP session ID for the life of a single request. +_fastmcp_session_id = contextvars.ContextVar("fastmcp_session_id", default=None) + + +def get_injected_oauth_credentials(): + """ + Retrieve injected OAuth credentials for the current request context. + This is called by the authentication layer to check for request-scoped credentials. + """ + return _injected_oauth_credentials.get() + + +def set_injected_oauth_credentials(credentials: Optional[dict]): + """ + Set or clear the injected OAuth credentials for the current request context. + This is called by the service decorator. + """ + _injected_oauth_credentials.set(credentials) + + +def get_fastmcp_session_id() -> Optional[str]: + """ + Retrieve the FastMCP session ID for the current request context. + This is called by authentication layer to get the current session. + """ + return _fastmcp_session_id.get() + + +def set_fastmcp_session_id(session_id: Optional[str]): + """ + Set or clear the FastMCP session ID for the current request context. + This is called when a FastMCP request starts. + """ + _fastmcp_session_id.set(session_id) diff --git a/core/log_formatter.py b/core/log_formatter.py new file mode 100644 index 0000000..9490054 --- /dev/null +++ b/core/log_formatter.py @@ -0,0 +1,207 @@ +""" +Enhanced Log Formatter for Google Workspace MCP + +Provides visually appealing log formatting with emojis and consistent styling +to match the safe_print output format. +""" + +import logging +import os +import re +import sys + + +class EnhancedLogFormatter(logging.Formatter): + """Custom log formatter that adds ASCII prefixes and visual enhancements to log messages.""" + + # Color codes for terminals that support ANSI colors + COLORS = { + "DEBUG": "\033[36m", # Cyan + "INFO": "\033[32m", # Green + "WARNING": "\033[33m", # Yellow + "ERROR": "\033[31m", # Red + "CRITICAL": "\033[35m", # Magenta + "RESET": "\033[0m", # Reset + } + + def __init__(self, use_colors: bool = True, *args, **kwargs): + """ + Initialize the emoji log formatter. + + Args: + use_colors: Whether to use ANSI color codes (default: True) + """ + super().__init__(*args, **kwargs) + self.use_colors = use_colors + + def format(self, record: logging.LogRecord) -> str: + """Format the log record with ASCII prefixes and enhanced styling.""" + # Get the appropriate ASCII prefix for the service + service_prefix = self._get_ascii_prefix(record.name, record.levelname) + + # Format the message with enhanced styling + formatted_msg = self._enhance_message(record.getMessage()) + + # Build the formatted log entry + if self.use_colors: + color = self.COLORS.get(record.levelname, "") + reset = self.COLORS["RESET"] + return f"{service_prefix} {color}{formatted_msg}{reset}" + else: + return f"{service_prefix} {formatted_msg}" + + def _get_ascii_prefix(self, logger_name: str, level_name: str) -> str: + """Get ASCII-safe prefix for Windows compatibility.""" + # ASCII-safe prefixes for different services + ascii_prefixes = { + "core.tool_tier_loader": "[TOOLS]", + "core.tool_registry": "[REGISTRY]", + "auth.scopes": "[AUTH]", + "core.utils": "[UTILS]", + "auth.google_auth": "[OAUTH]", + "auth.credential_store": "[CREDS]", + "gcalendar.calendar_tools": "[CALENDAR]", + "gdrive.drive_tools": "[DRIVE]", + "gmail.gmail_tools": "[GMAIL]", + "gdocs.docs_tools": "[DOCS]", + "gsheets.sheets_tools": "[SHEETS]", + "gchat.chat_tools": "[CHAT]", + "gforms.forms_tools": "[FORMS]", + "gslides.slides_tools": "[SLIDES]", + "gtasks.tasks_tools": "[TASKS]", + "gsearch.search_tools": "[SEARCH]", + } + + return ascii_prefixes.get(logger_name, f"[{level_name}]") + + def _enhance_message(self, message: str) -> str: + """Enhance the log message with better formatting.""" + # Handle common patterns for better visual appeal + + # Tool tier loading messages + if "resolved to" in message and "tools across" in message: + # Extract numbers and service names for better formatting + pattern = ( + r"Tier '(\w+)' resolved to (\d+) tools across (\d+) services: (.+)" + ) + match = re.search(pattern, message) + if match: + tier, tool_count, service_count, services = match.groups() + return f"Tool tier '{tier}' loaded: {tool_count} tools across {service_count} services [{services}]" + + # Configuration loading messages + if "Loaded tool tiers configuration from" in message: + path = message.split("from ")[-1] + return f"Configuration loaded from {path}" + + # Tool filtering messages + if "Tool tier filtering" in message: + pattern = r"removed (\d+) tools, (\d+) enabled" + match = re.search(pattern, message) + if match: + removed, enabled = match.groups() + return f"Tool filtering complete: {enabled} tools enabled ({removed} filtered out)" + + # Enabled tools messages + if "Enabled tools set for scope management" in message: + tools = message.split(": ")[-1] + return f"Scope management configured for tools: {tools}" + + # Credentials directory messages + if "Credentials directory permissions check passed" in message: + path = message.split(": ")[-1] + return f"Credentials directory verified: {path}" + + # If no specific pattern matches, return the original message + return message + + +def setup_enhanced_logging( + log_level: int = logging.INFO, use_colors: bool = True +) -> None: + """ + Set up enhanced logging with ASCII prefix formatter for the entire application. + + Args: + log_level: The logging level to use (default: INFO) + use_colors: Whether to use ANSI colors (default: True) + """ + # Create the enhanced formatter + formatter = EnhancedLogFormatter(use_colors=use_colors) + + # Get the root logger + root_logger = logging.getLogger() + + # Update existing console handlers + for handler in root_logger.handlers: + if isinstance(handler, logging.StreamHandler) and handler.stream.name in [ + "", + "", + ]: + handler.setFormatter(formatter) + + # If no console handler exists, create one + console_handlers = [ + h + for h in root_logger.handlers + if isinstance(h, logging.StreamHandler) + and h.stream.name in ["", ""] + ] + + if not console_handlers: + console_handler = logging.StreamHandler() + console_handler.setFormatter(formatter) + console_handler.setLevel(log_level) + root_logger.addHandler(console_handler) + + +def configure_file_logging(logger_name: str = None) -> bool: + """ + Configure file logging based on stateless mode setting. + + In stateless mode, file logging is completely disabled to avoid filesystem writes. + In normal mode, sets up detailed file logging to 'mcp_server_debug.log'. + + Args: + logger_name: Optional name for the logger (defaults to root logger) + + Returns: + bool: True if file logging was configured, False if skipped (stateless mode) + """ + # Check if stateless mode is enabled + stateless_mode = ( + os.getenv("WORKSPACE_MCP_STATELESS_MODE", "false").lower() == "true" + ) + + if stateless_mode: + logger = logging.getLogger(logger_name) + logger.debug("File logging disabled in stateless mode") + return False + + # Configure file logging for normal mode + try: + target_logger = logging.getLogger(logger_name) + log_file_dir = os.path.dirname(os.path.abspath(__file__)) + # Go up one level since we're in core/ subdirectory + log_file_dir = os.path.dirname(log_file_dir) + log_file_path = os.path.join(log_file_dir, "mcp_server_debug.log") + + file_handler = logging.FileHandler(log_file_path, mode="a") + file_handler.setLevel(logging.DEBUG) + + file_formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(process)d - %(threadName)s " + "[%(module)s.%(funcName)s:%(lineno)d] - %(message)s" + ) + file_handler.setFormatter(file_formatter) + target_logger.addHandler(file_handler) + + logger = logging.getLogger(logger_name) + logger.debug(f"Detailed file logging configured to: {log_file_path}") + return True + + except Exception as e: + sys.stderr.write( + f"CRITICAL: Failed to set up file logging to '{log_file_path}': {e}\n" + ) + return False diff --git a/core/server.py b/core/server.py new file mode 100644 index 0000000..69f970e --- /dev/null +++ b/core/server.py @@ -0,0 +1,620 @@ +import hashlib +import logging +import os +from typing import List, Optional +from importlib import metadata + +from fastapi.responses import HTMLResponse, JSONResponse, FileResponse +from starlette.applications import Starlette +from starlette.datastructures import MutableHeaders +from starlette.types import Scope, Receive, Send +from starlette.requests import Request +from starlette.middleware import Middleware + +from fastmcp import FastMCP +from fastmcp.server.auth.providers.google import GoogleProvider + +from auth.oauth21_session_store import get_oauth21_session_store, set_auth_provider +from auth.google_auth import handle_auth_callback, start_auth_flow, check_client_secrets +from auth.oauth_config import is_oauth21_enabled, is_external_oauth21_provider +from auth.mcp_session_middleware import MCPSessionMiddleware +from auth.oauth_responses import ( + create_error_response, + create_success_response, + create_server_error_response, +) +from auth.auth_info_middleware import AuthInfoMiddleware +from auth.scopes import SCOPES, get_current_scopes # noqa +from core.config import ( + USER_GOOGLE_EMAIL, + get_transport_mode, + set_transport_mode as _set_transport_mode, + get_oauth_redirect_uri as get_oauth_redirect_uri_for_current_mode, +) + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +_auth_provider: Optional[GoogleProvider] = None +_legacy_callback_registered = False + +session_middleware = Middleware(MCPSessionMiddleware) + + +class WellKnownCacheControlMiddleware: + """Force no-cache headers for OAuth well-known discovery endpoints.""" + + def __init__(self, app): + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + path = scope.get("path", "") + is_oauth_well_known = ( + path == "/.well-known/oauth-authorization-server" + or path.startswith("/.well-known/oauth-authorization-server/") + or path == "/.well-known/oauth-protected-resource" + or path.startswith("/.well-known/oauth-protected-resource/") + ) + if not is_oauth_well_known: + await self.app(scope, receive, send) + return + + async def send_with_no_cache_headers(message): + if message["type"] == "http.response.start": + headers = MutableHeaders(raw=message.setdefault("headers", [])) + headers["Cache-Control"] = "no-store, must-revalidate" + headers["ETag"] = f'"{_compute_scope_fingerprint()}"' + await send(message) + + await self.app(scope, receive, send_with_no_cache_headers) + + +well_known_cache_control_middleware = Middleware(WellKnownCacheControlMiddleware) + + +def _compute_scope_fingerprint() -> str: + """Compute a short hash of the current scope configuration for cache-busting.""" + scopes_str = ",".join(sorted(get_current_scopes())) + return hashlib.sha256(scopes_str.encode()).hexdigest()[:12] + + +# Custom FastMCP that adds secure middleware stack for OAuth 2.1 +class SecureFastMCP(FastMCP): + def http_app(self, **kwargs) -> "Starlette": + """Override to add secure middleware stack for OAuth 2.1.""" + app = super().http_app(**kwargs) + + # Add middleware in order (first added = outermost layer) + app.user_middleware.insert(0, well_known_cache_control_middleware) + + # Session Management - extracts session info for MCP context + app.user_middleware.insert(1, session_middleware) + + # Rebuild middleware stack + app.middleware_stack = app.build_middleware_stack() + logger.info("Added middleware stack: WellKnownCacheControl, Session Management") + return app + + +# Build server instructions with user email context for single-user mode +_server_instructions = None +if USER_GOOGLE_EMAIL: + _server_instructions = f"""Connected Google account: {USER_GOOGLE_EMAIL} + +When using Google Workspace tools, always use `{USER_GOOGLE_EMAIL}` as the `user_google_email` parameter. Do not ask the user for their email address.""" + logger.info(f"Server instructions configured for user: {USER_GOOGLE_EMAIL}") + +server = SecureFastMCP( + name="google_workspace", + auth=None, + instructions=_server_instructions, +) + +# Add the AuthInfo middleware to inject authentication into FastMCP context +auth_info_middleware = AuthInfoMiddleware() +server.add_middleware(auth_info_middleware) + + +def _parse_bool_env(value: str) -> bool: + """Parse environment variable string to boolean.""" + return value.lower() in ("1", "true", "yes", "on") + + +def set_transport_mode(mode: str): + """Sets the transport mode for the server.""" + _set_transport_mode(mode) + logger.info(f"Transport: {mode}") + + +def _ensure_legacy_callback_route() -> None: + global _legacy_callback_registered + if _legacy_callback_registered: + return + server.custom_route("/oauth2callback", methods=["GET"])(legacy_oauth2_callback) + _legacy_callback_registered = True + + +def configure_server_for_http(): + """ + Configures the authentication provider for HTTP transport. + This must be called BEFORE server.run(). + """ + global _auth_provider + + transport_mode = get_transport_mode() + + if transport_mode != "streamable-http": + return + + # Use centralized OAuth configuration + from auth.oauth_config import get_oauth_config + + config = get_oauth_config() + + # Check if OAuth 2.1 is enabled via centralized config + oauth21_enabled = config.is_oauth21_enabled() + + if oauth21_enabled: + if not config.is_configured(): + logger.warning("OAuth 2.1 enabled but OAuth credentials not configured") + return + + def validate_and_derive_jwt_key( + jwt_signing_key_override: str | None, client_secret: str + ) -> bytes: + """Validate JWT signing key override and derive the final JWT key.""" + if jwt_signing_key_override: + if len(jwt_signing_key_override) < 12: + logger.warning( + "OAuth 2.1: FASTMCP_SERVER_AUTH_GOOGLE_JWT_SIGNING_KEY is less than 12 characters; " + "use a longer secret to improve key derivation strength." + ) + return derive_jwt_key( + low_entropy_material=jwt_signing_key_override, + salt="fastmcp-jwt-signing-key", + ) + else: + return derive_jwt_key( + high_entropy_material=client_secret, + salt="fastmcp-jwt-signing-key", + ) + + try: + # Import common dependencies for storage backends + from key_value.aio.wrappers.encryption import FernetEncryptionWrapper + from cryptography.fernet import Fernet + from fastmcp.server.auth.jwt_issuer import derive_jwt_key + + required_scopes: List[str] = sorted(get_current_scopes()) + + client_storage = None + jwt_signing_key_override = ( + os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_JWT_SIGNING_KEY", "").strip() + or None + ) + storage_backend = ( + os.getenv("WORKSPACE_MCP_OAUTH_PROXY_STORAGE_BACKEND", "") + .strip() + .lower() + ) + valkey_host = os.getenv("WORKSPACE_MCP_OAUTH_PROXY_VALKEY_HOST", "").strip() + + # Determine storage backend: valkey, disk, memory (default) + use_valkey = storage_backend == "valkey" or bool(valkey_host) + use_disk = storage_backend == "disk" + + if use_valkey: + try: + from key_value.aio.stores.valkey import ValkeyStore + + valkey_port_raw = os.getenv( + "WORKSPACE_MCP_OAUTH_PROXY_VALKEY_PORT", "6379" + ).strip() + valkey_db_raw = os.getenv( + "WORKSPACE_MCP_OAUTH_PROXY_VALKEY_DB", "0" + ).strip() + + valkey_port = int(valkey_port_raw) + valkey_db = int(valkey_db_raw) + valkey_use_tls_raw = os.getenv( + "WORKSPACE_MCP_OAUTH_PROXY_VALKEY_USE_TLS", "" + ).strip() + valkey_use_tls = ( + _parse_bool_env(valkey_use_tls_raw) + if valkey_use_tls_raw + else valkey_port == 6380 + ) + + valkey_request_timeout_ms_raw = os.getenv( + "WORKSPACE_MCP_OAUTH_PROXY_VALKEY_REQUEST_TIMEOUT_MS", "" + ).strip() + valkey_connection_timeout_ms_raw = os.getenv( + "WORKSPACE_MCP_OAUTH_PROXY_VALKEY_CONNECTION_TIMEOUT_MS", "" + ).strip() + + valkey_request_timeout_ms = ( + int(valkey_request_timeout_ms_raw) + if valkey_request_timeout_ms_raw + else None + ) + valkey_connection_timeout_ms = ( + int(valkey_connection_timeout_ms_raw) + if valkey_connection_timeout_ms_raw + else None + ) + + valkey_username = ( + os.getenv( + "WORKSPACE_MCP_OAUTH_PROXY_VALKEY_USERNAME", "" + ).strip() + or None + ) + valkey_password = ( + os.getenv( + "WORKSPACE_MCP_OAUTH_PROXY_VALKEY_PASSWORD", "" + ).strip() + or None + ) + + if not valkey_host: + valkey_host = "localhost" + + client_storage = ValkeyStore( + host=valkey_host, + port=valkey_port, + db=valkey_db, + username=valkey_username, + password=valkey_password, + ) + + # Configure TLS and timeouts on the underlying Glide client config. + # ValkeyStore currently doesn't expose these settings directly. + glide_config = getattr(client_storage, "_client_config", None) + if glide_config is not None: + glide_config.use_tls = valkey_use_tls + + is_remote_host = valkey_host not in {"localhost", "127.0.0.1"} + if valkey_request_timeout_ms is None and ( + valkey_use_tls or is_remote_host + ): + # Glide defaults to 250ms if unset; increase for remote/TLS endpoints. + valkey_request_timeout_ms = 5000 + if valkey_request_timeout_ms is not None: + glide_config.request_timeout = valkey_request_timeout_ms + + if valkey_connection_timeout_ms is None and ( + valkey_use_tls or is_remote_host + ): + valkey_connection_timeout_ms = 10000 + if valkey_connection_timeout_ms is not None: + from glide_shared.config import ( + AdvancedGlideClientConfiguration, + ) + + glide_config.advanced_config = ( + AdvancedGlideClientConfiguration( + connection_timeout=valkey_connection_timeout_ms + ) + ) + + jwt_signing_key = validate_and_derive_jwt_key( + jwt_signing_key_override, config.client_secret + ) + + storage_encryption_key = derive_jwt_key( + high_entropy_material=jwt_signing_key.decode(), + salt="fastmcp-storage-encryption-key", + ) + + client_storage = FernetEncryptionWrapper( + key_value=client_storage, + fernet=Fernet(key=storage_encryption_key), + ) + logger.info( + "OAuth 2.1: Using ValkeyStore for FastMCP OAuth proxy client_storage (host=%s, port=%s, db=%s, tls=%s)", + valkey_host, + valkey_port, + valkey_db, + valkey_use_tls, + ) + if valkey_request_timeout_ms is not None: + logger.info( + "OAuth 2.1: Valkey request timeout set to %sms", + valkey_request_timeout_ms, + ) + if valkey_connection_timeout_ms is not None: + logger.info( + "OAuth 2.1: Valkey connection timeout set to %sms", + valkey_connection_timeout_ms, + ) + logger.info( + "OAuth 2.1: Applied Fernet encryption wrapper to Valkey client_storage (key derived from FASTMCP_SERVER_AUTH_GOOGLE_JWT_SIGNING_KEY or GOOGLE_OAUTH_CLIENT_SECRET)." + ) + except ImportError as exc: + logger.warning( + "OAuth 2.1: Valkey client_storage requested but Valkey dependencies are not installed (%s). " + "Install 'workspace-mcp[valkey]' (or 'py-key-value-aio[valkey]', which includes 'valkey-glide') " + "or unset WORKSPACE_MCP_OAUTH_PROXY_STORAGE_BACKEND/WORKSPACE_MCP_OAUTH_PROXY_VALKEY_HOST.", + exc, + ) + except ValueError as exc: + logger.warning( + "OAuth 2.1: Invalid Valkey configuration; falling back to default storage (%s).", + exc, + ) + elif use_disk: + try: + from key_value.aio.stores.filetree import FileTreeStore + + disk_directory = os.getenv( + "WORKSPACE_MCP_OAUTH_PROXY_DISK_DIRECTORY", "" + ).strip() + if not disk_directory: + # Default to FASTMCP_HOME/oauth-proxy or ~/.fastmcp/oauth-proxy + fastmcp_home = os.getenv("FASTMCP_HOME", "").strip() + if fastmcp_home: + disk_directory = os.path.join(fastmcp_home, "oauth-proxy") + else: + disk_directory = os.path.expanduser( + "~/.fastmcp/oauth-proxy" + ) + + client_storage = FileTreeStore(data_directory=disk_directory) + + jwt_signing_key = validate_and_derive_jwt_key( + jwt_signing_key_override, config.client_secret + ) + + storage_encryption_key = derive_jwt_key( + high_entropy_material=jwt_signing_key.decode(), + salt="fastmcp-storage-encryption-key", + ) + + client_storage = FernetEncryptionWrapper( + key_value=client_storage, + fernet=Fernet(key=storage_encryption_key), + ) + logger.info( + "OAuth 2.1: Using FileTreeStore for FastMCP OAuth proxy client_storage (directory=%s)", + disk_directory, + ) + except ImportError as exc: + logger.warning( + "OAuth 2.1: Disk storage requested but dependencies not available (%s). " + "Falling back to default storage.", + exc, + ) + elif storage_backend == "memory": + from key_value.aio.stores.memory import MemoryStore + + client_storage = MemoryStore() + logger.info( + "OAuth 2.1: Using MemoryStore for FastMCP OAuth proxy client_storage" + ) + # else: client_storage remains None, FastMCP uses its default + + # Ensure JWT signing key is always derived for all storage backends + if "jwt_signing_key" not in locals(): + jwt_signing_key = validate_and_derive_jwt_key( + jwt_signing_key_override, config.client_secret + ) + + # Check if external OAuth provider is configured + if config.is_external_oauth21_provider(): + # External OAuth mode: use custom provider that handles ya29.* access tokens + from auth.external_oauth_provider import ExternalOAuthProvider + + provider = ExternalOAuthProvider( + client_id=config.client_id, + client_secret=config.client_secret, + base_url=config.get_oauth_base_url(), + redirect_path=config.redirect_path, + required_scopes=required_scopes, + resource_server_url=config.get_oauth_base_url(), + ) + server.auth = provider + + logger.info("OAuth 2.1 enabled with EXTERNAL provider mode") + logger.info( + "Expecting Authorization bearer tokens in tool call headers" + ) + logger.info( + "Protected resource metadata points to Google's authorization server" + ) + else: + # Standard OAuth 2.1 mode: use FastMCP's GoogleProvider + provider = GoogleProvider( + client_id=config.client_id, + client_secret=config.client_secret, + base_url=config.get_oauth_base_url(), + redirect_path=config.redirect_path, + required_scopes=required_scopes, + client_storage=client_storage, + jwt_signing_key=jwt_signing_key, + ) + # Enable protocol-level auth + server.auth = provider + logger.info( + "OAuth 2.1 enabled using FastMCP GoogleProvider with protocol-level auth" + ) + + # Always set auth provider for token validation in middleware + set_auth_provider(provider) + _auth_provider = provider + except Exception as exc: + logger.error( + "Failed to initialize FastMCP GoogleProvider: %s", exc, exc_info=True + ) + raise + else: + logger.info("OAuth 2.0 mode - Server will use legacy authentication.") + server.auth = None + _auth_provider = None + set_auth_provider(None) + _ensure_legacy_callback_route() + + +def get_auth_provider() -> Optional[GoogleProvider]: + """Gets the global authentication provider instance.""" + return _auth_provider + + +@server.custom_route("/", methods=["GET"]) +@server.custom_route("/health", methods=["GET"]) +async def health_check(request: Request): + try: + version = metadata.version("workspace-mcp") + except metadata.PackageNotFoundError: + version = "dev" + return JSONResponse( + { + "status": "healthy", + "service": "workspace-mcp", + "version": version, + "transport": get_transport_mode(), + } + ) + + +@server.custom_route("/attachments/{file_id}", methods=["GET"]) +async def serve_attachment(request: Request): + """Serve a stored attachment file.""" + from core.attachment_storage import get_attachment_storage + + file_id = request.path_params["file_id"] + storage = get_attachment_storage() + metadata = storage.get_attachment_metadata(file_id) + + if not metadata: + return JSONResponse( + {"error": "Attachment not found or expired"}, status_code=404 + ) + + file_path = storage.get_attachment_path(file_id) + if not file_path: + return JSONResponse({"error": "Attachment file not found"}, status_code=404) + + return FileResponse( + path=str(file_path), + filename=metadata["filename"], + media_type=metadata["mime_type"], + ) + + +async def legacy_oauth2_callback(request: Request) -> HTMLResponse: + state = request.query_params.get("state") + code = request.query_params.get("code") + error = request.query_params.get("error") + + if error: + msg = ( + f"Authentication failed: Google returned an error: {error}. State: {state}." + ) + logger.error(msg) + return create_error_response(msg) + + if not code: + msg = "Authentication failed: No authorization code received from Google." + logger.error(msg) + return create_error_response(msg) + + try: + error_message = check_client_secrets() + if error_message: + return create_server_error_response(error_message) + + logger.info("OAuth callback: Received authorization code.") + + mcp_session_id = None + if hasattr(request, "state") and hasattr(request.state, "session_id"): + mcp_session_id = request.state.session_id + + verified_user_id, credentials = handle_auth_callback( + scopes=get_current_scopes(), + authorization_response=str(request.url), + redirect_uri=get_oauth_redirect_uri_for_current_mode(), + session_id=mcp_session_id, + ) + + logger.info( + f"OAuth callback: Successfully authenticated user: {verified_user_id}." + ) + + try: + store = get_oauth21_session_store() + + store.store_session( + user_email=verified_user_id, + access_token=credentials.token, + refresh_token=credentials.refresh_token, + token_uri=credentials.token_uri, + client_id=credentials.client_id, + client_secret=credentials.client_secret, + scopes=credentials.scopes, + expiry=credentials.expiry, + session_id=f"google-{state}", + mcp_session_id=mcp_session_id, + ) + logger.info( + f"Stored Google credentials in OAuth 2.1 session store for {verified_user_id}" + ) + except Exception as e: + logger.error(f"Failed to store credentials in OAuth 2.1 store: {e}") + + return create_success_response(verified_user_id) + except Exception as e: + logger.error(f"Error processing OAuth callback: {str(e)}", exc_info=True) + return create_server_error_response(str(e)) + + +@server.tool() +async def start_google_auth( + service_name: str, user_google_email: str = USER_GOOGLE_EMAIL +) -> str: + """ + Manually initiate Google OAuth authentication flow. + + NOTE: This is a legacy OAuth 2.0 tool and is disabled when OAuth 2.1 is enabled. + The authentication system automatically handles credential checks and prompts for + authentication when needed. Only use this tool if: + 1. You need to re-authenticate with different credentials + 2. You want to proactively authenticate before using other tools + 3. The automatic authentication flow failed and you need to retry + + In most cases, simply try calling the Google Workspace tool you need - it will + automatically handle authentication if required. + """ + if is_oauth21_enabled(): + if is_external_oauth21_provider(): + return ( + "start_google_auth is disabled when OAuth 2.1 is enabled. " + "Provide a valid OAuth 2.1 bearer token in the Authorization header " + "and retry the original tool." + ) + return ( + "start_google_auth is disabled when OAuth 2.1 is enabled. " + "Authenticate through your MCP client's OAuth 2.1 flow and retry the " + "original tool." + ) + + if not user_google_email: + raise ValueError("user_google_email must be provided.") + + error_message = check_client_secrets() + if error_message: + return f"**Authentication Error:** {error_message}" + + try: + auth_message = await start_auth_flow( + user_google_email=user_google_email, + service_name=service_name, + redirect_uri=get_oauth_redirect_uri_for_current_mode(), + ) + return auth_message + except Exception as e: + logger.error(f"Failed to start Google authentication flow: {e}", exc_info=True) + return f"**Error:** An unexpected error occurred: {e}" diff --git a/core/tool_registry.py b/core/tool_registry.py new file mode 100644 index 0000000..206d561 --- /dev/null +++ b/core/tool_registry.py @@ -0,0 +1,211 @@ +""" +Tool Registry for Conditional Tool Registration + +This module provides a registry system that allows tools to be conditionally registered +based on tier configuration, replacing direct @server.tool() decorators. +""" + +import logging +from typing import Set, Optional, Callable + +from auth.oauth_config import is_oauth21_enabled +from auth.permissions import is_permissions_mode, get_allowed_scopes_set +from auth.scopes import is_read_only_mode, get_all_read_only_scopes + +logger = logging.getLogger(__name__) + +# Global registry of enabled tools +_enabled_tools: Optional[Set[str]] = None + + +def set_enabled_tools(tool_names: Optional[Set[str]]): + """Set the globally enabled tools.""" + global _enabled_tools + _enabled_tools = tool_names + + +def get_enabled_tools() -> Optional[Set[str]]: + """Get the set of enabled tools, or None if all tools are enabled.""" + return _enabled_tools + + +def is_tool_enabled(tool_name: str) -> bool: + """Check if a specific tool is enabled.""" + if _enabled_tools is None: + return True # All tools enabled by default + return tool_name in _enabled_tools + + +def conditional_tool(server, tool_name: str): + """ + Decorator that conditionally registers a tool based on the enabled tools set. + + Args: + server: The FastMCP server instance + tool_name: The name of the tool to register + + Returns: + Either the registered tool decorator or a no-op decorator + """ + + def decorator(func: Callable) -> Callable: + if is_tool_enabled(tool_name): + logger.debug(f"Registering tool: {tool_name}") + return server.tool()(func) + else: + logger.debug(f"Skipping tool registration: {tool_name}") + return func + + return decorator + + +def wrap_server_tool_method(server): + """ + Track tool registrations and filter them post-registration. + """ + original_tool = server.tool + server._tracked_tools = [] + + def tracking_tool(*args, **kwargs): + original_decorator = original_tool(*args, **kwargs) + + def wrapper_decorator(func: Callable) -> Callable: + tool_name = func.__name__ + server._tracked_tools.append(tool_name) + # Always apply the original decorator to register the tool + return original_decorator(func) + + return wrapper_decorator + + server.tool = tracking_tool + + +def get_tool_components(server) -> dict: + """Get tool components dict from server's local_provider. + + Returns a dict mapping tool_name -> tool_object for introspection. + + Note: Uses local_provider._components because the public list_tools() + is async-only, and callers (startup filtering, CLI) run synchronously. + """ + lp = getattr(server, "local_provider", None) + if lp is None: + return {} + components = getattr(lp, "_components", {}) + tools = {} + for key, component in components.items(): + if key.startswith("tool:"): + # Keys are like "tool:name@version", extract the name + name = key.split(":", 1)[1].rsplit("@", 1)[0] + tools[name] = component + return tools + + +def filter_server_tools(server): + """Remove disabled tools from the server after registration.""" + enabled_tools = get_enabled_tools() + oauth21_enabled = is_oauth21_enabled() + permissions_mode = is_permissions_mode() + if ( + enabled_tools is None + and not oauth21_enabled + and not is_read_only_mode() + and not permissions_mode + ): + return + + tools_removed = 0 + tool_components = get_tool_components(server) + + read_only_mode = is_read_only_mode() + allowed_scopes = set(get_all_read_only_scopes()) if read_only_mode else None + + tools_to_remove = set() + + # 1. Tier filtering + if enabled_tools is not None: + for tool_name in tool_components: + if not is_tool_enabled(tool_name): + tools_to_remove.add(tool_name) + + # 2. OAuth 2.1 filtering + if oauth21_enabled and "start_google_auth" in tool_components: + tools_to_remove.add("start_google_auth") + logger.info("OAuth 2.1 enabled: disabling start_google_auth tool") + + # 3. Read-only mode filtering (skipped when granular permissions are active) + if read_only_mode and not permissions_mode: + for tool_name, tool_obj in tool_components.items(): + if tool_name in tools_to_remove: + continue + + # Check if tool has required scopes attached (from @require_google_service) + func_to_check = tool_obj + if hasattr(tool_obj, "fn"): + func_to_check = tool_obj.fn + + required_scopes = getattr(func_to_check, "_required_google_scopes", []) + + if required_scopes: + # If ANY required scope is not in the allowed read-only scopes, disable the tool + if not all(scope in allowed_scopes for scope in required_scopes): + logger.info( + f"Read-only mode: Disabling tool '{tool_name}' (requires write scopes: {required_scopes})" + ) + tools_to_remove.add(tool_name) + + # 4. Granular permissions filtering + # No scope hierarchy expansion here — permission levels are already cumulative + # and explicitly define allowed scopes. Hierarchy expansion would defeat the + # purpose (e.g. gmail.modify in the hierarchy covers gmail.send, but the + # "organize" permission level intentionally excludes gmail.send). + if permissions_mode: + perm_allowed = get_allowed_scopes_set() or set() + + for tool_name, tool_obj in tool_components.items(): + if tool_name in tools_to_remove: + continue + + func_to_check = tool_obj + if hasattr(tool_obj, "fn"): + func_to_check = tool_obj.fn + + required_scopes = getattr(func_to_check, "_required_google_scopes", []) + if required_scopes: + if not all(scope in perm_allowed for scope in required_scopes): + logger.info( + "Permissions mode: Disabling tool '%s' (requires: %s)", + tool_name, + required_scopes, + ) + tools_to_remove.add(tool_name) + + for tool_name in tools_to_remove: + try: + server.local_provider.remove_tool(tool_name) + except AttributeError: + logger.warning( + "Failed to remove tool '%s': remove_tool not available on server.local_provider", + tool_name, + ) + continue + except Exception as exc: + logger.warning( + "Failed to remove tool '%s': %s", + tool_name, + exc, + ) + continue + tools_removed += 1 + + if tools_removed > 0: + enabled_count = len(enabled_tools) if enabled_tools is not None else "all" + if permissions_mode: + mode = "Permissions" + elif is_read_only_mode(): + mode = "Read-Only" + else: + mode = "Full" + logger.info( + f"Tool filtering: removed {tools_removed} tools, {enabled_count} enabled. Mode: {mode}" + ) diff --git a/core/tool_tier_loader.py b/core/tool_tier_loader.py new file mode 100644 index 0000000..57bed1e --- /dev/null +++ b/core/tool_tier_loader.py @@ -0,0 +1,196 @@ +""" +Tool Tier Loader Module + +This module provides functionality to load and resolve tool tiers from the YAML configuration. +It integrates with the existing tool enablement workflow to support tiered tool loading. +""" + +import logging +from pathlib import Path +from typing import Dict, List, Set, Literal, Optional + +import yaml + +logger = logging.getLogger(__name__) + +TierLevel = Literal["core", "extended", "complete"] + + +class ToolTierLoader: + """Loads and manages tool tiers from configuration.""" + + def __init__(self, config_path: Optional[str] = None): + """ + Initialize the tool tier loader. + + Args: + config_path: Path to the tool_tiers.yaml file. If None, uses default location. + """ + if config_path is None: + # Default to core/tool_tiers.yaml relative to this file + config_path = Path(__file__).parent / "tool_tiers.yaml" + + self.config_path = Path(config_path) + self._tiers_config: Optional[Dict] = None + + def _load_config(self) -> Dict: + """Load the tool tiers configuration from YAML file.""" + if self._tiers_config is not None: + return self._tiers_config + + if not self.config_path.exists(): + raise FileNotFoundError( + f"Tool tiers configuration not found: {self.config_path}" + ) + + try: + with open(self.config_path, "r", encoding="utf-8") as f: + self._tiers_config = yaml.safe_load(f) + logger.info(f"Loaded tool tiers configuration from {self.config_path}") + return self._tiers_config + except yaml.YAMLError as e: + raise ValueError(f"Invalid YAML in tool tiers configuration: {e}") + except Exception as e: + raise RuntimeError(f"Failed to load tool tiers configuration: {e}") + + def get_available_services(self) -> List[str]: + """Get list of all available services defined in the configuration.""" + config = self._load_config() + return list(config.keys()) + + def get_tools_for_tier( + self, tier: TierLevel, services: Optional[List[str]] = None + ) -> List[str]: + """ + Get all tools for a specific tier level. + + Args: + tier: The tier level (core, extended, complete) + services: Optional list of services to filter by. If None, includes all services. + + Returns: + List of tool names for the specified tier level + """ + config = self._load_config() + tools = [] + + # If no services specified, use all available services + if services is None: + services = self.get_available_services() + + for service in services: + if service not in config: + logger.warning( + f"Service '{service}' not found in tool tiers configuration" + ) + continue + + service_config = config[service] + if tier not in service_config: + logger.debug(f"Tier '{tier}' not defined for service '{service}'") + continue + + tier_tools = service_config[tier] + if tier_tools: # Handle empty lists + tools.extend(tier_tools) + + return tools + + def get_tools_up_to_tier( + self, tier: TierLevel, services: Optional[List[str]] = None + ) -> List[str]: + """ + Get all tools up to and including the specified tier level. + + Args: + tier: The maximum tier level to include + services: Optional list of services to filter by. If None, includes all services. + + Returns: + List of tool names up to the specified tier level + """ + tier_order = ["core", "extended", "complete"] + max_tier_index = tier_order.index(tier) + + tools = [] + for i in range(max_tier_index + 1): + current_tier = tier_order[i] + tools.extend(self.get_tools_for_tier(current_tier, services)) + + # Remove duplicates while preserving order + seen = set() + unique_tools = [] + for tool in tools: + if tool not in seen: + seen.add(tool) + unique_tools.append(tool) + + return unique_tools + + def get_services_for_tools(self, tool_names: List[str]) -> Set[str]: + """ + Get the service names that provide the specified tools. + + Args: + tool_names: List of tool names to lookup + + Returns: + Set of service names that provide any of the specified tools + """ + config = self._load_config() + services = set() + + for service, service_config in config.items(): + for tier_name, tier_tools in service_config.items(): + if tier_tools and any(tool in tier_tools for tool in tool_names): + services.add(service) + break + + return services + + +def get_tools_for_tier( + tier: TierLevel, services: Optional[List[str]] = None +) -> List[str]: + """ + Convenience function to get tools for a specific tier. + + Args: + tier: The tier level (core, extended, complete) + services: Optional list of services to filter by + + Returns: + List of tool names for the specified tier level + """ + loader = ToolTierLoader() + return loader.get_tools_up_to_tier(tier, services) + + +def resolve_tools_from_tier( + tier: TierLevel, services: Optional[List[str]] = None +) -> tuple[List[str], List[str]]: + """ + Resolve tool names and service names for the specified tier. + + Args: + tier: The tier level (core, extended, complete) + services: Optional list of services to filter by + + Returns: + Tuple of (tool_names, service_names) where: + - tool_names: List of specific tool names for the tier + - service_names: List of service names that should be imported + """ + loader = ToolTierLoader() + + # Get all tools for the tier + tools = loader.get_tools_up_to_tier(tier, services) + + # Map back to service names + service_names = loader.get_services_for_tools(tools) + + logger.info( + f"Tier '{tier}' resolved to {len(tools)} tools across {len(service_names)} services: {sorted(service_names)}" + ) + + return tools, sorted(service_names) diff --git a/core/tool_tiers.yaml b/core/tool_tiers.yaml new file mode 100644 index 0000000..666833b --- /dev/null +++ b/core/tool_tiers.yaml @@ -0,0 +1,172 @@ +gmail: + core: + - search_gmail_messages + - get_gmail_message_content + - get_gmail_messages_content_batch + - send_gmail_message + + extended: + - get_gmail_attachment_content + - get_gmail_thread_content + - modify_gmail_message_labels + - list_gmail_labels + - manage_gmail_label + - draft_gmail_message + - list_gmail_filters + - manage_gmail_filter + + complete: + - get_gmail_threads_content_batch + - batch_modify_gmail_message_labels + - start_google_auth + +drive: + core: + - search_drive_files + - get_drive_file_content + - get_drive_file_download_url + - create_drive_file + - create_drive_folder + - import_to_google_doc + - get_drive_shareable_link + extended: + - list_drive_items + - copy_drive_file + - update_drive_file + - manage_drive_access + - set_drive_file_permissions + complete: + - get_drive_file_permissions + - check_drive_file_public_access + +calendar: + core: + - list_calendars + - get_events + - manage_event + extended: + - query_freebusy + complete: [] + +docs: + core: + - get_doc_content + - create_doc + - modify_doc_text + extended: + - export_doc_to_pdf + - search_docs + - find_and_replace_doc + - list_docs_in_folder + - insert_doc_elements + - update_paragraph_style + - get_doc_as_markdown + complete: + - insert_doc_image + - update_doc_headers_footers + - batch_update_doc + - inspect_doc_structure + - create_table_with_data + - debug_table_structure + - list_document_comments + - manage_document_comment + +sheets: + core: + - create_spreadsheet + - read_sheet_values + - modify_sheet_values + extended: + - list_spreadsheets + - get_spreadsheet_info + - format_sheet_range + complete: + - create_sheet + - list_spreadsheet_comments + - manage_spreadsheet_comment + - manage_conditional_formatting + +chat: + core: + - send_message + - get_messages + - search_messages + - create_reaction + extended: + - list_spaces + - download_chat_attachment + complete: [] + +forms: + core: + - create_form + - get_form + extended: + - list_form_responses + complete: + - set_publish_settings + - get_form_response + - batch_update_form + +slides: + core: + - create_presentation + - get_presentation + extended: + - batch_update_presentation + - get_page + - get_page_thumbnail + complete: + - list_presentation_comments + - manage_presentation_comment + +tasks: + core: + - get_task + - list_tasks + - manage_task + extended: [] + complete: + - list_task_lists + - get_task_list + - manage_task_list + +contacts: + core: + - search_contacts + - get_contact + - list_contacts + - manage_contact + extended: + - list_contact_groups + - get_contact_group + complete: + - manage_contacts_batch + - manage_contact_group + +search: + core: + - search_custom + extended: [] + complete: + - get_search_engine_info + +appscript: + core: + - list_script_projects + - get_script_project + - get_script_content + - create_script_project + - update_script_content + - run_script_function + - generate_trigger_code + extended: + - manage_deployment + - list_deployments + - delete_script_project + - list_versions + - create_version + - get_version + - list_script_processes + - get_script_metrics + complete: [] diff --git a/core/utils.py b/core/utils.py new file mode 100644 index 0000000..ee91fb3 --- /dev/null +++ b/core/utils.py @@ -0,0 +1,493 @@ +import io +import logging +import os +import zipfile +import ssl +import asyncio +import functools + +from pathlib import Path +from typing import List, Optional + +from defusedxml import ElementTree as ET + +from googleapiclient.errors import HttpError +from .api_enablement import get_api_enablement_message +from auth.google_auth import GoogleAuthenticationError +from auth.oauth_config import is_oauth21_enabled, is_external_oauth21_provider + +logger = logging.getLogger(__name__) + + +class TransientNetworkError(Exception): + """Custom exception for transient network errors after retries.""" + + pass + + +class UserInputError(Exception): + """Raised for user-facing input/validation errors that shouldn't be retried.""" + + pass + + +# Directories from which local file reads are allowed. +# The user's home directory is the default safe base. +# Override via ALLOWED_FILE_DIRS env var (os.pathsep-separated paths). +_ALLOWED_FILE_DIRS_ENV = "ALLOWED_FILE_DIRS" + + +def _get_allowed_file_dirs() -> list[Path]: + """Return the list of directories from which local file access is permitted.""" + env_val = os.environ.get(_ALLOWED_FILE_DIRS_ENV) + if env_val: + return [ + Path(p).expanduser().resolve() + for p in env_val.split(os.pathsep) + if p.strip() + ] + home = Path.home() + return [home] if home else [] + + +def validate_file_path(file_path: str) -> Path: + """ + Validate that a file path is safe to read from the server filesystem. + + Resolves the path canonically (following symlinks), then verifies it falls + within one of the allowed base directories. Rejects paths to sensitive + system locations regardless of allowlist. + + Args: + file_path: The raw file path string to validate. + + Returns: + Path: The resolved, validated Path object. + + Raises: + ValueError: If the path is outside allowed directories or targets + a sensitive location. + """ + resolved = Path(file_path).resolve() + + if not resolved.exists(): + raise FileNotFoundError(f"Path does not exist: {resolved}") + + # Block sensitive file patterns regardless of allowlist + resolved_str = str(resolved) + file_name = resolved.name.lower() + + # Block .env files and variants (.env, .env.local, .env.production, etc.) + if file_name == ".env" or file_name.startswith(".env."): + raise ValueError( + f"Access to '{resolved_str}' is not allowed: " + ".env files may contain secrets and cannot be read, uploaded, or attached." + ) + + # Block well-known sensitive system paths (including macOS /private variants) + sensitive_prefixes = ( + "/proc", + "/sys", + "/dev", + "/etc/shadow", + "/etc/passwd", + "/private/etc/shadow", + "/private/etc/passwd", + ) + for prefix in sensitive_prefixes: + if resolved_str == prefix or resolved_str.startswith(prefix + "/"): + raise ValueError( + f"Access to '{resolved_str}' is not allowed: " + "path is in a restricted system location." + ) + + # Block sensitive directories that commonly contain credentials/keys + sensitive_dirs = ( + ".ssh", + ".aws", + ".kube", + ".gnupg", + ".config/gcloud", + ) + for sensitive_dir in sensitive_dirs: + home = Path.home() + blocked = home / sensitive_dir + if resolved == blocked or str(resolved).startswith(str(blocked) + "/"): + raise ValueError( + f"Access to '{resolved_str}' is not allowed: " + "path is in a directory that commonly contains secrets or credentials." + ) + + # Block other credential/secret file patterns + sensitive_names = { + ".credentials", + ".credentials.json", + "credentials.json", + "client_secret.json", + "client_secrets.json", + "service_account.json", + "service-account.json", + ".npmrc", + ".pypirc", + ".netrc", + ".git-credentials", + ".docker/config.json", + } + if file_name in sensitive_names: + raise ValueError( + f"Access to '{resolved_str}' is not allowed: " + "this file commonly contains secrets or credentials." + ) + + allowed_dirs = _get_allowed_file_dirs() + if not allowed_dirs: + raise ValueError( + "No allowed file directories configured. " + "Set the ALLOWED_FILE_DIRS environment variable or ensure a home directory exists." + ) + + for allowed in allowed_dirs: + try: + resolved.relative_to(allowed) + return resolved + except ValueError: + continue + + raise ValueError( + f"Access to '{resolved_str}' is not allowed: " + f"path is outside permitted directories ({', '.join(str(d) for d in allowed_dirs)}). " + "Set ALLOWED_FILE_DIRS to adjust." + ) + + +def check_credentials_directory_permissions(credentials_dir: str = None) -> None: + """ + Check if the service has appropriate permissions to create and write to the .credentials directory. + + Args: + credentials_dir: Path to the credentials directory (default: uses get_default_credentials_dir()) + + Raises: + PermissionError: If the service lacks necessary permissions + OSError: If there are other file system issues + """ + if credentials_dir is None: + from auth.google_auth import get_default_credentials_dir + + credentials_dir = get_default_credentials_dir() + + try: + # Check if directory exists + if os.path.exists(credentials_dir): + # Directory exists, check if we can write to it + test_file = os.path.join(credentials_dir, ".permission_test") + try: + with open(test_file, "w") as f: + f.write("test") + os.remove(test_file) + logger.info( + f"Credentials directory permissions check passed: {os.path.abspath(credentials_dir)}" + ) + except (PermissionError, OSError) as e: + raise PermissionError( + f"Cannot write to existing credentials directory '{os.path.abspath(credentials_dir)}': {e}" + ) + else: + # Directory doesn't exist, try to create it and its parent directories + try: + os.makedirs(credentials_dir, exist_ok=True) + # Test writing to the new directory + test_file = os.path.join(credentials_dir, ".permission_test") + with open(test_file, "w") as f: + f.write("test") + os.remove(test_file) + logger.info( + f"Created credentials directory with proper permissions: {os.path.abspath(credentials_dir)}" + ) + except (PermissionError, OSError) as e: + # Clean up if we created the directory but can't write to it + try: + if os.path.exists(credentials_dir): + os.rmdir(credentials_dir) + except (PermissionError, OSError): + pass + raise PermissionError( + f"Cannot create or write to credentials directory '{os.path.abspath(credentials_dir)}': {e}" + ) + + except PermissionError: + raise + except Exception as e: + raise OSError( + f"Unexpected error checking credentials directory permissions: {e}" + ) + + +def extract_office_xml_text(file_bytes: bytes, mime_type: str) -> Optional[str]: + """ + Very light-weight XML scraper for Word, Excel, PowerPoint files. + Returns plain-text if something readable is found, else None. + Uses zipfile + defusedxml.ElementTree. + """ + shared_strings: List[str] = [] + ns_excel_main = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" + + try: + with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf: + targets: List[str] = [] + # Map MIME → iterable of XML files to inspect + if ( + mime_type + == "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ): + targets = ["word/document.xml"] + elif ( + mime_type + == "application/vnd.openxmlformats-officedocument.presentationml.presentation" + ): + targets = [n for n in zf.namelist() if n.startswith("ppt/slides/slide")] + elif ( + mime_type + == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ): + targets = [ + n + for n in zf.namelist() + if n.startswith("xl/worksheets/sheet") and "drawing" not in n + ] + # Attempt to parse sharedStrings.xml for Excel files + try: + shared_strings_xml = zf.read("xl/sharedStrings.xml") + shared_strings_root = ET.fromstring(shared_strings_xml) + for si_element in shared_strings_root.findall( + f"{{{ns_excel_main}}}si" + ): + text_parts = [] + # Find all elements, simple or within runs, and concatenate their text + for t_element in si_element.findall(f".//{{{ns_excel_main}}}t"): + if t_element.text: + text_parts.append(t_element.text) + shared_strings.append("".join(text_parts)) + except KeyError: + logger.info( + "No sharedStrings.xml found in Excel file (this is optional)." + ) + except ET.ParseError as e: + logger.error(f"Error parsing sharedStrings.xml: {e}") + except ( + Exception + ) as e: # Catch any other unexpected error during sharedStrings parsing + logger.error( + f"Unexpected error processing sharedStrings.xml: {e}", + exc_info=True, + ) + else: + return None + + pieces: List[str] = [] + for member in targets: + try: + xml_content = zf.read(member) + xml_root = ET.fromstring(xml_content) + member_texts: List[str] = [] + + if ( + mime_type + == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ): + for cell_element in xml_root.findall( + f".//{{{ns_excel_main}}}c" + ): # Find all elements + value_element = cell_element.find( + f"{{{ns_excel_main}}}v" + ) # Find under + + # Skip if cell has no value element or value element has no text + if value_element is None or value_element.text is None: + continue + + cell_type = cell_element.get("t") + if cell_type == "s": # Shared string + try: + ss_idx = int(value_element.text) + if 0 <= ss_idx < len(shared_strings): + member_texts.append(shared_strings[ss_idx]) + else: + logger.warning( + f"Invalid shared string index {ss_idx} in {member}. Max index: {len(shared_strings) - 1}" + ) + except ValueError: + logger.warning( + f"Non-integer shared string index: '{value_element.text}' in {member}." + ) + else: # Direct value (number, boolean, inline string if not 's') + member_texts.append(value_element.text) + else: # Word or PowerPoint + for elem in xml_root.iter(): + # For Word: where w is "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + # For PowerPoint: where a is "http://schemas.openxmlformats.org/drawingml/2006/main" + if ( + elem.tag.endswith("}t") and elem.text + ): # Check for any namespaced tag ending with 't' + cleaned_text = elem.text.strip() + if ( + cleaned_text + ): # Add only if there's non-whitespace text + member_texts.append(cleaned_text) + + if member_texts: + pieces.append( + " ".join(member_texts) + ) # Join texts from one member with spaces + + except ET.ParseError as e: + logger.warning( + f"Could not parse XML in member '{member}' for {mime_type} file: {e}" + ) + except Exception as e: + logger.error( + f"Error processing member '{member}' for {mime_type}: {e}", + exc_info=True, + ) + # continue processing other members + + if not pieces: # If no text was extracted at all + return None + + # Join content from different members (sheets/slides) with double newlines for separation + text = "\n\n".join(pieces).strip() + return text or None # Ensure None is returned if text is empty after strip + + except zipfile.BadZipFile: + logger.warning(f"File is not a valid ZIP archive (mime_type: {mime_type}).") + return None + except ( + ET.ParseError + ) as e: # Catch parsing errors at the top level if zipfile itself is XML-like + logger.error(f"XML parsing error at a high level for {mime_type}: {e}") + return None + except Exception as e: + logger.error( + f"Failed to extract office XML text for {mime_type}: {e}", exc_info=True + ) + return None + + +def handle_http_errors( + tool_name: str, is_read_only: bool = False, service_type: Optional[str] = None +): + """ + A decorator to handle Google API HttpErrors and transient SSL errors in a standardized way. + + It wraps a tool function, catches HttpError, logs a detailed error message, + and raises a generic Exception with a user-friendly message. + + If is_read_only is True, it will also catch ssl.SSLError and retry with + exponential backoff. After exhausting retries, it raises a TransientNetworkError. + + Args: + tool_name (str): The name of the tool being decorated (e.g., 'list_calendars'). + is_read_only (bool): If True, the operation is considered safe to retry on + transient network errors. Defaults to False. + service_type (str): Optional. The Google service type (e.g., 'calendar', 'gmail'). + """ + + def decorator(func): + @functools.wraps(func) + async def wrapper(*args, **kwargs): + max_retries = 3 + base_delay = 1 + + for attempt in range(max_retries): + try: + return await func(*args, **kwargs) + except ssl.SSLError as e: + if is_read_only and attempt < max_retries - 1: + delay = base_delay * (2**attempt) + logger.warning( + f"SSL error in {tool_name} on attempt {attempt + 1}: {e}. Retrying in {delay} seconds..." + ) + await asyncio.sleep(delay) + else: + logger.error( + f"SSL error in {tool_name} on final attempt: {e}. Raising exception." + ) + raise TransientNetworkError( + f"A transient SSL error occurred in '{tool_name}' after {max_retries} attempts. " + "This is likely a temporary network or certificate issue. Please try again shortly." + ) from e + except UserInputError as e: + message = f"Input error in {tool_name}: {e}" + logger.warning(message) + raise e + except HttpError as error: + user_google_email = kwargs.get("user_google_email", "N/A") + error_details = str(error) + + # Check if this is an API not enabled error + if ( + error.resp.status == 403 + and "accessNotConfigured" in error_details + ): + enablement_msg = get_api_enablement_message( + error_details, service_type + ) + + if enablement_msg: + message = ( + f"API error in {tool_name}: {enablement_msg}\n\n" + f"User: {user_google_email}" + ) + else: + message = ( + f"API error in {tool_name}: {error}. " + f"The required API is not enabled for your project. " + f"Please check the Google Cloud Console to enable it." + ) + elif error.resp.status in [401, 403]: + # Authentication/authorization errors + if is_oauth21_enabled(): + if is_external_oauth21_provider(): + auth_hint = ( + "LLM: Ask the user to provide a valid OAuth 2.1 " + "bearer token in the Authorization header and retry." + ) + else: + auth_hint = ( + "LLM: Ask the user to authenticate via their MCP " + "client's OAuth 2.1 flow and retry." + ) + else: + auth_hint = ( + "LLM: Try 'start_google_auth' with the user's email " + "and the appropriate service_name." + ) + message = ( + f"API error in {tool_name}: {error}. " + f"You might need to re-authenticate for user '{user_google_email}'. " + f"{auth_hint}" + ) + else: + # Other HTTP errors (400 Bad Request, etc.) - don't suggest re-auth + message = f"API error in {tool_name}: {error}" + + logger.error(f"API error in {tool_name}: {error}", exc_info=True) + raise Exception(message) from error + except TransientNetworkError: + # Re-raise without wrapping to preserve the specific error type + raise + except GoogleAuthenticationError: + # Re-raise authentication errors without wrapping + raise + except Exception as e: + message = f"An unexpected error occurred in {tool_name}: {e}" + logger.exception(message) + raise Exception(message) from e + + # Propagate _required_google_scopes if present (for tool filtering) + if hasattr(func, "_required_google_scopes"): + wrapper._required_google_scopes = func._required_google_scopes + + return wrapper + + return decorator diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..425c45a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + gws_mcp: + build: . + container_name: gws_mcp + ports: + - "8000:8000" + environment: + - GOOGLE_MCP_CREDENTIALS_DIR=/app/store_creds + volumes: + - ./client_secret.json:/app/client_secret.json:ro + - store_creds:/app/store_creds:rw + env_file: + - .env + +volumes: + store_creds: \ No newline at end of file diff --git a/fastmcp.json b/fastmcp.json new file mode 100644 index 0000000..434aca6 --- /dev/null +++ b/fastmcp.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", + "source": { + "path": "fastmcp_server.py", + "entrypoint": "mcp" + }, + "environment": { + "python": ">=3.10", + "project": "." + }, + "deployment": { + "transport": "http", + "host": "0.0.0.0", + "port": 8000, + "log_level": "INFO", + "env": { + "MCP_ENABLE_OAUTH21": "true", + "OAUTHLIB_INSECURE_TRANSPORT": "1" + } + } +} diff --git a/fastmcp_server.py b/fastmcp_server.py new file mode 100644 index 0000000..a1f15f6 --- /dev/null +++ b/fastmcp_server.py @@ -0,0 +1,180 @@ +# ruff: noqa +""" +FastMCP Cloud entrypoint for the Google Workspace MCP server. +Enforces OAuth 2.1 + stateless defaults required by FastMCP-hosted deployments. +""" + +import logging +import os +import sys +from dotenv import load_dotenv + +# Load environment variables BEFORE any other imports that might read them +dotenv_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env") +load_dotenv(dotenv_path=dotenv_path) + +from auth.oauth_config import reload_oauth_config, is_stateless_mode +from core.log_formatter import EnhancedLogFormatter, configure_file_logging +from core.utils import check_credentials_directory_permissions +from core.server import server, set_transport_mode, configure_server_for_http +from core.tool_registry import ( + set_enabled_tools as set_enabled_tool_names, + wrap_server_tool_method, + filter_server_tools, +) +from auth.scopes import set_enabled_tools + + +def enforce_fastmcp_cloud_defaults(): + """Force FastMCP Cloud-compatible OAuth settings before initializing the server.""" + enforced = [] + + required = { + "MCP_ENABLE_OAUTH21": "true", + "WORKSPACE_MCP_STATELESS_MODE": "true", + } + defaults = { + "MCP_SINGLE_USER_MODE": "false", + } + + for key, target in required.items(): + current = os.environ.get(key) + normalized = (current or "").lower() + if normalized != target: + os.environ[key] = target + enforced.append((key, current, target)) + + for key, target in defaults.items(): + current = os.environ.get(key) + if current != target: + os.environ[key] = target + enforced.append((key, current, target)) + + return enforced + + +_fastmcp_cloud_overrides = enforce_fastmcp_cloud_defaults() + +# Suppress googleapiclient discovery cache warning +logging.getLogger("googleapiclient.discovery_cache").setLevel(logging.ERROR) + +# Suppress httpx/httpcore INFO logs that leak access tokens in URLs +# (e.g. tokeninfo?access_token=ya29.xxx) +logging.getLogger("httpx").setLevel(logging.WARNING) +logging.getLogger("httpcore").setLevel(logging.WARNING) + +# Reload OAuth configuration after env vars loaded +reload_oauth_config() + +# Configure basic logging +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + +if _fastmcp_cloud_overrides: + for key, previous, new_value in _fastmcp_cloud_overrides: + if previous is None: + logger.info("FastMCP Cloud: set %s=%s", key, new_value) + else: + logger.warning( + "FastMCP Cloud: overriding %s from %s to %s", key, previous, new_value + ) +else: + logger.info("FastMCP Cloud: OAuth 2.1 stateless defaults already satisfied") + +# Configure file logging based on stateless mode +configure_file_logging() + + +def configure_safe_logging(): + """Configure safe Unicode handling for logging.""" + + class SafeEnhancedFormatter(EnhancedLogFormatter): + """Enhanced ASCII formatter with additional Windows safety.""" + + def format(self, record): + try: + return super().format(record) + except UnicodeEncodeError: + # Fallback to ASCII-safe formatting + service_prefix = self._get_ascii_prefix(record.name, record.levelname) + safe_msg = ( + str(record.getMessage()) + .encode("ascii", errors="replace") + .decode("ascii") + ) + return f"{service_prefix} {safe_msg}" + + # Replace all console handlers' formatters with safe enhanced ones + for handler in logging.root.handlers: + # Only apply to console/stream handlers, keep file handlers as-is + if isinstance(handler, logging.StreamHandler) and handler.stream.name in [ + "", + "", + ]: + safe_formatter = SafeEnhancedFormatter(use_colors=True) + handler.setFormatter(safe_formatter) + + +# Configure safe logging +configure_safe_logging() + +# Check credentials directory permissions (skip in stateless mode) +if not is_stateless_mode(): + try: + logger.info("Checking credentials directory permissions...") + check_credentials_directory_permissions() + logger.info("Credentials directory permissions verified") + except (PermissionError, OSError) as e: + logger.error(f"Credentials directory permission check failed: {e}") + logger.error( + " Please ensure the service has write permissions to create/access the credentials directory" + ) + sys.exit(1) +else: + logger.info("🔍 Skipping credentials directory check (stateless mode)") + +# Set transport mode for HTTP (FastMCP CLI defaults to streamable-http) +set_transport_mode("streamable-http") + +# Import all tool modules to register their @server.tool() decorators +import gmail.gmail_tools +import gdrive.drive_tools +import gcalendar.calendar_tools +import gdocs.docs_tools +import gsheets.sheets_tools +import gchat.chat_tools +import gforms.forms_tools +import gslides.slides_tools +import gtasks.tasks_tools +import gsearch.search_tools + +# Configure tool registration +wrap_server_tool_method(server) + +# Enable all tools and services by default +all_services = [ + "gmail", + "drive", + "calendar", + "docs", + "sheets", + "chat", + "forms", + "slides", + "tasks", + "search", +] +set_enabled_tools(all_services) # Set enabled services for scopes +set_enabled_tool_names(None) # Don't filter individual tools - enable all + +# Filter tools based on configuration +filter_server_tools(server) + +# Configure authentication after scopes are known +configure_server_for_http() + +# Export server instance for FastMCP CLI (looks for 'mcp', 'server', or 'app') +mcp = server +app = server diff --git a/gappsscript/README.md b/gappsscript/README.md new file mode 100644 index 0000000..aca1324 --- /dev/null +++ b/gappsscript/README.md @@ -0,0 +1,514 @@ +# Google Apps Script MCP Tools + +This module provides Model Context Protocol (MCP) tools for interacting with Google Apps Script API, enabling AI agents to create, manage, and execute Apps Script projects programmatically. + +## Overview + +Google Apps Script allows automation and extension of Google Workspace applications. This MCP integration provides 17 tools across core and extended tiers for complete Apps Script lifecycle management. + +## Why Apps Script? + +Apps Script is the automation glue of Google Workspace. While individual service APIs (Docs, Sheets, Gmail) operate on single resources, Apps Script enables: + +- **Cross-app automation** - Orchestrate workflows across Sheets, Gmail, Calendar, Forms, and Drive +- **Persistent logic** - Host custom business rules inside Google's environment +- **Scheduled execution** - Run automations on time-based or event-driven triggers +- **Advanced integration** - Access functionality not available through standard APIs + +This MCP integration allows AI agents to author, debug, deploy, and operate these automations end-to-end - something not possible with individual Workspace APIs alone. + +### What This Enables + +| Without Apps Script MCP | With Apps Script MCP | +|------------------------|---------------------| +| Read/update Sheets, Docs, Gmail individually | Create long-lived automations across services | +| No persistent automation logic | Host business logic that executes repeatedly | +| Manual workflow orchestration | Automated multi-step workflows | +| No execution history | Debug via execution logs and status | +| No deployment versioning | Manage deployments and roll back versions | + +### Complete Workflow Example + +**Scenario:** Automated weekly report system + +``` +User: "Create a script that runs every Monday at 9 AM. It should: +1. Read data from the 'Sales' spreadsheet +2. Calculate weekly totals and growth percentages +3. Generate a summary with the top 5 performers +4. Email the report to team@company.com +5. Log any errors to a monitoring sheet" +``` + +The AI agent: +1. Creates a new Apps Script project +2. Generates the complete automation code +3. Deploys the script +4. Sets up the time-based trigger +5. Tests execution and monitors results + +All through natural language - no JavaScript knowledge required. + +### AI Agent Workflow Pattern + +The MCP client typically follows this pattern when working with Apps Script: + +1. **Inspect** - Read existing script code and project structure +2. **Analyze** - Understand current functionality and identify issues +3. **Propose** - Generate code changes or new functionality +4. **Update** - Modify files atomically with complete version control +5. **Execute** - Run functions to test changes +6. **Deploy** - Create versioned deployments for production use +7. **Monitor** - Check execution logs and debug failures + +This ensures safe, auditable automation management. + +## Features + +### Project Management +- List all Apps Script projects +- Get complete project details including all files +- Create new standalone or bound script projects +- Update script content (add/modify JavaScript files) +- Delete script projects + +### Execution +- Execute functions with parameters +- Development mode for testing latest code +- Production deployment execution +- View execution history and status + +### Deployment Management +- Create new deployments +- List all deployments for a project +- Update deployment configurations +- Delete outdated deployments + +### Version Management +- List all versions of a script +- Create immutable version snapshots +- Get details of specific versions + +### Monitoring & Analytics +- View recent script executions +- Check execution status and results +- Monitor for errors and failures +- Get execution metrics (active users, total executions, failures) + +### Trigger Code Generation +- Generate Apps Script code for time-based triggers (minutes, hours, daily, weekly) +- Generate code for event triggers (onOpen, onEdit, onFormSubmit, onChange) +- Provides ready-to-use code snippets with setup instructions + +## Limitations & Non-Goals + +**Current Limitations** +- Direct trigger management via API is not supported (use `generate_trigger_code` instead) +- Real-time debugging and breakpoints are not available +- Advanced service enablement must be done manually in the script editor + +**Non-Goals** +- This integration does not replace the Apps Script editor UI +- Does not execute arbitrary JavaScript outside defined script functions +- Does not provide IDE features like autocomplete or syntax highlighting + +**Workarounds** +- Triggers: Use `generate_trigger_code` to get ready-to-use Apps Script code for any trigger type +- Advanced services can be enabled via the manifest file (appsscript.json) +- Debugging is supported through execution logs, metrics, and error monitoring + +## Prerequisites + +### 1. Google Cloud Project Setup + +Before using the Apps Script MCP tools, configure your Google Cloud project: + +**Step 1: Enable Required APIs** + +Enable these APIs in your Google Cloud Console: + +1. [Apps Script API](https://console.cloud.google.com/flows/enableapi?apiid=script.googleapis.com) (required for all operations) +2. [Google Drive API](https://console.cloud.google.com/flows/enableapi?apiid=drive.googleapis.com) (required for listing projects) + +**Step 2: Create OAuth Credentials** + +1. Go to [APIs & Services > Credentials](https://console.cloud.google.com/apis/credentials) +2. Click "Create Credentials" > "OAuth client ID" +3. Select "Desktop application" as the application type +4. Download the JSON file and save as `client_secret.json` + +**Step 3: Configure OAuth Consent Screen** + +1. Go to [OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent) +2. Add yourself as a test user (required for unverified apps) +3. Add the required scopes (see below) + +### 2. OAuth Scopes + +The following OAuth scopes are required: + +``` +https://www.googleapis.com/auth/script.projects +https://www.googleapis.com/auth/script.projects.readonly +https://www.googleapis.com/auth/script.deployments +https://www.googleapis.com/auth/script.deployments.readonly +https://www.googleapis.com/auth/script.processes +https://www.googleapis.com/auth/script.metrics +https://www.googleapis.com/auth/drive.file +``` + +These are automatically requested when using the appscript tool tier. + +### 3. Running the MCP Server + +Start the server with Apps Script tools enabled: + +```bash +uv run main.py --tools appscript --single-user +``` + +Or include with other tools: + +```bash +uv run main.py --tools appscript drive sheets +``` + +On first use, you will be prompted to authorize the application. Complete the OAuth flow in your browser. + +## Tool Tiers + +### Core Tier +Essential operations for reading, writing, and executing scripts: + +- `list_script_projects`: List accessible projects +- `get_script_project`: Get full project with all files +- `get_script_content`: Get specific file content +- `create_script_project`: Create new project +- `update_script_content`: Modify project files +- `run_script_function`: Execute functions +- `generate_trigger_code`: Generate trigger setup code + +### Extended Tier +Advanced deployment, versioning, and monitoring: + +- `create_deployment`: Create new deployment +- `list_deployments`: List all deployments +- `update_deployment`: Update deployment config +- `delete_deployment`: Remove deployment +- `delete_script_project`: Delete a project permanently +- `list_versions`: List all versions +- `create_version`: Create immutable version snapshot +- `get_version`: Get version details +- `list_script_processes`: View execution history +- `get_script_metrics`: Get execution analytics + +## Usage Examples + +### List Projects + +```python +# List all Apps Script projects +uv run main.py --tools appscript +# In MCP client: "Show me my Apps Script projects" +``` + +Example output: +``` +Found 3 Apps Script projects: +- Email Automation (ID: abc123) Created: 2025-01-10 Modified: 2026-01-12 +- Sheet Processor (ID: def456) Created: 2025-06-15 Modified: 2025-12-20 +- Form Handler (ID: ghi789) Created: 2024-11-03 Modified: 2025-08-14 +``` + +### Create New Project + +```python +# Create a new Apps Script project +# In MCP client: "Create a new Apps Script project called 'Data Sync'" +``` + +Example output: +``` +Created Apps Script project: Data Sync +Script ID: new123 +Edit URL: https://script.google.com/d/new123/edit +``` + +### Get Project Details + +```python +# Get complete project with all files +# In MCP client: "Show me the code for script abc123" +``` + +Example output: +``` +Project: Email Automation (ID: abc123) +Creator: user@example.com +Created: 2025-01-10 +Modified: 2026-01-12 + +Files: +1. Code.gs (SERVER_JS) + function sendDailyEmail() { + var sheet = SpreadsheetApp.getActiveSpreadsheet(); + // ... email logic + } + +2. appsscript.json (JSON) + {"timeZone": "America/New_York", "dependencies": {}} +``` + +### Update Script Content + +```python +# Update script files +# In MCP client: "Update my email script to add error handling" +``` + +The AI will: +1. Read current code +2. Generate improved version +3. Call `update_script_content` with new files + +### Run Script Function + +```python +# Execute a function +# In MCP client: "Run the sendDailyEmail function in script abc123" +``` + +Example output: +``` +Execution successful +Function: sendDailyEmail +Result: Emails sent to 5 recipients +``` + +### Create Deployment + +```python +# Deploy script for production +# In MCP client: "Deploy my email automation to production" +``` + +Example output: +``` +Created deployment for script: abc123 +Deployment ID: AKfy...xyz +Description: Production release +``` + +## Common Workflows + +### 1. Create Automated Workflow (Complete Example) + +**Scenario:** Form submission handler that sends customized emails + +``` +User: "When someone submits the Contact Form: +1. Get their email and department from the form response +2. Look up their manager in the Team Directory spreadsheet +3. Send a welcome email to the submitter +4. Send a notification to their manager +5. Log the interaction in the Onboarding Tracker sheet" +``` + +**AI Agent Steps:** +``` +1. "Create a new Apps Script bound to the Contact Form" +2. "Add a function that reads form submissions" +3. "Connect to the Team Directory spreadsheet to look up managers" +4. "Generate personalized email templates for both messages" +5. "Add logging to the Onboarding Tracker" +6. "Run the function to test it with sample data" +7. "Create a production deployment" +``` + +Result: Complete automation created and deployed without writing code. + +### 2. Debug Existing Script + +``` +User: "My expense tracker script is failing" +AI: "Show me the code for the expense tracker script" +AI: "What errors occurred in recent executions?" +AI: "The calculateTotal function has a division by zero error on line 23" +AI: "Fix the error by adding a check for zero values" +AI: "Run calculateTotal to verify the fix" +User: "Create a new deployment with the bug fix" +``` + +### 3. Modify and Extend Automation + +``` +User: "Update my weekly report script to include sales data from the Q1 sheet" +AI: "Read the current report generation script" +AI: "Add Q1 data fetching to the generateReport function" +AI: "Test the updated function" +User: "Looks good, deploy it" +AI: "Create a new deployment with description 'Added Q1 sales data'" +``` + +### 4. Run Existing Business Logic + +``` +User: "Run the monthlyCleanup function in my Data Management script" +User: "What does the calculateCommission function do?" +User: "Execute reconcileAccounts with parameters: ['2024', 'January']" +``` + +## File Types + +Apps Script projects support three file types: + +- **SERVER_JS**: Google Apps Script code (.gs files) +- **HTML**: HTML files for custom UIs +- **JSON**: Manifest file (appsscript.json) + +## API Limitations + +### Execution Timeouts +- Simple triggers: 30 seconds +- Custom functions: 30 seconds +- Script execution via API: 6 minutes + +### Quota Limits +- Script executions per day: varies by account type +- URL Fetch calls: 20,000 per day (consumer accounts) + +See [Apps Script Quotas](https://developers.google.com/apps-script/guides/services/quotas) for details. + +### Cannot Execute Arbitrary Code +The `run_script_function` tool can only execute functions that are defined in the script. You cannot run arbitrary JavaScript code directly. To run new code: + +1. Add function to script via `update_script_content` +2. Execute the function via `run_script_function` +3. Optionally remove the function after execution + +### run_script_function Requires API Executable Deployment + +The `run_script_function` tool requires additional manual configuration in the Apps Script editor: + +**Why this limitation exists:** +Google requires scripts to be explicitly deployed as "API Executable" before they can be invoked via the Apps Script API. This is a security measure to prevent unauthorized code execution. + +**To enable API execution:** + +1. Open the script in the Apps Script editor +2. Go to Project Settings (gear icon) +3. Under "Google Cloud Platform (GCP) Project", click "Change project" +4. Enter your GCP project number (found in Cloud Console dashboard) +5. Click "Deploy" > "New deployment" +6. Select type: "API Executable" +7. Set "Who has access" to "Anyone" or "Anyone with Google account" +8. Click "Deploy" + +After completing these steps, the `run_script_function` tool will work for that script. + +**Note:** All other tools (create, update, list, deploy) work without this manual step. Only function execution via API requires the API Executable deployment. + +## Error Handling + +Common errors and solutions: + +### 404: Script not found +- Verify script ID is correct +- Ensure you have access to the project + +### 403: Permission denied +- Check OAuth scopes are authorized +- Verify you own or have access to the project + +### Execution timeout +- Script exceeded 6-minute limit +- Optimize code or split into smaller functions + +### Script authorization required +- Function needs additional permissions +- User must manually authorize in script editor + +## Security Considerations + +### OAuth Scopes +Scripts inherit the OAuth scopes of the MCP server. Functions that access other Google services (Gmail, Drive, etc.) will only work if those scopes are authorized. + +### Script Permissions +Scripts run with the permissions of the script owner, not the user executing them. Be cautious when: +- Running scripts you did not create +- Granting additional permissions to scripts +- Executing functions that modify data + +### Code Review +Always review code before executing, especially for: +- Scripts from unknown sources +- Functions that access sensitive data +- Operations that modify or delete data + +## Testing + +### Unit Tests +Run unit tests with mocked API responses: + +```bash +uv run pytest tests/gappsscript/test_apps_script_tools.py +``` + +### Manual Testing +Test against real Apps Script API: + +```bash +python tests/gappsscript/manual_test.py +``` + +Note: Manual tests create real projects in your account. Delete test projects after running. + +## References + +### Apps Script Documentation +- [Apps Script Overview](https://developers.google.com/apps-script/overview) - Introduction and capabilities +- [Apps Script Guides](https://developers.google.com/apps-script/guides/services) - Service-specific guides +- [Apps Script Reference](https://developers.google.com/apps-script/reference) - Complete API reference + +### Apps Script API (for this MCP integration) +- [Apps Script API Overview](https://developers.google.com/apps-script/api) - API features and concepts +- [REST API Reference](https://developers.google.com/apps-script/api/reference/rest) - Endpoint documentation +- [OAuth Scopes](https://developers.google.com/apps-script/api/how-tos/authorization) - Required permissions + +### Useful Resources +- [Apps Script Quotas](https://developers.google.com/apps-script/guides/services/quotas) - Usage limits and restrictions +- [Best Practices](https://developers.google.com/apps-script/guides/support/best-practices) - Performance and optimization +- [Troubleshooting](https://developers.google.com/apps-script/guides/support/troubleshooting) - Common issues and solutions + +## Troubleshooting + +### "Apps Script API has not been used in project" +Enable the API in Google Cloud Console + +### "Insufficient Permission" +- Verify OAuth scopes are authorized +- Re-authenticate if needed + +### "Function not found" +- Check function name spelling +- Verify function exists in the script +- Ensure function is not private + +### "Invalid project structure" +- Ensure at least one .gs file exists +- Verify JSON files are valid JSON +- Check file names don't contain invalid characters + +## Contributing + +When adding new Apps Script tools: + +1. Follow existing patterns in `apps_script_tools.py` +2. Add comprehensive docstrings +3. Include unit tests +4. Update this README with examples +5. Test against real API before submitting + +## License + +MIT License - see project root LICENSE file diff --git a/gappsscript/TESTING.md b/gappsscript/TESTING.md new file mode 100644 index 0000000..8fdfd4a --- /dev/null +++ b/gappsscript/TESTING.md @@ -0,0 +1,254 @@ +# Apps Script MCP Testing Guide + +This document provides instructions for running unit tests and end-to-end (E2E) tests for the Apps Script MCP feature. + +## Test Structure + +``` +tests/gappsscript/ + __init__.py + test_apps_script_tools.py # Unit tests with mocked API + manual_test.py # E2E tests against real API +``` + +## Unit Tests + +Unit tests use mocked API responses and do not require Google credentials. + +### Running Unit Tests + +```bash +# Run all Apps Script unit tests +uv run pytest tests/gappsscript/test_apps_script_tools.py -v + +# Run specific test +uv run pytest tests/gappsscript/test_apps_script_tools.py::test_list_script_projects -v + +# Run with coverage +uv run pytest tests/gappsscript/test_apps_script_tools.py --cov=gappsscript +``` + +### Test Coverage + +Unit tests cover: +- list_script_projects (uses Drive API) +- get_script_project +- get_script_content +- create_script_project +- update_script_content +- run_script_function +- create_deployment +- list_deployments +- update_deployment +- delete_deployment +- list_script_processes + +## E2E Tests + +E2E tests interact with the real Google Apps Script API. They require valid OAuth credentials and will create real resources in your Google account. + +### Prerequisites + +1. **Google Cloud Project** with Apps Script API and Drive API enabled +2. **OAuth credentials** (Desktop application type) +3. **Test user** added to OAuth consent screen + +### Setup + +**Option 1: Default paths (recommended for CI)** + +Place credentials in the project root: +```bash +# Place your OAuth client credentials here +cp /path/to/your/client_secret.json ./client_secret.json +``` + +**Option 2: Custom paths via environment variables** + +```bash +export GOOGLE_CLIENT_SECRET_PATH=/path/to/client_secret.json +export GOOGLE_TOKEN_PATH=/path/to/token.pickle +``` + +### Running E2E Tests + +```bash +# Interactive mode (prompts for confirmation) +uv run python tests/gappsscript/manual_test.py + +# Non-interactive mode (for CI) +uv run python tests/gappsscript/manual_test.py --yes +``` + +### E2E Test Flow + +The test script performs the following operations: + +1. **List Projects** - Lists existing Apps Script projects via Drive API +2. **Create Project** - Creates a new test project +3. **Get Project** - Retrieves project details +4. **Update Content** - Adds code to the project +5. **Run Function** - Attempts to execute a function (see note below) +6. **Create Deployment** - Creates a versioned deployment +7. **List Deployments** - Lists all deployments +8. **List Processes** - Lists recent script executions + +### Cleanup + +The test script does not automatically delete created projects. After running tests: + +1. Go to [Google Apps Script](https://script.google.com/) +2. Find projects named "MCP Test Project" +3. Delete them manually via the menu (three dots) > Remove + +## Headless Linux Testing + +For headless environments (servers, CI/CD, WSL without GUI): + +### OAuth Authentication Flow + +The test script uses a headless-compatible OAuth flow: + +1. Script prints an authorization URL +2. Open the URL in any browser (can be on a different machine) +3. Complete Google sign-in and authorization +4. Browser redirects to `http://localhost/?code=...` (page will not load) +5. Copy the full URL from the browser address bar +6. Paste it into the terminal when prompted + +### Example Session + +``` +$ python tests/gappsscript/manual_test.py --yes + +============================================================ +HEADLESS AUTH +============================================================ + +1. Open this URL in any browser: + +https://accounts.google.com/o/oauth2/auth?response_type=code&client_id=... + +2. Sign in and authorize the app +3. You'll be redirected to http://localhost (won't load) +4. Copy the FULL URL from browser address bar + (looks like: http://localhost/?code=4/0A...&scope=...) +5. Paste it below: + +Paste full redirect URL: http://localhost/?code=4/0AQSTgQ...&scope=... + +Building API services... + +=== Test: List Projects === +Found 3 Apps Script projects: +... +``` + +### Credential Storage + +OAuth tokens are stored as pickle files: +- Default: `./test_token.pickle` in project root +- Custom: Set via `GOOGLE_TOKEN_PATH` environment variable + +Tokens are reused on subsequent runs until they expire or are revoked. + +## Known Limitations and Caveats + +### run_script_function Test Failure + +The "Run Function" test will fail with a 404 error unless you manually configure the script as an API Executable. This is a Google platform requirement, not a bug. + +To make run_script_function work: + +1. Open the created test script in Apps Script editor +2. Go to Project Settings > Change GCP project +3. Enter your GCP project number +4. Deploy as "API Executable" + +For E2E testing purposes, it is acceptable for this test to fail. All other tests should pass. + +### Drive API Requirement + +The `list_script_projects` function uses the Google Drive API (not the Apps Script API) because the Apps Script API does not provide a projects.list endpoint. Ensure the Drive API is enabled in your GCP project. + +### Scope Requirements + +The E2E tests require these scopes: +- `script.projects` and `script.projects.readonly` +- `script.deployments` and `script.deployments.readonly` +- `script.processes` +- `drive.readonly` + +If you encounter "insufficient scopes" errors, delete the stored token file and re-authenticate. + +### Rate Limits + +Google enforces rate limits on the Apps Script API. If running tests repeatedly, you may encounter quota errors. Wait a few minutes before retrying. + +## CI/CD Integration + +For automated testing in CI/CD pipelines: + +### Unit Tests Only (Recommended) + +```yaml +# GitHub Actions example +- name: Run unit tests + run: uv run pytest tests/gappsscript/test_apps_script_tools.py -v +``` + +### E2E Tests in CI + +E2E tests require OAuth credentials. Options: + +1. **Skip E2E in CI** - Run only unit tests in CI, run E2E locally +2. **Service Account** - Not supported (Apps Script API requires user OAuth) +3. **Pre-authenticated Token** - Store encrypted token in CI secrets + +To use a pre-authenticated token: +```bash +# Generate token locally +python tests/gappsscript/manual_test.py + +# Store test_token.pickle contents as base64 in CI secret +base64 test_token.pickle > token.b64 + +# In CI, restore and set path +echo $TOKEN_SECRET | base64 -d > test_token.pickle +export GOOGLE_TOKEN_PATH=./test_token.pickle +python tests/gappsscript/manual_test.py --yes +``` + +Note: Tokens expire and must be refreshed periodically. + +## Troubleshooting + +### "Apps Script API has not been used in project" + +Enable the Apps Script API in your GCP project: +https://console.cloud.google.com/flows/enableapi?apiid=script.googleapis.com + +### "Access Not Configured. Drive API has not been used" + +Enable the Drive API in your GCP project: +https://console.cloud.google.com/flows/enableapi?apiid=drive.googleapis.com + +### "Request had insufficient authentication scopes" + +Delete the token file and re-authenticate: +```bash +rm test_token.pickle +python tests/gappsscript/manual_test.py +``` + +### "User is not authorized to access this resource" + +Ensure your email is added as a test user in the OAuth consent screen configuration. + +### "Requested entity was not found" (404 on run) + +The script needs to be deployed as "API Executable". See the run_script_function section above. + +### OAuth redirect fails on headless machine + +The redirect to `http://localhost` is expected to fail. Copy the URL from the browser address bar (including the error page URL) and paste it into the terminal. diff --git a/gappsscript/__init__.py b/gappsscript/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gappsscript/apps_script_tools.py b/gappsscript/apps_script_tools.py new file mode 100644 index 0000000..eaf491d --- /dev/null +++ b/gappsscript/apps_script_tools.py @@ -0,0 +1,1284 @@ +""" +Google Apps Script MCP Tools + +This module provides MCP tools for interacting with Google Apps Script API. +""" + +import logging +import asyncio +from typing import List, Dict, Any, Optional + +from auth.service_decorator import require_google_service +from core.server import server +from core.utils import handle_http_errors + +logger = logging.getLogger(__name__) + + +# Internal implementation functions for testing +async def _list_script_projects_impl( + service: Any, + user_google_email: str, + page_size: int = 50, + page_token: Optional[str] = None, +) -> str: + """Internal implementation for list_script_projects. + + Uses Drive API to find Apps Script files since the Script API + does not have a projects.list method. + """ + logger.info( + f"[list_script_projects] Email: {user_google_email}, PageSize: {page_size}" + ) + + # Search for Apps Script files using Drive API + query = "mimeType='application/vnd.google-apps.script' and trashed=false" + request_params = { + "q": query, + "pageSize": page_size, + "fields": "nextPageToken, files(id, name, createdTime, modifiedTime)", + "orderBy": "modifiedTime desc", + } + if page_token: + request_params["pageToken"] = page_token + + response = await asyncio.to_thread(service.files().list(**request_params).execute) + + files = response.get("files", []) + + if not files: + return "No Apps Script projects found." + + output = [f"Found {len(files)} Apps Script projects:"] + for file in files: + title = file.get("name", "Untitled") + script_id = file.get("id", "Unknown ID") + create_time = file.get("createdTime", "Unknown") + update_time = file.get("modifiedTime", "Unknown") + + output.append( + f"- {title} (ID: {script_id}) Created: {create_time} Modified: {update_time}" + ) + + if "nextPageToken" in response: + output.append(f"\nNext page token: {response['nextPageToken']}") + + logger.info( + f"[list_script_projects] Found {len(files)} projects for {user_google_email}" + ) + return "\n".join(output) + + +@server.tool() +@handle_http_errors("list_script_projects", is_read_only=True, service_type="drive") +@require_google_service("drive", "drive_read") +async def list_script_projects( + service: Any, + user_google_email: str, + page_size: int = 50, + page_token: Optional[str] = None, +) -> str: + """ + Lists Google Apps Script projects accessible to the user. + + Uses Drive API to find Apps Script files. + + Args: + service: Injected Google API service client + user_google_email: User's email address + page_size: Number of results per page (default: 50) + page_token: Token for pagination (optional) + + Returns: + str: Formatted list of script projects + """ + return await _list_script_projects_impl( + service, user_google_email, page_size, page_token + ) + + +async def _get_script_project_impl( + service: Any, + user_google_email: str, + script_id: str, +) -> str: + """Internal implementation for get_script_project.""" + logger.info(f"[get_script_project] Email: {user_google_email}, ID: {script_id}") + + # Get project metadata and content concurrently (independent requests) + project, content = await asyncio.gather( + asyncio.to_thread(service.projects().get(scriptId=script_id).execute), + asyncio.to_thread(service.projects().getContent(scriptId=script_id).execute), + ) + + title = project.get("title", "Untitled") + project_script_id = project.get("scriptId", "Unknown") + creator = project.get("creator", {}).get("email", "Unknown") + create_time = project.get("createTime", "Unknown") + update_time = project.get("updateTime", "Unknown") + + output = [ + f"Project: {title} (ID: {project_script_id})", + f"Creator: {creator}", + f"Created: {create_time}", + f"Modified: {update_time}", + "", + "Files:", + ] + + files = content.get("files", []) + for i, file in enumerate(files, 1): + file_name = file.get("name", "Untitled") + file_type = file.get("type", "Unknown") + source = file.get("source", "") + + output.append(f"{i}. {file_name} ({file_type})") + if source: + output.append(f" {source[:200]}{'...' if len(source) > 200 else ''}") + output.append("") + + logger.info(f"[get_script_project] Retrieved project {script_id}") + return "\n".join(output) + + +@server.tool() +@handle_http_errors("get_script_project", is_read_only=True, service_type="script") +@require_google_service("script", "script_readonly") +async def get_script_project( + service: Any, + user_google_email: str, + script_id: str, +) -> str: + """ + Retrieves complete project details including all source files. + + Args: + service: Injected Google API service client + user_google_email: User's email address + script_id: The script project ID + + Returns: + str: Formatted project details with all file contents + """ + return await _get_script_project_impl(service, user_google_email, script_id) + + +async def _get_script_content_impl( + service: Any, + user_google_email: str, + script_id: str, + file_name: str, +) -> str: + """Internal implementation for get_script_content.""" + logger.info( + f"[get_script_content] Email: {user_google_email}, ID: {script_id}, File: {file_name}" + ) + + # Must use getContent() to retrieve files, not get() which only returns metadata + content = await asyncio.to_thread( + service.projects().getContent(scriptId=script_id).execute + ) + + files = content.get("files", []) + target_file = None + + for file in files: + if file.get("name") == file_name: + target_file = file + break + + if not target_file: + return f"File '{file_name}' not found in project {script_id}" + + source = target_file.get("source", "") + file_type = target_file.get("type", "Unknown") + + output = [f"File: {file_name} ({file_type})", "", source] + + logger.info(f"[get_script_content] Retrieved file {file_name} from {script_id}") + return "\n".join(output) + + +@server.tool() +@handle_http_errors("get_script_content", is_read_only=True, service_type="script") +@require_google_service("script", "script_readonly") +async def get_script_content( + service: Any, + user_google_email: str, + script_id: str, + file_name: str, +) -> str: + """ + Retrieves content of a specific file within a project. + + Args: + service: Injected Google API service client + user_google_email: User's email address + script_id: The script project ID + file_name: Name of the file to retrieve + + Returns: + str: File content as string + """ + return await _get_script_content_impl( + service, user_google_email, script_id, file_name + ) + + +async def _create_script_project_impl( + service: Any, + user_google_email: str, + title: str, + parent_id: Optional[str] = None, +) -> str: + """Internal implementation for create_script_project.""" + logger.info(f"[create_script_project] Email: {user_google_email}, Title: {title}") + + request_body = {"title": title} + + if parent_id: + request_body["parentId"] = parent_id + + project = await asyncio.to_thread( + service.projects().create(body=request_body).execute + ) + + script_id = project.get("scriptId", "Unknown") + edit_url = f"https://script.google.com/d/{script_id}/edit" + + output = [ + f"Created Apps Script project: {title}", + f"Script ID: {script_id}", + f"Edit URL: {edit_url}", + ] + + logger.info(f"[create_script_project] Created project {script_id}") + return "\n".join(output) + + +@server.tool() +@handle_http_errors("create_script_project", service_type="script") +@require_google_service("script", "script_projects") +async def create_script_project( + service: Any, + user_google_email: str, + title: str, + parent_id: Optional[str] = None, +) -> str: + """ + Creates a new Apps Script project. + + Args: + service: Injected Google API service client + user_google_email: User's email address + title: Project title + parent_id: Optional Drive folder ID or bound container ID + + Returns: + str: Formatted string with new project details + """ + return await _create_script_project_impl( + service, user_google_email, title, parent_id + ) + + +async def _update_script_content_impl( + service: Any, + user_google_email: str, + script_id: str, + files: List[Dict[str, str]], +) -> str: + """Internal implementation for update_script_content.""" + logger.info( + f"[update_script_content] Email: {user_google_email}, ID: {script_id}, Files: {len(files)}" + ) + + request_body = {"files": files} + + updated_content = await asyncio.to_thread( + service.projects().updateContent(scriptId=script_id, body=request_body).execute + ) + + output = [f"Updated script project: {script_id}", "", "Modified files:"] + + for file in updated_content.get("files", []): + file_name = file.get("name", "Untitled") + file_type = file.get("type", "Unknown") + output.append(f"- {file_name} ({file_type})") + + logger.info(f"[update_script_content] Updated {len(files)} files in {script_id}") + return "\n".join(output) + + +@server.tool() +@handle_http_errors("update_script_content", service_type="script") +@require_google_service("script", "script_projects") +async def update_script_content( + service: Any, + user_google_email: str, + script_id: str, + files: List[Dict[str, str]], +) -> str: + """ + Updates or creates files in a script project. + + Args: + service: Injected Google API service client + user_google_email: User's email address + script_id: The script project ID + files: List of file objects with name, type, and source + + Returns: + str: Formatted string confirming update with file list + """ + return await _update_script_content_impl( + service, user_google_email, script_id, files + ) + + +async def _run_script_function_impl( + service: Any, + user_google_email: str, + script_id: str, + function_name: str, + parameters: Optional[list[object]] = None, + dev_mode: bool = False, +) -> str: + """Internal implementation for run_script_function.""" + logger.info( + f"[run_script_function] Email: {user_google_email}, ID: {script_id}, Function: {function_name}" + ) + + request_body = {"function": function_name, "devMode": dev_mode} + + if parameters: + request_body["parameters"] = parameters + + try: + response = await asyncio.to_thread( + service.scripts().run(scriptId=script_id, body=request_body).execute + ) + + if "error" in response: + error_details = response["error"] + error_message = error_details.get("message", "Unknown error") + return ( + f"Execution failed\nFunction: {function_name}\nError: {error_message}" + ) + + result = response.get("response", {}).get("result") + output = [ + "Execution successful", + f"Function: {function_name}", + f"Result: {result}", + ] + + logger.info(f"[run_script_function] Successfully executed {function_name}") + return "\n".join(output) + + except Exception as e: + logger.error(f"[run_script_function] Execution error: {str(e)}") + return f"Execution failed\nFunction: {function_name}\nError: {str(e)}" + + +@server.tool() +@handle_http_errors("run_script_function", service_type="script") +@require_google_service("script", "script_projects") +async def run_script_function( + service: Any, + user_google_email: str, + script_id: str, + function_name: str, + parameters: Optional[list[object]] = None, + dev_mode: bool = False, +) -> str: + """ + Executes a function in a deployed script. + + Args: + service: Injected Google API service client + user_google_email: User's email address + script_id: The script project ID + function_name: Name of function to execute + parameters: Optional list of parameters to pass + dev_mode: Whether to run latest code vs deployed version + + Returns: + str: Formatted string with execution result or error + """ + return await _run_script_function_impl( + service, user_google_email, script_id, function_name, parameters, dev_mode + ) + + +async def _create_deployment_impl( + service: Any, + user_google_email: str, + script_id: str, + description: str, + version_description: Optional[str] = None, +) -> str: + """Internal implementation for create_deployment. + + Creates a new version first, then creates a deployment using that version. + """ + logger.info( + f"[create_deployment] Email: {user_google_email}, ID: {script_id}, Desc: {description}" + ) + + # First, create a new version + version_body = {"description": version_description or description} + version = await asyncio.to_thread( + service.projects() + .versions() + .create(scriptId=script_id, body=version_body) + .execute + ) + version_number = version.get("versionNumber") + logger.info(f"[create_deployment] Created version {version_number}") + + # Now create the deployment with the version number + deployment_body = { + "versionNumber": version_number, + "description": description, + } + + deployment = await asyncio.to_thread( + service.projects() + .deployments() + .create(scriptId=script_id, body=deployment_body) + .execute + ) + + deployment_id = deployment.get("deploymentId", "Unknown") + + output = [ + f"Created deployment for script: {script_id}", + f"Deployment ID: {deployment_id}", + f"Version: {version_number}", + f"Description: {description}", + ] + + logger.info(f"[create_deployment] Created deployment {deployment_id}") + return "\n".join(output) + + +@server.tool() +@handle_http_errors("manage_deployment", service_type="script") +@require_google_service("script", "script_deployments") +async def manage_deployment( + service: Any, + user_google_email: str, + action: str, + script_id: str, + deployment_id: Optional[str] = None, + description: Optional[str] = None, + version_description: Optional[str] = None, +) -> str: + """ + Manages Apps Script deployments. Supports creating, updating, and deleting deployments. + + Args: + service: Injected Google API service client + user_google_email: User's email address + action: Action to perform - "create", "update", or "delete" + script_id: The script project ID + deployment_id: The deployment ID (required for update and delete) + description: Deployment description (required for create and update) + version_description: Optional version description (for create only) + + Returns: + str: Formatted string with deployment details or confirmation + """ + action = action.lower().strip() + if action == "create": + if description is None or description.strip() == "": + raise ValueError("description is required for create action") + return await _create_deployment_impl( + service, user_google_email, script_id, description, version_description + ) + elif action == "update": + if not deployment_id: + raise ValueError("deployment_id is required for update action") + if description is None or description.strip() == "": + raise ValueError("description is required for update action") + return await _update_deployment_impl( + service, user_google_email, script_id, deployment_id, description + ) + elif action == "delete": + if not deployment_id: + raise ValueError("deployment_id is required for delete action") + return await _delete_deployment_impl( + service, user_google_email, script_id, deployment_id + ) + else: + raise ValueError( + f"Invalid action '{action}'. Must be 'create', 'update', or 'delete'." + ) + + +async def _list_deployments_impl( + service: Any, + user_google_email: str, + script_id: str, +) -> str: + """Internal implementation for list_deployments.""" + logger.info(f"[list_deployments] Email: {user_google_email}, ID: {script_id}") + + response = await asyncio.to_thread( + service.projects().deployments().list(scriptId=script_id).execute + ) + + deployments = response.get("deployments", []) + + if not deployments: + return f"No deployments found for script: {script_id}" + + output = [f"Deployments for script: {script_id}", ""] + + for i, deployment in enumerate(deployments, 1): + deployment_id = deployment.get("deploymentId", "Unknown") + description = deployment.get("description", "No description") + update_time = deployment.get("updateTime", "Unknown") + + output.append(f"{i}. {description} ({deployment_id})") + output.append(f" Updated: {update_time}") + output.append("") + + logger.info(f"[list_deployments] Found {len(deployments)} deployments") + return "\n".join(output) + + +@server.tool() +@handle_http_errors("list_deployments", is_read_only=True, service_type="script") +@require_google_service("script", "script_deployments_readonly") +async def list_deployments( + service: Any, + user_google_email: str, + script_id: str, +) -> str: + """ + Lists all deployments for a script project. + + Args: + service: Injected Google API service client + user_google_email: User's email address + script_id: The script project ID + + Returns: + str: Formatted string with deployment list + """ + return await _list_deployments_impl(service, user_google_email, script_id) + + +async def _update_deployment_impl( + service: Any, + user_google_email: str, + script_id: str, + deployment_id: str, + description: Optional[str] = None, +) -> str: + """Internal implementation for update_deployment.""" + logger.info( + f"[update_deployment] Email: {user_google_email}, Script: {script_id}, Deployment: {deployment_id}" + ) + + request_body = {} + if description: + request_body["description"] = description + + deployment = await asyncio.to_thread( + service.projects() + .deployments() + .update(scriptId=script_id, deploymentId=deployment_id, body=request_body) + .execute + ) + + output = [ + f"Updated deployment: {deployment_id}", + f"Script: {script_id}", + f"Description: {deployment.get('description', 'No description')}", + ] + + logger.info(f"[update_deployment] Updated deployment {deployment_id}") + return "\n".join(output) + + +async def _delete_deployment_impl( + service: Any, + user_google_email: str, + script_id: str, + deployment_id: str, +) -> str: + """Internal implementation for delete_deployment.""" + logger.info( + f"[delete_deployment] Email: {user_google_email}, Script: {script_id}, Deployment: {deployment_id}" + ) + + await asyncio.to_thread( + service.projects() + .deployments() + .delete(scriptId=script_id, deploymentId=deployment_id) + .execute + ) + + output = f"Deleted deployment: {deployment_id} from script: {script_id}" + + logger.info(f"[delete_deployment] Deleted deployment {deployment_id}") + return output + + +async def _list_script_processes_impl( + service: Any, + user_google_email: str, + page_size: int = 50, + script_id: Optional[str] = None, +) -> str: + """Internal implementation for list_script_processes.""" + logger.info( + f"[list_script_processes] Email: {user_google_email}, PageSize: {page_size}" + ) + + request_params = {"pageSize": page_size} + if script_id: + request_params["scriptId"] = script_id + + response = await asyncio.to_thread( + service.processes().list(**request_params).execute + ) + + processes = response.get("processes", []) + + if not processes: + return "No recent script executions found." + + output = ["Recent script executions:", ""] + + for i, process in enumerate(processes, 1): + function_name = process.get("functionName", "Unknown") + process_status = process.get("processStatus", "Unknown") + start_time = process.get("startTime", "Unknown") + duration = process.get("duration", "Unknown") + + output.append(f"{i}. {function_name}") + output.append(f" Status: {process_status}") + output.append(f" Started: {start_time}") + output.append(f" Duration: {duration}") + output.append("") + + logger.info(f"[list_script_processes] Found {len(processes)} processes") + return "\n".join(output) + + +@server.tool() +@handle_http_errors("list_script_processes", is_read_only=True, service_type="script") +@require_google_service("script", "script_readonly") +async def list_script_processes( + service: Any, + user_google_email: str, + page_size: int = 50, + script_id: Optional[str] = None, +) -> str: + """ + Lists recent execution processes for user's scripts. + + Args: + service: Injected Google API service client + user_google_email: User's email address + page_size: Number of results (default: 50) + script_id: Optional filter by script ID + + Returns: + str: Formatted string with process list + """ + return await _list_script_processes_impl( + service, user_google_email, page_size, script_id + ) + + +# ============================================================================ +# Delete Script Project +# ============================================================================ + + +async def _delete_script_project_impl( + service: Any, + user_google_email: str, + script_id: str, +) -> str: + """Internal implementation for delete_script_project.""" + logger.info( + f"[delete_script_project] Email: {user_google_email}, ScriptID: {script_id}" + ) + + # Apps Script projects are stored as Drive files + await asyncio.to_thread(service.files().delete(fileId=script_id).execute) + + logger.info(f"[delete_script_project] Deleted script {script_id}") + return f"Deleted Apps Script project: {script_id}" + + +@server.tool() +@handle_http_errors("delete_script_project", is_read_only=False, service_type="drive") +@require_google_service("drive", "drive_full") +async def delete_script_project( + service: Any, + user_google_email: str, + script_id: str, +) -> str: + """ + Deletes an Apps Script project. + + This permanently deletes the script project. The action cannot be undone. + + Args: + service: Injected Google API service client + user_google_email: User's email address + script_id: The script project ID to delete + + Returns: + str: Confirmation message + """ + return await _delete_script_project_impl(service, user_google_email, script_id) + + +# ============================================================================ +# Version Management +# ============================================================================ + + +async def _list_versions_impl( + service: Any, + user_google_email: str, + script_id: str, +) -> str: + """Internal implementation for list_versions.""" + logger.info(f"[list_versions] Email: {user_google_email}, ScriptID: {script_id}") + + response = await asyncio.to_thread( + service.projects().versions().list(scriptId=script_id).execute + ) + + versions = response.get("versions", []) + + if not versions: + return f"No versions found for script: {script_id}" + + output = [f"Versions for script: {script_id}", ""] + + for version in versions: + version_number = version.get("versionNumber", "Unknown") + description = version.get("description", "No description") + create_time = version.get("createTime", "Unknown") + + output.append(f"Version {version_number}: {description}") + output.append(f" Created: {create_time}") + output.append("") + + logger.info(f"[list_versions] Found {len(versions)} versions") + return "\n".join(output) + + +@server.tool() +@handle_http_errors("list_versions", is_read_only=True, service_type="script") +@require_google_service("script", "script_readonly") +async def list_versions( + service: Any, + user_google_email: str, + script_id: str, +) -> str: + """ + Lists all versions of a script project. + + Versions are immutable snapshots of your script code. + They are created when you deploy or explicitly create a version. + + Args: + service: Injected Google API service client + user_google_email: User's email address + script_id: The script project ID + + Returns: + str: Formatted string with version list + """ + return await _list_versions_impl(service, user_google_email, script_id) + + +async def _create_version_impl( + service: Any, + user_google_email: str, + script_id: str, + description: Optional[str] = None, +) -> str: + """Internal implementation for create_version.""" + logger.info(f"[create_version] Email: {user_google_email}, ScriptID: {script_id}") + + request_body = {} + if description: + request_body["description"] = description + + version = await asyncio.to_thread( + service.projects() + .versions() + .create(scriptId=script_id, body=request_body) + .execute + ) + + version_number = version.get("versionNumber", "Unknown") + create_time = version.get("createTime", "Unknown") + + output = [ + f"Created version {version_number} for script: {script_id}", + f"Description: {description or 'No description'}", + f"Created: {create_time}", + ] + + logger.info(f"[create_version] Created version {version_number}") + return "\n".join(output) + + +@server.tool() +@handle_http_errors("create_version", is_read_only=False, service_type="script") +@require_google_service("script", "script_full") +async def create_version( + service: Any, + user_google_email: str, + script_id: str, + description: Optional[str] = None, +) -> str: + """ + Creates a new immutable version of a script project. + + Versions capture a snapshot of the current script code. + Once created, versions cannot be modified. + + Args: + service: Injected Google API service client + user_google_email: User's email address + script_id: The script project ID + description: Optional description for this version + + Returns: + str: Formatted string with new version details + """ + return await _create_version_impl( + service, user_google_email, script_id, description + ) + + +async def _get_version_impl( + service: Any, + user_google_email: str, + script_id: str, + version_number: int, +) -> str: + """Internal implementation for get_version.""" + logger.info( + f"[get_version] Email: {user_google_email}, ScriptID: {script_id}, Version: {version_number}" + ) + + version = await asyncio.to_thread( + service.projects() + .versions() + .get(scriptId=script_id, versionNumber=version_number) + .execute + ) + + ver_num = version.get("versionNumber", "Unknown") + description = version.get("description", "No description") + create_time = version.get("createTime", "Unknown") + + output = [ + f"Version {ver_num} of script: {script_id}", + f"Description: {description}", + f"Created: {create_time}", + ] + + logger.info(f"[get_version] Retrieved version {ver_num}") + return "\n".join(output) + + +@server.tool() +@handle_http_errors("get_version", is_read_only=True, service_type="script") +@require_google_service("script", "script_readonly") +async def get_version( + service: Any, + user_google_email: str, + script_id: str, + version_number: int, +) -> str: + """ + Gets details of a specific version. + + Args: + service: Injected Google API service client + user_google_email: User's email address + script_id: The script project ID + version_number: The version number to retrieve (1, 2, 3, etc.) + + Returns: + str: Formatted string with version details + """ + return await _get_version_impl( + service, user_google_email, script_id, version_number + ) + + +# ============================================================================ +# Metrics +# ============================================================================ + + +async def _get_script_metrics_impl( + service: Any, + user_google_email: str, + script_id: str, + metrics_granularity: str = "DAILY", +) -> str: + """Internal implementation for get_script_metrics.""" + logger.info( + f"[get_script_metrics] Email: {user_google_email}, ScriptID: {script_id}, Granularity: {metrics_granularity}" + ) + + request_params = { + "scriptId": script_id, + "metricsGranularity": metrics_granularity, + } + + response = await asyncio.to_thread( + service.projects().getMetrics(**request_params).execute + ) + + output = [ + f"Metrics for script: {script_id}", + f"Granularity: {metrics_granularity}", + "", + ] + + # Active users + active_users = response.get("activeUsers", []) + if active_users: + output.append("Active Users:") + for metric in active_users: + start_time = metric.get("startTime", "Unknown") + end_time = metric.get("endTime", "Unknown") + value = metric.get("value", "0") + output.append(f" {start_time} to {end_time}: {value} users") + output.append("") + + # Total executions + total_executions = response.get("totalExecutions", []) + if total_executions: + output.append("Total Executions:") + for metric in total_executions: + start_time = metric.get("startTime", "Unknown") + end_time = metric.get("endTime", "Unknown") + value = metric.get("value", "0") + output.append(f" {start_time} to {end_time}: {value} executions") + output.append("") + + # Failed executions + failed_executions = response.get("failedExecutions", []) + if failed_executions: + output.append("Failed Executions:") + for metric in failed_executions: + start_time = metric.get("startTime", "Unknown") + end_time = metric.get("endTime", "Unknown") + value = metric.get("value", "0") + output.append(f" {start_time} to {end_time}: {value} failures") + output.append("") + + if not active_users and not total_executions and not failed_executions: + output.append("No metrics data available for this script.") + + logger.info(f"[get_script_metrics] Retrieved metrics for {script_id}") + return "\n".join(output) + + +@server.tool() +@handle_http_errors("get_script_metrics", is_read_only=True, service_type="script") +@require_google_service("script", "script_readonly") +async def get_script_metrics( + service: Any, + user_google_email: str, + script_id: str, + metrics_granularity: str = "DAILY", +) -> str: + """ + Gets execution metrics for a script project. + + Returns analytics data including active users, total executions, + and failed executions over time. + + Args: + service: Injected Google API service client + user_google_email: User's email address + script_id: The script project ID + metrics_granularity: Granularity of metrics - "DAILY" or "WEEKLY" + + Returns: + str: Formatted string with metrics data + """ + return await _get_script_metrics_impl( + service, user_google_email, script_id, metrics_granularity + ) + + +# ============================================================================ +# Trigger Code Generation +# ============================================================================ + + +def _generate_trigger_code_impl( + trigger_type: str, + function_name: str, + schedule: str = "", +) -> str: + """Internal implementation for generate_trigger_code.""" + code_lines = [] + + if trigger_type == "on_open": + code_lines = [ + "// Simple trigger - just rename your function to 'onOpen'", + "// This runs automatically when the document is opened", + "function onOpen(e) {", + f" {function_name}();", + "}", + ] + elif trigger_type == "on_edit": + code_lines = [ + "// Simple trigger - just rename your function to 'onEdit'", + "// This runs automatically when a user edits the spreadsheet", + "function onEdit(e) {", + f" {function_name}();", + "}", + ] + elif trigger_type == "time_minutes": + interval = schedule or "5" + code_lines = [ + "// Run this function ONCE to install the trigger", + f"function createTimeTrigger_{function_name}() {{", + " // Delete existing triggers for this function first", + " const triggers = ScriptApp.getProjectTriggers();", + " triggers.forEach(trigger => {", + f" if (trigger.getHandlerFunction() === '{function_name}') {{", + " ScriptApp.deleteTrigger(trigger);", + " }", + " });", + "", + f" // Create new trigger - runs every {interval} minutes", + f" ScriptApp.newTrigger('{function_name}')", + " .timeBased()", + f" .everyMinutes({interval})", + " .create();", + "", + f" Logger.log('Trigger created: {function_name} will run every {interval} minutes');", + "}", + ] + elif trigger_type == "time_hours": + interval = schedule or "1" + code_lines = [ + "// Run this function ONCE to install the trigger", + f"function createTimeTrigger_{function_name}() {{", + " // Delete existing triggers for this function first", + " const triggers = ScriptApp.getProjectTriggers();", + " triggers.forEach(trigger => {", + f" if (trigger.getHandlerFunction() === '{function_name}') {{", + " ScriptApp.deleteTrigger(trigger);", + " }", + " });", + "", + f" // Create new trigger - runs every {interval} hour(s)", + f" ScriptApp.newTrigger('{function_name}')", + " .timeBased()", + f" .everyHours({interval})", + " .create();", + "", + f" Logger.log('Trigger created: {function_name} will run every {interval} hour(s)');", + "}", + ] + elif trigger_type == "time_daily": + hour = schedule or "9" + code_lines = [ + "// Run this function ONCE to install the trigger", + f"function createDailyTrigger_{function_name}() {{", + " // Delete existing triggers for this function first", + " const triggers = ScriptApp.getProjectTriggers();", + " triggers.forEach(trigger => {", + f" if (trigger.getHandlerFunction() === '{function_name}') {{", + " ScriptApp.deleteTrigger(trigger);", + " }", + " });", + "", + f" // Create new trigger - runs daily at {hour}:00", + f" ScriptApp.newTrigger('{function_name}')", + " .timeBased()", + f" .atHour({hour})", + " .everyDays(1)", + " .create();", + "", + f" Logger.log('Trigger created: {function_name} will run daily at {hour}:00');", + "}", + ] + elif trigger_type == "time_weekly": + day = schedule.upper() if schedule else "MONDAY" + code_lines = [ + "// Run this function ONCE to install the trigger", + f"function createWeeklyTrigger_{function_name}() {{", + " // Delete existing triggers for this function first", + " const triggers = ScriptApp.getProjectTriggers();", + " triggers.forEach(trigger => {", + f" if (trigger.getHandlerFunction() === '{function_name}') {{", + " ScriptApp.deleteTrigger(trigger);", + " }", + " });", + "", + f" // Create new trigger - runs weekly on {day}", + f" ScriptApp.newTrigger('{function_name}')", + " .timeBased()", + f" .onWeekDay(ScriptApp.WeekDay.{day})", + " .atHour(9)", + " .create();", + "", + f" Logger.log('Trigger created: {function_name} will run every {day} at 9:00');", + "}", + ] + elif trigger_type == "on_form_submit": + code_lines = [ + "// Run this function ONCE to install the trigger", + "// This must be run from a script BOUND to the Google Form", + f"function createFormSubmitTrigger_{function_name}() {{", + " // Delete existing triggers for this function first", + " const triggers = ScriptApp.getProjectTriggers();", + " triggers.forEach(trigger => {", + f" if (trigger.getHandlerFunction() === '{function_name}') {{", + " ScriptApp.deleteTrigger(trigger);", + " }", + " });", + "", + " // Create new trigger - runs when form is submitted", + f" ScriptApp.newTrigger('{function_name}')", + " .forForm(FormApp.getActiveForm())", + " .onFormSubmit()", + " .create();", + "", + f" Logger.log('Trigger created: {function_name} will run on form submit');", + "}", + ] + elif trigger_type == "on_change": + code_lines = [ + "// Run this function ONCE to install the trigger", + "// This must be run from a script BOUND to a Google Sheet", + f"function createChangeTrigger_{function_name}() {{", + " // Delete existing triggers for this function first", + " const triggers = ScriptApp.getProjectTriggers();", + " triggers.forEach(trigger => {", + f" if (trigger.getHandlerFunction() === '{function_name}') {{", + " ScriptApp.deleteTrigger(trigger);", + " }", + " });", + "", + " // Create new trigger - runs when spreadsheet changes", + f" ScriptApp.newTrigger('{function_name}')", + " .forSpreadsheet(SpreadsheetApp.getActive())", + " .onChange()", + " .create();", + "", + f" Logger.log('Trigger created: {function_name} will run on spreadsheet change');", + "}", + ] + else: + return ( + f"Unknown trigger type: {trigger_type}\n\n" + "Valid types: time_minutes, time_hours, time_daily, time_weekly, " + "on_open, on_edit, on_form_submit, on_change" + ) + + code = "\n".join(code_lines) + + instructions = [] + if trigger_type.startswith("on_"): + if trigger_type in ("on_open", "on_edit"): + instructions = [ + "SIMPLE TRIGGER", + "=" * 50, + "", + "Add this code to your script. Simple triggers run automatically", + "when the event occurs - no setup function needed.", + "", + "Note: Simple triggers have limitations:", + "- Cannot access services that require authorization", + "- Cannot run longer than 30 seconds", + "- Cannot make external HTTP requests", + "", + "For more capabilities, use an installable trigger instead.", + "", + "CODE TO ADD:", + "-" * 50, + ] + else: + instructions = [ + "INSTALLABLE TRIGGER", + "=" * 50, + "", + "1. Add this code to your script", + f"2. Run the setup function once: createFormSubmitTrigger_{function_name}() or similar", + "3. The trigger will then run automatically", + "", + "CODE TO ADD:", + "-" * 50, + ] + else: + instructions = [ + "INSTALLABLE TRIGGER", + "=" * 50, + "", + "1. Add this code to your script using update_script_content", + "2. Run the setup function ONCE (manually in Apps Script editor or via run_script_function)", + "3. The trigger will then run automatically on schedule", + "", + "To check installed triggers: Apps Script editor > Triggers (clock icon)", + "", + "CODE TO ADD:", + "-" * 50, + ] + + return "\n".join(instructions) + "\n\n" + code + + +@server.tool() +async def generate_trigger_code( + trigger_type: str, + function_name: str, + schedule: str = "", +) -> str: + """ + Generates Apps Script code for creating triggers. + + The Apps Script API cannot create triggers directly - they must be created + from within Apps Script itself. This tool generates the code you need. + + Args: + trigger_type: Type of trigger. One of: + - "time_minutes" (run every N minutes: 1, 5, 10, 15, 30) + - "time_hours" (run every N hours: 1, 2, 4, 6, 8, 12) + - "time_daily" (run daily at a specific hour: 0-23) + - "time_weekly" (run weekly on a specific day) + - "on_open" (simple trigger - runs when document opens) + - "on_edit" (simple trigger - runs when user edits) + - "on_form_submit" (runs when form is submitted) + - "on_change" (runs when content changes) + + function_name: The function to run when trigger fires (e.g., "sendDailyReport") + + schedule: Schedule details (depends on trigger_type): + - For time_minutes: "1", "5", "10", "15", or "30" + - For time_hours: "1", "2", "4", "6", "8", or "12" + - For time_daily: hour as "0"-"23" (e.g., "9" for 9am) + - For time_weekly: "MONDAY", "TUESDAY", etc. + - For simple triggers (on_open, on_edit): not needed + + Returns: + str: Apps Script code to create the trigger + """ + return _generate_trigger_code_impl(trigger_type, function_name, schedule) diff --git a/gcalendar/__init__.py b/gcalendar/__init__.py new file mode 100644 index 0000000..0aa9534 --- /dev/null +++ b/gcalendar/__init__.py @@ -0,0 +1 @@ +# Make the calendar directory a Python package diff --git a/gcalendar/calendar_tools.py b/gcalendar/calendar_tools.py new file mode 100644 index 0000000..60b366d --- /dev/null +++ b/gcalendar/calendar_tools.py @@ -0,0 +1,1346 @@ +""" +Google Calendar MCP Tools + +This module provides MCP tools for interacting with Google Calendar API. +""" + +import datetime +import logging +import asyncio +import re +import uuid +import json +from typing import List, Optional, Dict, Any, Union + +from googleapiclient.errors import HttpError +from googleapiclient.discovery import build + +from auth.service_decorator import require_google_service +from core.utils import handle_http_errors + +from core.server import server + + +# Configure module logger +logger = logging.getLogger(__name__) + + +def _parse_reminders_json( + reminders_input: Optional[Union[str, List[Dict[str, Any]]]], function_name: str +) -> List[Dict[str, Any]]: + """ + Parse reminders from JSON string or list object and validate them. + + Args: + reminders_input: JSON string containing reminder objects or list of reminder objects + function_name: Name of calling function for logging + + Returns: + List of validated reminder objects + """ + if not reminders_input: + return [] + + # Handle both string (JSON) and list inputs + if isinstance(reminders_input, str): + try: + reminders = json.loads(reminders_input) + if not isinstance(reminders, list): + logger.warning( + f"[{function_name}] Reminders must be a JSON array, got {type(reminders).__name__}" + ) + return [] + except json.JSONDecodeError as e: + logger.warning(f"[{function_name}] Invalid JSON for reminders: {e}") + return [] + elif isinstance(reminders_input, list): + reminders = reminders_input + else: + logger.warning( + f"[{function_name}] Reminders must be a JSON string or list, got {type(reminders_input).__name__}" + ) + return [] + + # Validate reminders + if len(reminders) > 5: + logger.warning( + f"[{function_name}] More than 5 reminders provided, truncating to first 5" + ) + reminders = reminders[:5] + + validated_reminders = [] + for reminder in reminders: + if ( + not isinstance(reminder, dict) + or "method" not in reminder + or "minutes" not in reminder + ): + logger.warning( + f"[{function_name}] Invalid reminder format: {reminder}, skipping" + ) + continue + + method = reminder["method"].lower() + if method not in ["popup", "email"]: + logger.warning( + f"[{function_name}] Invalid reminder method '{method}', must be 'popup' or 'email', skipping" + ) + continue + + minutes = reminder["minutes"] + if not isinstance(minutes, int) or minutes < 0 or minutes > 40320: + logger.warning( + f"[{function_name}] Invalid reminder minutes '{minutes}', must be integer 0-40320, skipping" + ) + continue + + validated_reminders.append({"method": method, "minutes": minutes}) + + return validated_reminders + + +def _apply_transparency_if_valid( + event_body: Dict[str, Any], + transparency: Optional[str], + function_name: str, +) -> None: + """ + Apply transparency to the event body if the provided value is valid. + + Args: + event_body: Event payload being constructed. + transparency: Provided transparency value. + function_name: Name of the calling function for logging context. + """ + if transparency is None: + return + + valid_transparency_values = ["opaque", "transparent"] + if transparency in valid_transparency_values: + event_body["transparency"] = transparency + logger.info(f"[{function_name}] Set transparency to '{transparency}'") + else: + logger.warning( + f"[{function_name}] Invalid transparency value '{transparency}', must be 'opaque' or 'transparent', skipping" + ) + + +def _apply_visibility_if_valid( + event_body: Dict[str, Any], + visibility: Optional[str], + function_name: str, +) -> None: + """ + Apply visibility to the event body if the provided value is valid. + + Args: + event_body: Event payload being constructed. + visibility: Provided visibility value. + function_name: Name of the calling function for logging context. + """ + if visibility is None: + return + + valid_visibility_values = ["default", "public", "private", "confidential"] + if visibility in valid_visibility_values: + event_body["visibility"] = visibility + logger.info(f"[{function_name}] Set visibility to '{visibility}'") + else: + logger.warning( + f"[{function_name}] Invalid visibility value '{visibility}', must be 'default', 'public', 'private', or 'confidential', skipping" + ) + + +def _preserve_existing_fields( + event_body: Dict[str, Any], + existing_event: Dict[str, Any], + field_mappings: Dict[str, Any], +) -> None: + """ + Helper function to preserve existing event fields when not explicitly provided. + + Args: + event_body: The event body being built for the API call + existing_event: The existing event data from the API + field_mappings: Dict mapping field names to their new values (None means preserve existing) + """ + for field_name, new_value in field_mappings.items(): + if new_value is None and field_name in existing_event: + event_body[field_name] = existing_event[field_name] + logger.info(f"[modify_event] Preserving existing {field_name}") + elif new_value is not None: + event_body[field_name] = new_value + + +def _get_meeting_link(item: Dict[str, Any]) -> str: + """Extract video meeting link from event conference data or hangoutLink.""" + conference_data = item.get("conferenceData") + if conference_data and "entryPoints" in conference_data: + for entry_point in conference_data["entryPoints"]: + if entry_point.get("entryPointType") == "video": + uri = entry_point.get("uri", "") + if uri: + return uri + hangout_link = item.get("hangoutLink", "") + if hangout_link: + return hangout_link + return "" + + +def _format_attendee_details( + attendees: List[Dict[str, Any]], indent: str = " " +) -> str: + """ + Format attendee details including response status, organizer, and optional flags. + + Example output format: + " user@example.com: accepted + manager@example.com: declined (organizer) + optional-person@example.com: tentative (optional)" + + Args: + attendees: List of attendee dictionaries from Google Calendar API + indent: Indentation to use for newline-separated attendees (default: " ") + + Returns: + Formatted string with attendee details, or "None" if no attendees + """ + if not attendees: + return "None" + + attendee_details_list = [] + for a in attendees: + email = a.get("email", "unknown") + response_status = a.get("responseStatus", "unknown") + optional = a.get("optional", False) + organizer = a.get("organizer", False) + + detail_parts = [f"{email}: {response_status}"] + if organizer: + detail_parts.append("(organizer)") + if optional: + detail_parts.append("(optional)") + + attendee_details_list.append(" ".join(detail_parts)) + + return f"\n{indent}".join(attendee_details_list) + + +def _format_attachment_details( + attachments: List[Dict[str, Any]], indent: str = " " +) -> str: + """ + Format attachment details including file information. + + + Args: + attachments: List of attachment dictionaries from Google Calendar API + indent: Indentation to use for newline-separated attachments (default: " ") + + Returns: + Formatted string with attachment details, or "None" if no attachments + """ + if not attachments: + return "None" + + attachment_details_list = [] + for att in attachments: + title = att.get("title", "Untitled") + file_url = att.get("fileUrl", "No URL") + file_id = att.get("fileId", "No ID") + mime_type = att.get("mimeType", "Unknown") + + attachment_info = ( + f"{title}\n" + f"{indent}File URL: {file_url}\n" + f"{indent}File ID: {file_id}\n" + f"{indent}MIME Type: {mime_type}" + ) + attachment_details_list.append(attachment_info) + + return f"\n{indent}".join(attachment_details_list) + + +# Helper function to ensure time strings for API calls are correctly formatted +def _correct_time_format_for_api( + time_str: Optional[str], param_name: str +) -> Optional[str]: + if not time_str: + return None + + logger.info( + f"_correct_time_format_for_api: Processing {param_name} with value '{time_str}'" + ) + + # Handle date-only format (YYYY-MM-DD) + if len(time_str) == 10 and time_str.count("-") == 2: + try: + # Validate it's a proper date + datetime.datetime.strptime(time_str, "%Y-%m-%d") + # For date-only, append T00:00:00Z to make it RFC3339 compliant + formatted = f"{time_str}T00:00:00Z" + logger.info( + f"Formatting date-only {param_name} '{time_str}' to RFC3339: '{formatted}'" + ) + return formatted + except ValueError: + logger.warning( + f"{param_name} '{time_str}' looks like a date but is not valid YYYY-MM-DD. Using as is." + ) + return time_str + + # Specifically address YYYY-MM-DDTHH:MM:SS by appending 'Z' + if ( + len(time_str) == 19 + and time_str[10] == "T" + and time_str.count(":") == 2 + and not ( + time_str.endswith("Z") or ("+" in time_str[10:]) or ("-" in time_str[10:]) + ) + ): + try: + # Validate the format before appending 'Z' + datetime.datetime.strptime(time_str, "%Y-%m-%dT%H:%M:%S") + logger.info( + f"Formatting {param_name} '{time_str}' by appending 'Z' for UTC." + ) + return time_str + "Z" + except ValueError: + logger.warning( + f"{param_name} '{time_str}' looks like it needs 'Z' but is not valid YYYY-MM-DDTHH:MM:SS. Using as is." + ) + return time_str + + # If it already has timezone info or doesn't match our patterns, return as is + logger.info(f"{param_name} '{time_str}' doesn't need formatting, using as is.") + return time_str + + +@server.tool() +@handle_http_errors("list_calendars", is_read_only=True, service_type="calendar") +@require_google_service("calendar", "calendar_read") +async def list_calendars(service, user_google_email: str) -> str: + """ + Retrieves a list of calendars accessible to the authenticated user. + + Args: + user_google_email (str): The user's Google email address. Required. + + Returns: + str: A formatted list of the user's calendars (summary, ID, primary status). + """ + logger.info(f"[list_calendars] Invoked. Email: '{user_google_email}'") + + calendar_list_response = await asyncio.to_thread( + lambda: service.calendarList().list().execute() + ) + items = calendar_list_response.get("items", []) + if not items: + return f"No calendars found for {user_google_email}." + + calendars_summary_list = [ + f'- "{cal.get("summary", "No Summary")}"{" (Primary)" if cal.get("primary") else ""} (ID: {cal["id"]})' + for cal in items + ] + text_output = ( + f"Successfully listed {len(items)} calendars for {user_google_email}:\n" + + "\n".join(calendars_summary_list) + ) + logger.info(f"Successfully listed {len(items)} calendars for {user_google_email}.") + return text_output + + +@server.tool() +@handle_http_errors("get_events", is_read_only=True, service_type="calendar") +@require_google_service("calendar", "calendar_read") +async def get_events( + service, + user_google_email: str, + calendar_id: str = "primary", + event_id: Optional[str] = None, + time_min: Optional[str] = None, + time_max: Optional[str] = None, + max_results: int = 25, + query: Optional[str] = None, + detailed: bool = False, + include_attachments: bool = False, +) -> str: + """ + Retrieves events from a specified Google Calendar. Can retrieve a single event by ID or multiple events within a time range. + You can also search for events by keyword by supplying the optional "query" param. + + Args: + user_google_email (str): The user's Google email address. Required. + calendar_id (str): The ID of the calendar to query. Use 'primary' for the user's primary calendar. Defaults to 'primary'. Calendar IDs can be obtained using `list_calendars`. + event_id (Optional[str]): The ID of a specific event to retrieve. If provided, retrieves only this event and ignores time filtering parameters. + time_min (Optional[str]): The start of the time range (inclusive) in RFC3339 format (e.g., '2024-05-12T10:00:00Z' or '2024-05-12'). If omitted, defaults to the current time. Ignored if event_id is provided. + time_max (Optional[str]): The end of the time range (exclusive) in RFC3339 format. If omitted, events starting from `time_min` onwards are considered (up to `max_results`). Ignored if event_id is provided. + max_results (int): The maximum number of events to return. Defaults to 25. Ignored if event_id is provided. + query (Optional[str]): A keyword to search for within event fields (summary, description, location). Ignored if event_id is provided. + detailed (bool): Whether to return detailed event information including description, location, attendees, and attendee details (response status, organizer, optional flags). Defaults to False. + include_attachments (bool): Whether to include attachment information in detailed event output. When True, shows attachment details (fileId, fileUrl, mimeType, title) for events that have attachments. Only applies when detailed=True. Set this to True when you need to view or access files that have been attached to calendar events, such as meeting documents, presentations, or other shared files. Defaults to False. + + Returns: + str: A formatted list of events (summary, start and end times, link) within the specified range, or detailed information for a single event if event_id is provided. + """ + logger.info( + f"[get_events] Raw parameters - event_id: '{event_id}', time_min: '{time_min}', time_max: '{time_max}', query: '{query}', detailed: {detailed}, include_attachments: {include_attachments}" + ) + + # Handle single event retrieval + if event_id: + logger.info(f"[get_events] Retrieving single event with ID: {event_id}") + event = await asyncio.to_thread( + lambda: ( + service.events().get(calendarId=calendar_id, eventId=event_id).execute() + ) + ) + items = [event] + else: + # Handle multiple events retrieval with time filtering + # Ensure time_min and time_max are correctly formatted for the API + formatted_time_min = _correct_time_format_for_api(time_min, "time_min") + if formatted_time_min: + effective_time_min = formatted_time_min + else: + utc_now = datetime.datetime.now(datetime.timezone.utc) + effective_time_min = utc_now.isoformat().replace("+00:00", "Z") + if time_min is None: + logger.info( + f"time_min not provided, defaulting to current UTC time: {effective_time_min}" + ) + else: + logger.info( + f"time_min processing: original='{time_min}', formatted='{formatted_time_min}', effective='{effective_time_min}'" + ) + + effective_time_max = _correct_time_format_for_api(time_max, "time_max") + if time_max: + logger.info( + f"time_max processing: original='{time_max}', formatted='{effective_time_max}'" + ) + + logger.info( + f"[get_events] Final API parameters - calendarId: '{calendar_id}', timeMin: '{effective_time_min}', timeMax: '{effective_time_max}', maxResults: {max_results}, query: '{query}'" + ) + + # Build the request parameters dynamically + request_params = { + "calendarId": calendar_id, + "timeMin": effective_time_min, + "timeMax": effective_time_max, + "maxResults": max_results, + "singleEvents": True, + "orderBy": "startTime", + } + + if query: + request_params["q"] = query + + events_result = await asyncio.to_thread( + lambda: service.events().list(**request_params).execute() + ) + items = events_result.get("items", []) + if not items: + if event_id: + return f"Event with ID '{event_id}' not found in calendar '{calendar_id}' for {user_google_email}." + else: + return f"No events found in calendar '{calendar_id}' for {user_google_email} for the specified time range." + + # Handle returning detailed output for a single event when requested + if event_id and detailed: + item = items[0] + summary = item.get("summary", "No Title") + start = item["start"].get("dateTime", item["start"].get("date")) + end = item["end"].get("dateTime", item["end"].get("date")) + link = item.get("htmlLink", "No Link") + description = item.get("description", "No Description") + location = item.get("location", "No Location") + color_id = item.get("colorId", "None") + attendees = item.get("attendees", []) + attendee_emails = ( + ", ".join([a.get("email", "") for a in attendees]) if attendees else "None" + ) + attendee_details_str = _format_attendee_details(attendees, indent=" ") + + meeting_link = _get_meeting_link(item) + + event_details = ( + f"Event Details:\n" + f"- Title: {summary}\n" + f"- Starts: {start}\n" + f"- Ends: {end}\n" + f"- Description: {description}\n" + f"- Location: {location}\n" + f"- Color ID: {color_id}\n" + ) + if meeting_link: + event_details += f"- Meeting Link: {meeting_link}\n" + event_details += ( + f"- Attendees: {attendee_emails}\n" + f"- Attendee Details: {attendee_details_str}\n" + ) + + if include_attachments: + attachments = item.get("attachments", []) + attachment_details_str = _format_attachment_details( + attachments, indent=" " + ) + event_details += f"- Attachments: {attachment_details_str}\n" + + event_details += f"- Event ID: {event_id}\n- Link: {link}" + logger.info( + f"[get_events] Successfully retrieved detailed event {event_id} for {user_google_email}." + ) + return event_details + + # Handle multiple events or single event with basic output + event_details_list = [] + for item in items: + summary = item.get("summary", "No Title") + start_time = item["start"].get("dateTime", item["start"].get("date")) + end_time = item["end"].get("dateTime", item["end"].get("date")) + link = item.get("htmlLink", "No Link") + item_event_id = item.get("id", "No ID") + + if detailed: + # Add detailed information for multiple events + description = item.get("description", "No Description") + location = item.get("location", "No Location") + attendees = item.get("attendees", []) + attendee_emails = ( + ", ".join([a.get("email", "") for a in attendees]) + if attendees + else "None" + ) + attendee_details_str = _format_attendee_details(attendees, indent=" ") + + meeting_link = _get_meeting_link(item) + + event_detail_parts = ( + f'- "{summary}" (Starts: {start_time}, Ends: {end_time})\n' + f" Description: {description}\n" + f" Location: {location}\n" + ) + if meeting_link: + event_detail_parts += f" Meeting Link: {meeting_link}\n" + event_detail_parts += ( + f" Attendees: {attendee_emails}\n" + f" Attendee Details: {attendee_details_str}\n" + ) + + if include_attachments: + attachments = item.get("attachments", []) + attachment_details_str = _format_attachment_details( + attachments, indent=" " + ) + event_detail_parts += f" Attachments: {attachment_details_str}\n" + + event_detail_parts += f" ID: {item_event_id} | Link: {link}" + event_details_list.append(event_detail_parts) + else: + # Basic output format + meeting_link = _get_meeting_link(item) + basic_line = f'- "{summary}" (Starts: {start_time}, Ends: {end_time})' + if meeting_link: + basic_line += f" Meeting: {meeting_link}" + basic_line += f" ID: {item_event_id} | Link: {link}" + event_details_list.append(basic_line) + + if event_id: + # Single event basic output + text_output = ( + f"Successfully retrieved event from calendar '{calendar_id}' for {user_google_email}:\n" + + "\n".join(event_details_list) + ) + else: + # Multiple events output + text_output = ( + f"Successfully retrieved {len(items)} events from calendar '{calendar_id}' for {user_google_email}:\n" + + "\n".join(event_details_list) + ) + + logger.info(f"Successfully retrieved {len(items)} events for {user_google_email}.") + return text_output + + +# --------------------------------------------------------------------------- +# Internal implementation functions for event create/modify/delete. +# These are called by both the consolidated ``manage_event`` tool and the +# legacy single-action tools. +# --------------------------------------------------------------------------- + + +async def _create_event_impl( + service, + user_google_email: str, + summary: str, + start_time: str, + end_time: str, + calendar_id: str = "primary", + description: Optional[str] = None, + location: Optional[str] = None, + attendees: Optional[List[str]] = None, + timezone: Optional[str] = None, + attachments: Optional[List[str]] = None, + add_google_meet: bool = False, + reminders: Optional[Union[str, List[Dict[str, Any]]]] = None, + use_default_reminders: bool = True, + transparency: Optional[str] = None, + visibility: Optional[str] = None, + guests_can_modify: Optional[bool] = None, + guests_can_invite_others: Optional[bool] = None, + guests_can_see_other_guests: Optional[bool] = None, +) -> str: + """Internal implementation for creating a calendar event.""" + logger.info( + f"[create_event] Invoked. Email: '{user_google_email}', Summary: {summary}" + ) + logger.info(f"[create_event] Incoming attachments param: {attachments}") + # If attachments value is a string, split by comma and strip whitespace + if attachments and isinstance(attachments, str): + attachments = [a.strip() for a in attachments.split(",") if a.strip()] + logger.info( + f"[create_event] Parsed attachments list from string: {attachments}" + ) + event_body: Dict[str, Any] = { + "summary": summary, + "start": ( + {"date": start_time} if "T" not in start_time else {"dateTime": start_time} + ), + "end": ({"date": end_time} if "T" not in end_time else {"dateTime": end_time}), + } + if location: + event_body["location"] = location + if description: + event_body["description"] = description + if timezone: + if "dateTime" in event_body["start"]: + event_body["start"]["timeZone"] = timezone + if "dateTime" in event_body["end"]: + event_body["end"]["timeZone"] = timezone + if attendees: + event_body["attendees"] = [{"email": email} for email in attendees] + + # Handle reminders + if reminders is not None or not use_default_reminders: + # If custom reminders are provided, automatically disable default reminders + effective_use_default = use_default_reminders and reminders is None + + reminder_data = {"useDefault": effective_use_default} + if reminders is not None: + validated_reminders = _parse_reminders_json(reminders, "create_event") + if validated_reminders: + reminder_data["overrides"] = validated_reminders + logger.info( + f"[create_event] Added {len(validated_reminders)} custom reminders" + ) + if use_default_reminders: + logger.info( + "[create_event] Custom reminders provided - disabling default reminders" + ) + + event_body["reminders"] = reminder_data + + # Handle transparency validation + _apply_transparency_if_valid(event_body, transparency, "create_event") + + # Handle visibility validation + _apply_visibility_if_valid(event_body, visibility, "create_event") + + # Handle guest permissions + if guests_can_modify is not None: + event_body["guestsCanModify"] = guests_can_modify + logger.info(f"[create_event] Set guestsCanModify to {guests_can_modify}") + if guests_can_invite_others is not None: + event_body["guestsCanInviteOthers"] = guests_can_invite_others + logger.info( + f"[create_event] Set guestsCanInviteOthers to {guests_can_invite_others}" + ) + if guests_can_see_other_guests is not None: + event_body["guestsCanSeeOtherGuests"] = guests_can_see_other_guests + logger.info( + f"[create_event] Set guestsCanSeeOtherGuests to {guests_can_see_other_guests}" + ) + + if add_google_meet: + request_id = str(uuid.uuid4()) + event_body["conferenceData"] = { + "createRequest": { + "requestId": request_id, + "conferenceSolutionKey": {"type": "hangoutsMeet"}, + } + } + logger.info( + f"[create_event] Adding Google Meet conference with request ID: {request_id}" + ) + + if attachments: + # Accept both file URLs and file IDs. If a URL, extract the fileId. + event_body["attachments"] = [] + drive_service = None + try: + try: + drive_service = service._http and build( + "drive", "v3", http=service._http + ) + except Exception as e: + logger.warning( + f"Could not build Drive service for MIME type lookup: {e}" + ) + for att in attachments: + file_id = None + if att.startswith("https://"): + # Match /d/, /file/d/, ?id= + match = re.search(r"(?:/d/|/file/d/|id=)([\w-]+)", att) + file_id = match.group(1) if match else None + logger.info( + f"[create_event] Extracted file_id '{file_id}' from attachment URL '{att}'" + ) + else: + file_id = att + logger.info( + f"[create_event] Using direct file_id '{file_id}' for attachment" + ) + if file_id: + file_url = f"https://drive.google.com/open?id={file_id}" + mime_type = "application/vnd.google-apps.drive-sdk" + title = "Drive Attachment" + # Try to get the actual MIME type and filename from Drive + if drive_service: + try: + file_metadata = await asyncio.to_thread( + lambda: ( + drive_service.files() + .get( + fileId=file_id, + fields="mimeType,name", + supportsAllDrives=True, + ) + .execute() + ) + ) + mime_type = file_metadata.get("mimeType", mime_type) + filename = file_metadata.get("name") + if filename: + title = filename + logger.info( + f"[create_event] Using filename '{filename}' as attachment title" + ) + else: + logger.info( + "[create_event] No filename found, using generic title" + ) + except Exception as e: + logger.warning( + f"Could not fetch metadata for file {file_id}: {e}" + ) + event_body["attachments"].append( + { + "fileUrl": file_url, + "title": title, + "mimeType": mime_type, + } + ) + finally: + if drive_service: + drive_service.close() + created_event = await asyncio.to_thread( + lambda: ( + service.events() + .insert( + calendarId=calendar_id, + body=event_body, + supportsAttachments=True, + conferenceDataVersion=1 if add_google_meet else 0, + ) + .execute() + ) + ) + else: + created_event = await asyncio.to_thread( + lambda: ( + service.events() + .insert( + calendarId=calendar_id, + body=event_body, + conferenceDataVersion=1 if add_google_meet else 0, + ) + .execute() + ) + ) + link = created_event.get("htmlLink", "No link available") + confirmation_message = f"Successfully created event '{created_event.get('summary', summary)}' for {user_google_email}. Link: {link}" + + # Add Google Meet information if conference was created + if add_google_meet and "conferenceData" in created_event: + conference_data = created_event["conferenceData"] + if "entryPoints" in conference_data: + for entry_point in conference_data["entryPoints"]: + if entry_point.get("entryPointType") == "video": + meet_link = entry_point.get("uri", "") + if meet_link: + confirmation_message += f" Google Meet: {meet_link}" + break + + logger.info( + f"Event created successfully for {user_google_email}. ID: {created_event.get('id')}, Link: {link}" + ) + return confirmation_message + + +def _normalize_attendees( + attendees: Optional[Union[List[str], List[Dict[str, Any]]]], +) -> Optional[List[Dict[str, Any]]]: + """ + Normalize attendees input to list of attendee objects. + + Accepts either: + - List of email strings: ["user@example.com", "other@example.com"] + - List of attendee objects: [{"email": "user@example.com", "responseStatus": "accepted"}] + - Mixed list of both formats + + Returns list of attendee dicts with at minimum 'email' key. + """ + if attendees is None: + return None + + normalized = [] + for att in attendees: + if isinstance(att, str): + normalized.append({"email": att}) + elif isinstance(att, dict) and "email" in att: + normalized.append(att) + else: + logger.warning( + f"[_normalize_attendees] Invalid attendee format: {att}, skipping" + ) + return normalized if normalized else None + + +async def _modify_event_impl( + service, + user_google_email: str, + event_id: str, + calendar_id: str = "primary", + summary: Optional[str] = None, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + description: Optional[str] = None, + location: Optional[str] = None, + attendees: Optional[Union[List[str], List[Dict[str, Any]]]] = None, + timezone: Optional[str] = None, + add_google_meet: Optional[bool] = None, + reminders: Optional[Union[str, List[Dict[str, Any]]]] = None, + use_default_reminders: Optional[bool] = None, + transparency: Optional[str] = None, + visibility: Optional[str] = None, + color_id: Optional[str] = None, + guests_can_modify: Optional[bool] = None, + guests_can_invite_others: Optional[bool] = None, + guests_can_see_other_guests: Optional[bool] = None, +) -> str: + """Internal implementation for modifying a calendar event.""" + logger.info( + f"[modify_event] Invoked. Email: '{user_google_email}', Event ID: {event_id}" + ) + + # Build the event body with only the fields that are provided + event_body: Dict[str, Any] = {} + if summary is not None: + event_body["summary"] = summary + if start_time is not None: + event_body["start"] = ( + {"date": start_time} if "T" not in start_time else {"dateTime": start_time} + ) + if timezone is not None and "dateTime" in event_body["start"]: + event_body["start"]["timeZone"] = timezone + if end_time is not None: + event_body["end"] = ( + {"date": end_time} if "T" not in end_time else {"dateTime": end_time} + ) + if timezone is not None and "dateTime" in event_body["end"]: + event_body["end"]["timeZone"] = timezone + if description is not None: + event_body["description"] = description + if location is not None: + event_body["location"] = location + + # Normalize attendees - accepts both email strings and full attendee objects + normalized_attendees = _normalize_attendees(attendees) + if normalized_attendees is not None: + event_body["attendees"] = normalized_attendees + + if color_id is not None: + event_body["colorId"] = color_id + + # Handle reminders + if reminders is not None or use_default_reminders is not None: + reminder_data = {} + if use_default_reminders is not None: + reminder_data["useDefault"] = use_default_reminders + else: + # Preserve existing event's useDefault value if not explicitly specified + try: + existing_event = ( + service.events() + .get(calendarId=calendar_id, eventId=event_id) + .execute() + ) + reminder_data["useDefault"] = existing_event.get("reminders", {}).get( + "useDefault", True + ) + except Exception as e: + logger.warning( + f"[modify_event] Could not fetch existing event for reminders: {e}" + ) + reminder_data["useDefault"] = ( + True # Fallback to True if unable to fetch + ) + + # If custom reminders are provided, automatically disable default reminders + if reminders is not None: + if reminder_data.get("useDefault", False): + reminder_data["useDefault"] = False + logger.info( + "[modify_event] Custom reminders provided - disabling default reminders" + ) + + validated_reminders = _parse_reminders_json(reminders, "modify_event") + if reminders and not validated_reminders: + logger.warning( + "[modify_event] Reminders provided but failed validation. No custom reminders will be set." + ) + elif validated_reminders: + reminder_data["overrides"] = validated_reminders + logger.info( + f"[modify_event] Updated reminders with {len(validated_reminders)} custom reminders" + ) + + event_body["reminders"] = reminder_data + + # Handle transparency validation + _apply_transparency_if_valid(event_body, transparency, "modify_event") + + # Handle visibility validation + _apply_visibility_if_valid(event_body, visibility, "modify_event") + + # Handle guest permissions + if guests_can_modify is not None: + event_body["guestsCanModify"] = guests_can_modify + logger.info(f"[modify_event] Set guestsCanModify to {guests_can_modify}") + if guests_can_invite_others is not None: + event_body["guestsCanInviteOthers"] = guests_can_invite_others + logger.info( + f"[modify_event] Set guestsCanInviteOthers to {guests_can_invite_others}" + ) + if guests_can_see_other_guests is not None: + event_body["guestsCanSeeOtherGuests"] = guests_can_see_other_guests + logger.info( + f"[modify_event] Set guestsCanSeeOtherGuests to {guests_can_see_other_guests}" + ) + + if timezone is not None and "start" not in event_body and "end" not in event_body: + # If timezone is provided but start/end times are not, we need to fetch the existing event + # to apply the timezone correctly. This is a simplification; a full implementation + # might handle this more robustly or require start/end with timezone. + # For now, we'll log a warning and skip applying timezone if start/end are missing. + logger.warning( + "[modify_event] Timezone provided but start_time and end_time are missing. Timezone will not be applied unless start/end times are also provided." + ) + + if not event_body: + message = "No fields provided to modify the event." + logger.warning(f"[modify_event] {message}") + raise Exception(message) + + # Log the event ID for debugging + logger.info( + f"[modify_event] Attempting to update event with ID: '{event_id}' in calendar '{calendar_id}'" + ) + + # Get the existing event to preserve fields that aren't being updated + try: + existing_event = await asyncio.to_thread( + lambda: ( + service.events().get(calendarId=calendar_id, eventId=event_id).execute() + ) + ) + logger.info( + "[modify_event] Successfully retrieved existing event before update" + ) + + # Preserve existing fields if not provided in the update + _preserve_existing_fields( + event_body, + existing_event, + { + "summary": summary, + "description": description, + "location": location, + # Use the already-normalized attendee objects (if provided); otherwise preserve existing + "attendees": event_body.get("attendees"), + "colorId": event_body.get("colorId"), + }, + ) + + # Handle Google Meet conference data + if add_google_meet is not None: + if add_google_meet: + # Add Google Meet + request_id = str(uuid.uuid4()) + event_body["conferenceData"] = { + "createRequest": { + "requestId": request_id, + "conferenceSolutionKey": {"type": "hangoutsMeet"}, + } + } + logger.info( + f"[modify_event] Adding Google Meet conference with request ID: {request_id}" + ) + else: + # Remove Google Meet by setting conferenceData to empty + event_body["conferenceData"] = {} + logger.info("[modify_event] Removing Google Meet conference") + elif "conferenceData" in existing_event: + # Preserve existing conference data if not specified + event_body["conferenceData"] = existing_event["conferenceData"] + logger.info("[modify_event] Preserving existing conference data") + + except HttpError as get_error: + if get_error.resp.status == 404: + logger.error( + f"[modify_event] Event not found during pre-update verification: {get_error}" + ) + message = f"Event not found during verification. The event with ID '{event_id}' could not be found in calendar '{calendar_id}'. This may be due to incorrect ID format or the event no longer exists." + raise Exception(message) + else: + logger.warning( + f"[modify_event] Error during pre-update verification, but proceeding with update: {get_error}" + ) + + # Proceed with the update + updated_event = await asyncio.to_thread( + lambda: ( + service.events() + .update( + calendarId=calendar_id, + eventId=event_id, + body=event_body, + conferenceDataVersion=1, + ) + .execute() + ) + ) + + link = updated_event.get("htmlLink", "No link available") + confirmation_message = f"Successfully modified event '{updated_event.get('summary', summary)}' (ID: {event_id}) for {user_google_email}. Link: {link}" + + # Add Google Meet information if conference was added + if add_google_meet is True and "conferenceData" in updated_event: + conference_data = updated_event["conferenceData"] + if "entryPoints" in conference_data: + for entry_point in conference_data["entryPoints"]: + if entry_point.get("entryPointType") == "video": + meet_link = entry_point.get("uri", "") + if meet_link: + confirmation_message += f" Google Meet: {meet_link}" + break + elif add_google_meet is False: + confirmation_message += " (Google Meet removed)" + + logger.info( + f"Event modified successfully for {user_google_email}. ID: {updated_event.get('id')}, Link: {link}" + ) + return confirmation_message + + +async def _delete_event_impl( + service, + user_google_email: str, + event_id: str, + calendar_id: str = "primary", +) -> str: + """Internal implementation for deleting a calendar event.""" + logger.info( + f"[delete_event] Invoked. Email: '{user_google_email}', Event ID: {event_id}" + ) + + # Log the event ID for debugging + logger.info( + f"[delete_event] Attempting to delete event with ID: '{event_id}' in calendar '{calendar_id}'" + ) + + # Try to get the event first to verify it exists + try: + await asyncio.to_thread( + lambda: ( + service.events().get(calendarId=calendar_id, eventId=event_id).execute() + ) + ) + logger.info("[delete_event] Successfully verified event exists before deletion") + except HttpError as get_error: + if get_error.resp.status == 404: + logger.error( + f"[delete_event] Event not found during pre-delete verification: {get_error}" + ) + message = f"Event not found during verification. The event with ID '{event_id}' could not be found in calendar '{calendar_id}'. This may be due to incorrect ID format or the event no longer exists." + raise Exception(message) + else: + logger.warning( + f"[delete_event] Error during pre-delete verification, but proceeding with deletion: {get_error}" + ) + + # Proceed with the deletion + await asyncio.to_thread( + lambda: ( + service.events().delete(calendarId=calendar_id, eventId=event_id).execute() + ) + ) + + confirmation_message = f"Successfully deleted event (ID: {event_id}) from calendar '{calendar_id}' for {user_google_email}." + logger.info(f"Event deleted successfully for {user_google_email}. ID: {event_id}") + return confirmation_message + + +# --------------------------------------------------------------------------- +# Consolidated event management tool +# --------------------------------------------------------------------------- + + +@server.tool() +@handle_http_errors("manage_event", service_type="calendar") +@require_google_service("calendar", "calendar_events") +async def manage_event( + service, + user_google_email: str, + action: str, + summary: Optional[str] = None, + start_time: Optional[str] = None, + end_time: Optional[str] = None, + event_id: Optional[str] = None, + calendar_id: str = "primary", + description: Optional[str] = None, + location: Optional[str] = None, + attendees: Optional[Union[List[str], List[Dict[str, Any]]]] = None, + timezone: Optional[str] = None, + attachments: Optional[List[str]] = None, + add_google_meet: Optional[bool] = None, + reminders: Optional[Union[str, List[Dict[str, Any]]]] = None, + use_default_reminders: Optional[bool] = None, + transparency: Optional[str] = None, + visibility: Optional[str] = None, + color_id: Optional[str] = None, + guests_can_modify: Optional[bool] = None, + guests_can_invite_others: Optional[bool] = None, + guests_can_see_other_guests: Optional[bool] = None, +) -> str: + """ + Manages calendar events. Supports creating, updating, and deleting events. + + Args: + user_google_email (str): The user's Google email address. Required. + action (str): Action to perform - "create", "update", or "delete". + summary (Optional[str]): Event title (required for create). + start_time (Optional[str]): Start time in RFC3339 format (required for create). + end_time (Optional[str]): End time in RFC3339 format (required for create). + event_id (Optional[str]): Event ID (required for update and delete). + calendar_id (str): Calendar ID (default: 'primary'). + description (Optional[str]): Event description. + location (Optional[str]): Event location. + attendees (Optional[Union[List[str], List[Dict[str, Any]]]]): Attendee email addresses or objects. + timezone (Optional[str]): Timezone (e.g., "America/New_York"). + attachments (Optional[List[str]]): List of Google Drive file URLs or IDs to attach. + add_google_meet (Optional[bool]): Whether to add/remove Google Meet. + reminders (Optional[Union[str, List[Dict[str, Any]]]]): Custom reminder objects. + use_default_reminders (Optional[bool]): Whether to use default reminders. + transparency (Optional[str]): "opaque" (busy) or "transparent" (free). + visibility (Optional[str]): "default", "public", "private", or "confidential". + color_id (Optional[str]): Event color ID (1-11, update only). + guests_can_modify (Optional[bool]): Whether attendees can modify. + guests_can_invite_others (Optional[bool]): Whether attendees can invite others. + guests_can_see_other_guests (Optional[bool]): Whether attendees can see other guests. + + Returns: + str: Confirmation message with event details. + """ + action_lower = action.lower().strip() + if action_lower == "create": + if not summary or not start_time or not end_time: + raise ValueError( + "summary, start_time, and end_time are required for create action" + ) + return await _create_event_impl( + service=service, + user_google_email=user_google_email, + summary=summary, + start_time=start_time, + end_time=end_time, + calendar_id=calendar_id, + description=description, + location=location, + attendees=attendees, + timezone=timezone, + attachments=attachments, + add_google_meet=add_google_meet or False, + reminders=reminders, + use_default_reminders=use_default_reminders + if use_default_reminders is not None + else True, + transparency=transparency, + visibility=visibility, + guests_can_modify=guests_can_modify, + guests_can_invite_others=guests_can_invite_others, + guests_can_see_other_guests=guests_can_see_other_guests, + ) + elif action_lower == "update": + if not event_id: + raise ValueError("event_id is required for update action") + return await _modify_event_impl( + service=service, + user_google_email=user_google_email, + event_id=event_id, + calendar_id=calendar_id, + summary=summary, + start_time=start_time, + end_time=end_time, + description=description, + location=location, + attendees=attendees, + timezone=timezone, + add_google_meet=add_google_meet, + reminders=reminders, + use_default_reminders=use_default_reminders, + transparency=transparency, + visibility=visibility, + color_id=color_id, + guests_can_modify=guests_can_modify, + guests_can_invite_others=guests_can_invite_others, + guests_can_see_other_guests=guests_can_see_other_guests, + ) + elif action_lower == "delete": + if not event_id: + raise ValueError("event_id is required for delete action") + return await _delete_event_impl( + service=service, + user_google_email=user_google_email, + event_id=event_id, + calendar_id=calendar_id, + ) + else: + raise ValueError( + f"Invalid action '{action_lower}'. Must be 'create', 'update', or 'delete'." + ) + + +# --------------------------------------------------------------------------- +# Legacy single-action tools (deprecated -- prefer ``manage_event``) +# --------------------------------------------------------------------------- + + +@server.tool() +@handle_http_errors("query_freebusy", is_read_only=True, service_type="calendar") +@require_google_service("calendar", "calendar_read") +async def query_freebusy( + service, + user_google_email: str, + time_min: str, + time_max: str, + calendar_ids: Optional[List[str]] = None, + group_expansion_max: Optional[int] = None, + calendar_expansion_max: Optional[int] = None, +) -> str: + """ + Returns free/busy information for a set of calendars. + + Args: + user_google_email (str): The user's Google email address. Required. + time_min (str): The start of the interval for the query in RFC3339 format (e.g., '2024-05-12T10:00:00Z' or '2024-05-12'). + time_max (str): The end of the interval for the query in RFC3339 format (e.g., '2024-05-12T18:00:00Z' or '2024-05-12'). + calendar_ids (Optional[List[str]]): List of calendar identifiers to query. If not provided, queries the primary calendar. Use 'primary' for the user's primary calendar or specific calendar IDs obtained from `list_calendars`. + group_expansion_max (Optional[int]): Maximum number of calendar identifiers to be provided for a single group. Optional. An error is returned for a group with more members than this value. Maximum value is 100. + calendar_expansion_max (Optional[int]): Maximum number of calendars for which FreeBusy information is to be provided. Optional. Maximum value is 50. + + Returns: + str: A formatted response showing free/busy information for each requested calendar, including busy time periods. + """ + logger.info( + f"[query_freebusy] Invoked. Email: '{user_google_email}', time_min: '{time_min}', time_max: '{time_max}'" + ) + + # Format time parameters + formatted_time_min = _correct_time_format_for_api(time_min, "time_min") + formatted_time_max = _correct_time_format_for_api(time_max, "time_max") + + # Default to primary calendar if no calendar IDs provided + if not calendar_ids: + calendar_ids = ["primary"] + + # Build the request body + request_body: Dict[str, Any] = { + "timeMin": formatted_time_min, + "timeMax": formatted_time_max, + "items": [{"id": cal_id} for cal_id in calendar_ids], + } + + if group_expansion_max is not None: + request_body["groupExpansionMax"] = group_expansion_max + if calendar_expansion_max is not None: + request_body["calendarExpansionMax"] = calendar_expansion_max + + logger.info( + f"[query_freebusy] Request body: timeMin={formatted_time_min}, timeMax={formatted_time_max}, calendars={calendar_ids}" + ) + + # Execute the freebusy query + freebusy_result = await asyncio.to_thread( + lambda: service.freebusy().query(body=request_body).execute() + ) + + # Parse the response + calendars = freebusy_result.get("calendars", {}) + time_min_result = freebusy_result.get("timeMin", formatted_time_min) + time_max_result = freebusy_result.get("timeMax", formatted_time_max) + + if not calendars: + return f"No free/busy information found for the requested calendars for {user_google_email}." + + # Format the output + output_lines = [ + f"Free/Busy information for {user_google_email}:", + f"Time range: {time_min_result} to {time_max_result}", + "", + ] + + for cal_id, cal_data in calendars.items(): + output_lines.append(f"Calendar: {cal_id}") + + # Check for errors + errors = cal_data.get("errors", []) + if errors: + output_lines.append(" Errors:") + for error in errors: + domain = error.get("domain", "unknown") + reason = error.get("reason", "unknown") + output_lines.append(f" - {domain}: {reason}") + output_lines.append("") + continue + + # Get busy periods + busy_periods = cal_data.get("busy", []) + if not busy_periods: + output_lines.append(" Status: Free (no busy periods)") + else: + output_lines.append(f" Busy periods: {len(busy_periods)}") + for period in busy_periods: + start = period.get("start", "Unknown") + end = period.get("end", "Unknown") + output_lines.append(f" - {start} to {end}") + + output_lines.append("") + + result_text = "\n".join(output_lines) + logger.info( + f"[query_freebusy] Successfully retrieved free/busy information for {len(calendars)} calendar(s)" + ) + return result_text diff --git a/gchat/__init__.py b/gchat/__init__.py new file mode 100644 index 0000000..1792fd4 --- /dev/null +++ b/gchat/__init__.py @@ -0,0 +1,7 @@ +""" +Google Chat MCP Tools Package +""" + +from . import chat_tools + +__all__ = ["chat_tools"] diff --git a/gchat/chat_tools.py b/gchat/chat_tools.py new file mode 100644 index 0000000..762cc10 --- /dev/null +++ b/gchat/chat_tools.py @@ -0,0 +1,583 @@ +""" +Google Chat MCP Tools + +This module provides MCP tools for interacting with Google Chat API. +""" + +import base64 +import logging +import asyncio +from typing import Dict, List, Optional + +import httpx +from googleapiclient.errors import HttpError + +# Auth & server utilities +from auth.service_decorator import require_google_service, require_multiple_services +from core.server import server +from core.utils import handle_http_errors + +logger = logging.getLogger(__name__) + +# In-memory cache for user ID → display name (bounded to avoid unbounded growth) +_SENDER_CACHE_MAX_SIZE = 256 +_sender_name_cache: Dict[str, str] = {} + + +def _cache_sender(user_id: str, name: str) -> None: + """Store a resolved sender name, evicting oldest entries if cache is full.""" + if len(_sender_name_cache) >= _SENDER_CACHE_MAX_SIZE: + to_remove = list(_sender_name_cache.keys())[: _SENDER_CACHE_MAX_SIZE // 2] + for k in to_remove: + del _sender_name_cache[k] + _sender_name_cache[user_id] = name + + +async def _resolve_sender(people_service, sender_obj: dict) -> str: + """Resolve a Chat message sender to a display name. + + Fast path: use displayName if the API already provided it. + Slow path: look up the user via the People API directory and cache the result. + """ + # Fast path — Chat API sometimes provides displayName directly + display_name = sender_obj.get("displayName") + if display_name: + return display_name + + user_id = sender_obj.get("name", "") # e.g. "users/123456789" + if not user_id: + return "Unknown Sender" + + # Check cache + if user_id in _sender_name_cache: + return _sender_name_cache[user_id] + + # Try People API directory lookup + # Chat API uses "users/ID" but People API expects "people/ID" + people_resource = user_id.replace("users/", "people/", 1) + if people_service: + try: + person = await asyncio.to_thread( + people_service.people() + .get(resourceName=people_resource, personFields="names,emailAddresses") + .execute + ) + names = person.get("names", []) + if names: + resolved = names[0].get("displayName", user_id) + _cache_sender(user_id, resolved) + return resolved + # Fall back to email if no name + emails = person.get("emailAddresses", []) + if emails: + resolved = emails[0].get("value", user_id) + _cache_sender(user_id, resolved) + return resolved + except HttpError as e: + logger.debug(f"People API lookup failed for {user_id}: {e}") + except Exception as e: + logger.debug(f"Unexpected error resolving {user_id}: {e}") + + # Final fallback + _cache_sender(user_id, user_id) + return user_id + + +def _extract_rich_links(msg: dict) -> List[str]: + """Extract URLs from RICH_LINK annotations (smart chips). + + When a user pastes a Google Workspace URL in Chat and it renders as a + smart chip, the URL is NOT in the text field — it's only available in + the annotations array as a RICH_LINK with richLinkMetadata.uri. + """ + text = msg.get("text", "") + urls = [] + for ann in msg.get("annotations", []): + if ann.get("type") == "RICH_LINK": + uri = ann.get("richLinkMetadata", {}).get("uri", "") + if uri and uri not in text: + urls.append(uri) + return urls + + +@server.tool() +@require_google_service("chat", "chat_spaces_readonly") +@handle_http_errors("list_spaces", service_type="chat") +async def list_spaces( + service, + user_google_email: str, + page_size: int = 100, + space_type: str = "all", # "all", "room", "dm" +) -> str: + """ + Lists Google Chat spaces (rooms and direct messages) accessible to the user. + + Returns: + str: A formatted list of Google Chat spaces accessible to the user. + """ + logger.info(f"[list_spaces] Email={user_google_email}, Type={space_type}") + + # Build filter based on space_type + filter_param = None + if space_type == "room": + filter_param = "spaceType = SPACE" + elif space_type == "dm": + filter_param = "spaceType = DIRECT_MESSAGE" + + request_params = {"pageSize": page_size} + if filter_param: + request_params["filter"] = filter_param + + response = await asyncio.to_thread(service.spaces().list(**request_params).execute) + + spaces = response.get("spaces", []) + if not spaces: + return f"No Chat spaces found for type '{space_type}'." + + output = [f"Found {len(spaces)} Chat spaces (type: {space_type}):"] + for space in spaces: + space_name = space.get("displayName", "Unnamed Space") + space_id = space.get("name", "") + space_type_actual = space.get("spaceType", "UNKNOWN") + output.append(f"- {space_name} (ID: {space_id}, Type: {space_type_actual})") + + return "\n".join(output) + + +@server.tool() +@require_multiple_services( + [ + {"service_type": "chat", "scopes": "chat_read", "param_name": "chat_service"}, + { + "service_type": "people", + "scopes": "contacts_read", + "param_name": "people_service", + }, + ] +) +@handle_http_errors("get_messages", service_type="chat") +async def get_messages( + chat_service, + people_service, + user_google_email: str, + space_id: str, + page_size: int = 50, + order_by: str = "createTime desc", +) -> str: + """ + Retrieves messages from a Google Chat space. + + Returns: + str: Formatted messages from the specified space. + """ + logger.info(f"[get_messages] Space ID: '{space_id}' for user '{user_google_email}'") + + # Get space info first + space_info = await asyncio.to_thread( + chat_service.spaces().get(name=space_id).execute + ) + space_name = space_info.get("displayName", "Unknown Space") + + # Get messages + response = await asyncio.to_thread( + chat_service.spaces() + .messages() + .list(parent=space_id, pageSize=page_size, orderBy=order_by) + .execute + ) + + messages = response.get("messages", []) + if not messages: + return f"No messages found in space '{space_name}' (ID: {space_id})." + + # Pre-resolve unique senders in parallel + sender_lookup = {} + for msg in messages: + s = msg.get("sender", {}) + key = s.get("name", "") + if key and key not in sender_lookup: + sender_lookup[key] = s + resolved_names = await asyncio.gather( + *[_resolve_sender(people_service, s) for s in sender_lookup.values()] + ) + sender_map = dict(zip(sender_lookup.keys(), resolved_names)) + + output = [f"Messages from '{space_name}' (ID: {space_id}):\n"] + for msg in messages: + sender_obj = msg.get("sender", {}) + sender_key = sender_obj.get("name", "") + sender = sender_map.get(sender_key) or await _resolve_sender( + people_service, sender_obj + ) + create_time = msg.get("createTime", "Unknown Time") + text_content = msg.get("text", "No text content") + msg_name = msg.get("name", "") + + output.append(f"[{create_time}] {sender}:") + output.append(f" {text_content}") + rich_links = _extract_rich_links(msg) + for url in rich_links: + output.append(f" [linked: {url}]") + # Show attachments + attachments = msg.get("attachment", []) + for idx, att in enumerate(attachments): + att_name = att.get("contentName", "unnamed") + att_type = att.get("contentType", "unknown type") + att_resource = att.get("name", "") + output.append(f" [attachment {idx}: {att_name} ({att_type})]") + if att_resource: + output.append( + f" Use download_chat_attachment(message_id='{msg_name}', attachment_index={idx}) to download" + ) + # Show thread info if this is a threaded reply + thread = msg.get("thread", {}) + if msg.get("threadReply") and thread.get("name"): + output.append(f" [thread: {thread['name']}]") + # Show emoji reactions + reactions = msg.get("emojiReactionSummaries", []) + if reactions: + parts = [] + for r in reactions: + emoji = r.get("emoji", {}) + symbol = emoji.get("unicode", "") + if not symbol: + ce = emoji.get("customEmoji", {}) + symbol = f":{ce.get('uid', '?')}:" + count = r.get("reactionCount", 0) + parts.append(f"{symbol}x{count}") + output.append(f" [reactions: {', '.join(parts)}]") + output.append(f" (Message ID: {msg_name})\n") + + return "\n".join(output) + + +@server.tool() +@require_google_service("chat", "chat_write") +@handle_http_errors("send_message", service_type="chat") +async def send_message( + service, + user_google_email: str, + space_id: str, + message_text: str, + thread_key: Optional[str] = None, + thread_name: Optional[str] = None, +) -> str: + """ + Sends a message to a Google Chat space. + + Args: + thread_name: Reply in an existing thread by its resource name (e.g. spaces/X/threads/Y). + thread_key: Reply in a thread by app-defined key (creates thread if not found). + + Returns: + str: Confirmation message with sent message details. + """ + logger.info(f"[send_message] Email: '{user_google_email}', Space: '{space_id}'") + + message_body = {"text": message_text} + + request_params = {"parent": space_id, "body": message_body} + + # Thread reply support + if thread_name: + message_body["thread"] = {"name": thread_name} + request_params["messageReplyOption"] = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" + elif thread_key: + message_body["thread"] = {"threadKey": thread_key} + request_params["messageReplyOption"] = "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD" + + message = await asyncio.to_thread( + service.spaces().messages().create(**request_params).execute + ) + + message_name = message.get("name", "") + create_time = message.get("createTime", "") + + msg = f"Message sent to space '{space_id}' by {user_google_email}. Message ID: {message_name}, Time: {create_time}" + logger.info( + f"Successfully sent message to space '{space_id}' by {user_google_email}" + ) + return msg + + +@server.tool() +@require_multiple_services( + [ + {"service_type": "chat", "scopes": "chat_read", "param_name": "chat_service"}, + { + "service_type": "people", + "scopes": "contacts_read", + "param_name": "people_service", + }, + ] +) +@handle_http_errors("search_messages", service_type="chat") +async def search_messages( + chat_service, + people_service, + user_google_email: str, + query: str, + space_id: Optional[str] = None, + page_size: int = 25, +) -> str: + """ + Searches for messages in Google Chat spaces by text content. + + Returns: + str: A formatted list of messages matching the search query. + """ + logger.info(f"[search_messages] Email={user_google_email}, Query='{query}'") + + # If specific space provided, search within that space + if space_id: + response = await asyncio.to_thread( + chat_service.spaces() + .messages() + .list(parent=space_id, pageSize=page_size, filter=f'text:"{query}"') + .execute + ) + messages = response.get("messages", []) + context = f"space '{space_id}'" + else: + # Search across all accessible spaces (this may require iterating through spaces) + # For simplicity, we'll search the user's spaces first + spaces_response = await asyncio.to_thread( + chat_service.spaces().list(pageSize=100).execute + ) + spaces = spaces_response.get("spaces", []) + + messages = [] + for space in spaces[:10]: # Limit to first 10 spaces to avoid timeout + try: + space_messages = await asyncio.to_thread( + chat_service.spaces() + .messages() + .list( + parent=space.get("name"), pageSize=5, filter=f'text:"{query}"' + ) + .execute + ) + space_msgs = space_messages.get("messages", []) + for msg in space_msgs: + msg["_space_name"] = space.get("displayName", "Unknown") + messages.extend(space_msgs) + except HttpError as e: + logger.debug( + "Skipping space %s during search: %s", space.get("name"), e + ) + continue + context = "all accessible spaces" + + if not messages: + return f"No messages found matching '{query}' in {context}." + + # Pre-resolve unique senders in parallel + sender_lookup = {} + for msg in messages: + s = msg.get("sender", {}) + key = s.get("name", "") + if key and key not in sender_lookup: + sender_lookup[key] = s + resolved_names = await asyncio.gather( + *[_resolve_sender(people_service, s) for s in sender_lookup.values()] + ) + sender_map = dict(zip(sender_lookup.keys(), resolved_names)) + + output = [f"Found {len(messages)} messages matching '{query}' in {context}:"] + for msg in messages: + sender_obj = msg.get("sender", {}) + sender_key = sender_obj.get("name", "") + sender = sender_map.get(sender_key) or await _resolve_sender( + people_service, sender_obj + ) + create_time = msg.get("createTime", "Unknown Time") + text_content = msg.get("text", "No text content") + space_name = msg.get("_space_name", "Unknown Space") + + # Truncate long messages + if len(text_content) > 100: + text_content = text_content[:100] + "..." + + rich_links = _extract_rich_links(msg) + links_suffix = "".join(f" [linked: {url}]" for url in rich_links) + attachments = msg.get("attachment", []) + att_suffix = "".join( + f" [attachment: {a.get('contentName', 'unnamed')} ({a.get('contentType', 'unknown type')})]" + for a in attachments + ) + output.append( + f"- [{create_time}] {sender} in '{space_name}': {text_content}{links_suffix}{att_suffix}" + ) + + return "\n".join(output) + + +@server.tool() +@require_google_service("chat", "chat_write") +@handle_http_errors("create_reaction", service_type="chat") +async def create_reaction( + service, + user_google_email: str, + message_id: str, + emoji_unicode: str, +) -> str: + """ + Adds an emoji reaction to a Google Chat message. + + Args: + message_id: The message resource name (e.g. spaces/X/messages/Y). + emoji_unicode: The emoji character to react with (e.g. 👍). + + Returns: + str: Confirmation message. + """ + logger.info(f"[create_reaction] Message: '{message_id}', Emoji: '{emoji_unicode}'") + + reaction = await asyncio.to_thread( + service.spaces() + .messages() + .reactions() + .create( + parent=message_id, + body={"emoji": {"unicode": emoji_unicode}}, + ) + .execute + ) + + reaction_name = reaction.get("name", "") + return f"Reacted with {emoji_unicode} on message {message_id}. Reaction ID: {reaction_name}" + + +@server.tool() +@handle_http_errors("download_chat_attachment", is_read_only=True, service_type="chat") +@require_google_service("chat", "chat_read") +async def download_chat_attachment( + service, + user_google_email: str, + message_id: str, + attachment_index: int = 0, +) -> str: + """ + Downloads an attachment from a Google Chat message and saves it to local disk. + + In stdio mode, returns the local file path for direct access. + In HTTP mode, returns a temporary download URL (valid for 1 hour). + + Args: + message_id: The message resource name (e.g. spaces/X/messages/Y). + attachment_index: Zero-based index of the attachment to download (default 0). + + Returns: + str: Attachment metadata with either a local file path or download URL. + """ + logger.info( + f"[download_chat_attachment] Message: '{message_id}', Index: {attachment_index}" + ) + + # Fetch the message to get attachment metadata + msg = await asyncio.to_thread( + service.spaces().messages().get(name=message_id).execute + ) + + attachments = msg.get("attachment", []) + if not attachments: + return f"No attachments found on message {message_id}." + + if attachment_index < 0 or attachment_index >= len(attachments): + return ( + f"Invalid attachment_index {attachment_index}. " + f"Message has {len(attachments)} attachment(s) (0-{len(attachments) - 1})." + ) + + att = attachments[attachment_index] + filename = att.get("contentName", "attachment") + content_type = att.get("contentType", "application/octet-stream") + source = att.get("source", "") + + # The media endpoint needs attachmentDataRef.resourceName (e.g. + # "spaces/S/attachments/A"), NOT the attachment name which includes + # the /messages/ segment and causes 400 errors. + media_resource = att.get("attachmentDataRef", {}).get("resourceName", "") + att_name = att.get("name", "") + + logger.info( + f"[download_chat_attachment] Downloading '{filename}' ({content_type}), " + f"source={source}, mediaResource={media_resource}, name={att_name}" + ) + + # Download the attachment binary data via the Chat API media endpoint. + # We use httpx with the Bearer token directly because MediaIoBaseDownload + # and AuthorizedHttp fail in OAuth 2.1 (no refresh_token). The attachment's + # downloadUri points to chat.google.com which requires browser cookies. + if not media_resource and not att_name: + return f"No resource name available for attachment '{filename}'." + + # Prefer attachmentDataRef.resourceName for the media endpoint + resource_name = media_resource or att_name + download_url = f"https://chat.googleapis.com/v1/media/{resource_name}?alt=media" + + try: + access_token = service._http.credentials.token + async with httpx.AsyncClient(follow_redirects=True) as client: + resp = await client.get( + download_url, + headers={"Authorization": f"Bearer {access_token}"}, + ) + if resp.status_code != 200: + body = resp.text[:500] + return ( + f"Failed to download attachment '{filename}': " + f"HTTP {resp.status_code} from {download_url}\n{body}" + ) + file_bytes = resp.content + except Exception as e: + return f"Failed to download attachment '{filename}': {e}" + + size_bytes = len(file_bytes) + size_kb = size_bytes / 1024 + + # Check if we're in stateless mode (can't save files) + from auth.oauth_config import is_stateless_mode + + if is_stateless_mode(): + b64_preview = base64.urlsafe_b64encode(file_bytes).decode("utf-8")[:100] + return "\n".join( + [ + f"Attachment downloaded: {filename} ({content_type})", + f"Size: {size_kb:.1f} KB ({size_bytes} bytes)", + "", + "Stateless mode: File storage disabled.", + f"Base64 preview: {b64_preview}...", + ] + ) + + # Save to local disk + from core.attachment_storage import get_attachment_storage, get_attachment_url + from core.config import get_transport_mode + + storage = get_attachment_storage() + b64_data = base64.urlsafe_b64encode(file_bytes).decode("utf-8") + result = storage.save_attachment( + base64_data=b64_data, filename=filename, mime_type=content_type + ) + + result_lines = [ + f"Attachment downloaded: {filename}", + f"Type: {content_type}", + f"Size: {size_kb:.1f} KB ({size_bytes} bytes)", + ] + + if get_transport_mode() == "stdio": + result_lines.append(f"\nSaved to: {result.path}") + result_lines.append( + "\nThe file has been saved to disk and can be accessed directly via the file path." + ) + else: + download_url = get_attachment_url(result.file_id) + result_lines.append(f"\nDownload URL: {download_url}") + result_lines.append("\nThe file will expire after 1 hour.") + + logger.info( + f"[download_chat_attachment] Saved {size_kb:.1f} KB attachment to {result.path}" + ) + return "\n".join(result_lines) diff --git a/gcontacts/__init__.py b/gcontacts/__init__.py new file mode 100644 index 0000000..b37df17 --- /dev/null +++ b/gcontacts/__init__.py @@ -0,0 +1 @@ +# Google Contacts (People API) tools diff --git a/gcontacts/contacts_tools.py b/gcontacts/contacts_tools.py new file mode 100644 index 0000000..ab04053 --- /dev/null +++ b/gcontacts/contacts_tools.py @@ -0,0 +1,1052 @@ +""" +Google Contacts MCP Tools (People API) + +This module provides MCP tools for interacting with Google Contacts via the People API. +""" + +import asyncio +import logging +from typing import Any, Dict, List, Optional + +from googleapiclient.errors import HttpError +from mcp import Resource + +from auth.service_decorator import require_google_service +from core.server import server +from core.utils import UserInputError, handle_http_errors + +logger = logging.getLogger(__name__) + +# Default person fields for list/search operations +DEFAULT_PERSON_FIELDS = "names,emailAddresses,phoneNumbers,organizations" + +# Detailed person fields for get operations +DETAILED_PERSON_FIELDS = ( + "names,emailAddresses,phoneNumbers,organizations,biographies," + "addresses,birthdays,urls,photos,metadata,memberships" +) + +# Contact group fields +CONTACT_GROUP_FIELDS = "name,groupType,memberCount,metadata" + +# Cache warmup tracking +_search_cache_warmed_up: Dict[str, bool] = {} + + +def _format_contact(person: Dict[str, Any], detailed: bool = False) -> str: + """ + Format a Person resource into a readable string. + + Args: + person: The Person resource from the People API. + detailed: Whether to include detailed fields. + + Returns: + Formatted string representation of the contact. + """ + resource_name = person.get("resourceName", "Unknown") + contact_id = resource_name.replace("people/", "") if resource_name else "Unknown" + + lines = [f"Contact ID: {contact_id}"] + + # Names + names = person.get("names", []) + if names: + primary_name = names[0] + display_name = primary_name.get("displayName", "") + if display_name: + lines.append(f"Name: {display_name}") + + # Email addresses + emails = person.get("emailAddresses", []) + if emails: + email_list = [e.get("value", "") for e in emails if e.get("value")] + if email_list: + lines.append(f"Email: {', '.join(email_list)}") + + # Phone numbers + phones = person.get("phoneNumbers", []) + if phones: + phone_list = [p.get("value", "") for p in phones if p.get("value")] + if phone_list: + lines.append(f"Phone: {', '.join(phone_list)}") + + # Organizations + orgs = person.get("organizations", []) + if orgs: + org = orgs[0] + org_parts = [] + if org.get("title"): + org_parts.append(org["title"]) + if org.get("name"): + org_parts.append(f"at {org['name']}") + if org_parts: + lines.append(f"Organization: {' '.join(org_parts)}") + + if detailed: + # Addresses + addresses = person.get("addresses", []) + if addresses: + addr = addresses[0] + formatted_addr = addr.get("formattedValue", "") + if formatted_addr: + lines.append(f"Address: {formatted_addr}") + + # Birthday + birthdays = person.get("birthdays", []) + if birthdays: + bday = birthdays[0].get("date", {}) + if bday: + bday_str = f"{bday.get('month', '?')}/{bday.get('day', '?')}" + if bday.get("year"): + bday_str = f"{bday.get('year')}/{bday_str}" + lines.append(f"Birthday: {bday_str}") + + # URLs + urls = person.get("urls", []) + if urls: + url_list = [u.get("value", "") for u in urls if u.get("value")] + if url_list: + lines.append(f"URLs: {', '.join(url_list)}") + + # Biography/Notes + bios = person.get("biographies", []) + if bios: + bio = bios[0].get("value", "") + if bio: + # Truncate long bios + if len(bio) > 200: + bio = bio[:200] + "..." + lines.append(f"Notes: {bio}") + + # Metadata + metadata = person.get("metadata", {}) + if metadata: + sources = metadata.get("sources", []) + if sources: + source_types = [s.get("type", "") for s in sources] + if source_types: + lines.append(f"Sources: {', '.join(source_types)}") + + return "\n".join(lines) + + +def _build_person_body( + given_name: Optional[str] = None, + family_name: Optional[str] = None, + email: Optional[str] = None, + phone: Optional[str] = None, + organization: Optional[str] = None, + job_title: Optional[str] = None, + notes: Optional[str] = None, + address: Optional[str] = None, +) -> Dict[str, Any]: + """ + Build a Person resource body for create/update operations. + + Args: + given_name: First name. + family_name: Last name. + email: Email address. + phone: Phone number. + organization: Company/organization name. + job_title: Job title. + notes: Additional notes/biography. + address: Street address. + + Returns: + Person resource body dictionary. + """ + body: Dict[str, Any] = {} + + if given_name or family_name: + body["names"] = [ + { + "givenName": given_name or "", + "familyName": family_name or "", + } + ] + + if email: + body["emailAddresses"] = [{"value": email}] + + if phone: + body["phoneNumbers"] = [{"value": phone}] + + if organization or job_title: + org_entry: Dict[str, str] = {} + if organization: + org_entry["name"] = organization + if job_title: + org_entry["title"] = job_title + body["organizations"] = [org_entry] + + if notes: + body["biographies"] = [{"value": notes, "contentType": "TEXT_PLAIN"}] + + if address: + body["addresses"] = [{"formattedValue": address}] + + return body + + +async def _warmup_search_cache(service: Resource, user_google_email: str) -> None: + """ + Warm up the People API search cache. + + The People API requires an initial empty query to warm up the search cache + before searches will return results. + + Args: + service: Authenticated People API service. + user_google_email: User's email for tracking. + """ + global _search_cache_warmed_up + + if _search_cache_warmed_up.get(user_google_email): + return + + try: + logger.debug(f"[contacts] Warming up search cache for {user_google_email}") + await asyncio.to_thread( + service.people() + .searchContacts(query="", readMask="names", pageSize=1) + .execute + ) + _search_cache_warmed_up[user_google_email] = True + logger.debug(f"[contacts] Search cache warmed up for {user_google_email}") + except HttpError as e: + # Warmup failure is non-fatal, search may still work + logger.warning(f"[contacts] Search cache warmup failed: {e}") + + +# ============================================================================= +# Core Tier Tools +# ============================================================================= + + +@server.tool() +@require_google_service("people", "contacts_read") +@handle_http_errors("list_contacts", service_type="people") +async def list_contacts( + service: Resource, + user_google_email: str, + page_size: int = 100, + page_token: Optional[str] = None, + sort_order: Optional[str] = None, +) -> str: + """ + List contacts for the authenticated user. + + Args: + user_google_email (str): The user's Google email address. Required. + page_size (int): Maximum number of contacts to return (default: 100, max: 1000). + page_token (Optional[str]): Token for pagination. + sort_order (Optional[str]): Sort order: "LAST_MODIFIED_ASCENDING", "LAST_MODIFIED_DESCENDING", "FIRST_NAME_ASCENDING", or "LAST_NAME_ASCENDING". + + Returns: + str: List of contacts with their basic information. + """ + logger.info(f"[list_contacts] Invoked. Email: '{user_google_email}'") + + if page_size < 1: + raise UserInputError("page_size must be >= 1") + page_size = min(page_size, 1000) + + params: Dict[str, Any] = { + "resourceName": "people/me", + "personFields": DEFAULT_PERSON_FIELDS, + "pageSize": page_size, + } + + if page_token: + params["pageToken"] = page_token + if sort_order: + params["sortOrder"] = sort_order + + result = await asyncio.to_thread( + service.people().connections().list(**params).execute + ) + + connections = result.get("connections", []) + next_page_token = result.get("nextPageToken") + total_people = result.get("totalPeople", len(connections)) + + if not connections: + return f"No contacts found for {user_google_email}." + + response = ( + f"Contacts for {user_google_email} ({len(connections)} of {total_people}):\n\n" + ) + + for person in connections: + response += _format_contact(person) + "\n\n" + + if next_page_token: + response += f"Next page token: {next_page_token}" + + logger.info(f"Found {len(connections)} contacts for {user_google_email}") + return response + + +@server.tool() +@require_google_service("people", "contacts_read") +@handle_http_errors("get_contact", service_type="people") +async def get_contact( + service: Resource, + user_google_email: str, + contact_id: str, +) -> str: + """ + Get detailed information about a specific contact. + + Args: + user_google_email (str): The user's Google email address. Required. + contact_id (str): The contact ID (e.g., "c1234567890" or full resource name "people/c1234567890"). + + Returns: + str: Detailed contact information. + """ + # Normalize resource name + if not contact_id.startswith("people/"): + resource_name = f"people/{contact_id}" + else: + resource_name = contact_id + + logger.info( + f"[get_contact] Invoked. Email: '{user_google_email}', Contact: {resource_name}" + ) + + person = await asyncio.to_thread( + service.people() + .get(resourceName=resource_name, personFields=DETAILED_PERSON_FIELDS) + .execute + ) + + response = f"Contact Details for {user_google_email}:\n\n" + response += _format_contact(person, detailed=True) + + logger.info(f"Retrieved contact {resource_name} for {user_google_email}") + return response + + +@server.tool() +@require_google_service("people", "contacts_read") +@handle_http_errors("search_contacts", service_type="people") +async def search_contacts( + service: Resource, + user_google_email: str, + query: str, + page_size: int = 30, +) -> str: + """ + Search contacts by name, email, phone number, or other fields. + + Args: + user_google_email (str): The user's Google email address. Required. + query (str): Search query string (searches names, emails, phone numbers). + page_size (int): Maximum number of results to return (default: 30, max: 30). + + Returns: + str: Matching contacts with their basic information. + """ + logger.info( + f"[search_contacts] Invoked. Email: '{user_google_email}', Query: '{query}'" + ) + + if page_size < 1: + raise UserInputError("page_size must be >= 1") + page_size = min(page_size, 30) + + # Warm up the search cache if needed + await _warmup_search_cache(service, user_google_email) + + result = await asyncio.to_thread( + service.people() + .searchContacts( + query=query, + readMask=DEFAULT_PERSON_FIELDS, + pageSize=page_size, + ) + .execute + ) + + results = result.get("results", []) + + if not results: + return f"No contacts found matching '{query}' for {user_google_email}." + + response = f"Search Results for '{query}' ({len(results)} found):\n\n" + + for item in results: + person = item.get("person", {}) + response += _format_contact(person) + "\n\n" + + logger.info( + f"Found {len(results)} contacts matching '{query}' for {user_google_email}" + ) + return response + + +@server.tool() +@require_google_service("people", "contacts") +@handle_http_errors("manage_contact", service_type="people") +async def manage_contact( + service: Resource, + user_google_email: str, + action: str, + contact_id: Optional[str] = None, + given_name: Optional[str] = None, + family_name: Optional[str] = None, + email: Optional[str] = None, + phone: Optional[str] = None, + organization: Optional[str] = None, + job_title: Optional[str] = None, + notes: Optional[str] = None, +) -> str: + """ + Create, update, or delete a contact. Consolidated tool replacing create_contact, + update_contact, and delete_contact. + + Args: + user_google_email (str): The user's Google email address. Required. + action (str): The action to perform: "create", "update", or "delete". + contact_id (Optional[str]): The contact ID. Required for "update" and "delete" actions. + given_name (Optional[str]): First name (for create/update). + family_name (Optional[str]): Last name (for create/update). + email (Optional[str]): Email address (for create/update). + phone (Optional[str]): Phone number (for create/update). + organization (Optional[str]): Company/organization name (for create/update). + job_title (Optional[str]): Job title (for create/update). + notes (Optional[str]): Additional notes (for create/update). + + Returns: + str: Result of the action performed. + """ + action = action.lower().strip() + if action not in ("create", "update", "delete"): + raise UserInputError( + f"Invalid action '{action}'. Must be 'create', 'update', or 'delete'." + ) + + logger.info( + f"[manage_contact] Invoked. Action: '{action}', Email: '{user_google_email}'" + ) + + if action == "create": + body = _build_person_body( + given_name=given_name, + family_name=family_name, + email=email, + phone=phone, + organization=organization, + job_title=job_title, + notes=notes, + ) + + if not body: + raise UserInputError( + "At least one field (name, email, phone, etc.) must be provided." + ) + + result = await asyncio.to_thread( + service.people() + .createContact(body=body, personFields=DETAILED_PERSON_FIELDS) + .execute + ) + + response = f"Contact Created for {user_google_email}:\n\n" + response += _format_contact(result, detailed=True) + + created_id = result.get("resourceName", "").replace("people/", "") + logger.info(f"Created contact {created_id} for {user_google_email}") + return response + + # update and delete both require contact_id + if not contact_id: + raise UserInputError(f"contact_id is required for '{action}' action.") + + # Normalize resource name + if not contact_id.startswith("people/"): + resource_name = f"people/{contact_id}" + else: + resource_name = contact_id + + if action == "update": + # Fetch the contact to get the etag + current = await asyncio.to_thread( + service.people() + .get(resourceName=resource_name, personFields=DETAILED_PERSON_FIELDS) + .execute + ) + + etag = current.get("etag") + if not etag: + raise Exception("Unable to get contact etag for update.") + + body = _build_person_body( + given_name=given_name, + family_name=family_name, + email=email, + phone=phone, + organization=organization, + job_title=job_title, + notes=notes, + ) + + if not body: + raise UserInputError( + "At least one field (name, email, phone, etc.) must be provided." + ) + + body["etag"] = etag + + update_person_fields = [] + if "names" in body: + update_person_fields.append("names") + if "emailAddresses" in body: + update_person_fields.append("emailAddresses") + if "phoneNumbers" in body: + update_person_fields.append("phoneNumbers") + if "organizations" in body: + update_person_fields.append("organizations") + if "biographies" in body: + update_person_fields.append("biographies") + if "addresses" in body: + update_person_fields.append("addresses") + + result = await asyncio.to_thread( + service.people() + .updateContact( + resourceName=resource_name, + body=body, + updatePersonFields=",".join(update_person_fields), + personFields=DETAILED_PERSON_FIELDS, + ) + .execute + ) + + response = f"Contact Updated for {user_google_email}:\n\n" + response += _format_contact(result, detailed=True) + + logger.info(f"Updated contact {resource_name} for {user_google_email}") + return response + + # action == "delete" + await asyncio.to_thread( + service.people().deleteContact(resourceName=resource_name).execute + ) + + response = f"Contact {contact_id} has been deleted for {user_google_email}." + logger.info(f"Deleted contact {resource_name} for {user_google_email}") + return response + + +# ============================================================================= +# Extended Tier Tools +# ============================================================================= + + +@server.tool() +@require_google_service("people", "contacts_read") +@handle_http_errors("list_contact_groups", service_type="people") +async def list_contact_groups( + service: Resource, + user_google_email: str, + page_size: int = 100, + page_token: Optional[str] = None, +) -> str: + """ + List contact groups (labels) for the user. + + Args: + user_google_email (str): The user's Google email address. Required. + page_size (int): Maximum number of groups to return (default: 100, max: 1000). + page_token (Optional[str]): Token for pagination. + + Returns: + str: List of contact groups with their details. + """ + logger.info(f"[list_contact_groups] Invoked. Email: '{user_google_email}'") + + if page_size < 1: + raise UserInputError("page_size must be >= 1") + page_size = min(page_size, 1000) + + params: Dict[str, Any] = { + "pageSize": page_size, + "groupFields": CONTACT_GROUP_FIELDS, + } + + if page_token: + params["pageToken"] = page_token + + result = await asyncio.to_thread(service.contactGroups().list(**params).execute) + + groups = result.get("contactGroups", []) + next_page_token = result.get("nextPageToken") + + if not groups: + return f"No contact groups found for {user_google_email}." + + response = f"Contact Groups for {user_google_email}:\n\n" + + for group in groups: + resource_name = group.get("resourceName", "") + group_id = resource_name.replace("contactGroups/", "") + name = group.get("name", "Unnamed") + group_type = group.get("groupType", "USER_CONTACT_GROUP") + member_count = group.get("memberCount", 0) + + response += f"- {name}\n" + response += f" ID: {group_id}\n" + response += f" Type: {group_type}\n" + response += f" Members: {member_count}\n\n" + + if next_page_token: + response += f"Next page token: {next_page_token}" + + logger.info(f"Found {len(groups)} contact groups for {user_google_email}") + return response + + +@server.tool() +@require_google_service("people", "contacts_read") +@handle_http_errors("get_contact_group", service_type="people") +async def get_contact_group( + service: Resource, + user_google_email: str, + group_id: str, + max_members: int = 100, +) -> str: + """ + Get details of a specific contact group including its members. + + Args: + user_google_email (str): The user's Google email address. Required. + group_id (str): The contact group ID. + max_members (int): Maximum number of members to return (default: 100, max: 1000). + + Returns: + str: Contact group details including members. + """ + # Normalize resource name + if not group_id.startswith("contactGroups/"): + resource_name = f"contactGroups/{group_id}" + else: + resource_name = group_id + + logger.info( + f"[get_contact_group] Invoked. Email: '{user_google_email}', Group: {resource_name}" + ) + + if max_members < 1: + raise UserInputError("max_members must be >= 1") + max_members = min(max_members, 1000) + + result = await asyncio.to_thread( + service.contactGroups() + .get( + resourceName=resource_name, + maxMembers=max_members, + groupFields=CONTACT_GROUP_FIELDS, + ) + .execute + ) + + name = result.get("name", "Unnamed") + group_type = result.get("groupType", "USER_CONTACT_GROUP") + member_count = result.get("memberCount", 0) + member_resource_names = result.get("memberResourceNames", []) + + response = f"Contact Group Details for {user_google_email}:\n\n" + response += f"Name: {name}\n" + response += f"ID: {group_id}\n" + response += f"Type: {group_type}\n" + response += f"Total Members: {member_count}\n" + + if member_resource_names: + response += f"\nMembers ({len(member_resource_names)} shown):\n" + for member in member_resource_names: + contact_id = member.replace("people/", "") + response += f" - {contact_id}\n" + + logger.info(f"Retrieved contact group {resource_name} for {user_google_email}") + return response + + +# ============================================================================= +# Complete Tier Tools +# ============================================================================= + + +@server.tool() +@require_google_service("people", "contacts") +@handle_http_errors("manage_contacts_batch", service_type="people") +async def manage_contacts_batch( + service: Resource, + user_google_email: str, + action: str, + contacts: Optional[List[Dict[str, str]]] = None, + updates: Optional[List[Dict[str, str]]] = None, + contact_ids: Optional[List[str]] = None, +) -> str: + """ + Batch create, update, or delete contacts. Consolidated tool replacing + batch_create_contacts, batch_update_contacts, and batch_delete_contacts. + + Args: + user_google_email (str): The user's Google email address. Required. + action (str): The action to perform: "create", "update", or "delete". + contacts (Optional[List[Dict[str, str]]]): List of contact dicts for "create" action. + Each dict may contain: given_name, family_name, email, phone, organization, job_title. + updates (Optional[List[Dict[str, str]]]): List of update dicts for "update" action. + Each dict must contain contact_id and may contain: given_name, family_name, + email, phone, organization, job_title. + contact_ids (Optional[List[str]]): List of contact IDs for "delete" action. + + Returns: + str: Result of the batch action performed. + """ + action = action.lower().strip() + if action not in ("create", "update", "delete"): + raise UserInputError( + f"Invalid action '{action}'. Must be 'create', 'update', or 'delete'." + ) + + logger.info( + f"[manage_contacts_batch] Invoked. Action: '{action}', Email: '{user_google_email}'" + ) + + if action == "create": + if not contacts: + raise UserInputError("contacts parameter is required for 'create' action.") + + if len(contacts) > 200: + raise UserInputError("Maximum 200 contacts can be created in a batch.") + + contact_bodies = [] + for contact in contacts: + body = _build_person_body( + given_name=contact.get("given_name"), + family_name=contact.get("family_name"), + email=contact.get("email"), + phone=contact.get("phone"), + organization=contact.get("organization"), + job_title=contact.get("job_title"), + ) + if body: + contact_bodies.append({"contactPerson": body}) + + if not contact_bodies: + raise UserInputError("No valid contact data provided.") + + batch_body = { + "contacts": contact_bodies, + "readMask": DEFAULT_PERSON_FIELDS, + } + + result = await asyncio.to_thread( + service.people().batchCreateContacts(body=batch_body).execute + ) + + created_people = result.get("createdPeople", []) + + response = f"Batch Create Results for {user_google_email}:\n\n" + response += f"Created {len(created_people)} contacts:\n\n" + + for item in created_people: + person = item.get("person", {}) + response += _format_contact(person) + "\n\n" + + logger.info( + f"Batch created {len(created_people)} contacts for {user_google_email}" + ) + return response + + if action == "update": + if not updates: + raise UserInputError("updates parameter is required for 'update' action.") + + if len(updates) > 200: + raise UserInputError("Maximum 200 contacts can be updated in a batch.") + + # Fetch all contacts to get their etags + resource_names = [] + for update in updates: + cid = update.get("contact_id") + if not cid: + raise UserInputError("Each update must include a contact_id.") + if not cid.startswith("people/"): + cid = f"people/{cid}" + resource_names.append(cid) + + batch_get_result = await asyncio.to_thread( + service.people() + .getBatchGet( + resourceNames=resource_names, + personFields="metadata", + ) + .execute + ) + + etags = {} + for resp in batch_get_result.get("responses", []): + person = resp.get("person", {}) + rname = person.get("resourceName") + etag = person.get("etag") + if rname and etag: + etags[rname] = etag + + update_bodies = [] + update_fields_set: set = set() + + for update in updates: + cid = update.get("contact_id", "") + if not cid.startswith("people/"): + cid = f"people/{cid}" + + etag = etags.get(cid) + if not etag: + logger.warning(f"No etag found for {cid}, skipping") + continue + + body = _build_person_body( + given_name=update.get("given_name"), + family_name=update.get("family_name"), + email=update.get("email"), + phone=update.get("phone"), + organization=update.get("organization"), + job_title=update.get("job_title"), + ) + + if body: + body["resourceName"] = cid + body["etag"] = etag + update_bodies.append({"person": body}) + + if "names" in body: + update_fields_set.add("names") + if "emailAddresses" in body: + update_fields_set.add("emailAddresses") + if "phoneNumbers" in body: + update_fields_set.add("phoneNumbers") + if "organizations" in body: + update_fields_set.add("organizations") + + if not update_bodies: + raise UserInputError("No valid update data provided.") + + batch_body = { + "contacts": update_bodies, + "updateMask": ",".join(update_fields_set), + "readMask": DEFAULT_PERSON_FIELDS, + } + + result = await asyncio.to_thread( + service.people().batchUpdateContacts(body=batch_body).execute + ) + + update_results = result.get("updateResult", {}) + + response = f"Batch Update Results for {user_google_email}:\n\n" + response += f"Updated {len(update_results)} contacts:\n\n" + + for rname, update_result in update_results.items(): + person = update_result.get("person", {}) + response += _format_contact(person) + "\n\n" + + logger.info( + f"Batch updated {len(update_results)} contacts for {user_google_email}" + ) + return response + + # action == "delete" + if not contact_ids: + raise UserInputError("contact_ids parameter is required for 'delete' action.") + + if len(contact_ids) > 500: + raise UserInputError("Maximum 500 contacts can be deleted in a batch.") + + resource_names = [] + for cid in contact_ids: + if not cid.startswith("people/"): + resource_names.append(f"people/{cid}") + else: + resource_names.append(cid) + + batch_body = {"resourceNames": resource_names} + + await asyncio.to_thread( + service.people().batchDeleteContacts(body=batch_body).execute + ) + + response = f"Batch deleted {len(contact_ids)} contacts for {user_google_email}." + logger.info(f"Batch deleted {len(contact_ids)} contacts for {user_google_email}") + return response + + +@server.tool() +@require_google_service("people", "contacts") +@handle_http_errors("manage_contact_group", service_type="people") +async def manage_contact_group( + service: Resource, + user_google_email: str, + action: str, + group_id: Optional[str] = None, + name: Optional[str] = None, + delete_contacts: bool = False, + add_contact_ids: Optional[List[str]] = None, + remove_contact_ids: Optional[List[str]] = None, +) -> str: + """ + Create, update, delete a contact group, or modify its members. Consolidated tool + replacing create_contact_group, update_contact_group, delete_contact_group, and + modify_contact_group_members. + + Args: + user_google_email (str): The user's Google email address. Required. + action (str): The action to perform: "create", "update", "delete", or "modify_members". + group_id (Optional[str]): The contact group ID. Required for "update", "delete", + and "modify_members" actions. + name (Optional[str]): The group name. Required for "create" and "update" actions. + delete_contacts (bool): If True and action is "delete", also delete contacts in + the group (default: False). + add_contact_ids (Optional[List[str]]): Contact IDs to add (for "modify_members"). + remove_contact_ids (Optional[List[str]]): Contact IDs to remove (for "modify_members"). + + Returns: + str: Result of the action performed. + """ + action = action.lower().strip() + if action not in ("create", "update", "delete", "modify_members"): + raise UserInputError( + f"Invalid action '{action}'. Must be 'create', 'update', 'delete', or 'modify_members'." + ) + + logger.info( + f"[manage_contact_group] Invoked. Action: '{action}', Email: '{user_google_email}'" + ) + + if action == "create": + if not name: + raise UserInputError("name is required for 'create' action.") + + body = {"contactGroup": {"name": name}} + + result = await asyncio.to_thread( + service.contactGroups().create(body=body).execute + ) + + resource_name = result.get("resourceName", "") + created_group_id = resource_name.replace("contactGroups/", "") + created_name = result.get("name", name) + + response = f"Contact Group Created for {user_google_email}:\n\n" + response += f"Name: {created_name}\n" + response += f"ID: {created_group_id}\n" + response += f"Type: {result.get('groupType', 'USER_CONTACT_GROUP')}\n" + + logger.info(f"Created contact group '{name}' for {user_google_email}") + return response + + # All other actions require group_id + if not group_id: + raise UserInputError(f"group_id is required for '{action}' action.") + + # Normalize resource name + if not group_id.startswith("contactGroups/"): + resource_name = f"contactGroups/{group_id}" + else: + resource_name = group_id + + if action == "update": + if not name: + raise UserInputError("name is required for 'update' action.") + + body = {"contactGroup": {"name": name}} + + result = await asyncio.to_thread( + service.contactGroups() + .update(resourceName=resource_name, body=body) + .execute + ) + + updated_name = result.get("name", name) + + response = f"Contact Group Updated for {user_google_email}:\n\n" + response += f"Name: {updated_name}\n" + response += f"ID: {group_id}\n" + + logger.info(f"Updated contact group {resource_name} for {user_google_email}") + return response + + if action == "delete": + await asyncio.to_thread( + service.contactGroups() + .delete(resourceName=resource_name, deleteContacts=delete_contacts) + .execute + ) + + response = f"Contact group {group_id} has been deleted for {user_google_email}." + if delete_contacts: + response += " Contacts in the group were also deleted." + else: + response += " Contacts in the group were preserved." + + logger.info(f"Deleted contact group {resource_name} for {user_google_email}") + return response + + # action == "modify_members" + if not add_contact_ids and not remove_contact_ids: + raise UserInputError( + "At least one of add_contact_ids or remove_contact_ids must be provided." + ) + + modify_body: Dict[str, Any] = {} + + if add_contact_ids: + add_names = [] + for contact_id in add_contact_ids: + if not contact_id.startswith("people/"): + add_names.append(f"people/{contact_id}") + else: + add_names.append(contact_id) + modify_body["resourceNamesToAdd"] = add_names + + if remove_contact_ids: + remove_names = [] + for contact_id in remove_contact_ids: + if not contact_id.startswith("people/"): + remove_names.append(f"people/{contact_id}") + else: + remove_names.append(contact_id) + modify_body["resourceNamesToRemove"] = remove_names + + result = await asyncio.to_thread( + service.contactGroups() + .members() + .modify(resourceName=resource_name, body=modify_body) + .execute + ) + + not_found = result.get("notFoundResourceNames", []) + cannot_remove = result.get("canNotRemoveLastContactGroupResourceNames", []) + + response = f"Contact Group Members Modified for {user_google_email}:\n\n" + response += f"Group: {group_id}\n" + + if add_contact_ids: + response += f"Added: {len(add_contact_ids)} contacts\n" + if remove_contact_ids: + response += f"Removed: {len(remove_contact_ids)} contacts\n" + + if not_found: + response += f"\nNot found: {', '.join(not_found)}\n" + if cannot_remove: + response += f"\nCannot remove (last group): {', '.join(cannot_remove)}\n" + + logger.info( + f"Modified contact group members for {resource_name} for {user_google_email}" + ) + return response diff --git a/gdocs/__init__.py b/gdocs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gdocs/docs_helpers.py b/gdocs/docs_helpers.py new file mode 100644 index 0000000..0a26752 --- /dev/null +++ b/gdocs/docs_helpers.py @@ -0,0 +1,720 @@ +""" +Google Docs Helper Functions + +This module provides utility functions for common Google Docs operations +to simplify the implementation of document editing tools. +""" + +import logging +from typing import Dict, Any, Optional + +logger = logging.getLogger(__name__) + + +def _normalize_color( + color: Optional[str], param_name: str +) -> Optional[Dict[str, float]]: + """ + Normalize a user-supplied color into Docs API rgbColor format. + + Supports only hex strings in the form "#RRGGBB". + """ + if color is None: + return None + + if not isinstance(color, str): + raise ValueError(f"{param_name} must be a hex string like '#RRGGBB'") + + if len(color) != 7 or not color.startswith("#"): + raise ValueError(f"{param_name} must be a hex string like '#RRGGBB'") + + hex_color = color[1:] + if any(c not in "0123456789abcdefABCDEF" for c in hex_color): + raise ValueError(f"{param_name} must be a hex string like '#RRGGBB'") + + r = int(hex_color[0:2], 16) / 255 + g = int(hex_color[2:4], 16) / 255 + b = int(hex_color[4:6], 16) / 255 + return {"red": r, "green": g, "blue": b} + + +def build_text_style( + bold: bool = None, + italic: bool = None, + underline: bool = None, + font_size: int = None, + font_family: str = None, + text_color: str = None, + background_color: str = None, + link_url: str = None, +) -> tuple[Dict[str, Any], list[str]]: + """ + Build text style object for Google Docs API requests. + + Args: + bold: Whether text should be bold + italic: Whether text should be italic + underline: Whether text should be underlined + font_size: Font size in points + font_family: Font family name + text_color: Text color as hex string "#RRGGBB" + background_color: Background (highlight) color as hex string "#RRGGBB" + link_url: Hyperlink URL (http/https) + + Returns: + Tuple of (text_style_dict, list_of_field_names) + """ + text_style = {} + fields = [] + + if bold is not None: + text_style["bold"] = bold + fields.append("bold") + + if italic is not None: + text_style["italic"] = italic + fields.append("italic") + + if underline is not None: + text_style["underline"] = underline + fields.append("underline") + + if font_size is not None: + text_style["fontSize"] = {"magnitude": font_size, "unit": "PT"} + fields.append("fontSize") + + if font_family is not None: + text_style["weightedFontFamily"] = {"fontFamily": font_family} + fields.append("weightedFontFamily") + + if text_color is not None: + rgb = _normalize_color(text_color, "text_color") + text_style["foregroundColor"] = {"color": {"rgbColor": rgb}} + fields.append("foregroundColor") + + if background_color is not None: + rgb = _normalize_color(background_color, "background_color") + text_style["backgroundColor"] = {"color": {"rgbColor": rgb}} + fields.append("backgroundColor") + + if link_url is not None: + text_style["link"] = {"url": link_url} + fields.append("link") + + return text_style, fields + + +def build_paragraph_style( + heading_level: int = None, + alignment: str = None, + line_spacing: float = None, + indent_first_line: float = None, + indent_start: float = None, + indent_end: float = None, + space_above: float = None, + space_below: float = None, + named_style_type: str = None, +) -> tuple[Dict[str, Any], list[str]]: + """ + Build paragraph style object for Google Docs API requests. + + Args: + heading_level: Heading level 0-6 (0 = NORMAL_TEXT, 1-6 = HEADING_N) + alignment: Text alignment - 'START', 'CENTER', 'END', or 'JUSTIFIED' + line_spacing: Line spacing multiplier (1.0 = single, 2.0 = double) + indent_first_line: First line indent in points + indent_start: Left/start indent in points + indent_end: Right/end indent in points + space_above: Space above paragraph in points + space_below: Space below paragraph in points + named_style_type: Direct named style (TITLE, SUBTITLE, HEADING_1..6, NORMAL_TEXT). + Takes precedence over heading_level when both are provided. + + Returns: + Tuple of (paragraph_style_dict, list_of_field_names) + """ + paragraph_style = {} + fields = [] + + if named_style_type is not None: + valid_styles = [ + "NORMAL_TEXT", + "TITLE", + "SUBTITLE", + "HEADING_1", + "HEADING_2", + "HEADING_3", + "HEADING_4", + "HEADING_5", + "HEADING_6", + ] + if named_style_type not in valid_styles: + raise ValueError( + f"Invalid named_style_type '{named_style_type}'. " + f"Must be one of: {', '.join(valid_styles)}" + ) + paragraph_style["namedStyleType"] = named_style_type + fields.append("namedStyleType") + elif heading_level is not None: + if heading_level < 0 or heading_level > 6: + raise ValueError("heading_level must be between 0 (normal text) and 6") + if heading_level == 0: + paragraph_style["namedStyleType"] = "NORMAL_TEXT" + else: + paragraph_style["namedStyleType"] = f"HEADING_{heading_level}" + fields.append("namedStyleType") + + if alignment is not None: + valid_alignments = ["START", "CENTER", "END", "JUSTIFIED"] + alignment_upper = alignment.upper() + if alignment_upper not in valid_alignments: + raise ValueError( + f"Invalid alignment '{alignment}'. Must be one of: {valid_alignments}" + ) + paragraph_style["alignment"] = alignment_upper + fields.append("alignment") + + if line_spacing is not None: + if line_spacing <= 0: + raise ValueError("line_spacing must be positive") + paragraph_style["lineSpacing"] = line_spacing * 100 + fields.append("lineSpacing") + + if indent_first_line is not None: + paragraph_style["indentFirstLine"] = { + "magnitude": indent_first_line, + "unit": "PT", + } + fields.append("indentFirstLine") + + if indent_start is not None: + paragraph_style["indentStart"] = {"magnitude": indent_start, "unit": "PT"} + fields.append("indentStart") + + if indent_end is not None: + paragraph_style["indentEnd"] = {"magnitude": indent_end, "unit": "PT"} + fields.append("indentEnd") + + if space_above is not None: + paragraph_style["spaceAbove"] = {"magnitude": space_above, "unit": "PT"} + fields.append("spaceAbove") + + if space_below is not None: + paragraph_style["spaceBelow"] = {"magnitude": space_below, "unit": "PT"} + fields.append("spaceBelow") + + return paragraph_style, fields + + +def create_insert_text_request( + index: int, text: str, tab_id: Optional[str] = None +) -> Dict[str, Any]: + """ + Create an insertText request for Google Docs API. + + Args: + index: Position to insert text + text: Text to insert + tab_id: Optional ID of the tab to target + + Returns: + Dictionary representing the insertText request + """ + location = {"index": index} + if tab_id: + location["tabId"] = tab_id + return {"insertText": {"location": location, "text": text}} + + +def create_insert_text_segment_request( + index: int, text: str, segment_id: str, tab_id: Optional[str] = None +) -> Dict[str, Any]: + """ + Create an insertText request for Google Docs API with segmentId (for headers/footers). + + Args: + index: Position to insert text + text: Text to insert + segment_id: Segment ID (for targeting headers/footers) + tab_id: Optional ID of the tab to target + + Returns: + Dictionary representing the insertText request with segmentId and optional tabId + """ + location = {"segmentId": segment_id, "index": index} + if tab_id: + location["tabId"] = tab_id + return { + "insertText": { + "location": location, + "text": text, + } + } + + +def create_delete_range_request( + start_index: int, end_index: int, tab_id: Optional[str] = None +) -> Dict[str, Any]: + """ + Create a deleteContentRange request for Google Docs API. + + Args: + start_index: Start position of content to delete + end_index: End position of content to delete + tab_id: Optional ID of the tab to target + + Returns: + Dictionary representing the deleteContentRange request + """ + range_obj = {"startIndex": start_index, "endIndex": end_index} + if tab_id: + range_obj["tabId"] = tab_id + return {"deleteContentRange": {"range": range_obj}} + + +def create_format_text_request( + start_index: int, + end_index: int, + bold: bool = None, + italic: bool = None, + underline: bool = None, + font_size: int = None, + font_family: str = None, + text_color: str = None, + background_color: str = None, + link_url: str = None, + tab_id: Optional[str] = None, +) -> Optional[Dict[str, Any]]: + """ + Create an updateTextStyle request for Google Docs API. + + Args: + start_index: Start position of text to format + end_index: End position of text to format + bold: Whether text should be bold + italic: Whether text should be italic + underline: Whether text should be underlined + font_size: Font size in points + font_family: Font family name + text_color: Text color as hex string "#RRGGBB" + background_color: Background (highlight) color as hex string "#RRGGBB" + link_url: Hyperlink URL (http/https) + tab_id: Optional ID of the tab to target + + Returns: + Dictionary representing the updateTextStyle request, or None if no styles provided + """ + text_style, fields = build_text_style( + bold, + italic, + underline, + font_size, + font_family, + text_color, + background_color, + link_url, + ) + + if not text_style: + return None + + range_obj = {"startIndex": start_index, "endIndex": end_index} + if tab_id: + range_obj["tabId"] = tab_id + + return { + "updateTextStyle": { + "range": range_obj, + "textStyle": text_style, + "fields": ",".join(fields), + } + } + + +def create_update_paragraph_style_request( + start_index: int, + end_index: int, + heading_level: int = None, + alignment: str = None, + line_spacing: float = None, + indent_first_line: float = None, + indent_start: float = None, + indent_end: float = None, + space_above: float = None, + space_below: float = None, + tab_id: Optional[str] = None, + named_style_type: str = None, +) -> Optional[Dict[str, Any]]: + """ + Create an updateParagraphStyle request for Google Docs API. + + Args: + start_index: Start position of paragraph range + end_index: End position of paragraph range + heading_level: Heading level 0-6 (0 = NORMAL_TEXT, 1-6 = HEADING_N) + alignment: Text alignment - 'START', 'CENTER', 'END', or 'JUSTIFIED' + line_spacing: Line spacing multiplier (1.0 = single, 2.0 = double) + indent_first_line: First line indent in points + indent_start: Left/start indent in points + indent_end: Right/end indent in points + space_above: Space above paragraph in points + space_below: Space below paragraph in points + tab_id: Optional ID of the tab to target + named_style_type: Direct named style (TITLE, SUBTITLE, HEADING_1..6, NORMAL_TEXT) + + Returns: + Dictionary representing the updateParagraphStyle request, or None if no styles provided + """ + paragraph_style, fields = build_paragraph_style( + heading_level, + alignment, + line_spacing, + indent_first_line, + indent_start, + indent_end, + space_above, + space_below, + named_style_type, + ) + + if not paragraph_style: + return None + + range_obj = {"startIndex": start_index, "endIndex": end_index} + if tab_id: + range_obj["tabId"] = tab_id + + return { + "updateParagraphStyle": { + "range": range_obj, + "paragraphStyle": paragraph_style, + "fields": ",".join(fields), + } + } + + +def create_find_replace_request( + find_text: str, + replace_text: str, + match_case: bool = False, + tab_id: Optional[str] = None, +) -> Dict[str, Any]: + """ + Create a replaceAllText request for Google Docs API. + + Args: + find_text: Text to find + replace_text: Text to replace with + match_case: Whether to match case exactly + tab_id: Optional ID of the tab to target + + Returns: + Dictionary representing the replaceAllText request + """ + request = { + "replaceAllText": { + "containsText": {"text": find_text, "matchCase": match_case}, + "replaceText": replace_text, + } + } + if tab_id: + request["replaceAllText"]["tabsCriteria"] = {"tabIds": [tab_id]} + return request + + +def create_insert_table_request( + index: int, rows: int, columns: int, tab_id: Optional[str] = None +) -> Dict[str, Any]: + """ + Create an insertTable request for Google Docs API. + + Args: + index: Position to insert table + rows: Number of rows + columns: Number of columns + tab_id: Optional ID of the tab to target + + Returns: + Dictionary representing the insertTable request + """ + location = {"index": index} + if tab_id: + location["tabId"] = tab_id + return {"insertTable": {"location": location, "rows": rows, "columns": columns}} + + +def create_insert_page_break_request( + index: int, tab_id: Optional[str] = None +) -> Dict[str, Any]: + """ + Create an insertPageBreak request for Google Docs API. + + Args: + index: Position to insert page break + tab_id: Optional ID of the tab to target + + Returns: + Dictionary representing the insertPageBreak request + """ + location = {"index": index} + if tab_id: + location["tabId"] = tab_id + return {"insertPageBreak": {"location": location}} + + +def create_insert_doc_tab_request( + title: str, index: int, parent_tab_id: Optional[str] = None +) -> Dict[str, Any]: + """ + Create an addDocumentTab request for Google Docs API. + + Args: + title: Title of the new tab + index: Position to insert the tab + parent_tab_id: Optional ID of the parent tab to nest under + + Returns: + Dictionary representing the addDocumentTab request + """ + tab_properties: Dict[str, Any] = { + "title": title, + "index": index, + } + if parent_tab_id: + tab_properties["parentTabId"] = parent_tab_id + return { + "addDocumentTab": { + "tabProperties": tab_properties, + } + } + + +def create_delete_doc_tab_request(tab_id: str) -> Dict[str, Any]: + """ + Create a deleteDocumentTab request for Google Docs API. + + Args: + tab_id: ID of the tab to delete + + Returns: + Dictionary representing the deleteDocumentTab request + """ + return {"deleteTab": {"tabId": tab_id}} + + +def create_update_doc_tab_request(tab_id: str, title: str) -> Dict[str, Any]: + """ + Create an updateDocumentTab request for Google Docs API. + + Args: + tab_id: ID of the tab to update + title: New title for the tab + + Returns: + Dictionary representing the updateDocumentTab request + """ + return { + "updateDocumentTabProperties": { + "tabProperties": { + "tabId": tab_id, + "title": title, + }, + "fields": "title", + } + } + + +def create_insert_image_request( + index: int, + image_uri: str, + width: int = None, + height: int = None, + tab_id: Optional[str] = None, +) -> Dict[str, Any]: + """ + Create an insertInlineImage request for Google Docs API. + + Args: + index: Position to insert image + image_uri: URI of the image (Drive URL or public URL) + width: Image width in points + height: Image height in points + tab_id: Optional ID of the tab to target + + Returns: + Dictionary representing the insertInlineImage request + """ + location = {"index": index} + if tab_id: + location["tabId"] = tab_id + + request = {"insertInlineImage": {"location": location, "uri": image_uri}} + + # Add size properties if specified + object_size = {} + if width is not None: + object_size["width"] = {"magnitude": width, "unit": "PT"} + if height is not None: + object_size["height"] = {"magnitude": height, "unit": "PT"} + + if object_size: + request["insertInlineImage"]["objectSize"] = object_size + + return request + + +def create_bullet_list_request( + start_index: int, + end_index: int, + list_type: str = "UNORDERED", + nesting_level: int = None, + paragraph_start_indices: Optional[list[int]] = None, + doc_tab_id: Optional[str] = None, +) -> list[Dict[str, Any]]: + """ + Create requests to apply bullet list formatting with optional nesting. + + Google Docs infers list nesting from leading tab characters. To set a nested + level, this helper inserts literal tab characters before each targeted + paragraph, then calls createParagraphBullets. This is a Docs API workaround + and does temporarily mutate content/index positions while the batch executes. + + Args: + start_index: Start of text range to convert to list + end_index: End of text range to convert to list + list_type: Type of list ("UNORDERED" or "ORDERED") + nesting_level: Nesting level (0-8, where 0 is top level). If None or 0, no tabs added. + paragraph_start_indices: Optional paragraph start positions for ranges with + multiple paragraphs. If omitted, only start_index is tab-prefixed. + doc_tab_id: Optional ID of the tab to target + + Returns: + List of request dictionaries (insertText for nesting tabs if needed, + then createParagraphBullets) + """ + bullet_preset = ( + "BULLET_DISC_CIRCLE_SQUARE" + if list_type == "UNORDERED" + else "NUMBERED_DECIMAL_ALPHA_ROMAN" + ) + + # Validate nesting level + if nesting_level is not None: + if not isinstance(nesting_level, int): + raise ValueError("nesting_level must be an integer between 0 and 8") + if nesting_level < 0 or nesting_level > 8: + raise ValueError("nesting_level must be between 0 and 8") + + requests = [] + + # Insert tabs for nesting if needed (nesting_level > 0). + # For multi-paragraph ranges, callers should provide paragraph_start_indices. + if nesting_level and nesting_level > 0: + tabs = "\t" * nesting_level + paragraph_starts = paragraph_start_indices or [start_index] + paragraph_starts = sorted(set(paragraph_starts)) + + if any(not isinstance(idx, int) for idx in paragraph_starts): + raise ValueError("paragraph_start_indices must contain only integers") + + original_start = start_index + original_end = end_index + inserted_char_count = 0 + + for paragraph_start in paragraph_starts: + adjusted_start = paragraph_start + inserted_char_count + requests.append( + create_insert_text_request(adjusted_start, tabs, doc_tab_id) + ) + inserted_char_count += nesting_level + + # Keep createParagraphBullets range aligned to the same logical content. + start_index += ( + sum(1 for idx in paragraph_starts if idx < original_start) * nesting_level + ) + end_index += ( + sum(1 for idx in paragraph_starts if idx < original_end) * nesting_level + ) + + # Create the bullet list + range_obj = {"startIndex": start_index, "endIndex": end_index} + if doc_tab_id: + range_obj["tabId"] = doc_tab_id + + requests.append( + { + "createParagraphBullets": { + "range": range_obj, + "bulletPreset": bullet_preset, + } + } + ) + + return requests + + +def create_delete_bullet_list_request( + start_index: int, + end_index: int, + doc_tab_id: Optional[str] = None, +) -> Dict[str, Any]: + """ + Create a deleteParagraphBullets request to remove bullet/list formatting. + + Args: + start_index: Start of the paragraph range + end_index: End of the paragraph range + doc_tab_id: Optional ID of the tab to target + + Returns: + Dictionary representing the deleteParagraphBullets request + """ + range_obj = {"startIndex": start_index, "endIndex": end_index} + if doc_tab_id: + range_obj["tabId"] = doc_tab_id + + return { + "deleteParagraphBullets": { + "range": range_obj, + } + } + + +def validate_operation(operation: Dict[str, Any]) -> tuple[bool, str]: + """ + Validate a batch operation dictionary. + + Args: + operation: Operation dictionary to validate + + Returns: + Tuple of (is_valid, error_message) + """ + op_type = operation.get("type") + if not op_type: + return False, "Missing 'type' field" + + # Validate required fields for each operation type + required_fields = { + "insert_text": ["index", "text"], + "delete_text": ["start_index", "end_index"], + "replace_text": ["start_index", "end_index", "text"], + "format_text": ["start_index", "end_index"], + "update_paragraph_style": ["start_index", "end_index"], + "insert_table": ["index", "rows", "columns"], + "insert_page_break": ["index"], + "find_replace": ["find_text", "replace_text"], + "create_bullet_list": ["start_index", "end_index"], + "insert_doc_tab": ["title", "index"], + "delete_doc_tab": ["tab_id"], + "update_doc_tab": ["tab_id", "title"], + } + + if op_type not in required_fields: + return False, f"Unsupported operation type: {op_type or 'None'}" + + for field in required_fields[op_type]: + if field not in operation: + return False, f"Missing required field: {field}" + + return True, "" diff --git a/gdocs/docs_markdown.py b/gdocs/docs_markdown.py new file mode 100644 index 0000000..d9c183d --- /dev/null +++ b/gdocs/docs_markdown.py @@ -0,0 +1,344 @@ +""" +Google Docs to Markdown Converter + +Converts Google Docs API JSON responses to clean Markdown, preserving: +- Headings (H1-H6, Title, Subtitle) +- Bold, italic, strikethrough, code, links +- Ordered and unordered lists with nesting +- Checklists with checked/unchecked state +- Tables with header row separators +""" + +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + +MONO_FONTS = {"Courier New", "Consolas", "Roboto Mono", "Source Code Pro"} + +HEADING_MAP = { + "TITLE": "#", + "SUBTITLE": "##", + "HEADING_1": "#", + "HEADING_2": "##", + "HEADING_3": "###", + "HEADING_4": "####", + "HEADING_5": "#####", + "HEADING_6": "######", +} + + +def convert_doc_to_markdown(doc: dict[str, Any]) -> str: + """Convert a Google Docs API document response to markdown. + + Args: + doc: The document JSON from docs.documents.get() + + Returns: + Markdown string + """ + body = doc.get("body", {}) + content = body.get("content", []) + lists_meta = doc.get("lists", {}) + + lines: list[str] = [] + ordered_counters: dict[tuple[str, int], int] = {} + prev_was_list = False + + for element in content: + if "paragraph" in element: + para = element["paragraph"] + text = _convert_paragraph_text(para) + + if not text.strip(): + if prev_was_list: + prev_was_list = False + continue + + bullet = para.get("bullet") + if bullet: + list_id = bullet["listId"] + nesting = bullet.get("nestingLevel", 0) + + if _is_checklist(lists_meta, list_id, nesting): + checked = _is_checked(para) + checkbox = "[x]" if checked else "[ ]" + indent = " " * nesting + # Re-render text without strikethrough for checked items + # to avoid redundant ~~text~~ alongside [x] + cb_text = ( + _convert_paragraph_text(para, skip_strikethrough=True) + if checked + else text + ) + lines.append(f"{indent}- {checkbox} {cb_text}") + elif _is_ordered_list(lists_meta, list_id, nesting): + key = (list_id, nesting) + ordered_counters[key] = ordered_counters.get(key, 0) + 1 + counter = ordered_counters[key] + indent = " " * nesting + lines.append(f"{indent}{counter}. {text}") + else: + indent = " " * nesting + lines.append(f"{indent}- {text}") + prev_was_list = True + else: + if prev_was_list: + ordered_counters.clear() + lines.append("") + prev_was_list = False + + style = para.get("paragraphStyle", {}) + named_style = style.get("namedStyleType", "NORMAL_TEXT") + prefix = HEADING_MAP.get(named_style, "") + + if prefix: + lines.append(f"{prefix} {text}") + lines.append("") + else: + lines.append(text) + lines.append("") + + elif "table" in element: + if prev_was_list: + ordered_counters.clear() + lines.append("") + prev_was_list = False + table_md = _convert_table(element["table"]) + lines.append(table_md) + lines.append("") + + result = "\n".join(lines).rstrip("\n") + "\n" + return result + + +def _convert_paragraph_text( + para: dict[str, Any], skip_strikethrough: bool = False +) -> str: + """Convert paragraph elements to inline markdown text.""" + parts: list[str] = [] + for elem in para.get("elements", []): + if "textRun" in elem: + parts.append(_convert_text_run(elem["textRun"], skip_strikethrough)) + return "".join(parts).strip() + + +def _convert_text_run( + text_run: dict[str, Any], skip_strikethrough: bool = False +) -> str: + """Convert a single text run to markdown.""" + content = text_run.get("content", "") + style = text_run.get("textStyle", {}) + + text = content.rstrip("\n") + if not text: + return "" + + return _apply_text_style(text, style, skip_strikethrough) + + +def _apply_text_style( + text: str, style: dict[str, Any], skip_strikethrough: bool = False +) -> str: + """Apply markdown formatting based on text style.""" + link = style.get("link", {}) + url = link.get("url") + + font_family = style.get("weightedFontFamily", {}).get("fontFamily", "") + if font_family in MONO_FONTS: + return f"`{text}`" + + bold = style.get("bold", False) + italic = style.get("italic", False) + strikethrough = style.get("strikethrough", False) + + if bold and italic: + text = f"***{text}***" + elif bold: + text = f"**{text}**" + elif italic: + text = f"*{text}*" + + if strikethrough and not skip_strikethrough: + text = f"~~{text}~~" + + if url: + text = f"[{text}]({url})" + + return text + + +def _is_ordered_list(lists_meta: dict[str, Any], list_id: str, nesting: int) -> bool: + """Check if a list at a given nesting level is ordered.""" + list_info = lists_meta.get(list_id, {}) + nesting_levels = list_info.get("listProperties", {}).get("nestingLevels", []) + if nesting < len(nesting_levels): + level = nesting_levels[nesting] + glyph = level.get("glyphType", "") + return glyph not in ("", "GLYPH_TYPE_UNSPECIFIED") + return False + + +def _is_checklist(lists_meta: dict[str, Any], list_id: str, nesting: int) -> bool: + """Check if a list at a given nesting level is a checklist. + + Google Docs checklists are distinguished from regular bullet lists by having + GLYPH_TYPE_UNSPECIFIED with no glyphSymbol — the Docs UI renders interactive + checkboxes rather than a static glyph character. + """ + list_info = lists_meta.get(list_id, {}) + nesting_levels = list_info.get("listProperties", {}).get("nestingLevels", []) + if nesting < len(nesting_levels): + level = nesting_levels[nesting] + glyph_type = level.get("glyphType", "") + has_glyph_symbol = "glyphSymbol" in level + return glyph_type in ("", "GLYPH_TYPE_UNSPECIFIED") and not has_glyph_symbol + return False + + +def _is_checked(para: dict[str, Any]) -> bool: + """Check if a checklist item is checked. + + Google Docs marks checked checklist items by applying strikethrough + formatting to the paragraph text. + """ + for elem in para.get("elements", []): + if "textRun" in elem: + content = elem["textRun"].get("content", "").strip() + if content: + return elem["textRun"].get("textStyle", {}).get("strikethrough", False) + return False + + +def _convert_table(table: dict[str, Any]) -> str: + """Convert a table element to markdown.""" + rows = table.get("tableRows", []) + if not rows: + return "" + + md_rows: list[str] = [] + for i, row in enumerate(rows): + cells: list[str] = [] + for cell in row.get("tableCells", []): + cell_text = _extract_cell_text(cell) + cells.append(cell_text) + md_rows.append("| " + " | ".join(cells) + " |") + + if i == 0: + sep = "| " + " | ".join("---" for _ in cells) + " |" + md_rows.append(sep) + + return "\n".join(md_rows) + + +def _extract_cell_text(cell: dict[str, Any]) -> str: + """Extract text from a table cell.""" + parts: list[str] = [] + for content_elem in cell.get("content", []): + if "paragraph" in content_elem: + text = _convert_paragraph_text(content_elem["paragraph"]) + if text.strip(): + parts.append(text.strip()) + cell_text = " ".join(parts) + return cell_text.replace("|", "\\|") + + +def format_comments_inline(markdown: str, comments: list[dict[str, Any]]) -> str: + """Insert footnote-style comment annotations inline in markdown. + + For each comment, finds the anchor text in the markdown and inserts + a footnote reference. Unmatched comments go to an appendix at the bottom. + """ + if not comments: + return markdown + + footnotes: list[str] = [] + unmatched: list[dict[str, Any]] = [] + + for i, comment in enumerate(comments, 1): + ref = f"[^c{i}]" + anchor = comment.get("anchor_text", "") + + if anchor and anchor in markdown: + markdown = markdown.replace(anchor, anchor + ref, 1) + footnotes.append(_format_footnote(i, comment)) + else: + unmatched.append(comment) + + if footnotes: + markdown = markdown.rstrip("\n") + "\n\n" + "\n".join(footnotes) + "\n" + + if unmatched: + appendix = format_comments_appendix(unmatched) + if appendix.strip(): + markdown = markdown.rstrip("\n") + "\n\n" + appendix + + return markdown + + +def _format_footnote(num: int, comment: dict[str, Any]) -> str: + """Format a single footnote.""" + lines = [f"[^c{num}]: **{comment['author']}**: {comment['content']}"] + for reply in comment.get("replies", []): + lines.append(f" - **{reply['author']}**: {reply['content']}") + return "\n".join(lines) + + +def format_comments_appendix(comments: list[dict[str, Any]]) -> str: + """Format comments as an appendix section with blockquoted anchors.""" + if not comments: + return "" + + lines = ["## Comments", ""] + for comment in comments: + resolved_tag = " *(Resolved)*" if comment.get("resolved") else "" + anchor = comment.get("anchor_text", "") + if anchor: + lines.append(f"> {anchor}") + lines.append("") + lines.append(f"- **{comment['author']}**: {comment['content']}{resolved_tag}") + for reply in comment.get("replies", []): + lines.append(f" - **{reply['author']}**: {reply['content']}") + lines.append("") + + return "\n".join(lines) + + +def parse_drive_comments( + response: dict[str, Any], include_resolved: bool = False +) -> list[dict[str, Any]]: + """Parse Drive API comments response into structured dicts. + + Args: + response: Raw JSON from drive.comments.list() + include_resolved: Whether to include resolved comments + + Returns: + List of comment dicts with keys: author, content, anchor_text, + replies, resolved + """ + results = [] + for comment in response.get("comments", []): + if not include_resolved and comment.get("resolved", False): + continue + + anchor_text = comment.get("quotedFileContent", {}).get("value", "") + replies = [ + { + "author": r.get("author", {}).get("displayName", "Unknown"), + "content": r.get("content", ""), + } + for r in comment.get("replies", []) + ] + results.append( + { + "author": comment.get("author", {}).get("displayName", "Unknown"), + "content": comment.get("content", ""), + "anchor_text": anchor_text, + "replies": replies, + "resolved": comment.get("resolved", False), + } + ) + return results diff --git a/gdocs/docs_structure.py b/gdocs/docs_structure.py new file mode 100644 index 0000000..d57da20 --- /dev/null +++ b/gdocs/docs_structure.py @@ -0,0 +1,357 @@ +""" +Google Docs Document Structure Parsing and Analysis + +This module provides utilities for parsing and analyzing the structure +of Google Docs documents, including finding tables, cells, and other elements. +""" + +import logging +from typing import Any, Optional + +logger = logging.getLogger(__name__) + + +def parse_document_structure(doc_data: dict[str, Any]) -> dict[str, Any]: + """ + Parse the full document structure into a navigable format. + + Args: + doc_data: Raw document data from Google Docs API + + Returns: + Dictionary containing parsed structure with elements and their positions + """ + structure = { + "title": doc_data.get("title", ""), + "body": [], + "tables": [], + "headers": {}, + "footers": {}, + "total_length": 0, + } + + body = doc_data.get("body", {}) + content = body.get("content", []) + + for element in content: + element_info = _parse_element(element) + if element_info: + structure["body"].append(element_info) + if element_info["type"] == "table": + structure["tables"].append(element_info) + + # Calculate total document length + if structure["body"]: + last_element = structure["body"][-1] + structure["total_length"] = last_element.get("end_index", 0) + + # Parse headers and footers + for header_id, header_data in doc_data.get("headers", {}).items(): + structure["headers"][header_id] = _parse_segment(header_data) + + for footer_id, footer_data in doc_data.get("footers", {}).items(): + structure["footers"][footer_id] = _parse_segment(footer_data) + + return structure + + +def _parse_element(element: dict[str, Any]) -> Optional[dict[str, Any]]: + """ + Parse a single document element. + + Args: + element: Element data from document + + Returns: + Parsed element information or None + """ + element_info = { + "start_index": element.get("startIndex", 0), + "end_index": element.get("endIndex", 0), + } + + if "paragraph" in element: + paragraph = element["paragraph"] + element_info["type"] = "paragraph" + element_info["text"] = _extract_paragraph_text(paragraph) + element_info["style"] = paragraph.get("paragraphStyle", {}) + + elif "table" in element: + table = element["table"] + element_info["type"] = "table" + element_info["rows"] = len(table.get("tableRows", [])) + element_info["columns"] = len( + table.get("tableRows", [{}])[0].get("tableCells", []) + ) + element_info["cells"] = _parse_table_cells(table) + element_info["table_style"] = table.get("tableStyle", {}) + + elif "sectionBreak" in element: + element_info["type"] = "section_break" + element_info["section_style"] = element["sectionBreak"].get("sectionStyle", {}) + + elif "tableOfContents" in element: + element_info["type"] = "table_of_contents" + + else: + return None + + return element_info + + +def _parse_table_cells(table: dict[str, Any]) -> list[list[dict[str, Any]]]: + """ + Parse table cells with their positions and content. + + Args: + table: Table element data + + Returns: + 2D list of cell information + """ + cells = [] + for row_idx, row in enumerate(table.get("tableRows", [])): + row_cells = [] + for col_idx, cell in enumerate(row.get("tableCells", [])): + # Find the first paragraph in the cell for insertion + insertion_index = cell.get("startIndex", 0) + 1 # Default fallback + + # Look for the first paragraph in cell content + content_elements = cell.get("content", []) + for element in content_elements: + if "paragraph" in element: + paragraph = element["paragraph"] + # Get the first element in the paragraph + para_elements = paragraph.get("elements", []) + if para_elements: + first_element = para_elements[0] + if "startIndex" in first_element: + insertion_index = first_element["startIndex"] + break + + cell_info = { + "row": row_idx, + "column": col_idx, + "start_index": cell.get("startIndex", 0), + "end_index": cell.get("endIndex", 0), + "insertion_index": insertion_index, # Where to insert text in this cell + "content": _extract_cell_text(cell), + "content_elements": content_elements, + } + row_cells.append(cell_info) + cells.append(row_cells) + return cells + + +def _extract_paragraph_text(paragraph: dict[str, Any]) -> str: + """Extract text from a paragraph element.""" + text_parts = [] + for element in paragraph.get("elements", []): + if "textRun" in element: + text_parts.append(element["textRun"].get("content", "")) + return "".join(text_parts) + + +def _extract_cell_text(cell: dict[str, Any]) -> str: + """Extract text content from a table cell.""" + text_parts = [] + for element in cell.get("content", []): + if "paragraph" in element: + text_parts.append(_extract_paragraph_text(element["paragraph"])) + return "".join(text_parts) + + +def _parse_segment(segment_data: dict[str, Any]) -> dict[str, Any]: + """Parse a document segment (header/footer).""" + return { + "content": segment_data.get("content", []), + "start_index": segment_data.get("content", [{}])[0].get("startIndex", 0) + if segment_data.get("content") + else 0, + "end_index": segment_data.get("content", [{}])[-1].get("endIndex", 0) + if segment_data.get("content") + else 0, + } + + +def find_tables(doc_data: dict[str, Any]) -> list[dict[str, Any]]: + """ + Find all tables in the document with their positions and dimensions. + + Args: + doc_data: Raw document data from Google Docs API + + Returns: + List of table information dictionaries + """ + tables = [] + structure = parse_document_structure(doc_data) + + for idx, table_info in enumerate(structure["tables"]): + tables.append( + { + "index": idx, + "start_index": table_info["start_index"], + "end_index": table_info["end_index"], + "rows": table_info["rows"], + "columns": table_info["columns"], + "cells": table_info["cells"], + } + ) + + return tables + + +def get_table_cell_indices( + doc_data: dict[str, Any], table_index: int = 0 +) -> Optional[list[list[tuple[int, int]]]]: + """ + Get content indices for all cells in a specific table. + + Args: + doc_data: Raw document data from Google Docs API + table_index: Index of the table (0-based) + + Returns: + 2D list of (start_index, end_index) tuples for each cell, or None if table not found + """ + tables = find_tables(doc_data) + + if table_index >= len(tables): + logger.warning( + f"Table index {table_index} not found. Document has {len(tables)} tables." + ) + return None + + table = tables[table_index] + cell_indices = [] + + for row in table["cells"]: + row_indices = [] + for cell in row: + # Each cell contains at least one paragraph + # Find the first paragraph in the cell for content insertion + cell_content = cell.get("content_elements", []) + if cell_content: + # Look for the first paragraph in cell content + first_para = None + for element in cell_content: + if "paragraph" in element: + first_para = element["paragraph"] + break + + if first_para and "elements" in first_para and first_para["elements"]: + # Insert at the start of the first text run in the paragraph + first_text_element = first_para["elements"][0] + if "textRun" in first_text_element: + start_idx = first_text_element.get( + "startIndex", cell["start_index"] + 1 + ) + end_idx = first_text_element.get("endIndex", start_idx + 1) + row_indices.append((start_idx, end_idx)) + continue + + # Fallback: use cell boundaries with safe margins + content_start = cell["start_index"] + 1 + content_end = cell["end_index"] - 1 + row_indices.append((content_start, content_end)) + cell_indices.append(row_indices) + + return cell_indices + + +def find_element_at_index( + doc_data: dict[str, Any], index: int +) -> Optional[dict[str, Any]]: + """ + Find what element exists at a given index in the document. + + Args: + doc_data: Raw document data from Google Docs API + index: Position in the document + + Returns: + Information about the element at that position, or None + """ + structure = parse_document_structure(doc_data) + + for element in structure["body"]: + if element["start_index"] <= index < element["end_index"]: + element_copy = element.copy() + + # If it's a table, find which cell contains the index + if element["type"] == "table" and "cells" in element: + for row_idx, row in enumerate(element["cells"]): + for col_idx, cell in enumerate(row): + if cell["start_index"] <= index < cell["end_index"]: + element_copy["containing_cell"] = { + "row": row_idx, + "column": col_idx, + "cell_start": cell["start_index"], + "cell_end": cell["end_index"], + } + break + + return element_copy + + return None + + +def get_next_paragraph_index(doc_data: dict[str, Any], after_index: int = 0) -> int: + """ + Find the next safe position to insert content after a given index. + + Args: + doc_data: Raw document data from Google Docs API + after_index: Index after which to find insertion point + + Returns: + Safe index for insertion + """ + structure = parse_document_structure(doc_data) + + # Find the first paragraph element after the given index + for element in structure["body"]: + if element["type"] == "paragraph" and element["start_index"] > after_index: + # Insert at the end of the previous element or start of this paragraph + return element["start_index"] + + # If no paragraph found, return the end of document + return structure["total_length"] - 1 if structure["total_length"] > 0 else 1 + + +def analyze_document_complexity(doc_data: dict[str, Any]) -> dict[str, Any]: + """ + Analyze document complexity and provide statistics. + + Args: + doc_data: Raw document data from Google Docs API + + Returns: + Dictionary with document statistics + """ + structure = parse_document_structure(doc_data) + + stats = { + "total_elements": len(structure["body"]), + "tables": len(structure["tables"]), + "paragraphs": sum(1 for e in structure["body"] if e.get("type") == "paragraph"), + "section_breaks": sum( + 1 for e in structure["body"] if e.get("type") == "section_break" + ), + "total_length": structure["total_length"], + "has_headers": bool(structure["headers"]), + "has_footers": bool(structure["footers"]), + } + + # Add table statistics + if structure["tables"]: + total_cells = sum( + table["rows"] * table["columns"] for table in structure["tables"] + ) + stats["total_table_cells"] = total_cells + stats["largest_table"] = max( + (t["rows"] * t["columns"] for t in structure["tables"]), default=0 + ) + + return stats diff --git a/gdocs/docs_tables.py b/gdocs/docs_tables.py new file mode 100644 index 0000000..7ff53f8 --- /dev/null +++ b/gdocs/docs_tables.py @@ -0,0 +1,464 @@ +""" +Google Docs Table Operations + +This module provides utilities for creating and manipulating tables +in Google Docs, including population with data and formatting. +""" + +import logging +from typing import Dict, Any, List, Optional, Union, Tuple + +logger = logging.getLogger(__name__) + + +def build_table_population_requests( + table_info: Dict[str, Any], data: List[List[str]], bold_headers: bool = True +) -> List[Dict[str, Any]]: + """ + Build batch requests to populate a table with data. + + Args: + table_info: Table information from document structure including cell indices + data: 2D array of data to insert into table + bold_headers: Whether to make the first row bold + + Returns: + List of request dictionaries for batch update + """ + requests = [] + cells = table_info.get("cells", []) + + if not cells: + logger.warning("No cell information found in table_info") + return requests + + # Process each cell - ONLY INSERT, DON'T DELETE + for row_idx, row_data in enumerate(data): + if row_idx >= len(cells): + logger.warning( + f"Data has more rows ({len(data)}) than table ({len(cells)})" + ) + break + + for col_idx, cell_text in enumerate(row_data): + if col_idx >= len(cells[row_idx]): + logger.warning( + f"Data has more columns ({len(row_data)}) than table row {row_idx} ({len(cells[row_idx])})" + ) + break + + cell = cells[row_idx][col_idx] + + # For new empty tables, use the insertion index + # For tables with existing content, check if cell only contains newline + existing_content = cell.get("content", "").strip() + + # Only insert if we have text to insert + if cell_text: + # Use the specific insertion index for this cell + insertion_index = cell.get("insertion_index", cell["start_index"] + 1) + + # If cell only contains a newline, replace it + if existing_content == "" or existing_content == "\n": + # Cell is empty (just newline), insert at the insertion index + requests.append( + { + "insertText": { + "location": {"index": insertion_index}, + "text": cell_text, + } + } + ) + + # Apply bold formatting to first row if requested + if bold_headers and row_idx == 0: + requests.append( + { + "updateTextStyle": { + "range": { + "startIndex": insertion_index, + "endIndex": insertion_index + len(cell_text), + }, + "textStyle": {"bold": True}, + "fields": "bold", + } + } + ) + else: + # Cell has content, append after existing content + # Find the end of existing content + cell_end = cell["end_index"] - 1 # Don't include cell end marker + requests.append( + { + "insertText": { + "location": {"index": cell_end}, + "text": cell_text, + } + } + ) + + # Apply bold formatting to first row if requested + if bold_headers and row_idx == 0: + requests.append( + { + "updateTextStyle": { + "range": { + "startIndex": cell_end, + "endIndex": cell_end + len(cell_text), + }, + "textStyle": {"bold": True}, + "fields": "bold", + } + } + ) + + return requests + + +def calculate_cell_positions( + table_start_index: int, + rows: int, + cols: int, + existing_table_data: Optional[Dict[str, Any]] = None, +) -> List[List[Dict[str, int]]]: + """ + Calculate estimated positions for each cell in a table. + + Args: + table_start_index: Starting index of the table + rows: Number of rows + cols: Number of columns + existing_table_data: Optional existing table data with actual positions + + Returns: + 2D list of cell position dictionaries + """ + if existing_table_data and "cells" in existing_table_data: + # Use actual positions from existing table + return existing_table_data["cells"] + + # Estimate positions for a new table + # Note: These are estimates; actual positions depend on content + cells = [] + current_index = table_start_index + 2 # Account for table start + + for row_idx in range(rows): + row_cells = [] + for col_idx in range(cols): + # Each cell typically starts with a paragraph marker + cell_start = current_index + cell_end = current_index + 2 # Minimum cell size + + row_cells.append( + { + "row": row_idx, + "column": col_idx, + "start_index": cell_start, + "end_index": cell_end, + } + ) + + current_index = cell_end + 1 + + cells.append(row_cells) + + return cells + + +def format_table_data( + raw_data: Union[List[List[str]], List[str], str], +) -> List[List[str]]: + """ + Normalize various data formats into a 2D array for table insertion. + + Args: + raw_data: Data in various formats (2D list, 1D list, or delimited string) + + Returns: + Normalized 2D list of strings + """ + if isinstance(raw_data, str): + # Parse delimited string (detect delimiter) + lines = raw_data.strip().split("\n") + if "\t" in raw_data: + # Tab-delimited + return [line.split("\t") for line in lines] + elif "," in raw_data: + # Comma-delimited (simple CSV) + return [line.split(",") for line in lines] + else: + # Space-delimited or single column + return [[cell.strip() for cell in line.split()] for line in lines] + + elif isinstance(raw_data, list): + if not raw_data: + return [[]] + + # Check if it's already a 2D list + if isinstance(raw_data[0], list): + # Ensure all cells are strings + return [[str(cell) for cell in row] for row in raw_data] + else: + # Convert 1D list to single-column table + return [[str(cell)] for cell in raw_data] + + else: + # Convert single value to 1x1 table + return [[str(raw_data)]] + + +def create_table_with_data( + index: int, + data: List[List[str]], + headers: Optional[List[str]] = None, + bold_headers: bool = True, +) -> List[Dict[str, Any]]: + """ + Create a table and populate it with data in one operation. + + Args: + index: Position to insert the table + data: 2D array of table data + headers: Optional header row (will be prepended to data) + bold_headers: Whether to make headers bold + + Returns: + List of request dictionaries for batch update + """ + requests = [] + + # Prepare data with headers if provided + if headers: + full_data = [headers] + data + else: + full_data = data + + # Normalize the data + full_data = format_table_data(full_data) + + if not full_data or not full_data[0]: + raise ValueError("Cannot create table with empty data") + + rows = len(full_data) + cols = len(full_data[0]) + + # Ensure all rows have the same number of columns + for row in full_data: + while len(row) < cols: + row.append("") + + # Create the table + requests.append( + {"insertTable": {"location": {"index": index}, "rows": rows, "columns": cols}} + ) + + # Build text insertion requests for each cell + # Note: In practice, we'd need to get the actual document structure + # after table creation to get accurate indices + + return requests + + +def build_table_style_requests( + table_start_index: int, style_options: Dict[str, Any] +) -> List[Dict[str, Any]]: + """ + Build requests to style a table. + + Args: + table_start_index: Starting index of the table + style_options: Dictionary of style options + - border_width: Width of borders in points + - border_color: RGB color for borders + - background_color: RGB color for cell backgrounds + - header_background: RGB color for header row background + + Returns: + List of request dictionaries for styling + """ + requests = [] + + # Table cell style update + if any( + k in style_options for k in ["border_width", "border_color", "background_color"] + ): + table_cell_style = {} + fields = [] + + if "border_width" in style_options: + border_width = {"magnitude": style_options["border_width"], "unit": "PT"} + table_cell_style["borderTop"] = {"width": border_width} + table_cell_style["borderBottom"] = {"width": border_width} + table_cell_style["borderLeft"] = {"width": border_width} + table_cell_style["borderRight"] = {"width": border_width} + fields.extend(["borderTop", "borderBottom", "borderLeft", "borderRight"]) + + if "border_color" in style_options: + border_color = {"color": {"rgbColor": style_options["border_color"]}} + if "borderTop" in table_cell_style: + table_cell_style["borderTop"]["color"] = border_color["color"] + table_cell_style["borderBottom"]["color"] = border_color["color"] + table_cell_style["borderLeft"]["color"] = border_color["color"] + table_cell_style["borderRight"]["color"] = border_color["color"] + + if "background_color" in style_options: + table_cell_style["backgroundColor"] = { + "color": {"rgbColor": style_options["background_color"]} + } + fields.append("backgroundColor") + + if table_cell_style and fields: + requests.append( + { + "updateTableCellStyle": { + "tableStartLocation": {"index": table_start_index}, + "tableCellStyle": table_cell_style, + "fields": ",".join(fields), + } + } + ) + + # Header row specific styling + if "header_background" in style_options: + requests.append( + { + "updateTableCellStyle": { + "tableRange": { + "tableCellLocation": { + "tableStartLocation": {"index": table_start_index}, + "rowIndex": 0, + "columnIndex": 0, + }, + "rowSpan": 1, + "columnSpan": 100, # Large number to cover all columns + }, + "tableCellStyle": { + "backgroundColor": { + "color": {"rgbColor": style_options["header_background"]} + } + }, + "fields": "backgroundColor", + } + } + ) + + return requests + + +def extract_table_as_data(table_info: Dict[str, Any]) -> List[List[str]]: + """ + Extract table content as a 2D array of strings. + + Args: + table_info: Table information from document structure + + Returns: + 2D list of cell contents + """ + data = [] + cells = table_info.get("cells", []) + + for row in cells: + row_data = [] + for cell in row: + row_data.append(cell.get("content", "").strip()) + data.append(row_data) + + return data + + +def find_table_by_content( + tables: List[Dict[str, Any]], search_text: str, case_sensitive: bool = False +) -> Optional[int]: + """ + Find a table index by searching for content within it. + + Args: + tables: List of table information from document + search_text: Text to search for in table cells + case_sensitive: Whether to do case-sensitive search + + Returns: + Index of the first matching table, or None + """ + search_text = search_text if case_sensitive else search_text.lower() + + for idx, table in enumerate(tables): + for row in table.get("cells", []): + for cell in row: + cell_content = cell.get("content", "") + if not case_sensitive: + cell_content = cell_content.lower() + + if search_text in cell_content: + return idx + + return None + + +def validate_table_data(data: List[List[str]]) -> Tuple[bool, str]: + """ + Validates table data format and provides specific error messages for LLMs. + + WHAT THIS CHECKS: + - Data is a 2D list (list of lists) + - All rows have consistent column counts + - Dimensions are within Google Docs limits + - No None or undefined values + + VALID FORMAT EXAMPLE: + [ + ["Header1", "Header2"], # Row 0 - 2 columns + ["Data1", "Data2"], # Row 1 - 2 columns + ["Data3", "Data4"] # Row 2 - 2 columns + ] + + INVALID FORMATS: + - [["col1"], ["col1", "col2"]] # Inconsistent column counts + - ["col1", "col2"] # Not 2D (missing inner lists) + - [["col1", None]] # Contains None values + - [] or [[]] # Empty data + + Args: + data: 2D array of data to validate + + Returns: + Tuple of (is_valid, error_message_with_examples) + """ + if not data: + return ( + False, + "Data is empty. Use format: [['col1', 'col2'], ['row1col1', 'row1col2']]", + ) + + if not isinstance(data, list): + return ( + False, + f"Data must be a list, got {type(data).__name__}. Use format: [['col1', 'col2'], ['row1col1', 'row1col2']]", + ) + + if not all(isinstance(row, list) for row in data): + return ( + False, + f"Data must be a 2D list (list of lists). Each row must be a list. Check your format: {data}", + ) + + # Check for consistent column count + col_counts = [len(row) for row in data] + if len(set(col_counts)) > 1: + return ( + False, + f"All rows must have same number of columns. Found: {col_counts}. Fix your data format.", + ) + + # Check for reasonable size + rows = len(data) + cols = col_counts[0] if col_counts else 0 + + if rows > 1000: + return False, f"Too many rows ({rows}). Google Docs limit is 1000 rows." + + if cols > 20: + return False, f"Too many columns ({cols}). Google Docs limit is 20 columns." + + return True, f"Valid table data: {rows}x{cols} table format" diff --git a/gdocs/docs_tools.py b/gdocs/docs_tools.py new file mode 100644 index 0000000..3d7b316 --- /dev/null +++ b/gdocs/docs_tools.py @@ -0,0 +1,1918 @@ +""" +Google Docs MCP Tools + +This module provides MCP tools for interacting with Google Docs API and managing Google Docs via Drive. +""" + +import logging +import asyncio +import io +import re +from typing import List, Dict, Any, Optional + +from googleapiclient.http import MediaIoBaseDownload, MediaIoBaseUpload + +# Auth & server utilities +from auth.service_decorator import require_google_service, require_multiple_services +from core.utils import extract_office_xml_text, handle_http_errors +from core.server import server +from core.comments import create_comment_tools + +# Import helper functions for document operations +from gdocs.docs_helpers import ( + create_insert_text_request, + create_delete_range_request, + create_format_text_request, + create_find_replace_request, + create_insert_table_request, + create_insert_page_break_request, + create_insert_image_request, + create_bullet_list_request, + create_insert_doc_tab_request, + create_update_doc_tab_request, + create_delete_doc_tab_request, +) + +# Import document structure and table utilities +from gdocs.docs_structure import ( + parse_document_structure, + find_tables, + analyze_document_complexity, +) +from gdocs.docs_tables import extract_table_as_data +from gdocs.docs_markdown import ( + convert_doc_to_markdown, + format_comments_inline, + format_comments_appendix, + parse_drive_comments, +) + +# Import operation managers for complex business logic +from gdocs.managers import ( + TableOperationManager, + HeaderFooterManager, + ValidationManager, + BatchOperationManager, +) +import json + +logger = logging.getLogger(__name__) + + +@server.tool() +@handle_http_errors("search_docs", is_read_only=True, service_type="docs") +@require_google_service("drive", "drive_read") +async def search_docs( + service: Any, + user_google_email: str, + query: str, + page_size: int = 10, +) -> str: + """ + Searches for Google Docs by name using Drive API (mimeType filter). + + Returns: + str: A formatted list of Google Docs matching the search query. + """ + logger.info(f"[search_docs] Email={user_google_email}, Query='{query}'") + + escaped_query = query.replace("'", "\\'") + + response = await asyncio.to_thread( + service.files() + .list( + q=f"name contains '{escaped_query}' and mimeType='application/vnd.google-apps.document' and trashed=false", + pageSize=page_size, + fields="files(id, name, createdTime, modifiedTime, webViewLink)", + supportsAllDrives=True, + includeItemsFromAllDrives=True, + ) + .execute + ) + files = response.get("files", []) + if not files: + return f"No Google Docs found matching '{query}'." + + output = [f"Found {len(files)} Google Docs matching '{query}':"] + for f in files: + output.append( + f"- {f['name']} (ID: {f['id']}) Modified: {f.get('modifiedTime')} Link: {f.get('webViewLink')}" + ) + return "\n".join(output) + + +@server.tool() +@handle_http_errors("get_doc_content", is_read_only=True, service_type="docs") +@require_multiple_services( + [ + { + "service_type": "drive", + "scopes": "drive_read", + "param_name": "drive_service", + }, + {"service_type": "docs", "scopes": "docs_read", "param_name": "docs_service"}, + ] +) +async def get_doc_content( + drive_service: Any, + docs_service: Any, + user_google_email: str, + document_id: str, +) -> str: + """ + Retrieves content of a Google Doc or a Drive file (like .docx) identified by document_id. + - Native Google Docs: Fetches content via Docs API. + - Office files (.docx, etc.) stored in Drive: Downloads via Drive API and extracts text. + + Returns: + str: The document content with metadata header. + """ + logger.info( + f"[get_doc_content] Invoked. Document/File ID: '{document_id}' for user '{user_google_email}'" + ) + + # Step 2: Get file metadata from Drive + file_metadata = await asyncio.to_thread( + drive_service.files() + .get( + fileId=document_id, + fields="id, name, mimeType, webViewLink", + supportsAllDrives=True, + ) + .execute + ) + mime_type = file_metadata.get("mimeType", "") + file_name = file_metadata.get("name", "Unknown File") + web_view_link = file_metadata.get("webViewLink", "#") + + logger.info( + f"[get_doc_content] File '{file_name}' (ID: {document_id}) has mimeType: '{mime_type}'" + ) + + body_text = "" # Initialize body_text + + # Step 3: Process based on mimeType + if mime_type == "application/vnd.google-apps.document": + logger.info("[get_doc_content] Processing as native Google Doc.") + doc_data = await asyncio.to_thread( + docs_service.documents() + .get(documentId=document_id, includeTabsContent=True) + .execute + ) + # Tab header format constant + TAB_HEADER_FORMAT = "\n--- TAB: {tab_name} (ID: {tab_id}) ---\n" + + def extract_text_from_elements(elements, tab_name=None, tab_id=None, depth=0): + """Extract text from document elements (paragraphs, tables, etc.)""" + # Prevent infinite recursion by limiting depth + if depth > 5: + return "" + text_lines = [] + if tab_name: + text_lines.append( + TAB_HEADER_FORMAT.format(tab_name=tab_name, tab_id=tab_id) + ) + + for element in elements: + if "paragraph" in element: + paragraph = element.get("paragraph", {}) + para_elements = paragraph.get("elements", []) + current_line_text = "" + for pe in para_elements: + text_run = pe.get("textRun", {}) + if text_run and "content" in text_run: + current_line_text += text_run["content"] + if current_line_text.strip(): + text_lines.append(current_line_text) + elif "table" in element: + # Handle table content + table = element.get("table", {}) + table_rows = table.get("tableRows", []) + for row in table_rows: + row_cells = row.get("tableCells", []) + for cell in row_cells: + cell_content = cell.get("content", []) + cell_text = extract_text_from_elements( + cell_content, depth=depth + 1 + ) + if cell_text.strip(): + text_lines.append(cell_text) + return "".join(text_lines) + + def process_tab_hierarchy(tab, level=0): + """Process a tab and its nested child tabs recursively""" + tab_text = "" + + if "documentTab" in tab: + props = tab.get("tabProperties", {}) + tab_title = props.get("title", "Untitled Tab") + tab_id = props.get("tabId", "Unknown ID") + # Add indentation for nested tabs to show hierarchy + if level > 0: + tab_title = " " * level + f"{tab_title}" + tab_body = tab.get("documentTab", {}).get("body", {}).get("content", []) + tab_text += extract_text_from_elements(tab_body, tab_title, tab_id) + + # Process child tabs (nested tabs) + child_tabs = tab.get("childTabs", []) + for child_tab in child_tabs: + tab_text += process_tab_hierarchy(child_tab, level + 1) + + return tab_text + + processed_text_lines = [] + + # Process main document body + body_elements = doc_data.get("body", {}).get("content", []) + main_content = extract_text_from_elements(body_elements) + if main_content.strip(): + processed_text_lines.append(main_content) + + # Process all tabs + tabs = doc_data.get("tabs", []) + for tab in tabs: + tab_content = process_tab_hierarchy(tab) + if tab_content.strip(): + processed_text_lines.append(tab_content) + + body_text = "".join(processed_text_lines) + else: + logger.info( + f"[get_doc_content] Processing as Drive file (e.g., .docx, other). MimeType: {mime_type}" + ) + + export_mime_type_map = { + # Example: "application/vnd.google-apps.spreadsheet"z: "text/csv", + # Native GSuite types that are not Docs would go here if this function + # was intended to export them. For .docx, direct download is used. + } + effective_export_mime = export_mime_type_map.get(mime_type) + + request_obj = ( + drive_service.files().export_media( + fileId=document_id, + mimeType=effective_export_mime, + supportsAllDrives=True, + ) + if effective_export_mime + else drive_service.files().get_media( + fileId=document_id, supportsAllDrives=True + ) + ) + + fh = io.BytesIO() + downloader = MediaIoBaseDownload(fh, request_obj) + loop = asyncio.get_event_loop() + done = False + while not done: + status, done = await loop.run_in_executor(None, downloader.next_chunk) + + file_content_bytes = fh.getvalue() + + office_text = extract_office_xml_text(file_content_bytes, mime_type) + if office_text: + body_text = office_text + else: + try: + body_text = file_content_bytes.decode("utf-8") + except UnicodeDecodeError: + body_text = ( + f"[Binary or unsupported text encoding for mimeType '{mime_type}' - " + f"{len(file_content_bytes)} bytes]" + ) + + header = ( + f'File: "{file_name}" (ID: {document_id}, Type: {mime_type})\n' + f"Link: {web_view_link}\n\n--- CONTENT ---\n" + ) + return header + body_text + + +@server.tool() +@handle_http_errors("list_docs_in_folder", is_read_only=True, service_type="docs") +@require_google_service("drive", "drive_read") +async def list_docs_in_folder( + service: Any, user_google_email: str, folder_id: str = "root", page_size: int = 100 +) -> str: + """ + Lists Google Docs within a specific Drive folder. + + Returns: + str: A formatted list of Google Docs in the specified folder. + """ + logger.info( + f"[list_docs_in_folder] Invoked. Email: '{user_google_email}', Folder ID: '{folder_id}'" + ) + + rsp = await asyncio.to_thread( + service.files() + .list( + q=f"'{folder_id}' in parents and mimeType='application/vnd.google-apps.document' and trashed=false", + pageSize=page_size, + fields="files(id, name, modifiedTime, webViewLink)", + supportsAllDrives=True, + includeItemsFromAllDrives=True, + ) + .execute + ) + items = rsp.get("files", []) + if not items: + return f"No Google Docs found in folder '{folder_id}'." + out = [f"Found {len(items)} Docs in folder '{folder_id}':"] + for f in items: + out.append( + f"- {f['name']} (ID: {f['id']}) Modified: {f.get('modifiedTime')} Link: {f.get('webViewLink')}" + ) + return "\n".join(out) + + +@server.tool() +@handle_http_errors("create_doc", service_type="docs") +@require_google_service("docs", "docs_write") +async def create_doc( + service: Any, + user_google_email: str, + title: str, + content: str = "", +) -> str: + """ + Creates a new Google Doc and optionally inserts initial content. + + Returns: + str: Confirmation message with document ID and link. + """ + logger.info(f"[create_doc] Invoked. Email: '{user_google_email}', Title='{title}'") + + doc = await asyncio.to_thread( + service.documents().create(body={"title": title}).execute + ) + doc_id = doc.get("documentId") + if content: + requests = [{"insertText": {"location": {"index": 1}, "text": content}}] + await asyncio.to_thread( + service.documents() + .batchUpdate(documentId=doc_id, body={"requests": requests}) + .execute + ) + link = f"https://docs.google.com/document/d/{doc_id}/edit" + msg = f"Created Google Doc '{title}' (ID: {doc_id}) for {user_google_email}. Link: {link}" + logger.info( + f"Successfully created Google Doc '{title}' (ID: {doc_id}) for {user_google_email}. Link: {link}" + ) + return msg + + +@server.tool() +@handle_http_errors("modify_doc_text", service_type="docs") +@require_google_service("docs", "docs_write") +async def modify_doc_text( + service: Any, + user_google_email: str, + document_id: str, + start_index: int, + end_index: int = None, + text: str = None, + bold: bool = None, + italic: bool = None, + underline: bool = None, + font_size: int = None, + font_family: str = None, + text_color: str = None, + background_color: str = None, + link_url: str = None, +) -> str: + """ + Modifies text in a Google Doc - can insert/replace text and/or apply formatting in a single operation. + + Args: + user_google_email: User's Google email address + document_id: ID of the document to update + start_index: Start position for operation (0-based) + end_index: End position for text replacement/formatting (if not provided with text, text is inserted) + text: New text to insert or replace with (optional - can format existing text without changing it) + bold: Whether to make text bold (True/False/None to leave unchanged) + italic: Whether to make text italic (True/False/None to leave unchanged) + underline: Whether to underline text (True/False/None to leave unchanged) + font_size: Font size in points + font_family: Font family name (e.g., "Arial", "Times New Roman") + text_color: Foreground text color (#RRGGBB) + background_color: Background/highlight color (#RRGGBB) + link_url: Hyperlink URL (http/https) + + Returns: + str: Confirmation message with operation details + """ + logger.info( + f"[modify_doc_text] Doc={document_id}, start={start_index}, end={end_index}, text={text is not None}, " + f"formatting={any(p is not None for p in [bold, italic, underline, font_size, font_family, text_color, background_color, link_url])}" + ) + + # Input validation + validator = ValidationManager() + + is_valid, error_msg = validator.validate_document_id(document_id) + if not is_valid: + return f"Error: {error_msg}" + + # Validate that we have something to do + formatting_params = [ + bold, + italic, + underline, + font_size, + font_family, + text_color, + background_color, + link_url, + ] + if text is None and not any(p is not None for p in formatting_params): + return "Error: Must provide either 'text' to insert/replace, or formatting parameters (bold, italic, underline, font_size, font_family, text_color, background_color, link_url)." + + # Validate text formatting params if provided + if any(p is not None for p in formatting_params): + is_valid, error_msg = validator.validate_text_formatting_params( + bold, + italic, + underline, + font_size, + font_family, + text_color, + background_color, + link_url, + ) + if not is_valid: + return f"Error: {error_msg}" + + # For formatting, we need end_index + if end_index is None: + return "Error: 'end_index' is required when applying formatting." + + is_valid, error_msg = validator.validate_index_range(start_index, end_index) + if not is_valid: + return f"Error: {error_msg}" + + requests = [] + operations = [] + + # Handle text insertion/replacement + if text is not None: + if end_index is not None and end_index > start_index: + # Text replacement + if start_index == 0: + # Special case: Cannot delete at index 0 (first section break) + # Instead, we insert new text at index 1 and then delete the old text + requests.append(create_insert_text_request(1, text)) + adjusted_end = end_index + len(text) + requests.append( + create_delete_range_request(1 + len(text), adjusted_end) + ) + operations.append( + f"Replaced text from index {start_index} to {end_index}" + ) + else: + # Normal replacement: delete old text, then insert new text + requests.extend( + [ + create_delete_range_request(start_index, end_index), + create_insert_text_request(start_index, text), + ] + ) + operations.append( + f"Replaced text from index {start_index} to {end_index}" + ) + else: + # Text insertion + actual_index = 1 if start_index == 0 else start_index + requests.append(create_insert_text_request(actual_index, text)) + operations.append(f"Inserted text at index {start_index}") + + # Handle formatting + if any(p is not None for p in formatting_params): + # Adjust range for formatting based on text operations + format_start = start_index + format_end = end_index + + if text is not None: + if end_index is not None and end_index > start_index: + # Text was replaced - format the new text + format_end = start_index + len(text) + else: + # Text was inserted - format the inserted text + actual_index = 1 if start_index == 0 else start_index + format_start = actual_index + format_end = actual_index + len(text) + + # Handle special case for formatting at index 0 + if format_start == 0: + format_start = 1 + if format_end is not None and format_end <= format_start: + format_end = format_start + 1 + + requests.append( + create_format_text_request( + format_start, + format_end, + bold, + italic, + underline, + font_size, + font_family, + text_color, + background_color, + link_url, + ) + ) + + format_details = [ + f"{name}={value}" + for name, value in [ + ("bold", bold), + ("italic", italic), + ("underline", underline), + ("font_size", font_size), + ("font_family", font_family), + ("text_color", text_color), + ("background_color", background_color), + ("link_url", link_url), + ] + if value is not None + ] + + operations.append( + f"Applied formatting ({', '.join(format_details)}) to range {format_start}-{format_end}" + ) + + await asyncio.to_thread( + service.documents() + .batchUpdate(documentId=document_id, body={"requests": requests}) + .execute + ) + + link = f"https://docs.google.com/document/d/{document_id}/edit" + operation_summary = "; ".join(operations) + text_info = f" Text length: {len(text)} characters." if text else "" + return f"{operation_summary} in document {document_id}.{text_info} Link: {link}" + + +@server.tool() +@handle_http_errors("find_and_replace_doc", service_type="docs") +@require_google_service("docs", "docs_write") +async def find_and_replace_doc( + service: Any, + user_google_email: str, + document_id: str, + find_text: str, + replace_text: str, + match_case: bool = False, + tab_id: Optional[str] = None, +) -> str: + """ + Finds and replaces text throughout a Google Doc. + + Args: + user_google_email: User's Google email address + document_id: ID of the document to update + find_text: Text to search for + replace_text: Text to replace with + match_case: Whether to match case exactly + tab_id: Optional ID of the tab to target + + Returns: + str: Confirmation message with replacement count + """ + logger.info( + f"[find_and_replace_doc] Doc={document_id}, find='{find_text}', replace='{replace_text}', tab='{tab_id}'" + ) + + requests = [ + create_find_replace_request(find_text, replace_text, match_case, tab_id) + ] + + result = await asyncio.to_thread( + service.documents() + .batchUpdate(documentId=document_id, body={"requests": requests}) + .execute + ) + + # Extract number of replacements from response + replacements = 0 + if "replies" in result and result["replies"]: + reply = result["replies"][0] + if "replaceAllText" in reply: + replacements = reply["replaceAllText"].get("occurrencesChanged", 0) + + link = f"https://docs.google.com/document/d/{document_id}/edit" + return f"Replaced {replacements} occurrence(s) of '{find_text}' with '{replace_text}' in document {document_id}. Link: {link}" + + +@server.tool() +@handle_http_errors("insert_doc_elements", service_type="docs") +@require_google_service("docs", "docs_write") +async def insert_doc_elements( + service: Any, + user_google_email: str, + document_id: str, + element_type: str, + index: int, + rows: int = None, + columns: int = None, + list_type: str = None, + text: str = None, +) -> str: + """ + Inserts structural elements like tables, lists, or page breaks into a Google Doc. + + Args: + user_google_email: User's Google email address + document_id: ID of the document to update + element_type: Type of element to insert ("table", "list", "page_break") + index: Position to insert element (0-based) + rows: Number of rows for table (required for table) + columns: Number of columns for table (required for table) + list_type: Type of list ("UNORDERED", "ORDERED") (required for list) + text: Initial text content for list items + + Returns: + str: Confirmation message with insertion details + """ + logger.info( + f"[insert_doc_elements] Doc={document_id}, type={element_type}, index={index}" + ) + + # Handle the special case where we can't insert at the first section break + # If index is 0, bump it to 1 to avoid the section break + if index == 0: + logger.debug("Adjusting index from 0 to 1 to avoid first section break") + index = 1 + + requests = [] + + if element_type == "table": + if not rows or not columns: + return "Error: 'rows' and 'columns' parameters are required for table insertion." + + requests.append(create_insert_table_request(index, rows, columns)) + description = f"table ({rows}x{columns})" + + elif element_type == "list": + if not list_type: + return "Error: 'list_type' parameter is required for list insertion ('UNORDERED' or 'ORDERED')." + + if not text: + text = "List item" + + # Insert text first, then create list + requests.extend( + [ + create_insert_text_request(index, text + "\n"), + create_bullet_list_request(index, index + len(text), list_type), + ] + ) + description = f"{list_type.lower()} list" + + elif element_type == "page_break": + requests.append(create_insert_page_break_request(index)) + description = "page break" + + else: + return f"Error: Unsupported element type '{element_type}'. Supported types: 'table', 'list', 'page_break'." + + await asyncio.to_thread( + service.documents() + .batchUpdate(documentId=document_id, body={"requests": requests}) + .execute + ) + + link = f"https://docs.google.com/document/d/{document_id}/edit" + return f"Inserted {description} at index {index} in document {document_id}. Link: {link}" + + +@server.tool() +@handle_http_errors("insert_doc_image", service_type="docs") +@require_multiple_services( + [ + {"service_type": "docs", "scopes": "docs_write", "param_name": "docs_service"}, + { + "service_type": "drive", + "scopes": "drive_read", + "param_name": "drive_service", + }, + ] +) +async def insert_doc_image( + docs_service: Any, + drive_service: Any, + user_google_email: str, + document_id: str, + image_source: str, + index: int, + width: int = 0, + height: int = 0, +) -> str: + """ + Inserts an image into a Google Doc from Drive or a URL. + + Args: + user_google_email: User's Google email address + document_id: ID of the document to update + image_source: Drive file ID or public image URL + index: Position to insert image (0-based) + width: Image width in points (optional) + height: Image height in points (optional) + + Returns: + str: Confirmation message with insertion details + """ + logger.info( + f"[insert_doc_image] Doc={document_id}, source={image_source}, index={index}" + ) + + # Handle the special case where we can't insert at the first section break + # If index is 0, bump it to 1 to avoid the section break + if index == 0: + logger.debug("Adjusting index from 0 to 1 to avoid first section break") + index = 1 + + # Determine if source is a Drive file ID or URL + is_drive_file = not ( + image_source.startswith("http://") or image_source.startswith("https://") + ) + + if is_drive_file: + # Verify Drive file exists and get metadata + try: + file_metadata = await asyncio.to_thread( + drive_service.files() + .get( + fileId=image_source, + fields="id, name, mimeType", + supportsAllDrives=True, + ) + .execute + ) + mime_type = file_metadata.get("mimeType", "") + if not mime_type.startswith("image/"): + return f"Error: File {image_source} is not an image (MIME type: {mime_type})." + + image_uri = f"https://drive.google.com/uc?id={image_source}" + source_description = f"Drive file {file_metadata.get('name', image_source)}" + except Exception as e: + return f"Error: Could not access Drive file {image_source}: {str(e)}" + else: + image_uri = image_source + source_description = "URL image" + + # Use helper to create image request + requests = [create_insert_image_request(index, image_uri, width, height)] + + await asyncio.to_thread( + docs_service.documents() + .batchUpdate(documentId=document_id, body={"requests": requests}) + .execute + ) + + size_info = "" + if width or height: + size_info = f" (size: {width or 'auto'}x{height or 'auto'} points)" + + link = f"https://docs.google.com/document/d/{document_id}/edit" + return f"Inserted {source_description}{size_info} at index {index} in document {document_id}. Link: {link}" + + +@server.tool() +@handle_http_errors("update_doc_headers_footers", service_type="docs") +@require_google_service("docs", "docs_write") +async def update_doc_headers_footers( + service: Any, + user_google_email: str, + document_id: str, + section_type: str, + content: str, + header_footer_type: str = "DEFAULT", +) -> str: + """ + Updates headers or footers in a Google Doc. + + Args: + user_google_email: User's Google email address + document_id: ID of the document to update + section_type: Type of section to update ("header" or "footer") + content: Text content for the header/footer + header_footer_type: Type of header/footer ("DEFAULT", "FIRST_PAGE_ONLY", "EVEN_PAGE") + + Returns: + str: Confirmation message with update details + """ + logger.info(f"[update_doc_headers_footers] Doc={document_id}, type={section_type}") + + # Input validation + validator = ValidationManager() + + is_valid, error_msg = validator.validate_document_id(document_id) + if not is_valid: + return f"Error: {error_msg}" + + is_valid, error_msg = validator.validate_header_footer_params( + section_type, header_footer_type + ) + if not is_valid: + return f"Error: {error_msg}" + + is_valid, error_msg = validator.validate_text_content(content) + if not is_valid: + return f"Error: {error_msg}" + + # Use HeaderFooterManager to handle the complex logic + header_footer_manager = HeaderFooterManager(service) + + success, message = await header_footer_manager.update_header_footer_content( + document_id, section_type, content, header_footer_type + ) + + if success: + link = f"https://docs.google.com/document/d/{document_id}/edit" + return f"{message}. Link: {link}" + else: + return f"Error: {message}" + + +@server.tool() +@handle_http_errors("batch_update_doc", service_type="docs") +@require_google_service("docs", "docs_write") +async def batch_update_doc( + service: Any, + user_google_email: str, + document_id: str, + operations: List[Dict[str, Any]], +) -> str: + """ + Executes multiple document operations in a single atomic batch update. + + Args: + user_google_email: User's Google email address + document_id: ID of the document to update + operations: List of operation dicts. Each operation MUST have a 'type' field. + All operations accept an optional 'tab_id' to target a specific tab. + + Supported operation types and their parameters: + + insert_text - required: index (int), text (str) + delete_text - required: start_index (int), end_index (int) + replace_text - required: start_index (int), end_index (int), text (str) + format_text - required: start_index (int), end_index (int) + optional: bold, italic, underline, font_size, font_family, + text_color, background_color, link_url + update_paragraph_style + - required: start_index (int), end_index (int) + optional: heading_level (0-6, 0=normal), alignment + (START/CENTER/END/JUSTIFIED), line_spacing, + indent_first_line, indent_start, indent_end, + space_above, space_below + insert_table - required: index (int), rows (int), columns (int) + insert_page_break- required: index (int) + find_replace - required: find_text (str), replace_text (str) + optional: match_case (bool, default false) + create_bullet_list - required: start_index (int), end_index (int) + optional: list_type ('UNORDERED'|'ORDERED'|'NONE', default UNORDERED), + nesting_level (0-8), paragraph_start_indices (list[int]) + Use list_type='NONE' to remove existing bullet/list formatting + insert_doc_tab - required: title (str), index (int) + optional: parent_tab_id (str) + delete_doc_tab - required: tab_id (str) + update_doc_tab - required: tab_id (str), title (str) + + Example operations: + [ + {"type": "insert_text", "index": 1, "text": "Hello World"}, + {"type": "format_text", "start_index": 1, "end_index": 12, "bold": true}, + {"type": "update_paragraph_style", "start_index": 1, "end_index": 12, + "heading_level": 1, "alignment": "CENTER"}, + {"type": "find_replace", "find_text": "foo", "replace_text": "bar"}, + {"type": "insert_table", "index": 20, "rows": 2, "columns": 3}, + {"type": "insert_doc_tab", "title": "Appendix", "index": 1} + ] + + Returns: + str: Confirmation message with batch operation results + """ + logger.debug(f"[batch_update_doc] Doc={document_id}, operations={len(operations)}") + + # Input validation + validator = ValidationManager() + + is_valid, error_msg = validator.validate_document_id(document_id) + if not is_valid: + return f"Error: {error_msg}" + + is_valid, error_msg = validator.validate_batch_operations(operations) + if not is_valid: + return f"Error: {error_msg}" + + # Use BatchOperationManager to handle the complex logic + batch_manager = BatchOperationManager(service) + + success, message, metadata = await batch_manager.execute_batch_operations( + document_id, operations + ) + + if success: + link = f"https://docs.google.com/document/d/{document_id}/edit" + replies_count = metadata.get("replies_count", 0) + return f"{message} on document {document_id}. API replies: {replies_count}. Link: {link}" + else: + return f"Error: {message}" + + +@server.tool() +@handle_http_errors("inspect_doc_structure", is_read_only=True, service_type="docs") +@require_google_service("docs", "docs_read") +async def inspect_doc_structure( + service: Any, + user_google_email: str, + document_id: str, + detailed: bool = False, + tab_id: str = None, +) -> str: + """ + Essential tool for finding safe insertion points and understanding document structure. + + USE THIS FOR: + - Finding the correct index for table insertion + - Understanding document layout before making changes + - Locating existing tables and their positions + - Getting document statistics and complexity info + - Inspecting structure of specific tabs + + CRITICAL FOR TABLE OPERATIONS: + ALWAYS call this BEFORE creating tables to get a safe insertion index. + + WHAT THE OUTPUT SHOWS: + - total_elements: Number of document elements + - total_length: Maximum safe index for insertion + - tables: Number of existing tables + - table_details: Position and dimensions of each table + - tabs: List of available tabs in the document (if no tab_id specified) + + WORKFLOW: + Step 1: Call this function + Step 2: Note the "total_length" value + Step 3: Use an index < total_length for table insertion + Step 4: Create your table + + Args: + user_google_email: User's Google email address + document_id: ID of the document to inspect + detailed: Whether to return detailed structure information + tab_id: Optional ID of the tab to inspect. If not provided, inspects main document. + + Returns: + str: JSON string containing document structure and safe insertion indices + """ + logger.debug( + f"[inspect_doc_structure] Doc={document_id}, detailed={detailed}, tab_id={tab_id}" + ) + + # Get the document + doc = await asyncio.to_thread( + service.documents().get(documentId=document_id, includeTabsContent=True).execute + ) + + # If tab_id is specified, find the tab and use its content + target_content = doc.get("body", {}) + + def find_tab(tabs, target_id): + for tab in tabs: + if tab.get("tabProperties", {}).get("tabId") == target_id: + return tab + if "childTabs" in tab: + found = find_tab(tab["childTabs"], target_id) + if found: + return found + return None + + if tab_id: + tab = find_tab(doc.get("tabs", []), tab_id) + if tab and "documentTab" in tab: + target_content = tab["documentTab"].get("body", {}) + elif tab: + return f"Error: Tab {tab_id} is not a document tab and has no body content." + else: + return f"Error: Tab {tab_id} not found in document." + + # Create a dummy doc object for analysis tools that expect a full doc + analysis_doc = doc.copy() + analysis_doc["body"] = target_content + + if detailed: + # Return full parsed structure + structure = parse_document_structure(analysis_doc) + + # Simplify for JSON serialization + result = { + "title": structure["title"], + "total_length": structure["total_length"], + "statistics": { + "elements": len(structure["body"]), + "tables": len(structure["tables"]), + "paragraphs": sum( + 1 for e in structure["body"] if e.get("type") == "paragraph" + ), + "has_headers": bool(structure["headers"]), + "has_footers": bool(structure["footers"]), + }, + "elements": [], + } + + # Add element summaries + for element in structure["body"]: + elem_summary = { + "type": element["type"], + "start_index": element["start_index"], + "end_index": element["end_index"], + } + + if element["type"] == "table": + elem_summary["rows"] = element["rows"] + elem_summary["columns"] = element["columns"] + elem_summary["cell_count"] = len(element.get("cells", [])) + elif element["type"] == "paragraph": + elem_summary["text_preview"] = element.get("text", "")[:100] + + result["elements"].append(elem_summary) + + # Add table details + if structure["tables"]: + result["tables"] = [] + for i, table in enumerate(structure["tables"]): + table_data = extract_table_as_data(table) + result["tables"].append( + { + "index": i, + "position": { + "start": table["start_index"], + "end": table["end_index"], + }, + "dimensions": { + "rows": table["rows"], + "columns": table["columns"], + }, + "preview": table_data[:3] if table_data else [], # First 3 rows + } + ) + + else: + # Return basic analysis + result = analyze_document_complexity(analysis_doc) + + # Add table information + tables = find_tables(analysis_doc) + if tables: + result["table_details"] = [] + for i, table in enumerate(tables): + result["table_details"].append( + { + "index": i, + "rows": table["rows"], + "columns": table["columns"], + "start_index": table["start_index"], + "end_index": table["end_index"], + } + ) + + # Always include available tabs if no tab_id was specified + if not tab_id: + + def get_tabs_summary(tabs): + summary = [] + for tab in tabs: + props = tab.get("tabProperties", {}) + tab_info = { + "title": props.get("title"), + "tab_id": props.get("tabId"), + } + if "childTabs" in tab: + tab_info["child_tabs"] = get_tabs_summary(tab["childTabs"]) + summary.append(tab_info) + return summary + + result["tabs"] = get_tabs_summary(doc.get("tabs", [])) + + if tab_id: + result["inspected_tab_id"] = tab_id + + link = f"https://docs.google.com/document/d/{document_id}/edit" + return f"Document structure analysis for {document_id}:\n\n{json.dumps(result, indent=2)}\n\nLink: {link}" + + +@server.tool() +@handle_http_errors("create_table_with_data", service_type="docs") +@require_google_service("docs", "docs_write") +async def create_table_with_data( + service: Any, + user_google_email: str, + document_id: str, + table_data: List[List[str]], + index: int, + bold_headers: bool = True, + tab_id: Optional[str] = None, +) -> str: + """ + Creates a table and populates it with data in one reliable operation. + + CRITICAL: YOU MUST CALL inspect_doc_structure FIRST TO GET THE INDEX! + + MANDATORY WORKFLOW - DO THESE STEPS IN ORDER: + + Step 1: ALWAYS call inspect_doc_structure first + Step 2: Use the 'total_length' value from inspect_doc_structure as your index + Step 3: Format data as 2D list: [["col1", "col2"], ["row1col1", "row1col2"]] + Step 4: Call this function with the correct index and data + + EXAMPLE DATA FORMAT: + table_data = [ + ["Header1", "Header2", "Header3"], # Row 0 - headers + ["Data1", "Data2", "Data3"], # Row 1 - first data row + ["Data4", "Data5", "Data6"] # Row 2 - second data row + ] + + CRITICAL INDEX REQUIREMENTS: + - NEVER use index values like 1, 2, 10 without calling inspect_doc_structure first + - ALWAYS get index from inspect_doc_structure 'total_length' field + - Index must be a valid insertion point in the document + + DATA FORMAT REQUIREMENTS: + - Must be 2D list of strings only + - Each inner list = one table row + - All rows MUST have same number of columns + - Use empty strings "" for empty cells, never None + - Use debug_table_structure after creation to verify results + + Args: + user_google_email: User's Google email address + document_id: ID of the document to update + table_data: 2D list of strings - EXACT format: [["col1", "col2"], ["row1col1", "row1col2"]] + index: Document position (MANDATORY: get from inspect_doc_structure 'total_length') + bold_headers: Whether to make first row bold (default: true) + tab_id: Optional tab ID to create the table in a specific tab + + Returns: + str: Confirmation with table details and link + """ + logger.debug(f"[create_table_with_data] Doc={document_id}, index={index}") + + # Input validation + validator = ValidationManager() + + is_valid, error_msg = validator.validate_document_id(document_id) + if not is_valid: + return f"ERROR: {error_msg}" + + is_valid, error_msg = validator.validate_table_data(table_data) + if not is_valid: + return f"ERROR: {error_msg}" + + is_valid, error_msg = validator.validate_index(index, "Index") + if not is_valid: + return f"ERROR: {error_msg}" + + # Use TableOperationManager to handle the complex logic + table_manager = TableOperationManager(service) + + # Try to create the table, and if it fails due to index being at document end, retry with index-1 + success, message, metadata = await table_manager.create_and_populate_table( + document_id, table_data, index, bold_headers, tab_id + ) + + # If it failed due to index being at or beyond document end, retry with adjusted index + if not success and "must be less than the end index" in message: + logger.debug( + f"Index {index} is at document boundary, retrying with index {index - 1}" + ) + success, message, metadata = await table_manager.create_and_populate_table( + document_id, table_data, index - 1, bold_headers, tab_id + ) + + if success: + link = f"https://docs.google.com/document/d/{document_id}/edit" + rows = metadata.get("rows", 0) + columns = metadata.get("columns", 0) + + return ( + f"SUCCESS: {message}. Table: {rows}x{columns}, Index: {index}. Link: {link}" + ) + else: + return f"ERROR: {message}" + + +@server.tool() +@handle_http_errors("debug_table_structure", is_read_only=True, service_type="docs") +@require_google_service("docs", "docs_read") +async def debug_table_structure( + service: Any, + user_google_email: str, + document_id: str, + table_index: int = 0, +) -> str: + """ + ESSENTIAL DEBUGGING TOOL - Use this whenever tables don't work as expected. + + USE THIS IMMEDIATELY WHEN: + - Table population put data in wrong cells + - You get "table not found" errors + - Data appears concatenated in first cell + - Need to understand existing table structure + - Planning to use populate_existing_table + + WHAT THIS SHOWS YOU: + - Exact table dimensions (rows × columns) + - Each cell's position coordinates (row,col) + - Current content in each cell + - Insertion indices for each cell + - Table boundaries and ranges + + HOW TO READ THE OUTPUT: + - "dimensions": "2x3" = 2 rows, 3 columns + - "position": "(0,0)" = first row, first column + - "current_content": What's actually in each cell right now + - "insertion_index": Where new text would be inserted in that cell + + WORKFLOW INTEGRATION: + 1. After creating table → Use this to verify structure + 2. Before populating → Use this to plan your data format + 3. After population fails → Use this to see what went wrong + 4. When debugging → Compare your data array to actual table structure + + Args: + user_google_email: User's Google email address + document_id: ID of the document to inspect + table_index: Which table to debug (0 = first table, 1 = second table, etc.) + + Returns: + str: Detailed JSON structure showing table layout, cell positions, and current content + """ + logger.debug( + f"[debug_table_structure] Doc={document_id}, table_index={table_index}" + ) + + # Get the document + doc = await asyncio.to_thread( + service.documents().get(documentId=document_id).execute + ) + + # Find tables + tables = find_tables(doc) + if table_index >= len(tables): + return f"Error: Table index {table_index} not found. Document has {len(tables)} table(s)." + + table_info = tables[table_index] + + # Extract detailed cell information + debug_info = { + "table_index": table_index, + "dimensions": f"{table_info['rows']}x{table_info['columns']}", + "table_range": f"[{table_info['start_index']}-{table_info['end_index']}]", + "cells": [], + } + + for row_idx, row in enumerate(table_info["cells"]): + row_info = [] + for col_idx, cell in enumerate(row): + cell_debug = { + "position": f"({row_idx},{col_idx})", + "range": f"[{cell['start_index']}-{cell['end_index']}]", + "insertion_index": cell.get("insertion_index", "N/A"), + "current_content": repr(cell.get("content", "")), + "content_elements_count": len(cell.get("content_elements", [])), + } + row_info.append(cell_debug) + debug_info["cells"].append(row_info) + + link = f"https://docs.google.com/document/d/{document_id}/edit" + return f"Table structure debug for table {table_index}:\n\n{json.dumps(debug_info, indent=2)}\n\nLink: {link}" + + +@server.tool() +@handle_http_errors("export_doc_to_pdf", service_type="drive") +@require_google_service("drive", "drive_file") +async def export_doc_to_pdf( + service: Any, + user_google_email: str, + document_id: str, + pdf_filename: str = None, + folder_id: str = None, +) -> str: + """ + Exports a Google Doc to PDF format and saves it to Google Drive. + + Args: + user_google_email: User's Google email address + document_id: ID of the Google Doc to export + pdf_filename: Name for the PDF file (optional - if not provided, uses original name + "_PDF") + folder_id: Drive folder ID to save PDF in (optional - if not provided, saves in root) + + Returns: + str: Confirmation message with PDF file details and links + """ + logger.info( + f"[export_doc_to_pdf] Email={user_google_email}, Doc={document_id}, pdf_filename={pdf_filename}, folder_id={folder_id}" + ) + + # Get file metadata first to validate it's a Google Doc + try: + file_metadata = await asyncio.to_thread( + service.files() + .get( + fileId=document_id, + fields="id, name, mimeType, webViewLink", + supportsAllDrives=True, + ) + .execute + ) + except Exception as e: + return f"Error: Could not access document {document_id}: {str(e)}" + + mime_type = file_metadata.get("mimeType", "") + original_name = file_metadata.get("name", "Unknown Document") + web_view_link = file_metadata.get("webViewLink", "#") + + # Verify it's a Google Doc + if mime_type != "application/vnd.google-apps.document": + return f"Error: File '{original_name}' is not a Google Doc (MIME type: {mime_type}). Only native Google Docs can be exported to PDF." + + logger.info(f"[export_doc_to_pdf] Exporting '{original_name}' to PDF") + + # Export the document as PDF + try: + request_obj = service.files().export_media( + fileId=document_id, mimeType="application/pdf" + ) + + fh = io.BytesIO() + downloader = MediaIoBaseDownload(fh, request_obj) + + done = False + while not done: + _, done = await asyncio.to_thread(downloader.next_chunk) + + pdf_content = fh.getvalue() + pdf_size = len(pdf_content) + + except Exception as e: + return f"Error: Failed to export document to PDF: {str(e)}" + + # Determine PDF filename + if not pdf_filename: + pdf_filename = f"{original_name}_PDF.pdf" + elif not pdf_filename.endswith(".pdf"): + pdf_filename += ".pdf" + + # Upload PDF to Drive + try: + # Reuse the existing BytesIO object by resetting to the beginning + fh.seek(0) + # Create media upload object + media = MediaIoBaseUpload(fh, mimetype="application/pdf", resumable=True) + + # Prepare file metadata for upload + file_metadata = {"name": pdf_filename, "mimeType": "application/pdf"} + + # Add parent folder if specified + if folder_id: + file_metadata["parents"] = [folder_id] + + # Upload the file + uploaded_file = await asyncio.to_thread( + service.files() + .create( + body=file_metadata, + media_body=media, + fields="id, name, webViewLink, parents", + supportsAllDrives=True, + ) + .execute + ) + + pdf_file_id = uploaded_file.get("id") + pdf_web_link = uploaded_file.get("webViewLink", "#") + pdf_parents = uploaded_file.get("parents", []) + + logger.info( + f"[export_doc_to_pdf] Successfully uploaded PDF to Drive: {pdf_file_id}" + ) + + folder_info = "" + if folder_id: + folder_info = f" in folder {folder_id}" + elif pdf_parents: + folder_info = f" in folder {pdf_parents[0]}" + + return f"Successfully exported '{original_name}' to PDF and saved to Drive as '{pdf_filename}' (ID: {pdf_file_id}, {pdf_size:,} bytes){folder_info}. PDF: {pdf_web_link} | Original: {web_view_link}" + + except Exception as e: + return f"Error: Failed to upload PDF to Drive: {str(e)}. PDF was generated successfully ({pdf_size:,} bytes) but could not be saved to Drive." + + +# ============================================================================== +# STYLING TOOLS - Paragraph Formatting +# ============================================================================== + + +async def _get_paragraph_start_indices_in_range( + service: Any, document_id: str, start_index: int, end_index: int +) -> list[int]: + """ + Fetch paragraph start indices that overlap a target range. + """ + doc_data = await asyncio.to_thread( + service.documents() + .get( + documentId=document_id, + fields="body/content(startIndex,endIndex,paragraph)", + ) + .execute + ) + + paragraph_starts = [] + for element in doc_data.get("body", {}).get("content", []): + if "paragraph" not in element: + continue + paragraph_start = element.get("startIndex") + paragraph_end = element.get("endIndex") + if not isinstance(paragraph_start, int) or not isinstance(paragraph_end, int): + continue + if paragraph_end > start_index and paragraph_start < end_index: + paragraph_starts.append(paragraph_start) + + return paragraph_starts or [start_index] + + +@server.tool() +@handle_http_errors("update_paragraph_style", service_type="docs") +@require_google_service("docs", "docs_write") +async def update_paragraph_style( + service: Any, + user_google_email: str, + document_id: str, + start_index: int, + end_index: int, + heading_level: int = None, + alignment: str = None, + line_spacing: float = None, + indent_first_line: float = None, + indent_start: float = None, + indent_end: float = None, + space_above: float = None, + space_below: float = None, + named_style_type: str = None, + list_type: str = None, + list_nesting_level: int = None, +) -> str: + """ + Apply paragraph-level formatting, heading styles, and/or list formatting to a range in a Google Doc. + + This tool can apply named heading styles (H1-H6) for semantic document structure, + create bulleted or numbered lists with nested indentation, and customize paragraph + properties like alignment, spacing, and indentation. All operations can be applied + in a single call. + + Args: + user_google_email: User's Google email address + document_id: Document ID to modify + start_index: Start position (1-based) + end_index: End position (exclusive) - should cover the entire paragraph + heading_level: Heading level 0-6 (0 = NORMAL_TEXT, 1 = H1, 2 = H2, etc.) + Use for semantic document structure + alignment: Text alignment - 'START' (left), 'CENTER', 'END' (right), or 'JUSTIFIED' + line_spacing: Line spacing multiplier (1.0 = single, 1.5 = 1.5x, 2.0 = double) + indent_first_line: First line indent in points (e.g., 36 for 0.5 inch) + indent_start: Left/start indent in points + indent_end: Right/end indent in points + space_above: Space above paragraph in points (e.g., 12 for one line) + space_below: Space below paragraph in points + named_style_type: Direct named style type - 'NORMAL_TEXT', 'TITLE', 'SUBTITLE', + 'HEADING_1' through 'HEADING_6'. Mutually exclusive with heading_level. + list_type: Create a list from existing paragraphs ('UNORDERED' for bullets, 'ORDERED' for numbers) + list_nesting_level: Nesting level for lists (0-8, where 0 is top level, default is 0) + Use higher levels for nested/indented list items + + Returns: + str: Confirmation message with formatting details + + Examples: + # Apply H1 heading style + update_paragraph_style(document_id="...", start_index=1, end_index=20, heading_level=1) + + # Create a bulleted list + update_paragraph_style(document_id="...", start_index=1, end_index=50, + list_type="UNORDERED") + + # Create a nested numbered list item + update_paragraph_style(document_id="...", start_index=1, end_index=30, + list_type="ORDERED", list_nesting_level=1) + + # Apply H2 heading with custom spacing + update_paragraph_style(document_id="...", start_index=1, end_index=30, + heading_level=2, space_above=18, space_below=12) + + # Center-align a paragraph with double spacing + update_paragraph_style(document_id="...", start_index=1, end_index=50, + alignment="CENTER", line_spacing=2.0) + """ + logger.info( + f"[update_paragraph_style] Doc={document_id}, Range: {start_index}-{end_index}" + ) + + # Validate range + if start_index < 1: + return "Error: start_index must be >= 1" + if end_index <= start_index: + return "Error: end_index must be greater than start_index" + + # Validate list parameters + list_type_value = list_type + if list_type_value is not None: + # Coerce non-string inputs to string before normalization to avoid AttributeError + if not isinstance(list_type_value, str): + list_type_value = str(list_type_value) + valid_list_types = ["UNORDERED", "ORDERED"] + normalized_list_type = list_type_value.upper() + if normalized_list_type not in valid_list_types: + return f"Error: list_type must be one of: {', '.join(valid_list_types)}" + + list_type_value = normalized_list_type + + if list_nesting_level is not None: + if list_type_value is None: + return "Error: list_nesting_level requires list_type parameter" + if not isinstance(list_nesting_level, int): + return "Error: list_nesting_level must be an integer" + if list_nesting_level < 0 or list_nesting_level > 8: + return "Error: list_nesting_level must be between 0 and 8" + + # Validate named_style_type + if named_style_type is not None and heading_level is not None: + return "Error: heading_level and named_style_type are mutually exclusive; provide only one" + + if named_style_type is not None: + valid_styles = [ + "NORMAL_TEXT", "TITLE", "SUBTITLE", + "HEADING_1", "HEADING_2", "HEADING_3", + "HEADING_4", "HEADING_5", "HEADING_6", + ] + if named_style_type not in valid_styles: + return f"Error: Invalid named_style_type '{named_style_type}'. Must be one of: {', '.join(valid_styles)}" + + # Build paragraph style object + paragraph_style = {} + fields = [] + + # Handle named_style_type (direct named style) + if named_style_type is not None: + paragraph_style["namedStyleType"] = named_style_type + fields.append("namedStyleType") + + # Handle heading level (named style) + elif heading_level is not None: + if heading_level < 0 or heading_level > 6: + return "Error: heading_level must be between 0 (normal text) and 6" + if heading_level == 0: + paragraph_style["namedStyleType"] = "NORMAL_TEXT" + else: + paragraph_style["namedStyleType"] = f"HEADING_{heading_level}" + fields.append("namedStyleType") + + # Handle alignment + if alignment is not None: + valid_alignments = ["START", "CENTER", "END", "JUSTIFIED"] + alignment_upper = alignment.upper() + if alignment_upper not in valid_alignments: + return f"Error: Invalid alignment '{alignment}'. Must be one of: {valid_alignments}" + paragraph_style["alignment"] = alignment_upper + fields.append("alignment") + + # Handle line spacing + if line_spacing is not None: + if line_spacing <= 0: + return "Error: line_spacing must be positive" + paragraph_style["lineSpacing"] = line_spacing * 100 # Convert to percentage + fields.append("lineSpacing") + + # Handle indentation + if indent_first_line is not None: + paragraph_style["indentFirstLine"] = { + "magnitude": indent_first_line, + "unit": "PT", + } + fields.append("indentFirstLine") + + if indent_start is not None: + paragraph_style["indentStart"] = {"magnitude": indent_start, "unit": "PT"} + fields.append("indentStart") + + if indent_end is not None: + paragraph_style["indentEnd"] = {"magnitude": indent_end, "unit": "PT"} + fields.append("indentEnd") + + # Handle spacing + if space_above is not None: + paragraph_style["spaceAbove"] = {"magnitude": space_above, "unit": "PT"} + fields.append("spaceAbove") + + if space_below is not None: + paragraph_style["spaceBelow"] = {"magnitude": space_below, "unit": "PT"} + fields.append("spaceBelow") + + # Create batch update requests + requests = [] + + # Add paragraph style update if we have any style changes + if paragraph_style: + requests.append( + { + "updateParagraphStyle": { + "range": {"startIndex": start_index, "endIndex": end_index}, + "paragraphStyle": paragraph_style, + "fields": ",".join(fields), + } + } + ) + + # Add list creation if requested + if list_type_value is not None: + # Default to level 0 if not specified + nesting_level = list_nesting_level if list_nesting_level is not None else 0 + try: + paragraph_start_indices = None + if nesting_level > 0: + paragraph_start_indices = await _get_paragraph_start_indices_in_range( + service, document_id, start_index, end_index + ) + list_requests = create_bullet_list_request( + start_index, + end_index, + list_type_value, + nesting_level, + paragraph_start_indices=paragraph_start_indices, + ) + requests.extend(list_requests) + except ValueError as e: + return f"Error: {e}" + + # Validate we have at least one operation + if not requests: + return f"No paragraph style changes or list creation specified for document {document_id}" + + await asyncio.to_thread( + service.documents() + .batchUpdate(documentId=document_id, body={"requests": requests}) + .execute + ) + + # Build summary + summary_parts = [] + if "namedStyleType" in paragraph_style: + summary_parts.append(paragraph_style["namedStyleType"]) + format_fields = [f for f in fields if f != "namedStyleType"] + if format_fields: + summary_parts.append(", ".join(format_fields)) + if list_type_value is not None: + list_desc = f"{list_type_value.lower()} list" + if list_nesting_level is not None and list_nesting_level > 0: + list_desc += f" (level {list_nesting_level})" + summary_parts.append(list_desc) + + link = f"https://docs.google.com/document/d/{document_id}/edit" + return f"Applied paragraph formatting ({', '.join(summary_parts)}) to range {start_index}-{end_index} in document {document_id}. Link: {link}" + + +@server.tool() +@handle_http_errors("get_doc_as_markdown", is_read_only=True, service_type="docs") +@require_multiple_services( + [ + { + "service_type": "drive", + "scopes": "drive_read", + "param_name": "drive_service", + }, + {"service_type": "docs", "scopes": "docs_read", "param_name": "docs_service"}, + ] +) +async def get_doc_as_markdown( + drive_service: Any, + docs_service: Any, + user_google_email: str, + document_id: str, + include_comments: bool = True, + comment_mode: str = "inline", + include_resolved: bool = False, +) -> str: + """ + Reads a Google Doc and returns it as clean Markdown with optional comment context. + + Unlike get_doc_content which returns plain text, this tool preserves document + formatting as Markdown: headings, bold/italic/strikethrough, links, code spans, + ordered/unordered lists with nesting, and tables. + + When comments are included (the default), each comment's anchor text — the specific + text the comment was attached to — is preserved, giving full context for the discussion. + + Args: + user_google_email: User's Google email address + document_id: ID of the Google Doc (or full URL) + include_comments: Whether to include comments (default: True) + comment_mode: How to display comments: + - "inline": Footnote-style references placed at the anchor text location (default) + - "appendix": All comments grouped at the bottom with blockquoted anchor text + - "none": No comments included + include_resolved: Whether to include resolved comments (default: False) + + Returns: + str: The document content as Markdown, optionally with comments + """ + # Extract doc ID from URL if a full URL was provided + url_match = re.search(r"/d/([\w-]+)", document_id) + if url_match: + document_id = url_match.group(1) + + valid_modes = ("inline", "appendix", "none") + if comment_mode not in valid_modes: + return f"Error: comment_mode must be one of {valid_modes}, got '{comment_mode}'" + + logger.info( + f"[get_doc_as_markdown] Doc={document_id}, comments={include_comments}, mode={comment_mode}" + ) + + # Fetch document content via Docs API + doc = await asyncio.to_thread( + docs_service.documents().get(documentId=document_id).execute + ) + + markdown = convert_doc_to_markdown(doc) + + if not include_comments or comment_mode == "none": + return markdown + + # Fetch comments via Drive API + all_comments = [] + page_token = None + + while True: + response = await asyncio.to_thread( + drive_service.comments() + .list( + fileId=document_id, + fields="comments(id,content,author,createdTime,modifiedTime," + "resolved,quotedFileContent," + "replies(id,content,author,createdTime,modifiedTime))," + "nextPageToken", + includeDeleted=False, + pageToken=page_token, + ) + .execute + ) + all_comments.extend(response.get("comments", [])) + page_token = response.get("nextPageToken") + if not page_token: + break + + comments = parse_drive_comments( + {"comments": all_comments}, include_resolved=include_resolved + ) + + if not comments: + return markdown + + if comment_mode == "inline": + return format_comments_inline(markdown, comments) + else: + appendix = format_comments_appendix(comments) + return markdown.rstrip("\n") + "\n\n" + appendix + + +@server.tool() +@handle_http_errors("insert_doc_tab", service_type="docs") +@require_google_service("docs", "docs_write") +async def insert_doc_tab( + service: Any, + user_google_email: str, + document_id: str, + title: str, + index: int, + parent_tab_id: Optional[str] = None, +) -> str: + """ + Inserts a new tab into a Google Doc. + + Args: + user_google_email: User's Google email address + document_id: ID of the document to update + title: Title of the new tab + index: Position index for the new tab (0-based among sibling tabs) + parent_tab_id: Optional ID of a parent tab to nest the new tab under + + Returns: + str: Confirmation message with document link + """ + logger.info(f"[insert_doc_tab] Doc={document_id}, title='{title}', index={index}") + + request = create_insert_doc_tab_request(title, index, parent_tab_id) + result = await asyncio.to_thread( + service.documents() + .batchUpdate(documentId=document_id, body={"requests": [request]}) + .execute + ) + + # Extract the new tab ID from the batchUpdate response + tab_id = None + if "replies" in result and result["replies"]: + reply = result["replies"][0] + if "createDocumentTab" in reply: + tab_id = reply["createDocumentTab"].get("tabProperties", {}).get("tabId") + + link = f"https://docs.google.com/document/d/{document_id}/edit" + msg = f"Inserted tab '{title}' at index {index} in document {document_id}." + if tab_id: + msg += f" Tab ID: {tab_id}." + if parent_tab_id: + msg += f" Nested under parent tab {parent_tab_id}." + return f"{msg} Link: {link}" + + +@server.tool() +@handle_http_errors("delete_doc_tab", service_type="docs") +@require_google_service("docs", "docs_write") +async def delete_doc_tab( + service: Any, + user_google_email: str, + document_id: str, + tab_id: str, +) -> str: + """ + Deletes a tab from a Google Doc by its tab ID. + + Args: + user_google_email: User's Google email address + document_id: ID of the document to update + tab_id: ID of the tab to delete (use inspect_doc_structure to find tab IDs) + + Returns: + str: Confirmation message with document link + """ + logger.info(f"[delete_doc_tab] Doc={document_id}, tab_id='{tab_id}'") + + request = create_delete_doc_tab_request(tab_id) + await asyncio.to_thread( + service.documents() + .batchUpdate(documentId=document_id, body={"requests": [request]}) + .execute + ) + + link = f"https://docs.google.com/document/d/{document_id}/edit" + return f"Deleted tab '{tab_id}' from document {document_id}. Link: {link}" + + +@server.tool() +@handle_http_errors("update_doc_tab", service_type="docs") +@require_google_service("docs", "docs_write") +async def update_doc_tab( + service: Any, + user_google_email: str, + document_id: str, + tab_id: str, + title: str, +) -> str: + """ + Renames a tab in a Google Doc. + + Args: + user_google_email: User's Google email address + document_id: ID of the document to update + tab_id: ID of the tab to rename (use inspect_doc_structure to find tab IDs) + title: New title for the tab + + Returns: + str: Confirmation message with document link + """ + logger.info( + f"[update_doc_tab] Doc={document_id}, tab_id='{tab_id}', title='{title}'" + ) + + request = create_update_doc_tab_request(tab_id, title) + await asyncio.to_thread( + service.documents() + .batchUpdate(documentId=document_id, body={"requests": [request]}) + .execute + ) + + link = f"https://docs.google.com/document/d/{document_id}/edit" + return ( + f"Renamed tab '{tab_id}' to '{title}' in document {document_id}. Link: {link}" + ) + + +# Create comment management tools for documents +_comment_tools = create_comment_tools("document", "document_id") + +# Extract and register the functions +list_document_comments = _comment_tools["list_comments"] +manage_document_comment = _comment_tools["manage_comment"] diff --git a/gdocs/managers/__init__.py b/gdocs/managers/__init__.py new file mode 100644 index 0000000..3e8f679 --- /dev/null +++ b/gdocs/managers/__init__.py @@ -0,0 +1,18 @@ +""" +Google Docs Operation Managers + +This package provides high-level manager classes for complex Google Docs operations, +extracting business logic from the main tools module to improve maintainability. +""" + +from .table_operation_manager import TableOperationManager +from .header_footer_manager import HeaderFooterManager +from .validation_manager import ValidationManager +from .batch_operation_manager import BatchOperationManager + +__all__ = [ + "TableOperationManager", + "HeaderFooterManager", + "ValidationManager", + "BatchOperationManager", +] diff --git a/gdocs/managers/batch_operation_manager.py b/gdocs/managers/batch_operation_manager.py new file mode 100644 index 0000000..c0d5368 --- /dev/null +++ b/gdocs/managers/batch_operation_manager.py @@ -0,0 +1,534 @@ +""" +Batch Operation Manager + +This module provides high-level batch operation management for Google Docs, +extracting complex validation and request building logic. +""" + +import logging +import asyncio +from typing import Any, Union, Dict, List, Tuple + +from gdocs.docs_helpers import ( + create_insert_text_request, + create_delete_range_request, + create_format_text_request, + create_update_paragraph_style_request, + create_find_replace_request, + create_insert_table_request, + create_insert_page_break_request, + create_bullet_list_request, + create_delete_bullet_list_request, + create_insert_doc_tab_request, + create_delete_doc_tab_request, + create_update_doc_tab_request, + validate_operation, +) + +logger = logging.getLogger(__name__) + + +class BatchOperationManager: + """ + High-level manager for Google Docs batch operations. + + Handles complex multi-operation requests including: + - Operation validation and request building + - Batch execution with proper error handling + - Operation result processing and reporting + """ + + def __init__(self, service): + """ + Initialize the batch operation manager. + + Args: + service: Google Docs API service instance + """ + self.service = service + + async def execute_batch_operations( + self, document_id: str, operations: list[dict[str, Any]] + ) -> tuple[bool, str, dict[str, Any]]: + """ + Execute multiple document operations in a single atomic batch. + + This method extracts the complex logic from batch_update_doc tool function. + + Args: + document_id: ID of the document to update + operations: List of operation dictionaries + + Returns: + Tuple of (success, message, metadata) + """ + logger.info(f"Executing batch operations on document {document_id}") + logger.info(f"Operations count: {len(operations)}") + + if not operations: + return ( + False, + "No operations provided. Please provide at least one operation.", + {}, + ) + + try: + # Validate and build requests + requests, operation_descriptions = await self._validate_and_build_requests( + operations + ) + + if not requests: + return False, "No valid requests could be built from operations", {} + + # Execute the batch + result = await self._execute_batch_requests(document_id, requests) + + # Process results + metadata = { + "operations_count": len(operations), + "requests_count": len(requests), + "replies_count": len(result.get("replies", [])), + "operation_summary": operation_descriptions[:5], # First 5 operations + } + + # Extract new tab IDs from insert_doc_tab replies + created_tabs = self._extract_created_tabs(result) + if created_tabs: + metadata["created_tabs"] = created_tabs + + summary = self._build_operation_summary(operation_descriptions) + msg = f"Successfully executed {len(operations)} operations ({summary})" + if created_tabs: + tab_info = ", ".join( + f"'{t['title']}' (tab_id: {t['tab_id']})" for t in created_tabs + ) + msg += f". Created tabs: {tab_info}" + + return True, msg, metadata + + except Exception as e: + logger.error(f"Failed to execute batch operations: {str(e)}") + return False, f"Batch operation failed: {str(e)}", {} + + async def _validate_and_build_requests( + self, operations: list[dict[str, Any]] + ) -> tuple[list[dict[str, Any]], list[str]]: + """ + Validate operations and build API requests. + + Args: + operations: List of operation dictionaries + + Returns: + Tuple of (requests, operation_descriptions) + """ + requests = [] + operation_descriptions = [] + + for i, op in enumerate(operations): + # Validate operation structure + is_valid, error_msg = validate_operation(op) + if not is_valid: + raise ValueError(f"Operation {i + 1}: {error_msg}") + + op_type = op.get("type") + + try: + # Build request based on operation type + result = self._build_operation_request(op, op_type) + + # Handle both single request and list of requests + if isinstance(result[0], list): + # Multiple requests (e.g., replace_text) + for req in result[0]: + requests.append(req) + operation_descriptions.append(result[1]) + elif result[0]: + # Single request + requests.append(result[0]) + operation_descriptions.append(result[1]) + + except KeyError as e: + raise ValueError( + f"Operation {i + 1} ({op_type}) missing required field: {e}" + ) + except Exception as e: + raise ValueError( + f"Operation {i + 1} ({op_type}) failed validation: {str(e)}" + ) + + return requests, operation_descriptions + + def _build_operation_request( + self, op: dict[str, Any], op_type: str + ) -> Tuple[Union[Dict[str, Any], List[Dict[str, Any]]], str]: + """ + Build a single operation request. + + Args: + op: Operation dictionary + op_type: Operation type + + Returns: + Tuple of (request, description) + """ + tab_id = op.get("tab_id") + + if op_type == "insert_text": + request = create_insert_text_request(op["index"], op["text"], tab_id) + description = f"insert text at {op['index']}" + + elif op_type == "delete_text": + request = create_delete_range_request( + op["start_index"], op["end_index"], tab_id + ) + description = f"delete text {op['start_index']}-{op['end_index']}" + + elif op_type == "replace_text": + # Replace is delete + insert (must be done in this order) + delete_request = create_delete_range_request( + op["start_index"], op["end_index"], tab_id + ) + insert_request = create_insert_text_request( + op["start_index"], op["text"], tab_id + ) + # Return both requests as a list + request = [delete_request, insert_request] + description = f"replace text {op['start_index']}-{op['end_index']} with '{op['text'][:20]}{'...' if len(op['text']) > 20 else ''}'" + + elif op_type == "format_text": + request = create_format_text_request( + op["start_index"], + op["end_index"], + op.get("bold"), + op.get("italic"), + op.get("underline"), + op.get("font_size"), + op.get("font_family"), + op.get("text_color"), + op.get("background_color"), + op.get("link_url"), + tab_id, + ) + + if not request: + raise ValueError("No formatting options provided") + + # Build format description + format_changes = [] + for param, name in [ + ("bold", "bold"), + ("italic", "italic"), + ("underline", "underline"), + ("font_size", "font size"), + ("font_family", "font family"), + ("text_color", "text color"), + ("background_color", "background color"), + ("link_url", "link"), + ]: + if op.get(param) is not None: + value = f"{op[param]}pt" if param == "font_size" else op[param] + format_changes.append(f"{name}: {value}") + + description = f"format text {op['start_index']}-{op['end_index']} ({', '.join(format_changes)})" + + elif op_type == "update_paragraph_style": + request = create_update_paragraph_style_request( + op["start_index"], + op["end_index"], + op.get("heading_level"), + op.get("alignment"), + op.get("line_spacing"), + op.get("indent_first_line"), + op.get("indent_start"), + op.get("indent_end"), + op.get("space_above"), + op.get("space_below"), + tab_id, + op.get("named_style_type"), + ) + + if not request: + raise ValueError("No paragraph style options provided") + + _PT_PARAMS = { + "indent_first_line", + "indent_start", + "indent_end", + "space_above", + "space_below", + } + _SUFFIX = { + "heading_level": lambda v: f"H{v}", + "line_spacing": lambda v: f"{v}x", + } + + style_changes = [] + for param, name in [ + ("heading_level", "heading"), + ("alignment", "alignment"), + ("line_spacing", "line spacing"), + ("indent_first_line", "first line indent"), + ("indent_start", "start indent"), + ("indent_end", "end indent"), + ("space_above", "space above"), + ("space_below", "space below"), + ]: + if op.get(param) is not None: + raw = op[param] + fmt = _SUFFIX.get(param) + if fmt: + value = fmt(raw) + elif param in _PT_PARAMS: + value = f"{raw}pt" + else: + value = raw + style_changes.append(f"{name}: {value}") + + description = f"paragraph style {op['start_index']}-{op['end_index']} ({', '.join(style_changes)})" + + elif op_type == "insert_table": + request = create_insert_table_request( + op["index"], op["rows"], op["columns"], tab_id + ) + description = f"insert {op['rows']}x{op['columns']} table at {op['index']}" + + elif op_type == "insert_page_break": + request = create_insert_page_break_request(op["index"], tab_id) + description = f"insert page break at {op['index']}" + + elif op_type == "find_replace": + request = create_find_replace_request( + op["find_text"], op["replace_text"], op.get("match_case", False), tab_id + ) + description = f"find/replace '{op['find_text']}' → '{op['replace_text']}'" + + elif op_type == "create_bullet_list": + list_type = op.get("list_type", "UNORDERED") + if list_type not in ("UNORDERED", "ORDERED", "NONE"): + raise ValueError( + f"Invalid list_type '{list_type}'. Must be 'UNORDERED', 'ORDERED', or 'NONE'" + ) + if list_type == "NONE": + request = create_delete_bullet_list_request( + op["start_index"], op["end_index"], tab_id + ) + description = f"remove bullets {op['start_index']}-{op['end_index']}" + else: + request = create_bullet_list_request( + op["start_index"], + op["end_index"], + list_type, + op.get("nesting_level"), + op.get("paragraph_start_indices"), + tab_id, + ) + style = "bulleted" if list_type == "UNORDERED" else "numbered" + description = ( + f"create {style} list {op['start_index']}-{op['end_index']}" + ) + if op.get("nesting_level"): + description += f" (nesting level {op['nesting_level']})" + + elif op_type == "insert_doc_tab": + request = create_insert_doc_tab_request( + op["title"], op["index"], op.get("parent_tab_id") + ) + description = f"insert tab '{op['title']}' at {op['index']}" + if op.get("parent_tab_id"): + description += f" under parent tab {op['parent_tab_id']}" + + elif op_type == "delete_doc_tab": + request = create_delete_doc_tab_request(op["tab_id"]) + description = f"delete tab '{op['tab_id']}'" + + elif op_type == "update_doc_tab": + request = create_update_doc_tab_request(op["tab_id"], op["title"]) + description = f"rename tab '{op['tab_id']}' to '{op['title']}'" + + else: + supported_types = [ + "insert_text", + "delete_text", + "replace_text", + "format_text", + "update_paragraph_style", + "insert_table", + "insert_page_break", + "find_replace", + "create_bullet_list", + "insert_doc_tab", + "delete_doc_tab", + "update_doc_tab", + ] + raise ValueError( + f"Unsupported operation type '{op_type}'. Supported: {', '.join(supported_types)}" + ) + + return request, description + + async def _execute_batch_requests( + self, document_id: str, requests: list[dict[str, Any]] + ) -> dict[str, Any]: + """ + Execute the batch requests against the Google Docs API. + + Args: + document_id: Document ID + requests: List of API requests + + Returns: + API response + """ + return await asyncio.to_thread( + self.service.documents() + .batchUpdate(documentId=document_id, body={"requests": requests}) + .execute + ) + + def _extract_created_tabs(self, result: dict[str, Any]) -> list[dict[str, str]]: + """ + Extract tab IDs from insert_doc_tab replies in the batchUpdate response. + + Args: + result: The batchUpdate API response + + Returns: + List of dicts with tab_id and title for each created tab + """ + created_tabs = [] + for reply in result.get("replies", []): + if "createDocumentTab" in reply: + props = reply["createDocumentTab"].get("tabProperties", {}) + tab_id = props.get("tabId") + title = props.get("title", "") + if tab_id: + created_tabs.append({"tab_id": tab_id, "title": title}) + return created_tabs + + def _build_operation_summary(self, operation_descriptions: list[str]) -> str: + """ + Build a concise summary of operations performed. + + Args: + operation_descriptions: List of operation descriptions + + Returns: + Summary string + """ + if not operation_descriptions: + return "no operations" + + summary_items = operation_descriptions[:3] # Show first 3 operations + summary = ", ".join(summary_items) + + if len(operation_descriptions) > 3: + remaining = len(operation_descriptions) - 3 + summary += f" and {remaining} more operation{'s' if remaining > 1 else ''}" + + return summary + + def get_supported_operations(self) -> dict[str, Any]: + """ + Get information about supported batch operations. + + Returns: + Dictionary with supported operation types and their required parameters + """ + return { + "supported_operations": { + "insert_text": { + "required": ["index", "text"], + "description": "Insert text at specified index", + }, + "delete_text": { + "required": ["start_index", "end_index"], + "description": "Delete text in specified range", + }, + "replace_text": { + "required": ["start_index", "end_index", "text"], + "description": "Replace text in range with new text", + }, + "format_text": { + "required": ["start_index", "end_index"], + "optional": [ + "bold", + "italic", + "underline", + "font_size", + "font_family", + "text_color", + "background_color", + "link_url", + ], + "description": "Apply formatting to text range", + }, + "update_paragraph_style": { + "required": ["start_index", "end_index"], + "optional": [ + "heading_level", + "alignment", + "line_spacing", + "indent_first_line", + "indent_start", + "indent_end", + "space_above", + "space_below", + "named_style_type", + ], + "description": "Apply paragraph-level styling (headings, alignment, spacing, indentation)", + }, + "insert_table": { + "required": ["index", "rows", "columns"], + "description": "Insert table at specified index", + }, + "insert_page_break": { + "required": ["index"], + "description": "Insert page break at specified index", + }, + "find_replace": { + "required": ["find_text", "replace_text"], + "optional": ["match_case"], + "description": "Find and replace text throughout document", + }, + "create_bullet_list": { + "required": ["start_index", "end_index"], + "optional": [ + "list_type", + "nesting_level", + "paragraph_start_indices", + ], + "description": "Apply or remove native bullet/numbered list formatting (list_type: UNORDERED, ORDERED, or NONE to remove; nesting_level: 0-8)", + }, + "insert_doc_tab": { + "required": ["title", "index"], + "description": "Insert a new document tab with given title at specified index", + }, + "delete_doc_tab": { + "required": ["tab_id"], + "description": "Delete a document tab by its ID", + }, + "update_doc_tab": { + "required": ["tab_id", "title"], + "description": "Rename a document tab", + }, + }, + "example_operations": [ + {"type": "insert_text", "index": 1, "text": "Hello World"}, + { + "type": "format_text", + "start_index": 1, + "end_index": 12, + "bold": True, + }, + {"type": "insert_table", "index": 20, "rows": 2, "columns": 3}, + { + "type": "update_paragraph_style", + "start_index": 1, + "end_index": 20, + "heading_level": 1, + "alignment": "CENTER", + }, + ], + } diff --git a/gdocs/managers/header_footer_manager.py b/gdocs/managers/header_footer_manager.py new file mode 100644 index 0000000..50fad88 --- /dev/null +++ b/gdocs/managers/header_footer_manager.py @@ -0,0 +1,339 @@ +""" +Header Footer Manager + +This module provides high-level operations for managing headers and footers +in Google Docs, extracting complex logic from the main tools module. +""" + +import logging +import asyncio +from typing import Any, Optional + +logger = logging.getLogger(__name__) + + +class HeaderFooterManager: + """ + High-level manager for Google Docs header and footer operations. + + Handles complex header/footer operations including: + - Finding and updating existing headers/footers + - Content replacement with proper range calculation + - Section type management + """ + + def __init__(self, service): + """ + Initialize the header footer manager. + + Args: + service: Google Docs API service instance + """ + self.service = service + + async def update_header_footer_content( + self, + document_id: str, + section_type: str, + content: str, + header_footer_type: str = "DEFAULT", + ) -> tuple[bool, str]: + """ + Updates header or footer content in a document. + + This method extracts the complex logic from update_doc_headers_footers tool function. + + Args: + document_id: ID of the document to update + section_type: Type of section ("header" or "footer") + content: New content for the section + header_footer_type: Type of header/footer ("DEFAULT", "FIRST_PAGE_ONLY", "EVEN_PAGE") + + Returns: + Tuple of (success, message) + """ + logger.info(f"Updating {section_type} in document {document_id}") + + # Validate section type + if section_type not in ["header", "footer"]: + return False, "section_type must be 'header' or 'footer'" + + # Validate header/footer type + if header_footer_type not in ["DEFAULT", "FIRST_PAGE_ONLY", "EVEN_PAGE"]: + return ( + False, + "header_footer_type must be 'DEFAULT', 'FIRST_PAGE_ONLY', or 'EVEN_PAGE'", + ) + + try: + # Get document structure + doc = await self._get_document(document_id) + + # Find the target section + target_section, section_id = await self._find_target_section( + doc, section_type, header_footer_type + ) + + if not target_section: + return ( + False, + f"No {section_type} found in document. Please create a {section_type} first in Google Docs.", + ) + + # Update the content + success = await self._replace_section_content( + document_id, target_section, content + ) + + if success: + return True, f"Updated {section_type} content in document {document_id}" + else: + return ( + False, + f"Could not find content structure in {section_type} to update", + ) + + except Exception as e: + logger.error(f"Failed to update {section_type}: {str(e)}") + return False, f"Failed to update {section_type}: {str(e)}" + + async def _get_document(self, document_id: str) -> dict[str, Any]: + """Get the full document data.""" + return await asyncio.to_thread( + self.service.documents().get(documentId=document_id).execute + ) + + async def _find_target_section( + self, doc: dict[str, Any], section_type: str, header_footer_type: str + ) -> tuple[Optional[dict[str, Any]], Optional[str]]: + """ + Find the target header or footer section. + + Args: + doc: Document data + section_type: "header" or "footer" + header_footer_type: Type of header/footer + + Returns: + Tuple of (section_data, section_id) or (None, None) if not found + """ + if section_type == "header": + sections = doc.get("headers", {}) + else: + sections = doc.get("footers", {}) + + # Try to match section based on header_footer_type + # Google Docs API typically uses section IDs that correspond to types + + # First, try to find an exact match based on common patterns + for section_id, section_data in sections.items(): + # Check if section_data contains type information + if "type" in section_data and section_data["type"] == header_footer_type: + return section_data, section_id + + # If no exact match, try pattern matching on section ID + # Google Docs often uses predictable section ID patterns + target_patterns = { + "DEFAULT": ["default", "kix"], # DEFAULT headers often have these patterns + "FIRST_PAGE": ["first", "firstpage"], + "EVEN_PAGE": ["even", "evenpage"], + "FIRST_PAGE_ONLY": ["first", "firstpage"], # Legacy support + } + + patterns = target_patterns.get(header_footer_type, []) + for pattern in patterns: + for section_id, section_data in sections.items(): + if pattern.lower() in section_id.lower(): + return section_data, section_id + + # If still no match, return the first available section as fallback + # This maintains backward compatibility + for section_id, section_data in sections.items(): + return section_data, section_id + + return None, None + + async def _replace_section_content( + self, document_id: str, section: dict[str, Any], new_content: str + ) -> bool: + """ + Replace the content in a header or footer section. + + Args: + document_id: Document ID + section: Section data containing content elements + new_content: New content to insert + + Returns: + True if successful, False otherwise + """ + content_elements = section.get("content", []) + if not content_elements: + return False + + # Find the first paragraph to replace content + first_para = self._find_first_paragraph(content_elements) + if not first_para: + return False + + # Calculate content range + start_index = first_para.get("startIndex", 0) + end_index = first_para.get("endIndex", 0) + + # Build requests to replace content + requests = [] + + # Delete existing content if any (preserve paragraph structure) + if end_index > start_index: + requests.append( + { + "deleteContentRange": { + "range": { + "startIndex": start_index, + "endIndex": end_index - 1, # Keep the paragraph end marker + } + } + } + ) + + # Insert new content + requests.append( + {"insertText": {"location": {"index": start_index}, "text": new_content}} + ) + + try: + await asyncio.to_thread( + self.service.documents() + .batchUpdate(documentId=document_id, body={"requests": requests}) + .execute + ) + return True + + except Exception as e: + logger.error(f"Failed to replace section content: {str(e)}") + return False + + def _find_first_paragraph( + self, content_elements: list[dict[str, Any]] + ) -> Optional[dict[str, Any]]: + """Find the first paragraph element in content.""" + for element in content_elements: + if "paragraph" in element: + return element + return None + + async def get_header_footer_info(self, document_id: str) -> dict[str, Any]: + """ + Get information about all headers and footers in the document. + + Args: + document_id: Document ID + + Returns: + Dictionary with header and footer information + """ + try: + doc = await self._get_document(document_id) + + headers_info = {} + for header_id, header_data in doc.get("headers", {}).items(): + headers_info[header_id] = self._extract_section_info(header_data) + + footers_info = {} + for footer_id, footer_data in doc.get("footers", {}).items(): + footers_info[footer_id] = self._extract_section_info(footer_data) + + return { + "headers": headers_info, + "footers": footers_info, + "has_headers": bool(headers_info), + "has_footers": bool(footers_info), + } + + except Exception as e: + logger.error(f"Failed to get header/footer info: {str(e)}") + return {"error": str(e)} + + def _extract_section_info(self, section_data: dict[str, Any]) -> dict[str, Any]: + """Extract useful information from a header/footer section.""" + content_elements = section_data.get("content", []) + + # Extract text content + text_content = "" + for element in content_elements: + if "paragraph" in element: + para = element["paragraph"] + for para_element in para.get("elements", []): + if "textRun" in para_element: + text_content += para_element["textRun"].get("content", "") + + return { + "content_preview": text_content[:100] if text_content else "(empty)", + "element_count": len(content_elements), + "start_index": content_elements[0].get("startIndex", 0) + if content_elements + else 0, + "end_index": content_elements[-1].get("endIndex", 0) + if content_elements + else 0, + } + + async def create_header_footer( + self, document_id: str, section_type: str, header_footer_type: str = "DEFAULT" + ) -> tuple[bool, str]: + """ + Create a new header or footer section. + + Args: + document_id: Document ID + section_type: "header" or "footer" + header_footer_type: Type of header/footer ("DEFAULT", "FIRST_PAGE", or "EVEN_PAGE") + + Returns: + Tuple of (success, message) + """ + if section_type not in ["header", "footer"]: + return False, "section_type must be 'header' or 'footer'" + + # Map our type names to API type names + type_mapping = { + "DEFAULT": "DEFAULT", + "FIRST_PAGE": "FIRST_PAGE", + "EVEN_PAGE": "EVEN_PAGE", + "FIRST_PAGE_ONLY": "FIRST_PAGE", # Support legacy name + } + + api_type = type_mapping.get(header_footer_type, header_footer_type) + if api_type not in ["DEFAULT", "FIRST_PAGE", "EVEN_PAGE"]: + return ( + False, + "header_footer_type must be 'DEFAULT', 'FIRST_PAGE', or 'EVEN_PAGE'", + ) + + try: + # Build the request + request = {"type": api_type} + + # Create the appropriate request type + if section_type == "header": + batch_request = {"createHeader": request} + else: + batch_request = {"createFooter": request} + + # Execute the request + await asyncio.to_thread( + self.service.documents() + .batchUpdate(documentId=document_id, body={"requests": [batch_request]}) + .execute + ) + + return True, f"Successfully created {section_type} with type {api_type}" + + except Exception as e: + error_msg = str(e) + if "already exists" in error_msg.lower(): + return ( + False, + f"A {section_type} of type {api_type} already exists in the document", + ) + return False, f"Failed to create {section_type}: {error_msg}" diff --git a/gdocs/managers/table_operation_manager.py b/gdocs/managers/table_operation_manager.py new file mode 100644 index 0000000..d28aa90 --- /dev/null +++ b/gdocs/managers/table_operation_manager.py @@ -0,0 +1,405 @@ +""" +Table Operation Manager + +This module provides high-level table operations that orchestrate +multiple Google Docs API calls for complex table manipulations. +""" + +import logging +import asyncio +from typing import List, Dict, Any, Tuple + +from gdocs.docs_helpers import create_insert_table_request +from gdocs.docs_structure import find_tables +from gdocs.docs_tables import validate_table_data + +logger = logging.getLogger(__name__) + + +class TableOperationManager: + """ + High-level manager for Google Docs table operations. + + Handles complex multi-step table operations including: + - Creating tables with data population + - Populating existing tables + - Managing cell-by-cell operations with proper index refreshing + """ + + def __init__(self, service): + """ + Initialize the table operation manager. + + Args: + service: Google Docs API service instance + """ + self.service = service + + async def create_and_populate_table( + self, + document_id: str, + table_data: List[List[str]], + index: int, + bold_headers: bool = True, + tab_id: str = None, + ) -> Tuple[bool, str, Dict[str, Any]]: + """ + Creates a table and populates it with data in a reliable multi-step process. + + This method extracts the complex logic from create_table_with_data tool function. + + Args: + document_id: ID of the document to update + table_data: 2D list of strings for table content + index: Position to insert the table + bold_headers: Whether to make the first row bold + tab_id: Optional tab ID for targeting a specific tab + + Returns: + Tuple of (success, message, metadata) + """ + logger.debug( + f"Creating table at index {index}, dimensions: {len(table_data)}x{len(table_data[0]) if table_data and len(table_data) > 0 else 0}" + ) + + # Validate input data + is_valid, error_msg = validate_table_data(table_data) + if not is_valid: + return False, f"Invalid table data: {error_msg}", {} + + rows = len(table_data) + cols = len(table_data[0]) + + try: + # Step 1: Create empty table + await self._create_empty_table(document_id, index, rows, cols, tab_id) + + # Step 2: Get fresh document structure to find actual cell positions + fresh_tables = await self._get_document_tables(document_id, tab_id) + if not fresh_tables: + return False, "Could not find table after creation", {} + + # Step 3: Populate each cell with proper index refreshing + population_count = await self._populate_table_cells( + document_id, table_data, bold_headers, tab_id + ) + + metadata = { + "rows": rows, + "columns": cols, + "populated_cells": population_count, + "table_index": len(fresh_tables) - 1, + } + + return ( + True, + f"Successfully created {rows}x{cols} table and populated {population_count} cells", + metadata, + ) + + except Exception as e: + logger.error(f"Failed to create and populate table: {str(e)}") + return False, f"Table creation failed: {str(e)}", {} + + async def _create_empty_table( + self, document_id: str, index: int, rows: int, cols: int, tab_id: str = None + ) -> None: + """Create an empty table at the specified index.""" + logger.debug(f"Creating {rows}x{cols} table at index {index}") + + await asyncio.to_thread( + self.service.documents() + .batchUpdate( + documentId=document_id, + body={ + "requests": [create_insert_table_request(index, rows, cols, tab_id)] + }, + ) + .execute + ) + + async def _get_document_tables( + self, document_id: str, tab_id: str = None + ) -> List[Dict[str, Any]]: + """Get fresh document structure and extract table information.""" + doc = await asyncio.to_thread( + self.service.documents() + .get(documentId=document_id, includeTabsContent=True) + .execute + ) + + if tab_id: + tab = self._find_tab(doc.get("tabs", []), tab_id) + if tab and "documentTab" in tab: + doc = doc.copy() + doc["body"] = tab["documentTab"].get("body", {}) + + return find_tables(doc) + + @staticmethod + def _find_tab(tabs: list, target_id: str): + """Recursively find a tab by ID.""" + for tab in tabs: + if tab.get("tabProperties", {}).get("tabId") == target_id: + return tab + if "childTabs" in tab: + found = TableOperationManager._find_tab(tab["childTabs"], target_id) + if found: + return found + return None + + async def _populate_table_cells( + self, + document_id: str, + table_data: List[List[str]], + bold_headers: bool, + tab_id: str = None, + ) -> int: + """ + Populate table cells with data, refreshing structure after each insertion. + + This prevents index shifting issues by getting fresh cell positions + before each insertion. + """ + population_count = 0 + + for row_idx, row_data in enumerate(table_data): + logger.debug(f"Processing row {row_idx}: {len(row_data)} cells") + + for col_idx, cell_text in enumerate(row_data): + if not cell_text: # Skip empty cells + continue + + try: + # CRITICAL: Refresh document structure before each insertion + success = await self._populate_single_cell( + document_id, + row_idx, + col_idx, + cell_text, + bold_headers and row_idx == 0, + tab_id, + ) + + if success: + population_count += 1 + logger.debug(f"Populated cell ({row_idx},{col_idx})") + else: + logger.warning(f"Failed to populate cell ({row_idx},{col_idx})") + + except Exception as e: + logger.error( + f"Error populating cell ({row_idx},{col_idx}): {str(e)}" + ) + + return population_count + + async def _populate_single_cell( + self, + document_id: str, + row_idx: int, + col_idx: int, + cell_text: str, + apply_bold: bool = False, + tab_id: str = None, + ) -> bool: + """ + Populate a single cell with text, with optional bold formatting. + + Returns True if successful, False otherwise. + """ + try: + # Get fresh table structure to avoid index shifting issues + tables = await self._get_document_tables(document_id, tab_id) + if not tables: + return False + + table = tables[-1] # Use the last table (newly created one) + cells = table.get("cells", []) + + # Bounds checking + if row_idx >= len(cells) or col_idx >= len(cells[row_idx]): + logger.error(f"Cell ({row_idx},{col_idx}) out of bounds") + return False + + cell = cells[row_idx][col_idx] + insertion_index = cell.get("insertion_index") + + if not insertion_index: + logger.warning(f"No insertion_index for cell ({row_idx},{col_idx})") + return False + + # Insert text + await asyncio.to_thread( + self.service.documents() + .batchUpdate( + documentId=document_id, + body={ + "requests": [ + { + "insertText": { + "location": {"index": insertion_index}, + "text": cell_text, + } + } + ] + }, + ) + .execute + ) + + # Apply bold formatting if requested + if apply_bold: + await self._apply_bold_formatting( + document_id, insertion_index, insertion_index + len(cell_text) + ) + + return True + + except Exception as e: + logger.error(f"Failed to populate single cell: {str(e)}") + return False + + async def _apply_bold_formatting( + self, document_id: str, start_index: int, end_index: int + ) -> None: + """Apply bold formatting to a text range.""" + await asyncio.to_thread( + self.service.documents() + .batchUpdate( + documentId=document_id, + body={ + "requests": [ + { + "updateTextStyle": { + "range": { + "startIndex": start_index, + "endIndex": end_index, + }, + "textStyle": {"bold": True}, + "fields": "bold", + } + } + ] + }, + ) + .execute + ) + + async def populate_existing_table( + self, + document_id: str, + table_index: int, + table_data: List[List[str]], + clear_existing: bool = False, + ) -> Tuple[bool, str, Dict[str, Any]]: + """ + Populate an existing table with data. + + Args: + document_id: ID of the document + table_index: Index of the table to populate (0-based) + table_data: 2D list of data to insert + clear_existing: Whether to clear existing content first + + Returns: + Tuple of (success, message, metadata) + """ + try: + tables = await self._get_document_tables(document_id) + if table_index >= len(tables): + return ( + False, + f"Table index {table_index} not found. Document has {len(tables)} tables", + {}, + ) + + table_info = tables[table_index] + + # Validate dimensions + table_rows = table_info["rows"] + table_cols = table_info["columns"] + data_rows = len(table_data) + data_cols = len(table_data[0]) if table_data else 0 + + if data_rows > table_rows or data_cols > table_cols: + return ( + False, + f"Data ({data_rows}x{data_cols}) exceeds table dimensions ({table_rows}x{table_cols})", + {}, + ) + + # Populate cells + population_count = await self._populate_existing_table_cells( + document_id, table_index, table_data + ) + + metadata = { + "table_index": table_index, + "populated_cells": population_count, + "table_dimensions": f"{table_rows}x{table_cols}", + "data_dimensions": f"{data_rows}x{data_cols}", + } + + return ( + True, + f"Successfully populated {population_count} cells in existing table", + metadata, + ) + + except Exception as e: + return False, f"Failed to populate existing table: {str(e)}", {} + + async def _populate_existing_table_cells( + self, document_id: str, table_index: int, table_data: List[List[str]] + ) -> int: + """Populate cells in an existing table.""" + population_count = 0 + + for row_idx, row_data in enumerate(table_data): + for col_idx, cell_text in enumerate(row_data): + if not cell_text: + continue + + # Get fresh table structure for each cell + tables = await self._get_document_tables(document_id) + if table_index >= len(tables): + break + + table = tables[table_index] + cells = table.get("cells", []) + + if row_idx >= len(cells) or col_idx >= len(cells[row_idx]): + continue + + cell = cells[row_idx][col_idx] + + # For existing tables, append to existing content + cell_end = cell["end_index"] - 1 # Don't include cell end marker + + try: + await asyncio.to_thread( + self.service.documents() + .batchUpdate( + documentId=document_id, + body={ + "requests": [ + { + "insertText": { + "location": {"index": cell_end}, + "text": cell_text, + } + } + ] + }, + ) + .execute + ) + population_count += 1 + + except Exception as e: + logger.error( + f"Failed to populate existing cell ({row_idx},{col_idx}): {str(e)}" + ) + + return population_count diff --git a/gdocs/managers/validation_manager.py b/gdocs/managers/validation_manager.py new file mode 100644 index 0000000..69ffd21 --- /dev/null +++ b/gdocs/managers/validation_manager.py @@ -0,0 +1,727 @@ +""" +Validation Manager + +This module provides centralized validation logic for Google Docs operations, +extracting validation patterns from individual tool functions. +""" + +import logging +from typing import Dict, Any, List, Tuple, Optional +from urllib.parse import urlparse + +from gdocs.docs_helpers import validate_operation + +logger = logging.getLogger(__name__) + + +class ValidationManager: + """ + Centralized validation manager for Google Docs operations. + + Provides consistent validation patterns and error messages across + all document operations, reducing code duplication and improving + error message quality. + """ + + def __init__(self): + """Initialize the validation manager.""" + self.validation_rules = self._setup_validation_rules() + + def _setup_validation_rules(self) -> Dict[str, Any]: + """Setup validation rules and constraints.""" + return { + "table_max_rows": 1000, + "table_max_columns": 20, + "document_id_pattern": r"^[a-zA-Z0-9-_]+$", + "max_text_length": 1000000, # 1MB text limit + "font_size_range": (1, 400), # Google Docs font size limits + "valid_header_footer_types": ["DEFAULT", "FIRST_PAGE_ONLY", "EVEN_PAGE"], + "valid_section_types": ["header", "footer"], + "valid_list_types": ["UNORDERED", "ORDERED"], + "valid_element_types": ["table", "list", "page_break"], + "valid_alignments": ["START", "CENTER", "END", "JUSTIFIED"], + "heading_level_range": (0, 6), + } + + def validate_document_id(self, document_id: str) -> Tuple[bool, str]: + """ + Validate Google Docs document ID format. + + Args: + document_id: Document ID to validate + + Returns: + Tuple of (is_valid, error_message) + """ + if not document_id: + return False, "Document ID cannot be empty" + + if not isinstance(document_id, str): + return ( + False, + f"Document ID must be a string, got {type(document_id).__name__}", + ) + + # Basic length check (Google Docs IDs are typically 40+ characters) + if len(document_id) < 20: + return False, "Document ID appears too short to be valid" + + return True, "" + + def validate_table_data(self, table_data: List[List[str]]) -> Tuple[bool, str]: + """ + Comprehensive validation for table data format. + + This extracts and centralizes table validation logic from multiple functions. + + Args: + table_data: 2D array of data to validate + + Returns: + Tuple of (is_valid, detailed_error_message) + """ + if not table_data: + return ( + False, + "Table data cannot be empty. Required format: [['col1', 'col2'], ['row1col1', 'row1col2']]", + ) + + if not isinstance(table_data, list): + return ( + False, + f"Table data must be a list, got {type(table_data).__name__}. Required format: [['col1', 'col2'], ['row1col1', 'row1col2']]", + ) + + # Check if it's a 2D list + if not all(isinstance(row, list) for row in table_data): + non_list_rows = [ + i for i, row in enumerate(table_data) if not isinstance(row, list) + ] + return ( + False, + f"All rows must be lists. Rows {non_list_rows} are not lists. Required format: [['col1', 'col2'], ['row1col1', 'row1col2']]", + ) + + # Check for empty rows + if any(len(row) == 0 for row in table_data): + empty_rows = [i for i, row in enumerate(table_data) if len(row) == 0] + return ( + False, + f"Rows cannot be empty. Empty rows found at indices: {empty_rows}", + ) + + # Check column consistency + col_counts = [len(row) for row in table_data] + if len(set(col_counts)) > 1: + return ( + False, + f"All rows must have the same number of columns. Found column counts: {col_counts}. Fix your data structure.", + ) + + rows = len(table_data) + cols = col_counts[0] + + # Check dimension limits + if rows > self.validation_rules["table_max_rows"]: + return ( + False, + f"Too many rows ({rows}). Maximum allowed: {self.validation_rules['table_max_rows']}", + ) + + if cols > self.validation_rules["table_max_columns"]: + return ( + False, + f"Too many columns ({cols}). Maximum allowed: {self.validation_rules['table_max_columns']}", + ) + + # Check cell content types + for row_idx, row in enumerate(table_data): + for col_idx, cell in enumerate(row): + if cell is None: + return ( + False, + f"Cell ({row_idx},{col_idx}) is None. All cells must be strings, use empty string '' for empty cells.", + ) + + if not isinstance(cell, str): + return ( + False, + f"Cell ({row_idx},{col_idx}) is {type(cell).__name__}, not string. All cells must be strings. Value: {repr(cell)}", + ) + + return True, f"Valid table data: {rows}×{cols} table format" + + def validate_text_formatting_params( + self, + bold: Optional[bool] = None, + italic: Optional[bool] = None, + underline: Optional[bool] = None, + font_size: Optional[int] = None, + font_family: Optional[str] = None, + text_color: Optional[str] = None, + background_color: Optional[str] = None, + link_url: Optional[str] = None, + ) -> Tuple[bool, str]: + """ + Validate text formatting parameters. + + Args: + bold: Bold setting + italic: Italic setting + underline: Underline setting + font_size: Font size in points + font_family: Font family name + text_color: Text color in "#RRGGBB" format + background_color: Background color in "#RRGGBB" format + link_url: Hyperlink URL (http/https) + + Returns: + Tuple of (is_valid, error_message) + """ + # Check if at least one formatting option is provided + formatting_params = [ + bold, + italic, + underline, + font_size, + font_family, + text_color, + background_color, + link_url, + ] + if all(param is None for param in formatting_params): + return ( + False, + "At least one formatting parameter must be provided (bold, italic, underline, font_size, font_family, text_color, background_color, or link_url)", + ) + + # Validate boolean parameters + for param, name in [ + (bold, "bold"), + (italic, "italic"), + (underline, "underline"), + ]: + if param is not None and not isinstance(param, bool): + return ( + False, + f"{name} parameter must be boolean (True/False), got {type(param).__name__}", + ) + + # Validate font size + if font_size is not None: + if not isinstance(font_size, int): + return ( + False, + f"font_size must be an integer, got {type(font_size).__name__}", + ) + + min_size, max_size = self.validation_rules["font_size_range"] + if not (min_size <= font_size <= max_size): + return ( + False, + f"font_size must be between {min_size} and {max_size} points, got {font_size}", + ) + + # Validate font family + if font_family is not None: + if not isinstance(font_family, str): + return ( + False, + f"font_family must be a string, got {type(font_family).__name__}", + ) + + if not font_family.strip(): + return False, "font_family cannot be empty" + + # Validate colors + is_valid, error_msg = self.validate_color_param(text_color, "text_color") + if not is_valid: + return False, error_msg + + is_valid, error_msg = self.validate_color_param( + background_color, "background_color" + ) + if not is_valid: + return False, error_msg + + is_valid, error_msg = self.validate_link_url(link_url) + if not is_valid: + return False, error_msg + + return True, "" + + def validate_link_url(self, link_url: Optional[str]) -> Tuple[bool, str]: + """Validate hyperlink URL parameters.""" + if link_url is None: + return True, "" + + if not isinstance(link_url, str): + return False, f"link_url must be a string, got {type(link_url).__name__}" + + if not link_url.strip(): + return False, "link_url cannot be empty" + + parsed = urlparse(link_url) + if parsed.scheme not in ("http", "https"): + return False, "link_url must start with http:// or https://" + + if not parsed.netloc: + return False, "link_url must include a valid host" + + return True, "" + + def validate_paragraph_style_params( + self, + heading_level: Optional[int] = None, + alignment: Optional[str] = None, + line_spacing: Optional[float] = None, + indent_first_line: Optional[float] = None, + indent_start: Optional[float] = None, + indent_end: Optional[float] = None, + space_above: Optional[float] = None, + space_below: Optional[float] = None, + named_style_type: Optional[str] = None, + ) -> Tuple[bool, str]: + """ + Validate paragraph style parameters. + + Args: + heading_level: Heading level 0-6 (0 = NORMAL_TEXT, 1-6 = HEADING_N) + alignment: Text alignment - 'START', 'CENTER', 'END', or 'JUSTIFIED' + line_spacing: Line spacing multiplier (must be positive) + indent_first_line: First line indent in points + indent_start: Left/start indent in points + indent_end: Right/end indent in points + space_above: Space above paragraph in points + space_below: Space below paragraph in points + named_style_type: Direct named style (TITLE, SUBTITLE, HEADING_1..6, NORMAL_TEXT) + + Returns: + Tuple of (is_valid, error_message) + """ + style_params = [ + heading_level, + alignment, + line_spacing, + indent_first_line, + indent_start, + indent_end, + space_above, + space_below, + named_style_type, + ] + if all(param is None for param in style_params): + return ( + False, + "At least one paragraph style parameter must be provided (heading_level, alignment, line_spacing, indent_first_line, indent_start, indent_end, space_above, space_below, or named_style_type)", + ) + + if heading_level is not None and named_style_type is not None: + return ( + False, + "heading_level and named_style_type are mutually exclusive; provide only one", + ) + + if named_style_type is not None: + valid_styles = [ + "NORMAL_TEXT", + "TITLE", + "SUBTITLE", + "HEADING_1", + "HEADING_2", + "HEADING_3", + "HEADING_4", + "HEADING_5", + "HEADING_6", + ] + if named_style_type not in valid_styles: + return ( + False, + f"Invalid named_style_type '{named_style_type}'. Must be one of: {', '.join(valid_styles)}", + ) + + if heading_level is not None: + if not isinstance(heading_level, int): + return ( + False, + f"heading_level must be an integer, got {type(heading_level).__name__}", + ) + min_level, max_level = self.validation_rules["heading_level_range"] + if not (min_level <= heading_level <= max_level): + return ( + False, + f"heading_level must be between {min_level} and {max_level}, got {heading_level}", + ) + + if alignment is not None: + if not isinstance(alignment, str): + return ( + False, + f"alignment must be a string, got {type(alignment).__name__}", + ) + valid = self.validation_rules["valid_alignments"] + if alignment.upper() not in valid: + return ( + False, + f"alignment must be one of: {', '.join(valid)}, got '{alignment}'", + ) + + if line_spacing is not None: + if not isinstance(line_spacing, (int, float)): + return ( + False, + f"line_spacing must be a number, got {type(line_spacing).__name__}", + ) + if line_spacing <= 0: + return False, "line_spacing must be positive" + + for param, name in [ + (indent_first_line, "indent_first_line"), + (indent_start, "indent_start"), + (indent_end, "indent_end"), + (space_above, "space_above"), + (space_below, "space_below"), + ]: + if param is not None: + if not isinstance(param, (int, float)): + return ( + False, + f"{name} must be a number, got {type(param).__name__}", + ) + # indent_first_line may be negative (hanging indent) + if name != "indent_first_line" and param < 0: + return False, f"{name} must be non-negative, got {param}" + + return True, "" + + def validate_color_param( + self, color: Optional[str], param_name: str + ) -> Tuple[bool, str]: + """Validate color parameters (hex string "#RRGGBB").""" + if color is None: + return True, "" + + if not isinstance(color, str): + return False, f"{param_name} must be a hex string like '#RRGGBB'" + + if len(color) != 7 or not color.startswith("#"): + return False, f"{param_name} must be a hex string like '#RRGGBB'" + + hex_color = color[1:] + if any(c not in "0123456789abcdefABCDEF" for c in hex_color): + return False, f"{param_name} must be a hex string like '#RRGGBB'" + + return True, "" + + def validate_index(self, index: int, context: str = "Index") -> Tuple[bool, str]: + """ + Validate a single document index. + + Args: + index: Index to validate + context: Context description for error messages + + Returns: + Tuple of (is_valid, error_message) + """ + if not isinstance(index, int): + return False, f"{context} must be an integer, got {type(index).__name__}" + + if index < 0: + return ( + False, + f"{context} {index} is negative. You MUST call inspect_doc_structure first to get the proper insertion index.", + ) + + return True, "" + + def validate_index_range( + self, + start_index: int, + end_index: Optional[int] = None, + document_length: Optional[int] = None, + ) -> Tuple[bool, str]: + """ + Validate document index ranges. + + Args: + start_index: Starting index + end_index: Ending index (optional) + document_length: Total document length for bounds checking + + Returns: + Tuple of (is_valid, error_message) + """ + # Validate start_index + if not isinstance(start_index, int): + return ( + False, + f"start_index must be an integer, got {type(start_index).__name__}", + ) + + if start_index < 0: + return False, f"start_index cannot be negative, got {start_index}" + + # Validate end_index if provided + if end_index is not None: + if not isinstance(end_index, int): + return ( + False, + f"end_index must be an integer, got {type(end_index).__name__}", + ) + + if end_index <= start_index: + return ( + False, + f"end_index ({end_index}) must be greater than start_index ({start_index})", + ) + + # Validate against document length if provided + if document_length is not None: + if start_index >= document_length: + return ( + False, + f"start_index ({start_index}) exceeds document length ({document_length})", + ) + + if end_index is not None and end_index > document_length: + return ( + False, + f"end_index ({end_index}) exceeds document length ({document_length})", + ) + + return True, "" + + def validate_element_insertion_params( + self, element_type: str, index: int, **kwargs + ) -> Tuple[bool, str]: + """ + Validate parameters for element insertion. + + Args: + element_type: Type of element to insert + index: Insertion index + **kwargs: Additional parameters specific to element type + + Returns: + Tuple of (is_valid, error_message) + """ + # Validate element type + if element_type not in self.validation_rules["valid_element_types"]: + valid_types = ", ".join(self.validation_rules["valid_element_types"]) + return ( + False, + f"Invalid element_type '{element_type}'. Must be one of: {valid_types}", + ) + + # Validate index + if not isinstance(index, int) or index < 0: + return False, f"index must be a non-negative integer, got {index}" + + # Validate element-specific parameters + if element_type == "table": + rows = kwargs.get("rows") + columns = kwargs.get("columns") + + if not rows or not columns: + return False, "Table insertion requires 'rows' and 'columns' parameters" + + if not isinstance(rows, int) or not isinstance(columns, int): + return False, "Table rows and columns must be integers" + + if rows <= 0 or columns <= 0: + return False, "Table rows and columns must be positive integers" + + if rows > self.validation_rules["table_max_rows"]: + return ( + False, + f"Too many rows ({rows}). Maximum: {self.validation_rules['table_max_rows']}", + ) + + if columns > self.validation_rules["table_max_columns"]: + return ( + False, + f"Too many columns ({columns}). Maximum: {self.validation_rules['table_max_columns']}", + ) + + elif element_type == "list": + list_type = kwargs.get("list_type") + + if not list_type: + return False, "List insertion requires 'list_type' parameter" + + if list_type not in self.validation_rules["valid_list_types"]: + valid_types = ", ".join(self.validation_rules["valid_list_types"]) + return ( + False, + f"Invalid list_type '{list_type}'. Must be one of: {valid_types}", + ) + + return True, "" + + def validate_header_footer_params( + self, section_type: str, header_footer_type: str = "DEFAULT" + ) -> Tuple[bool, str]: + """ + Validate header/footer operation parameters. + + Args: + section_type: Type of section ("header" or "footer") + header_footer_type: Specific header/footer type + + Returns: + Tuple of (is_valid, error_message) + """ + if section_type not in self.validation_rules["valid_section_types"]: + valid_types = ", ".join(self.validation_rules["valid_section_types"]) + return ( + False, + f"section_type must be one of: {valid_types}, got '{section_type}'", + ) + + if header_footer_type not in self.validation_rules["valid_header_footer_types"]: + valid_types = ", ".join(self.validation_rules["valid_header_footer_types"]) + return ( + False, + f"header_footer_type must be one of: {valid_types}, got '{header_footer_type}'", + ) + + return True, "" + + def validate_batch_operations( + self, operations: List[Dict[str, Any]] + ) -> Tuple[bool, str]: + """ + Validate a list of batch operations. + + Args: + operations: List of operation dictionaries + + Returns: + Tuple of (is_valid, error_message) + """ + if not operations: + return False, "Operations list cannot be empty" + + if not isinstance(operations, list): + return False, f"Operations must be a list, got {type(operations).__name__}" + + # Validate each operation + for i, op in enumerate(operations): + if not isinstance(op, dict): + return ( + False, + f"Operation {i + 1} must be a dictionary, got {type(op).__name__}", + ) + + if "type" not in op: + return False, f"Operation {i + 1} missing required 'type' field" + + # Validate required fields for the operation type + is_valid, error_msg = validate_operation(op) + if not is_valid: + return False, f"Operation {i + 1}: {error_msg}" + + op_type = op["type"] + + if op_type == "format_text": + is_valid, error_msg = self.validate_text_formatting_params( + op.get("bold"), + op.get("italic"), + op.get("underline"), + op.get("font_size"), + op.get("font_family"), + op.get("text_color"), + op.get("background_color"), + op.get("link_url"), + ) + if not is_valid: + return False, f"Operation {i + 1} (format_text): {error_msg}" + + is_valid, error_msg = self.validate_index_range( + op["start_index"], op["end_index"] + ) + if not is_valid: + return False, f"Operation {i + 1} (format_text): {error_msg}" + + elif op_type == "update_paragraph_style": + is_valid, error_msg = self.validate_paragraph_style_params( + op.get("heading_level"), + op.get("alignment"), + op.get("line_spacing"), + op.get("indent_first_line"), + op.get("indent_start"), + op.get("indent_end"), + op.get("space_above"), + op.get("space_below"), + op.get("named_style_type"), + ) + if not is_valid: + return ( + False, + f"Operation {i + 1} (update_paragraph_style): {error_msg}", + ) + + is_valid, error_msg = self.validate_index_range( + op["start_index"], op["end_index"] + ) + if not is_valid: + return ( + False, + f"Operation {i + 1} (update_paragraph_style): {error_msg}", + ) + + return True, "" + + def validate_text_content( + self, text: str, max_length: Optional[int] = None + ) -> Tuple[bool, str]: + """ + Validate text content for insertion. + + Args: + text: Text to validate + max_length: Maximum allowed length + + Returns: + Tuple of (is_valid, error_message) + """ + if not isinstance(text, str): + return False, f"Text must be a string, got {type(text).__name__}" + + max_len = max_length or self.validation_rules["max_text_length"] + if len(text) > max_len: + return False, f"Text too long ({len(text)} characters). Maximum: {max_len}" + + return True, "" + + def get_validation_summary(self) -> Dict[str, Any]: + """ + Get a summary of all validation rules and constraints. + + Returns: + Dictionary containing validation rules + """ + return { + "constraints": self.validation_rules.copy(), + "supported_operations": { + "table_operations": ["create_table", "populate_table"], + "text_operations": [ + "insert_text", + "format_text", + "find_replace", + "update_paragraph_style", + ], + "element_operations": [ + "insert_table", + "insert_list", + "insert_page_break", + ], + "header_footer_operations": ["update_header", "update_footer"], + }, + "data_formats": { + "table_data": "2D list of strings: [['col1', 'col2'], ['row1col1', 'row1col2']]", + "text_formatting": "Optional boolean/integer parameters for styling", + "document_indices": "Non-negative integers for position specification", + }, + } diff --git a/gdrive/__init__.py b/gdrive/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gdrive/drive_helpers.py b/gdrive/drive_helpers.py new file mode 100644 index 0000000..55e342a --- /dev/null +++ b/gdrive/drive_helpers.py @@ -0,0 +1,375 @@ +""" +Google Drive Helper Functions + +Shared utilities for Google Drive operations including permission checking. +""" + +import asyncio +import re +from typing import List, Dict, Any, Optional, Tuple + +VALID_SHARE_ROLES = {"reader", "commenter", "writer"} +VALID_SHARE_TYPES = {"user", "group", "domain", "anyone"} + + +def check_public_link_permission(permissions: List[Dict[str, Any]]) -> bool: + """ + Check if file has 'anyone with the link' permission. + + Args: + permissions: List of permission objects from Google Drive API + + Returns: + bool: True if file has public link sharing enabled + """ + return any( + p.get("type") == "anyone" and p.get("role") in ["reader", "writer", "commenter"] + for p in permissions + ) + + +def format_public_sharing_error(file_name: str, file_id: str) -> str: + """ + Format error message for files without public sharing. + + Args: + file_name: Name of the file + file_id: Google Drive file ID + + Returns: + str: Formatted error message + """ + return ( + f"❌ Permission Error: '{file_name}' not shared publicly. " + f"Set 'Anyone with the link' → 'Viewer' in Google Drive sharing. " + f"File: https://drive.google.com/file/d/{file_id}/view" + ) + + +def get_drive_image_url(file_id: str) -> str: + """ + Get the correct Drive URL format for publicly shared images. + + Args: + file_id: Google Drive file ID + + Returns: + str: URL for embedding Drive images + """ + return f"https://drive.google.com/uc?export=view&id={file_id}" + + +def validate_share_role(role: str) -> None: + """ + Validate that the role is valid for sharing. + + Args: + role: The permission role to validate + + Raises: + ValueError: If role is not reader, commenter, or writer + """ + if role not in VALID_SHARE_ROLES: + raise ValueError( + f"Invalid role '{role}'. Must be one of: {', '.join(sorted(VALID_SHARE_ROLES))}" + ) + + +def validate_share_type(share_type: str) -> None: + """ + Validate that the share type is valid. + + Args: + share_type: The type of sharing to validate + + Raises: + ValueError: If share_type is not user, group, domain, or anyone + """ + if share_type not in VALID_SHARE_TYPES: + raise ValueError( + f"Invalid share_type '{share_type}'. Must be one of: {', '.join(sorted(VALID_SHARE_TYPES))}" + ) + + +RFC3339_PATTERN = re.compile( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$" +) + + +def validate_expiration_time(expiration_time: str) -> None: + """ + Validate that expiration_time is in RFC 3339 format. + + Args: + expiration_time: The expiration time string to validate + + Raises: + ValueError: If expiration_time is not valid RFC 3339 format + """ + if not RFC3339_PATTERN.match(expiration_time): + raise ValueError( + f"Invalid expiration_time '{expiration_time}'. " + "Must be RFC 3339 format (e.g., '2025-01-15T00:00:00Z')" + ) + + +def format_permission_info(permission: Dict[str, Any]) -> str: + """ + Format a permission object for display. + + Args: + permission: Permission object from Google Drive API + + Returns: + str: Human-readable permission description with ID + """ + perm_type = permission.get("type", "unknown") + role = permission.get("role", "unknown") + perm_id = permission.get("id", "") + + if perm_type == "anyone": + base = f"Anyone with the link ({role}) [id: {perm_id}]" + elif perm_type == "user": + email = permission.get("emailAddress", "unknown") + base = f"User: {email} ({role}) [id: {perm_id}]" + elif perm_type == "group": + email = permission.get("emailAddress", "unknown") + base = f"Group: {email} ({role}) [id: {perm_id}]" + elif perm_type == "domain": + domain = permission.get("domain", "unknown") + base = f"Domain: {domain} ({role}) [id: {perm_id}]" + else: + base = f"{perm_type} ({role}) [id: {perm_id}]" + + extras = [] + if permission.get("expirationTime"): + extras.append(f"expires: {permission['expirationTime']}") + + perm_details = permission.get("permissionDetails", []) + if perm_details: + for detail in perm_details: + if detail.get("inherited") and detail.get("inheritedFrom"): + extras.append(f"inherited from: {detail['inheritedFrom']}") + break + + if extras: + return f"{base} | {', '.join(extras)}" + return base + + +# Precompiled regex patterns for Drive query detection +DRIVE_QUERY_PATTERNS = [ + re.compile(r'\b\w+\s*(=|!=|>|<)\s*[\'"].*?[\'"]', re.IGNORECASE), # field = 'value' + re.compile(r"\b\w+\s*(=|!=|>|<)\s*\d+", re.IGNORECASE), # field = number + re.compile(r"\bcontains\b", re.IGNORECASE), # contains operator + re.compile(r"\bin\s+parents\b", re.IGNORECASE), # in parents + re.compile(r"\bhas\s*\{", re.IGNORECASE), # has {properties} + re.compile(r"\btrashed\s*=\s*(true|false)\b", re.IGNORECASE), # trashed=true/false + re.compile(r"\bstarred\s*=\s*(true|false)\b", re.IGNORECASE), # starred=true/false + re.compile( + r'[\'"][^\'"]+[\'"]\s+in\s+parents', re.IGNORECASE + ), # 'parentId' in parents + re.compile(r"\bfullText\s+contains\b", re.IGNORECASE), # fullText contains + re.compile(r"\bname\s*(=|contains)\b", re.IGNORECASE), # name = or name contains + re.compile(r"\bmimeType\s*(=|!=)\b", re.IGNORECASE), # mimeType operators +] + + +def build_drive_list_params( + query: str, + page_size: int, + drive_id: Optional[str] = None, + include_items_from_all_drives: bool = True, + corpora: Optional[str] = None, + page_token: Optional[str] = None, + detailed: bool = True, +) -> Dict[str, Any]: + """ + Helper function to build common list parameters for Drive API calls. + + Args: + query: The search query string + page_size: Maximum number of items to return + drive_id: Optional shared drive ID + include_items_from_all_drives: Whether to include items from all drives + corpora: Optional corpus specification + page_token: Optional page token for pagination (from a previous nextPageToken) + detailed: Whether to request size, modifiedTime, and webViewLink fields. + Defaults to True to preserve existing behavior. + + Returns: + Dictionary of parameters for Drive API list calls + """ + if detailed: + fields = "nextPageToken, files(id, name, mimeType, webViewLink, iconLink, modifiedTime, size)" + else: + fields = "nextPageToken, files(id, name, mimeType)" + list_params = { + "q": query, + "pageSize": page_size, + "fields": fields, + "supportsAllDrives": True, + "includeItemsFromAllDrives": include_items_from_all_drives, + } + + if page_token: + list_params["pageToken"] = page_token + + if drive_id: + list_params["driveId"] = drive_id + if corpora: + list_params["corpora"] = corpora + else: + list_params["corpora"] = "drive" + elif corpora: + list_params["corpora"] = corpora + + return list_params + + +SHORTCUT_MIME_TYPE = "application/vnd.google-apps.shortcut" +FOLDER_MIME_TYPE = "application/vnd.google-apps.folder" + +# RFC 6838 token-style MIME type validation (safe for Drive query interpolation). +MIME_TYPE_PATTERN = re.compile(r"^[A-Za-z0-9!#$&^_.+-]+/[A-Za-z0-9!#$&^_.+-]+$") + +# Mapping from friendly type names to Google Drive MIME types. +# Raw MIME type strings (containing '/') are always accepted as-is. +FILE_TYPE_MIME_MAP: Dict[str, str] = { + "folder": "application/vnd.google-apps.folder", + "folders": "application/vnd.google-apps.folder", + "document": "application/vnd.google-apps.document", + "doc": "application/vnd.google-apps.document", + "documents": "application/vnd.google-apps.document", + "docs": "application/vnd.google-apps.document", + "spreadsheet": "application/vnd.google-apps.spreadsheet", + "sheet": "application/vnd.google-apps.spreadsheet", + "spreadsheets": "application/vnd.google-apps.spreadsheet", + "sheets": "application/vnd.google-apps.spreadsheet", + "presentation": "application/vnd.google-apps.presentation", + "presentations": "application/vnd.google-apps.presentation", + "slide": "application/vnd.google-apps.presentation", + "slides": "application/vnd.google-apps.presentation", + "form": "application/vnd.google-apps.form", + "forms": "application/vnd.google-apps.form", + "drawing": "application/vnd.google-apps.drawing", + "drawings": "application/vnd.google-apps.drawing", + "pdf": "application/pdf", + "pdfs": "application/pdf", + "shortcut": "application/vnd.google-apps.shortcut", + "shortcuts": "application/vnd.google-apps.shortcut", + "script": "application/vnd.google-apps.script", + "scripts": "application/vnd.google-apps.script", + "site": "application/vnd.google-apps.site", + "sites": "application/vnd.google-apps.site", + "jam": "application/vnd.google-apps.jam", + "jamboard": "application/vnd.google-apps.jam", + "jamboards": "application/vnd.google-apps.jam", +} + + +def resolve_file_type_mime(file_type: str) -> str: + """ + Resolve a friendly file type name or raw MIME type string to a Drive MIME type. + + If `file_type` contains '/' it is returned as-is (treated as a raw MIME type). + Otherwise it is looked up in FILE_TYPE_MIME_MAP. + + Args: + file_type: A friendly name ('folder', 'document', 'pdf', …) or a raw MIME + type string ('application/vnd.google-apps.document', …). + + Returns: + str: The resolved MIME type string. + + Raises: + ValueError: If the value is not a recognised friendly name and contains no '/'. + """ + normalized = file_type.strip() + if not normalized: + raise ValueError("file_type cannot be empty.") + + if "/" in normalized: + normalized_mime = normalized.lower() + if not MIME_TYPE_PATTERN.fullmatch(normalized_mime): + raise ValueError( + f"Invalid MIME type '{file_type}'. Expected format like 'application/pdf'." + ) + return normalized_mime + lower = normalized.lower() + if lower not in FILE_TYPE_MIME_MAP: + valid = ", ".join(sorted(FILE_TYPE_MIME_MAP.keys())) + raise ValueError( + f"Unknown file_type '{file_type}'. Pass a MIME type directly (e.g. " + f"'application/pdf') or use one of the friendly names: {valid}" + ) + return FILE_TYPE_MIME_MAP[lower] + + +BASE_SHORTCUT_FIELDS = ( + "id, mimeType, parents, shortcutDetails(targetId, targetMimeType)" +) + + +async def resolve_drive_item( + service, + file_id: str, + *, + extra_fields: Optional[str] = None, + max_depth: int = 5, +) -> Tuple[str, Dict[str, Any]]: + """ + Resolve a Drive shortcut so downstream callers operate on the real item. + + Returns the resolved file ID and its metadata. Raises if shortcut targets loop + or exceed max_depth to avoid infinite recursion. + """ + current_id = file_id + depth = 0 + fields = BASE_SHORTCUT_FIELDS + if extra_fields: + fields = f"{fields}, {extra_fields}" + + while True: + metadata = await asyncio.to_thread( + service.files() + .get(fileId=current_id, fields=fields, supportsAllDrives=True) + .execute + ) + mime_type = metadata.get("mimeType") + if mime_type != SHORTCUT_MIME_TYPE: + return current_id, metadata + + shortcut_details = metadata.get("shortcutDetails") or {} + target_id = shortcut_details.get("targetId") + if not target_id: + raise Exception(f"Shortcut '{current_id}' is missing target details.") + + depth += 1 + if depth > max_depth: + raise Exception( + f"Shortcut resolution exceeded {max_depth} hops starting from '{file_id}'." + ) + current_id = target_id + + +async def resolve_folder_id( + service, + folder_id: str, + *, + max_depth: int = 5, +) -> str: + """ + Resolve a folder ID that might be a shortcut and ensure the final target is a folder. + """ + resolved_id, metadata = await resolve_drive_item( + service, + folder_id, + max_depth=max_depth, + ) + mime_type = metadata.get("mimeType") + if mime_type != FOLDER_MIME_TYPE: + raise Exception( + f"Resolved ID '{resolved_id}' (from '{folder_id}') is not a folder; mimeType={mime_type}." + ) + return resolved_id diff --git a/gdrive/drive_tools.py b/gdrive/drive_tools.py new file mode 100644 index 0000000..f2120e8 --- /dev/null +++ b/gdrive/drive_tools.py @@ -0,0 +1,2383 @@ +""" +Google Drive MCP Tools + +This module provides MCP tools for interacting with Google Drive API. +""" + +import asyncio +import logging +import io +import httpx +import base64 +import ipaddress +import socket +from contextlib import asynccontextmanager + +from typing import AsyncIterator, Optional, List, Dict, Any +from tempfile import NamedTemporaryFile +from urllib.parse import urljoin, urlparse, urlunparse +from urllib.request import url2pathname +from pathlib import Path + +from googleapiclient.errors import HttpError +from googleapiclient.http import MediaIoBaseDownload, MediaIoBaseUpload + +from auth.service_decorator import require_google_service +from auth.oauth_config import is_stateless_mode +from core.attachment_storage import get_attachment_storage, get_attachment_url +from core.utils import extract_office_xml_text, handle_http_errors, validate_file_path +from core.server import server +from core.config import get_transport_mode +from gdrive.drive_helpers import ( + DRIVE_QUERY_PATTERNS, + FOLDER_MIME_TYPE, + build_drive_list_params, + check_public_link_permission, + format_permission_info, + get_drive_image_url, + resolve_drive_item, + resolve_file_type_mime, + resolve_folder_id, + validate_expiration_time, + validate_share_role, + validate_share_type, +) + +logger = logging.getLogger(__name__) + +DOWNLOAD_CHUNK_SIZE_BYTES = 256 * 1024 # 256 KB +UPLOAD_CHUNK_SIZE_BYTES = 5 * 1024 * 1024 # 5 MB (Google recommended minimum) +MAX_DOWNLOAD_BYTES = 2 * 1024 * 1024 * 1024 # 2 GB safety limit for URL downloads + + +@server.tool() +@handle_http_errors("search_drive_files", is_read_only=True, service_type="drive") +@require_google_service("drive", "drive_read") +async def search_drive_files( + service, + user_google_email: str, + query: str, + page_size: int = 10, + page_token: Optional[str] = None, + drive_id: Optional[str] = None, + include_items_from_all_drives: bool = True, + corpora: Optional[str] = None, + file_type: Optional[str] = None, + detailed: bool = True, +) -> str: + """ + Searches for files and folders within a user's Google Drive, including shared drives. + + Args: + user_google_email (str): The user's Google email address. Required. + query (str): The search query string. Supports Google Drive search operators. + page_size (int): The maximum number of files to return. Defaults to 10. + page_token (Optional[str]): Page token from a previous response's nextPageToken to retrieve the next page of results. + drive_id (Optional[str]): ID of the shared drive to search. If None, behavior depends on `corpora` and `include_items_from_all_drives`. + include_items_from_all_drives (bool): Whether shared drive items should be included in results. Defaults to True. This is effective when not specifying a `drive_id`. + corpora (Optional[str]): Bodies of items to query (e.g., 'user', 'domain', 'drive', 'allDrives'). + If 'drive_id' is specified and 'corpora' is None, it defaults to 'drive'. + Otherwise, Drive API default behavior applies. Prefer 'user' or 'drive' over 'allDrives' for efficiency. + file_type (Optional[str]): Restrict results to a specific file type. Accepts a friendly + name ('folder', 'document'/'doc', 'spreadsheet'/'sheet', + 'presentation'/'slides', 'form', 'drawing', 'pdf', 'shortcut', + 'script', 'site', 'jam'/'jamboard') or any raw MIME type + string (e.g. 'application/pdf'). Defaults to None (all types). + detailed (bool): Whether to include size, modified time, and link in results. Defaults to True. + + Returns: + str: A formatted list of found files/folders with their details (ID, name, type, and optionally size, modified time, link). + Includes a nextPageToken line when more results are available. + """ + logger.info( + f"[search_drive_files] Invoked. Email: '{user_google_email}', Query: '{query}', file_type: '{file_type}'" + ) + + # Check if the query looks like a structured Drive query or free text + # Look for Drive API operators and structured query patterns + is_structured_query = any(pattern.search(query) for pattern in DRIVE_QUERY_PATTERNS) + + if is_structured_query: + final_query = query + logger.info( + f"[search_drive_files] Using structured query as-is: '{final_query}'" + ) + else: + # For free text queries, wrap in fullText contains + escaped_query = query.replace("'", "\\'") + final_query = f"fullText contains '{escaped_query}'" + logger.info( + f"[search_drive_files] Reformatting free text query '{query}' to '{final_query}'" + ) + + if file_type is not None: + mime = resolve_file_type_mime(file_type) + final_query = f"({final_query}) and mimeType = '{mime}'" + logger.info(f"[search_drive_files] Added mimeType filter: '{mime}'") + + list_params = build_drive_list_params( + query=final_query, + page_size=page_size, + drive_id=drive_id, + include_items_from_all_drives=include_items_from_all_drives, + corpora=corpora, + page_token=page_token, + detailed=detailed, + ) + + results = await asyncio.to_thread(service.files().list(**list_params).execute) + files = results.get("files", []) + if not files: + return f"No files found for '{query}'." + + next_token = results.get("nextPageToken") + header = f"Found {len(files)} files for {user_google_email} matching '{query}':" + formatted_files_text_parts = [header] + for item in files: + if detailed: + size_str = f", Size: {item.get('size', 'N/A')}" if "size" in item else "" + formatted_files_text_parts.append( + f'- Name: "{item["name"]}" (ID: {item["id"]}, Type: {item["mimeType"]}{size_str}, Modified: {item.get("modifiedTime", "N/A")}) Link: {item.get("webViewLink", "#")}' + ) + else: + formatted_files_text_parts.append( + f'- Name: "{item["name"]}" (ID: {item["id"]}, Type: {item["mimeType"]})' + ) + if next_token: + formatted_files_text_parts.append(f"nextPageToken: {next_token}") + text_output = "\n".join(formatted_files_text_parts) + return text_output + + +@server.tool() +@handle_http_errors("get_drive_file_content", is_read_only=True, service_type="drive") +@require_google_service("drive", "drive_read") +async def get_drive_file_content( + service, + user_google_email: str, + file_id: str, +) -> str: + """ + Retrieves the content of a specific Google Drive file by ID, supporting files in shared drives. + + • Native Google Docs, Sheets, Slides → exported as text / CSV. + • Office files (.docx, .xlsx, .pptx) → unzipped & parsed with std-lib to + extract readable text. + • Any other file → downloaded; tries UTF-8 decode, else notes binary. + + Args: + user_google_email: The user’s Google email address. + file_id: Drive file ID. + + Returns: + str: The file content as plain text with metadata header. + """ + logger.info(f"[get_drive_file_content] Invoked. File ID: '{file_id}'") + + resolved_file_id, file_metadata = await resolve_drive_item( + service, + file_id, + extra_fields="name, webViewLink", + ) + file_id = resolved_file_id + mime_type = file_metadata.get("mimeType", "") + file_name = file_metadata.get("name", "Unknown File") + export_mime_type = { + "application/vnd.google-apps.document": "text/plain", + "application/vnd.google-apps.spreadsheet": "text/csv", + "application/vnd.google-apps.presentation": "text/plain", + }.get(mime_type) + + request_obj = ( + service.files().export_media(fileId=file_id, mimeType=export_mime_type) + if export_mime_type + else service.files().get_media(fileId=file_id) + ) + fh = io.BytesIO() + downloader = MediaIoBaseDownload(fh, request_obj) + loop = asyncio.get_event_loop() + done = False + while not done: + status, done = await loop.run_in_executor(None, downloader.next_chunk) + + file_content_bytes = fh.getvalue() + + # Attempt Office XML extraction only for actual Office XML files + office_mime_types = { + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + } + + if mime_type in office_mime_types: + office_text = extract_office_xml_text(file_content_bytes, mime_type) + if office_text: + body_text = office_text + else: + # Fallback: try UTF-8; otherwise flag binary + try: + body_text = file_content_bytes.decode("utf-8") + except UnicodeDecodeError: + body_text = ( + f"[Binary or unsupported text encoding for mimeType '{mime_type}' - " + f"{len(file_content_bytes)} bytes]" + ) + else: + # For non-Office files (including Google native files), try UTF-8 decode directly + try: + body_text = file_content_bytes.decode("utf-8") + except UnicodeDecodeError: + body_text = ( + f"[Binary or unsupported text encoding for mimeType '{mime_type}' - " + f"{len(file_content_bytes)} bytes]" + ) + + # Assemble response + header = ( + f'File: "{file_name}" (ID: {file_id}, Type: {mime_type})\n' + f"Link: {file_metadata.get('webViewLink', '#')}\n\n--- CONTENT ---\n" + ) + return header + body_text + + +@server.tool() +@handle_http_errors( + "get_drive_file_download_url", is_read_only=True, service_type="drive" +) +@require_google_service("drive", "drive_read") +async def get_drive_file_download_url( + service, + user_google_email: str, + file_id: str, + export_format: Optional[str] = None, +) -> str: + """ + Downloads a Google Drive file and saves it to local disk. + + In stdio mode, returns the local file path for direct access. + In HTTP mode, returns a temporary download URL (valid for 1 hour). + + For Google native files (Docs, Sheets, Slides), exports to a useful format: + - Google Docs -> PDF (default) or DOCX if export_format='docx' + - Google Sheets -> XLSX (default), PDF if export_format='pdf', or CSV if export_format='csv' + - Google Slides -> PDF (default) or PPTX if export_format='pptx' + + For other files, downloads the original file format. + + Args: + user_google_email: The user's Google email address. Required. + file_id: The Google Drive file ID to download. + export_format: Optional export format for Google native files. + Options: 'pdf', 'docx', 'xlsx', 'csv', 'pptx'. + If not specified, uses sensible defaults (PDF for Docs/Slides, XLSX for Sheets). + For Sheets: supports 'csv', 'pdf', or 'xlsx' (default). + + Returns: + str: File metadata with either a local file path or download URL. + """ + logger.info( + f"[get_drive_file_download_url] Invoked. File ID: '{file_id}', Export format: {export_format}" + ) + + # Resolve shortcuts and get file metadata + resolved_file_id, file_metadata = await resolve_drive_item( + service, + file_id, + extra_fields="name, webViewLink, mimeType", + ) + file_id = resolved_file_id + mime_type = file_metadata.get("mimeType", "") + file_name = file_metadata.get("name", "Unknown File") + + # Determine export format for Google native files + export_mime_type = None + output_filename = file_name + output_mime_type = mime_type + + if mime_type == "application/vnd.google-apps.document": + # Google Docs + if export_format == "docx": + export_mime_type = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + output_mime_type = export_mime_type + if not output_filename.endswith(".docx"): + output_filename = f"{Path(output_filename).stem}.docx" + else: + # Default to PDF + export_mime_type = "application/pdf" + output_mime_type = export_mime_type + if not output_filename.endswith(".pdf"): + output_filename = f"{Path(output_filename).stem}.pdf" + + elif mime_type == "application/vnd.google-apps.spreadsheet": + # Google Sheets + if export_format == "csv": + export_mime_type = "text/csv" + output_mime_type = export_mime_type + if not output_filename.endswith(".csv"): + output_filename = f"{Path(output_filename).stem}.csv" + elif export_format == "pdf": + export_mime_type = "application/pdf" + output_mime_type = export_mime_type + if not output_filename.endswith(".pdf"): + output_filename = f"{Path(output_filename).stem}.pdf" + else: + # Default to XLSX + export_mime_type = ( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + output_mime_type = export_mime_type + if not output_filename.endswith(".xlsx"): + output_filename = f"{Path(output_filename).stem}.xlsx" + + elif mime_type == "application/vnd.google-apps.presentation": + # Google Slides + if export_format == "pptx": + export_mime_type = "application/vnd.openxmlformats-officedocument.presentationml.presentation" + output_mime_type = export_mime_type + if not output_filename.endswith(".pptx"): + output_filename = f"{Path(output_filename).stem}.pptx" + else: + # Default to PDF + export_mime_type = "application/pdf" + output_mime_type = export_mime_type + if not output_filename.endswith(".pdf"): + output_filename = f"{Path(output_filename).stem}.pdf" + + # Download the file + request_obj = ( + service.files().export_media(fileId=file_id, mimeType=export_mime_type) + if export_mime_type + else service.files().get_media(fileId=file_id) + ) + + fh = io.BytesIO() + downloader = MediaIoBaseDownload(fh, request_obj) + loop = asyncio.get_event_loop() + done = False + while not done: + status, done = await loop.run_in_executor(None, downloader.next_chunk) + + file_content_bytes = fh.getvalue() + size_bytes = len(file_content_bytes) + size_kb = size_bytes / 1024 if size_bytes else 0 + + # Check if we're in stateless mode (can't save files) + if is_stateless_mode(): + result_lines = [ + "File downloaded successfully!", + f"File: {file_name}", + f"File ID: {file_id}", + f"Size: {size_kb:.1f} KB ({size_bytes} bytes)", + f"MIME Type: {output_mime_type}", + "\n⚠️ Stateless mode: File storage disabled.", + "\nBase64-encoded content (first 100 characters shown):", + f"{base64.b64encode(file_content_bytes[:100]).decode('utf-8')}...", + ] + logger.info( + f"[get_drive_file_download_url] Successfully downloaded {size_kb:.1f} KB file (stateless mode)" + ) + return "\n".join(result_lines) + + # Save file to local disk and return file path + try: + storage = get_attachment_storage() + + # Encode bytes to base64 (as expected by AttachmentStorage) + base64_data = base64.urlsafe_b64encode(file_content_bytes).decode("utf-8") + + # Save attachment to local disk + result = storage.save_attachment( + base64_data=base64_data, + filename=output_filename, + mime_type=output_mime_type, + ) + + result_lines = [ + "File downloaded successfully!", + f"File: {file_name}", + f"File ID: {file_id}", + f"Size: {size_kb:.1f} KB ({size_bytes} bytes)", + f"MIME Type: {output_mime_type}", + ] + + if get_transport_mode() == "stdio": + result_lines.append(f"\n📎 Saved to: {result.path}") + result_lines.append( + "\nThe file has been saved to disk and can be accessed directly via the file path." + ) + else: + download_url = get_attachment_url(result.file_id) + result_lines.append(f"\n📎 Download URL: {download_url}") + result_lines.append("\nThe file will expire after 1 hour.") + + if export_mime_type: + result_lines.append( + f"\nNote: Google native file exported to {output_mime_type} format." + ) + + logger.info( + f"[get_drive_file_download_url] Successfully saved {size_kb:.1f} KB file to {result.path}" + ) + return "\n".join(result_lines) + + except Exception as e: + logger.error(f"[get_drive_file_download_url] Failed to save file: {e}") + return ( + f"Error: Failed to save file for download.\n" + f"File was downloaded successfully ({size_kb:.1f} KB) but could not be saved.\n\n" + f"Error details: {str(e)}" + ) + + +@server.tool() +@handle_http_errors("list_drive_items", is_read_only=True, service_type="drive") +@require_google_service("drive", "drive_read") +async def list_drive_items( + service, + user_google_email: str, + folder_id: str = "root", + page_size: int = 100, + page_token: Optional[str] = None, + drive_id: Optional[str] = None, + include_items_from_all_drives: bool = True, + corpora: Optional[str] = None, + file_type: Optional[str] = None, + detailed: bool = True, +) -> str: + """ + Lists files and folders, supporting shared drives. + If `drive_id` is specified, lists items within that shared drive. `folder_id` is then relative to that drive (or use drive_id as folder_id for root). + If `drive_id` is not specified, lists items from user's "My Drive" and accessible shared drives (if `include_items_from_all_drives` is True). + + Args: + user_google_email (str): The user's Google email address. Required. + folder_id (str): The ID of the Google Drive folder. Defaults to 'root'. For a shared drive, this can be the shared drive's ID to list its root, or a folder ID within that shared drive. + page_size (int): The maximum number of items to return. Defaults to 100. + page_token (Optional[str]): Page token from a previous response's nextPageToken to retrieve the next page of results. + drive_id (Optional[str]): ID of the shared drive. If provided, the listing is scoped to this drive. + include_items_from_all_drives (bool): Whether items from all accessible shared drives should be included if `drive_id` is not set. Defaults to True. + corpora (Optional[str]): Corpus to query ('user', 'drive', 'allDrives'). If `drive_id` is set and `corpora` is None, 'drive' is used. If None and no `drive_id`, API defaults apply. + file_type (Optional[str]): Restrict results to a specific file type. Accepts a friendly + name ('folder', 'document'/'doc', 'spreadsheet'/'sheet', + 'presentation'/'slides', 'form', 'drawing', 'pdf', 'shortcut', + 'script', 'site', 'jam'/'jamboard') or any raw MIME type + string (e.g. 'application/pdf'). Defaults to None (all types). + detailed (bool): Whether to include size, modified time, and link in results. Defaults to True. + + Returns: + str: A formatted list of files/folders in the specified folder. + Includes a nextPageToken line when more results are available. + """ + logger.info( + f"[list_drive_items] Invoked. Email: '{user_google_email}', Folder ID: '{folder_id}', File Type: '{file_type}'" + ) + + resolved_folder_id = await resolve_folder_id(service, folder_id) + final_query = f"'{resolved_folder_id}' in parents and trashed=false" + + if file_type is not None: + mime = resolve_file_type_mime(file_type) + final_query = f"({final_query}) and mimeType = '{mime}'" + logger.info(f"[list_drive_items] Added mimeType filter: '{mime}'") + + list_params = build_drive_list_params( + query=final_query, + page_size=page_size, + drive_id=drive_id, + include_items_from_all_drives=include_items_from_all_drives, + corpora=corpora, + page_token=page_token, + detailed=detailed, + ) + + results = await asyncio.to_thread(service.files().list(**list_params).execute) + files = results.get("files", []) + if not files: + return f"No items found in folder '{folder_id}'." + + next_token = results.get("nextPageToken") + header = ( + f"Found {len(files)} items in folder '{folder_id}' for {user_google_email}:" + ) + formatted_items_text_parts = [header] + for item in files: + if detailed: + size_str = f", Size: {item.get('size', 'N/A')}" if "size" in item else "" + formatted_items_text_parts.append( + f'- Name: "{item["name"]}" (ID: {item["id"]}, Type: {item["mimeType"]}{size_str}, Modified: {item.get("modifiedTime", "N/A")}) Link: {item.get("webViewLink", "#")}' + ) + else: + formatted_items_text_parts.append( + f'- Name: "{item["name"]}" (ID: {item["id"]}, Type: {item["mimeType"]})' + ) + if next_token: + formatted_items_text_parts.append(f"nextPageToken: {next_token}") + text_output = "\n".join(formatted_items_text_parts) + return text_output + + +async def _create_drive_folder_impl( + service, + user_google_email: str, + folder_name: str, + parent_folder_id: str = "root", +) -> str: + """Internal implementation for create_drive_folder. Used by tests.""" + resolved_folder_id = await resolve_folder_id(service, parent_folder_id) + file_metadata = { + "name": folder_name, + "parents": [resolved_folder_id], + "mimeType": FOLDER_MIME_TYPE, + } + created_file = await asyncio.to_thread( + service.files() + .create( + body=file_metadata, + fields="id, name, webViewLink", + supportsAllDrives=True, + ) + .execute + ) + link = created_file.get("webViewLink", "") + return ( + f"Successfully created folder '{created_file.get('name', folder_name)}' (ID: {created_file.get('id', 'N/A')}) " + f"in folder '{parent_folder_id}' for {user_google_email}. Link: {link}" + ) + + +@server.tool() +@handle_http_errors("create_drive_folder", service_type="drive") +@require_google_service("drive", "drive_file") +async def create_drive_folder( + service, + user_google_email: str, + folder_name: str, + parent_folder_id: str = "root", +) -> str: + """ + Creates a new folder in Google Drive, supporting creation within shared drives. + + Args: + user_google_email (str): The user's Google email address. Required. + folder_name (str): The name for the new folder. + parent_folder_id (str): The ID of the parent folder. Defaults to 'root'. + For shared drives, use a folder ID within that shared drive. + + Returns: + str: Confirmation message with folder name, ID, and link. + """ + logger.info( + f"[create_drive_folder] Invoked. Email: '{user_google_email}', Folder: '{folder_name}', Parent: '{parent_folder_id}'" + ) + return await _create_drive_folder_impl( + service, user_google_email, folder_name, parent_folder_id + ) + + +@server.tool() +@handle_http_errors("create_drive_file", service_type="drive") +@require_google_service("drive", "drive_file") +async def create_drive_file( + service, + user_google_email: str, + file_name: str, + content: Optional[str] = None, # Now explicitly Optional + folder_id: str = "root", + mime_type: str = "text/plain", + fileUrl: Optional[str] = None, # Now explicitly Optional +) -> str: + """ + Creates a new file in Google Drive, supporting creation within shared drives. + Accepts either direct content or a fileUrl to fetch the content from. + + Args: + user_google_email (str): The user's Google email address. Required. + file_name (str): The name for the new file. + content (Optional[str]): If provided, the content to write to the file. + folder_id (str): The ID of the parent folder. Defaults to 'root'. For shared drives, this must be a folder ID within the shared drive. + mime_type (str): The MIME type of the file. Defaults to 'text/plain'. + fileUrl (Optional[str]): If provided, fetches the file content from this URL. Supports file://, http://, and https:// protocols. + + Returns: + str: Confirmation message of the successful file creation with file link. + """ + logger.info( + f"[create_drive_file] Invoked. Email: '{user_google_email}', File Name: {file_name}, Folder ID: {folder_id}, fileUrl: {fileUrl}" + ) + + if content is None and fileUrl is None and mime_type != FOLDER_MIME_TYPE: + raise Exception("You must provide either 'content' or 'fileUrl'.") + + # Create folder (no content or media_body). Prefer create_drive_folder for new code. + if mime_type == FOLDER_MIME_TYPE: + return await _create_drive_folder_impl( + service, user_google_email, file_name, folder_id + ) + + file_data = None + resolved_folder_id = await resolve_folder_id(service, folder_id) + + file_metadata = { + "name": file_name, + "parents": [resolved_folder_id], + "mimeType": mime_type, + } + + # Prefer fileUrl if both are provided + if fileUrl: + logger.info(f"[create_drive_file] Fetching file from URL: {fileUrl}") + + # Check if this is a file:// URL + parsed_url = urlparse(fileUrl) + if parsed_url.scheme == "file": + # Handle file:// URL - read from local filesystem + logger.info( + "[create_drive_file] Detected file:// URL, reading from local filesystem" + ) + transport_mode = get_transport_mode() + running_streamable = transport_mode == "streamable-http" + if running_streamable: + logger.warning( + "[create_drive_file] file:// URL requested while server runs in streamable-http mode. Ensure the file path is accessible to the server (e.g., Docker volume) or use an HTTP(S) URL." + ) + + # Convert file:// URL to a cross-platform local path + raw_path = parsed_url.path or "" + netloc = parsed_url.netloc + if netloc and netloc.lower() != "localhost": + raw_path = f"//{netloc}{raw_path}" + file_path = url2pathname(raw_path) + + # Validate path safety and verify file exists + path_obj = validate_file_path(file_path) + if not path_obj.exists(): + extra = ( + " The server is running via streamable-http, so file:// URLs must point to files inside the container or remote host." + if running_streamable + else "" + ) + raise Exception(f"Local file does not exist: {file_path}.{extra}") + if not path_obj.is_file(): + extra = ( + " In streamable-http/Docker deployments, mount the file into the container or provide an HTTP(S) URL." + if running_streamable + else "" + ) + raise Exception(f"Path is not a file: {file_path}.{extra}") + + logger.info(f"[create_drive_file] Reading local file: {file_path}") + + # Read file and upload + file_data = await asyncio.to_thread(path_obj.read_bytes) + total_bytes = len(file_data) + logger.info(f"[create_drive_file] Read {total_bytes} bytes from local file") + + media = MediaIoBaseUpload( + io.BytesIO(file_data), + mimetype=mime_type, + resumable=True, + chunksize=UPLOAD_CHUNK_SIZE_BYTES, + ) + + logger.info("[create_drive_file] Starting upload to Google Drive...") + created_file = await asyncio.to_thread( + service.files() + .create( + body=file_metadata, + media_body=media, + fields="id, name, webViewLink", + supportsAllDrives=True, + ) + .execute + ) + # Handle HTTP/HTTPS URLs + elif parsed_url.scheme in ("http", "https"): + # when running in stateless mode, deployment may not have access to local file system + if is_stateless_mode(): + resp = await _ssrf_safe_fetch(fileUrl) + if resp.status_code != 200: + raise Exception( + f"Failed to fetch file from URL: {fileUrl} (status {resp.status_code})" + ) + file_data = resp.content + # Try to get MIME type from Content-Type header + content_type = resp.headers.get("Content-Type") + if content_type and content_type != "application/octet-stream": + mime_type = content_type + file_metadata["mimeType"] = content_type + logger.info( + f"[create_drive_file] Using MIME type from Content-Type header: {content_type}" + ) + + media = MediaIoBaseUpload( + io.BytesIO(file_data), + mimetype=mime_type, + resumable=True, + chunksize=UPLOAD_CHUNK_SIZE_BYTES, + ) + + created_file = await asyncio.to_thread( + service.files() + .create( + body=file_metadata, + media_body=media, + fields="id, name, webViewLink", + supportsAllDrives=True, + ) + .execute + ) + else: + # Stream download to temp file with SSRF protection, then upload + with NamedTemporaryFile() as temp_file: + total_bytes = 0 + content_type = None + + async with _ssrf_safe_stream(fileUrl) as resp: + if resp.status_code != 200: + raise Exception( + f"Failed to fetch file from URL: {fileUrl} " + f"(status {resp.status_code})" + ) + + content_type = resp.headers.get("Content-Type") + + async for chunk in resp.aiter_bytes( + chunk_size=DOWNLOAD_CHUNK_SIZE_BYTES + ): + total_bytes += len(chunk) + if total_bytes > MAX_DOWNLOAD_BYTES: + raise Exception( + f"Download exceeded {MAX_DOWNLOAD_BYTES} byte limit" + ) + await asyncio.to_thread(temp_file.write, chunk) + + logger.info( + f"[create_drive_file] Downloaded {total_bytes} bytes " + f"from URL before upload." + ) + + if content_type and content_type != "application/octet-stream": + mime_type = content_type + file_metadata["mimeType"] = mime_type + logger.info( + f"[create_drive_file] Using MIME type from " + f"Content-Type header: {mime_type}" + ) + + # Reset file pointer to beginning for upload + temp_file.seek(0) + + media = MediaIoBaseUpload( + temp_file, + mimetype=mime_type, + resumable=True, + chunksize=UPLOAD_CHUNK_SIZE_BYTES, + ) + + logger.info( + "[create_drive_file] Starting upload to Google Drive..." + ) + created_file = await asyncio.to_thread( + service.files() + .create( + body=file_metadata, + media_body=media, + fields="id, name, webViewLink", + supportsAllDrives=True, + ) + .execute + ) + else: + if not parsed_url.scheme: + raise Exception( + "fileUrl is missing a URL scheme. Use file://, http://, or https://." + ) + raise Exception( + f"Unsupported URL scheme '{parsed_url.scheme}'. Only file://, http://, and https:// are supported." + ) + elif content is not None: + file_data = content.encode("utf-8") + media = io.BytesIO(file_data) + + created_file = await asyncio.to_thread( + service.files() + .create( + body=file_metadata, + media_body=MediaIoBaseUpload(media, mimetype=mime_type, resumable=True), + fields="id, name, webViewLink", + supportsAllDrives=True, + ) + .execute + ) + + link = created_file.get("webViewLink", "No link available") + confirmation_message = f"Successfully created file '{created_file.get('name', file_name)}' (ID: {created_file.get('id', 'N/A')}) in folder '{folder_id}' for {user_google_email}. Link: {link}" + logger.info(f"Successfully created file. Link: {link}") + return confirmation_message + + +# Mapping of file extensions to source MIME types for Google Docs conversion +GOOGLE_DOCS_IMPORT_FORMATS = { + ".md": "text/markdown", + ".markdown": "text/markdown", + ".txt": "text/plain", + ".text": "text/plain", + ".html": "text/html", + ".htm": "text/html", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".doc": "application/msword", + ".rtf": "application/rtf", + ".odt": "application/vnd.oasis.opendocument.text", +} + +GOOGLE_DOCS_MIME_TYPE = "application/vnd.google-apps.document" + + +def _resolve_and_validate_host(hostname: str) -> list[str]: + """ + Resolve a hostname to IP addresses and validate none are private/internal. + + Uses getaddrinfo to handle both IPv4 and IPv6. Fails closed on DNS errors. + + Returns: + list[str]: Validated resolved IP address strings. + + Raises: + ValueError: If hostname resolves to private/internal IPs or DNS fails. + """ + if not hostname: + raise ValueError("Invalid URL: no hostname") + + # Block localhost variants + if hostname.lower() in ("localhost", "127.0.0.1", "::1", "0.0.0.0"): + raise ValueError("URLs pointing to localhost are not allowed") + + # Resolve hostname using getaddrinfo (handles both IPv4 and IPv6) + try: + addr_infos = socket.getaddrinfo(hostname, None) + except socket.gaierror as e: + raise ValueError( + f"Cannot resolve hostname '{hostname}': {e}. " + "Refusing request (fail-closed)." + ) + + if not addr_infos: + raise ValueError(f"No addresses found for hostname: {hostname}") + + resolved_ips: list[str] = [] + seen_ips: set[str] = set() + for _family, _type, _proto, _canonname, sockaddr in addr_infos: + ip_str = sockaddr[0] + ip = ipaddress.ip_address(ip_str) + if not ip.is_global: + raise ValueError( + f"URLs pointing to private/internal networks are not allowed: " + f"{hostname} resolves to {ip_str}" + ) + if ip_str not in seen_ips: + seen_ips.add(ip_str) + resolved_ips.append(ip_str) + + return resolved_ips + + +def _validate_url_not_internal(url: str) -> list[str]: + """ + Validate that a URL doesn't point to internal/private networks (SSRF protection). + + Returns: + list[str]: Validated resolved IP addresses for the hostname. + + Raises: + ValueError: If URL points to localhost or private IP ranges. + """ + parsed = urlparse(url) + return _resolve_and_validate_host(parsed.hostname) + + +def _format_host_header(hostname: str, scheme: str, port: Optional[int]) -> str: + """Format the Host header value for IPv4/IPv6 hostnames.""" + host_value = hostname + if ":" in host_value and not host_value.startswith("["): + host_value = f"[{host_value}]" + + is_default_port = (scheme == "http" and (port is None or port == 80)) or ( + scheme == "https" and (port is None or port == 443) + ) + if not is_default_port and port is not None: + host_value = f"{host_value}:{port}" + return host_value + + +def _build_pinned_url(parsed_url, ip_address_str: str) -> str: + """Build a URL that targets a resolved IP while preserving path/query.""" + pinned_host = ip_address_str + if ":" in pinned_host and not pinned_host.startswith("["): + pinned_host = f"[{pinned_host}]" + + userinfo = "" + if parsed_url.username is not None: + userinfo = parsed_url.username + if parsed_url.password is not None: + userinfo += f":{parsed_url.password}" + userinfo += "@" + + port_part = f":{parsed_url.port}" if parsed_url.port is not None else "" + netloc = f"{userinfo}{pinned_host}{port_part}" + + path = parsed_url.path or "/" + return urlunparse( + ( + parsed_url.scheme, + netloc, + path, + parsed_url.params, + parsed_url.query, + parsed_url.fragment, + ) + ) + + +async def _fetch_url_with_pinned_ip(url: str) -> httpx.Response: + """ + Fetch URL content by connecting to a validated, pre-resolved IP address. + + This prevents DNS rebinding between validation and the outbound connection. + """ + parsed_url = urlparse(url) + if parsed_url.scheme not in ("http", "https"): + raise ValueError(f"Only http:// and https:// are supported: {url}") + if not parsed_url.hostname: + raise ValueError(f"Invalid URL: missing hostname ({url})") + + resolved_ips = _validate_url_not_internal(url) + host_header = _format_host_header( + parsed_url.hostname, parsed_url.scheme, parsed_url.port + ) + + last_error: Optional[Exception] = None + for resolved_ip in resolved_ips: + pinned_url = _build_pinned_url(parsed_url, resolved_ip) + try: + async with httpx.AsyncClient( + follow_redirects=False, trust_env=False + ) as client: + request = client.build_request( + "GET", + pinned_url, + headers={"Host": host_header}, + extensions={"sni_hostname": parsed_url.hostname}, + ) + return await client.send(request) + except httpx.HTTPError as exc: + last_error = exc + logger.warning( + f"[ssrf_safe_fetch] Failed request via resolved IP {resolved_ip} for host " + f"{parsed_url.hostname}: {exc}" + ) + + raise Exception( + f"Failed to fetch URL after trying {len(resolved_ips)} validated IP(s): {url}" + ) from last_error + + +async def _ssrf_safe_fetch(url: str, *, stream: bool = False) -> httpx.Response: + """ + Fetch a URL with SSRF protection that covers redirects and DNS rebinding. + + Validates the initial URL and every redirect target against private/internal + networks. Disables automatic redirect following and handles redirects manually. + + Args: + url: The URL to fetch. + stream: If True, returns a streaming response (caller must manage context). + + Returns: + httpx.Response with the final response content. + + Raises: + ValueError: If any URL in the redirect chain points to a private network. + Exception: If the HTTP request fails. + """ + if stream: + raise ValueError("Streaming mode is not supported by _ssrf_safe_fetch.") + + max_redirects = 10 + current_url = url + + for _ in range(max_redirects): + resp = await _fetch_url_with_pinned_ip(current_url) + + if resp.status_code in (301, 302, 303, 307, 308): + location = resp.headers.get("location") + if not location: + raise Exception(f"Redirect with no Location header from {current_url}") + + # Resolve relative redirects against the current URL + location = urljoin(current_url, location) + + redirect_parsed = urlparse(location) + if redirect_parsed.scheme not in ("http", "https"): + raise ValueError( + f"Redirect to disallowed scheme: {redirect_parsed.scheme}" + ) + + current_url = location + continue + + return resp + + raise Exception(f"Too many redirects (max {max_redirects}) fetching {url}") + + +@asynccontextmanager +async def _ssrf_safe_stream(url: str) -> AsyncIterator[httpx.Response]: + """ + SSRF-safe streaming fetch: validates each redirect target against private + networks, then streams the final response body without buffering it all + in memory. + + Usage:: + + async with _ssrf_safe_stream(file_url) as resp: + async for chunk in resp.aiter_bytes(chunk_size=DOWNLOAD_CHUNK_SIZE_BYTES): + ... + """ + max_redirects = 10 + current_url = url + + # Resolve redirects manually so every hop is SSRF-validated + for _ in range(max_redirects): + parsed = urlparse(current_url) + if parsed.scheme not in ("http", "https"): + raise ValueError(f"Only http:// and https:// are supported: {current_url}") + if not parsed.hostname: + raise ValueError(f"Invalid URL: missing hostname ({current_url})") + + resolved_ips = _validate_url_not_internal(current_url) + host_header = _format_host_header(parsed.hostname, parsed.scheme, parsed.port) + + last_error: Optional[Exception] = None + resp: Optional[httpx.Response] = None + for resolved_ip in resolved_ips: + pinned_url = _build_pinned_url(parsed, resolved_ip) + client = httpx.AsyncClient(follow_redirects=False, trust_env=False) + try: + request = client.build_request( + "GET", + pinned_url, + headers={"Host": host_header}, + extensions={"sni_hostname": parsed.hostname}, + ) + resp = await client.send(request, stream=True) + break + except httpx.HTTPError as exc: + last_error = exc + await client.aclose() + logger.warning( + f"[ssrf_safe_stream] Failed via IP {resolved_ip} for " + f"{parsed.hostname}: {exc}" + ) + except Exception: + await client.aclose() + raise + + if resp is None: + raise Exception( + f"Failed to fetch URL after trying {len(resolved_ips)} validated IP(s): " + f"{current_url}" + ) from last_error + + if resp.status_code in (301, 302, 303, 307, 308): + location = resp.headers.get("location") + await resp.aclose() + await client.aclose() + if not location: + raise Exception(f"Redirect with no Location header from {current_url}") + location = urljoin(current_url, location) + redirect_parsed = urlparse(location) + if redirect_parsed.scheme not in ("http", "https"): + raise ValueError( + f"Redirect to disallowed scheme: {redirect_parsed.scheme}" + ) + current_url = location + continue + + # Non-redirect — yield the streaming response + try: + yield resp + finally: + await resp.aclose() + await client.aclose() + return + + raise Exception(f"Too many redirects (max {max_redirects}) fetching {url}") + + +def _detect_source_format(file_name: str, content: Optional[str] = None) -> str: + """ + Detect the source MIME type based on file extension. + Falls back to text/plain if unknown. + """ + ext = Path(file_name).suffix.lower() + if ext in GOOGLE_DOCS_IMPORT_FORMATS: + return GOOGLE_DOCS_IMPORT_FORMATS[ext] + + # If content is provided and looks like markdown, use markdown + if content and (content.startswith("#") or "```" in content or "**" in content): + return "text/markdown" + + return "text/plain" + + +@server.tool() +@handle_http_errors("import_to_google_doc", service_type="drive") +@require_google_service("drive", "drive_file") +async def import_to_google_doc( + service, + user_google_email: str, + file_name: str, + content: Optional[str] = None, + file_path: Optional[str] = None, + file_url: Optional[str] = None, + source_format: Optional[str] = None, + folder_id: str = "root", +) -> str: + """ + Imports a file (Markdown, DOCX, TXT, HTML, RTF, ODT) into Google Docs format with automatic conversion. + + Google Drive automatically converts the source file to native Google Docs format, + preserving formatting like headings, lists, bold, italic, etc. + + Args: + user_google_email (str): The user's Google email address. Required. + file_name (str): The name for the new Google Doc (extension will be ignored). + content (Optional[str]): Text content for text-based formats (MD, TXT, HTML). + file_path (Optional[str]): Local file path for binary formats (DOCX, ODT). Supports file:// URLs. + file_url (Optional[str]): Remote URL to fetch the file from (http/https). + source_format (Optional[str]): Source format hint ('md', 'markdown', 'docx', 'txt', 'html', 'rtf', 'odt'). + Auto-detected from file_name extension if not provided. + folder_id (str): The ID of the parent folder. Defaults to 'root'. + + Returns: + str: Confirmation message with the new Google Doc link. + + Examples: + # Import markdown content directly + import_to_google_doc(file_name="My Doc.md", content="# Title\\n\\nHello **world**") + + # Import a local DOCX file + import_to_google_doc(file_name="Report", file_path="/path/to/report.docx") + + # Import from URL + import_to_google_doc(file_name="Remote Doc", file_url="https://example.com/doc.md") + """ + logger.info( + f"[import_to_google_doc] Invoked. Email: '{user_google_email}', " + f"File Name: '{file_name}', Source Format: '{source_format}', Folder ID: '{folder_id}'" + ) + + # Validate inputs + source_count = sum(1 for x in [content, file_path, file_url] if x is not None) + if source_count == 0: + raise ValueError( + "You must provide one of: 'content', 'file_path', or 'file_url'." + ) + if source_count > 1: + raise ValueError("Provide only one of: 'content', 'file_path', or 'file_url'.") + + # Determine source MIME type + if source_format: + # Normalize format hint + format_key = f".{source_format.lower().lstrip('.')}" + if format_key in GOOGLE_DOCS_IMPORT_FORMATS: + source_mime_type = GOOGLE_DOCS_IMPORT_FORMATS[format_key] + else: + raise ValueError( + f"Unsupported source_format: '{source_format}'. " + f"Supported: {', '.join(ext.lstrip('.') for ext in GOOGLE_DOCS_IMPORT_FORMATS.keys())}" + ) + else: + # Auto-detect from file_name, file_path, or file_url + detection_name = file_path or file_url or file_name + source_mime_type = _detect_source_format(detection_name, content) + + logger.info(f"[import_to_google_doc] Detected source MIME type: {source_mime_type}") + + # Clean up file name (remove extension since it becomes a Google Doc) + doc_name = Path(file_name).stem if Path(file_name).suffix else file_name + + # Resolve folder + resolved_folder_id = await resolve_folder_id(service, folder_id) + + # File metadata - destination is Google Docs format + file_metadata = { + "name": doc_name, + "parents": [resolved_folder_id], + "mimeType": GOOGLE_DOCS_MIME_TYPE, # Target format = Google Docs + } + + file_data: bytes + + # Handle content (string input for text formats) + if content is not None: + file_data = content.encode("utf-8") + logger.info(f"[import_to_google_doc] Using content: {len(file_data)} bytes") + + # Handle file_path (local file) + elif file_path is not None: + parsed_url = urlparse(file_path) + + # Handle file:// URL format + if parsed_url.scheme == "file": + raw_path = parsed_url.path or "" + netloc = parsed_url.netloc + if netloc and netloc.lower() != "localhost": + raw_path = f"//{netloc}{raw_path}" + actual_path = url2pathname(raw_path) + elif parsed_url.scheme == "": + # Regular path + actual_path = file_path + else: + raise ValueError( + f"file_path should be a local path or file:// URL, got: {file_path}" + ) + + path_obj = validate_file_path(actual_path) + if not path_obj.exists(): + raise FileNotFoundError(f"File not found: {actual_path}") + if not path_obj.is_file(): + raise ValueError(f"Path is not a file: {actual_path}") + + file_data = await asyncio.to_thread(path_obj.read_bytes) + logger.info(f"[import_to_google_doc] Read local file: {len(file_data)} bytes") + + # Re-detect format from actual file if not specified + if not source_format: + source_mime_type = _detect_source_format(actual_path) + logger.info( + f"[import_to_google_doc] Re-detected from path: {source_mime_type}" + ) + + # Handle file_url (remote file) + elif file_url is not None: + parsed_url = urlparse(file_url) + if parsed_url.scheme not in ("http", "https"): + raise ValueError(f"file_url must be http:// or https://, got: {file_url}") + + # SSRF protection: block internal/private network URLs and validate redirects + resp = await _ssrf_safe_fetch(file_url) + if resp.status_code != 200: + raise Exception( + f"Failed to fetch file from URL: {file_url} (status {resp.status_code})" + ) + file_data = resp.content + + logger.info( + f"[import_to_google_doc] Downloaded from URL: {len(file_data)} bytes" + ) + + # Re-detect format from URL if not specified + if not source_format: + source_mime_type = _detect_source_format(file_url) + logger.info( + f"[import_to_google_doc] Re-detected from URL: {source_mime_type}" + ) + + # Upload with conversion + media = MediaIoBaseUpload( + io.BytesIO(file_data), + mimetype=source_mime_type, # Source format + resumable=True, + chunksize=UPLOAD_CHUNK_SIZE_BYTES, + ) + + logger.info( + f"[import_to_google_doc] Uploading to Google Drive with conversion: " + f"{source_mime_type} → {GOOGLE_DOCS_MIME_TYPE}" + ) + + created_file = await asyncio.to_thread( + service.files() + .create( + body=file_metadata, + media_body=media, + fields="id, name, webViewLink, mimeType", + supportsAllDrives=True, + ) + .execute + ) + + result_mime = created_file.get("mimeType", "unknown") + if result_mime != GOOGLE_DOCS_MIME_TYPE: + logger.warning( + f"[import_to_google_doc] Conversion may have failed. " + f"Expected {GOOGLE_DOCS_MIME_TYPE}, got {result_mime}" + ) + + link = created_file.get("webViewLink", "No link available") + doc_id = created_file.get("id", "N/A") + + confirmation = ( + f"✅ Successfully imported '{doc_name}' as Google Doc\n" + f" Document ID: {doc_id}\n" + f" Source format: {source_mime_type}\n" + f" Folder: {folder_id}\n" + f" Link: {link}" + ) + + logger.info(f"[import_to_google_doc] Success. Link: {link}") + return confirmation + + +@server.tool() +@handle_http_errors( + "get_drive_file_permissions", is_read_only=True, service_type="drive" +) +@require_google_service("drive", "drive_read") +async def get_drive_file_permissions( + service, + user_google_email: str, + file_id: str, +) -> str: + """ + Gets detailed metadata about a Google Drive file including sharing permissions. + + Args: + user_google_email (str): The user's Google email address. Required. + file_id (str): The ID of the file to check permissions for. + + Returns: + str: Detailed file metadata including sharing status and URLs. + """ + logger.info( + f"[get_drive_file_permissions] Checking file {file_id} for {user_google_email}" + ) + + resolved_file_id, _ = await resolve_drive_item(service, file_id) + file_id = resolved_file_id + + try: + # Get comprehensive file metadata including permissions with details + file_metadata = await asyncio.to_thread( + service.files() + .get( + fileId=file_id, + fields="id, name, mimeType, size, modifiedTime, owners, " + "permissions(id, type, role, emailAddress, domain, expirationTime, permissionDetails), " + "webViewLink, webContentLink, shared, sharingUser, viewersCanCopyContent", + supportsAllDrives=True, + ) + .execute + ) + + # Format the response + output_parts = [ + f"File: {file_metadata.get('name', 'Unknown')}", + f"ID: {file_id}", + f"Type: {file_metadata.get('mimeType', 'Unknown')}", + f"Size: {file_metadata.get('size', 'N/A')} bytes", + f"Modified: {file_metadata.get('modifiedTime', 'N/A')}", + "", + "Sharing Status:", + f" Shared: {file_metadata.get('shared', False)}", + ] + + # Add sharing user if available + sharing_user = file_metadata.get("sharingUser") + if sharing_user: + output_parts.append( + f" Shared by: {sharing_user.get('displayName', 'Unknown')} ({sharing_user.get('emailAddress', 'Unknown')})" + ) + + # Process permissions + permissions = file_metadata.get("permissions", []) + if permissions: + output_parts.append(f" Number of permissions: {len(permissions)}") + output_parts.append(" Permissions:") + for perm in permissions: + output_parts.append(f" - {format_permission_info(perm)}") + else: + output_parts.append(" No additional permissions (private file)") + + # Add URLs + output_parts.extend( + [ + "", + "URLs:", + f" View Link: {file_metadata.get('webViewLink', 'N/A')}", + ] + ) + + # webContentLink is only available for files that can be downloaded + web_content_link = file_metadata.get("webContentLink") + if web_content_link: + output_parts.append(f" Direct Download Link: {web_content_link}") + + has_public_link = check_public_link_permission(permissions) + + if has_public_link: + output_parts.extend( + [ + "", + "✅ This file is shared with 'Anyone with the link' - it can be inserted into Google Docs", + ] + ) + else: + output_parts.extend( + [ + "", + "❌ This file is NOT shared with 'Anyone with the link' - it cannot be inserted into Google Docs", + " To fix: Right-click the file in Google Drive → Share → Anyone with the link → Viewer", + ] + ) + + return "\n".join(output_parts) + + except Exception as e: + logger.error(f"Error getting file permissions: {e}") + return f"Error getting file permissions: {e}" + + +@server.tool() +@handle_http_errors( + "check_drive_file_public_access", is_read_only=True, service_type="drive" +) +@require_google_service("drive", "drive_read") +async def check_drive_file_public_access( + service, + user_google_email: str, + file_name: str, +) -> str: + """ + Searches for a file by name and checks if it has public link sharing enabled. + + Args: + user_google_email (str): The user's Google email address. Required. + file_name (str): The name of the file to check. + + Returns: + str: Information about the file's sharing status and whether it can be used in Google Docs. + """ + logger.info(f"[check_drive_file_public_access] Searching for {file_name}") + + # Search for the file + escaped_name = file_name.replace("'", "\\'") + query = f"name = '{escaped_name}'" + + list_params = { + "q": query, + "pageSize": 10, + "fields": "files(id, name, mimeType, webViewLink)", + "supportsAllDrives": True, + "includeItemsFromAllDrives": True, + } + + results = await asyncio.to_thread(service.files().list(**list_params).execute) + + files = results.get("files", []) + if not files: + return f"No file found with name '{file_name}'" + + if len(files) > 1: + output_parts = [f"Found {len(files)} files with name '{file_name}':"] + for f in files: + output_parts.append(f" - {f['name']} (ID: {f['id']})") + output_parts.append("\nChecking the first file...") + output_parts.append("") + else: + output_parts = [] + + # Check permissions for the first file + file_id = files[0]["id"] + resolved_file_id, _ = await resolve_drive_item(service, file_id) + file_id = resolved_file_id + + # Get detailed permissions + file_metadata = await asyncio.to_thread( + service.files() + .get( + fileId=file_id, + fields="id, name, mimeType, permissions, webViewLink, webContentLink, shared", + supportsAllDrives=True, + ) + .execute + ) + + permissions = file_metadata.get("permissions", []) + + has_public_link = check_public_link_permission(permissions) + + output_parts.extend( + [ + f"File: {file_metadata['name']}", + f"ID: {file_id}", + f"Type: {file_metadata['mimeType']}", + f"Shared: {file_metadata.get('shared', False)}", + "", + ] + ) + + if has_public_link: + output_parts.extend( + [ + "✅ PUBLIC ACCESS ENABLED - This file can be inserted into Google Docs", + f"Use with insert_doc_image_url: {get_drive_image_url(file_id)}", + ] + ) + else: + output_parts.extend( + [ + "❌ NO PUBLIC ACCESS - Cannot insert into Google Docs", + "Fix: Drive → Share → 'Anyone with the link' → 'Viewer'", + ] + ) + + return "\n".join(output_parts) + + +@server.tool() +@handle_http_errors("update_drive_file", is_read_only=False, service_type="drive") +@require_google_service("drive", "drive_file") +async def update_drive_file( + service, + user_google_email: str, + file_id: str, + # File metadata updates + name: Optional[str] = None, + description: Optional[str] = None, + mime_type: Optional[str] = None, + # Folder organization + add_parents: Optional[str] = None, # Comma-separated folder IDs to add + remove_parents: Optional[str] = None, # Comma-separated folder IDs to remove + # File status + starred: Optional[bool] = None, + trashed: Optional[bool] = None, + # Sharing and permissions + writers_can_share: Optional[bool] = None, + copy_requires_writer_permission: Optional[bool] = None, + # Custom properties + properties: Optional[dict] = None, # User-visible custom properties +) -> str: + """ + Updates metadata and properties of a Google Drive file. + + Args: + user_google_email (str): The user's Google email address. Required. + file_id (str): The ID of the file to update. Required. + name (Optional[str]): New name for the file. + description (Optional[str]): New description for the file. + mime_type (Optional[str]): New MIME type (note: changing type may require content upload). + add_parents (Optional[str]): Comma-separated folder IDs to add as parents. + remove_parents (Optional[str]): Comma-separated folder IDs to remove from parents. + starred (Optional[bool]): Whether to star/unstar the file. + trashed (Optional[bool]): Whether to move file to/from trash. + writers_can_share (Optional[bool]): Whether editors can share the file. + copy_requires_writer_permission (Optional[bool]): Whether copying requires writer permission. + properties (Optional[dict]): Custom key-value properties for the file. + + Returns: + str: Confirmation message with details of the updates applied. + """ + logger.info(f"[update_drive_file] Updating file {file_id} for {user_google_email}") + + current_file_fields = ( + "name, description, mimeType, parents, starred, trashed, webViewLink, " + "writersCanShare, copyRequiresWriterPermission, properties" + ) + resolved_file_id, current_file = await resolve_drive_item( + service, + file_id, + extra_fields=current_file_fields, + ) + file_id = resolved_file_id + + # Build the update body with only specified fields + update_body = {} + if name is not None: + update_body["name"] = name + if description is not None: + update_body["description"] = description + if mime_type is not None: + update_body["mimeType"] = mime_type + if starred is not None: + update_body["starred"] = starred + if trashed is not None: + update_body["trashed"] = trashed + if writers_can_share is not None: + update_body["writersCanShare"] = writers_can_share + if copy_requires_writer_permission is not None: + update_body["copyRequiresWriterPermission"] = copy_requires_writer_permission + if properties is not None: + update_body["properties"] = properties + + async def _resolve_parent_arguments(parent_arg: Optional[str]) -> Optional[str]: + if not parent_arg: + return None + parent_ids = [part.strip() for part in parent_arg.split(",") if part.strip()] + if not parent_ids: + return None + + resolved_ids = [] + for parent in parent_ids: + resolved_parent = await resolve_folder_id(service, parent) + resolved_ids.append(resolved_parent) + return ",".join(resolved_ids) + + resolved_add_parents = await _resolve_parent_arguments(add_parents) + resolved_remove_parents = await _resolve_parent_arguments(remove_parents) + + # Build query parameters for parent changes + query_params = { + "fileId": file_id, + "supportsAllDrives": True, + "fields": "id, name, description, mimeType, parents, starred, trashed, webViewLink, writersCanShare, copyRequiresWriterPermission, properties", + } + + if resolved_add_parents: + query_params["addParents"] = resolved_add_parents + if resolved_remove_parents: + query_params["removeParents"] = resolved_remove_parents + + # Only include body if there are updates + if update_body: + query_params["body"] = update_body + + # Perform the update + updated_file = await asyncio.to_thread( + service.files().update(**query_params).execute + ) + + # Build response message + output_parts = [ + f"✅ Successfully updated file: {updated_file.get('name', current_file['name'])}" + ] + output_parts.append(f" File ID: {file_id}") + + # Report what changed + changes = [] + if name is not None and name != current_file.get("name"): + changes.append(f" • Name: '{current_file.get('name')}' → '{name}'") + if description is not None: + old_desc_value = current_file.get("description") + new_desc_value = description + should_report_change = (old_desc_value or "") != (new_desc_value or "") + if should_report_change: + old_desc_display = ( + old_desc_value if old_desc_value not in (None, "") else "(empty)" + ) + new_desc_display = ( + new_desc_value if new_desc_value not in (None, "") else "(empty)" + ) + changes.append(f" • Description: {old_desc_display} → {new_desc_display}") + if add_parents: + changes.append(f" • Added to folder(s): {add_parents}") + if remove_parents: + changes.append(f" • Removed from folder(s): {remove_parents}") + current_starred = current_file.get("starred") + if starred is not None and starred != current_starred: + star_status = "starred" if starred else "unstarred" + changes.append(f" • File {star_status}") + current_trashed = current_file.get("trashed") + if trashed is not None and trashed != current_trashed: + trash_status = "moved to trash" if trashed else "restored from trash" + changes.append(f" • File {trash_status}") + current_writers_can_share = current_file.get("writersCanShare") + if writers_can_share is not None and writers_can_share != current_writers_can_share: + share_status = "can" if writers_can_share else "cannot" + changes.append(f" • Writers {share_status} share the file") + current_copy_requires_writer_permission = current_file.get( + "copyRequiresWriterPermission" + ) + if ( + copy_requires_writer_permission is not None + and copy_requires_writer_permission != current_copy_requires_writer_permission + ): + copy_status = ( + "requires" if copy_requires_writer_permission else "doesn't require" + ) + changes.append(f" • Copying {copy_status} writer permission") + if properties: + changes.append(f" • Updated custom properties: {properties}") + + if changes: + output_parts.append("") + output_parts.append("Changes applied:") + output_parts.extend(changes) + else: + output_parts.append(" (No changes were made)") + + output_parts.append("") + output_parts.append(f"View file: {updated_file.get('webViewLink', '#')}") + + return "\n".join(output_parts) + + +@server.tool() +@handle_http_errors("get_drive_shareable_link", is_read_only=True, service_type="drive") +@require_google_service("drive", "drive_read") +async def get_drive_shareable_link( + service, + user_google_email: str, + file_id: str, +) -> str: + """ + Gets the shareable link for a Google Drive file or folder. + + Args: + user_google_email (str): The user's Google email address. Required. + file_id (str): The ID of the file or folder to get the shareable link for. Required. + + Returns: + str: The shareable links and current sharing status. + """ + logger.info( + f"[get_drive_shareable_link] Invoked. Email: '{user_google_email}', File ID: '{file_id}'" + ) + + resolved_file_id, _ = await resolve_drive_item(service, file_id) + file_id = resolved_file_id + + file_metadata = await asyncio.to_thread( + service.files() + .get( + fileId=file_id, + fields="id, name, mimeType, webViewLink, webContentLink, shared, " + "permissions(id, type, role, emailAddress, domain, expirationTime)", + supportsAllDrives=True, + ) + .execute + ) + + output_parts = [ + f"File: {file_metadata.get('name', 'Unknown')}", + f"ID: {file_id}", + f"Type: {file_metadata.get('mimeType', 'Unknown')}", + f"Shared: {file_metadata.get('shared', False)}", + "", + "Links:", + f" View: {file_metadata.get('webViewLink', 'N/A')}", + ] + + web_content_link = file_metadata.get("webContentLink") + if web_content_link: + output_parts.append(f" Download: {web_content_link}") + + permissions = file_metadata.get("permissions", []) + if permissions: + output_parts.append("") + output_parts.append("Current permissions:") + for perm in permissions: + output_parts.append(f" - {format_permission_info(perm)}") + + return "\n".join(output_parts) + + +@server.tool() +@handle_http_errors("manage_drive_access", is_read_only=False, service_type="drive") +@require_google_service("drive", "drive_file") +async def manage_drive_access( + service, + user_google_email: str, + file_id: str, + action: str, + share_with: Optional[str] = None, + role: Optional[str] = None, + share_type: str = "user", + permission_id: Optional[str] = None, + recipients: Optional[List[Dict[str, Any]]] = None, + send_notification: bool = True, + email_message: Optional[str] = None, + expiration_time: Optional[str] = None, + allow_file_discovery: Optional[bool] = None, + new_owner_email: Optional[str] = None, + move_to_new_owners_root: bool = False, +) -> str: + """ + Consolidated tool for managing Google Drive file and folder access permissions. + + Supports granting, batch-granting, updating, revoking permissions, and + transferring file ownership -- all through a single entry point. + + Args: + user_google_email (str): The user's Google email address. Required. + file_id (str): The ID of the file or folder. Required. + action (str): The access management action to perform. Required. One of: + - "grant": Share with a single user, group, domain, or anyone. + - "grant_batch": Share with multiple recipients in one call. + - "update": Modify an existing permission (role or expiration). + - "revoke": Remove an existing permission. + - "transfer_owner": Transfer file ownership to another user. + share_with (Optional[str]): Email address (user/group), domain name (domain), + or omit for 'anyone'. Used by "grant". + role (Optional[str]): Permission role -- 'reader', 'commenter', or 'writer'. + Used by "grant" (defaults to 'reader') and "update". + share_type (str): Type of sharing -- 'user', 'group', 'domain', or 'anyone'. + Used by "grant". Defaults to 'user'. + permission_id (Optional[str]): The permission ID to modify or remove. + Required for "update" and "revoke" actions. + recipients (Optional[List[Dict[str, Any]]]): List of recipient objects for + "grant_batch". Each should have: email (str), role (str, optional), + share_type (str, optional), expiration_time (str, optional). For domain + shares use 'domain' field instead of 'email'. + send_notification (bool): Whether to send notification emails. Defaults to True. + Used by "grant" and "grant_batch". + email_message (Optional[str]): Custom notification email message. + Used by "grant" and "grant_batch". + expiration_time (Optional[str]): Expiration in RFC 3339 format + (e.g., "2025-01-15T00:00:00Z"). Used by "grant" and "update". + allow_file_discovery (Optional[bool]): For 'domain'/'anyone' shares, whether + the file appears in search. Used by "grant". + new_owner_email (Optional[str]): Email of the new owner. + Required for "transfer_owner". + move_to_new_owners_root (bool): Move file to the new owner's My Drive root. + Defaults to False. Used by "transfer_owner". + + Returns: + str: Confirmation with details of the permission change applied. + """ + valid_actions = ("grant", "grant_batch", "update", "revoke", "transfer_owner") + if action not in valid_actions: + raise ValueError( + f"Invalid action '{action}'. Must be one of: {', '.join(valid_actions)}" + ) + + logger.info( + f"[manage_drive_access] Invoked. Email: '{user_google_email}', " + f"File ID: '{file_id}', Action: '{action}'" + ) + + # --- grant: share with a single recipient --- + if action == "grant": + effective_role = role or "reader" + validate_share_role(effective_role) + validate_share_type(share_type) + + if share_type in ("user", "group") and not share_with: + raise ValueError(f"share_with is required for share_type '{share_type}'") + if share_type == "domain" and not share_with: + raise ValueError( + "share_with (domain name) is required for share_type 'domain'" + ) + + resolved_file_id, file_metadata = await resolve_drive_item( + service, file_id, extra_fields="name, webViewLink" + ) + file_id = resolved_file_id + + permission_body: Dict[str, Any] = { + "type": share_type, + "role": effective_role, + } + if share_type in ("user", "group"): + permission_body["emailAddress"] = share_with + elif share_type == "domain": + permission_body["domain"] = share_with + + if expiration_time: + validate_expiration_time(expiration_time) + permission_body["expirationTime"] = expiration_time + + if share_type in ("domain", "anyone") and allow_file_discovery is not None: + permission_body["allowFileDiscovery"] = allow_file_discovery + + create_params: Dict[str, Any] = { + "fileId": file_id, + "body": permission_body, + "supportsAllDrives": True, + "fields": "id, type, role, emailAddress, domain, expirationTime", + } + if share_type in ("user", "group"): + create_params["sendNotificationEmail"] = send_notification + if email_message: + create_params["emailMessage"] = email_message + + created_permission = await asyncio.to_thread( + service.permissions().create(**create_params).execute + ) + + return "\n".join( + [ + f"Successfully shared '{file_metadata.get('name', 'Unknown')}'", + "", + "Permission created:", + f" - {format_permission_info(created_permission)}", + "", + f"View link: {file_metadata.get('webViewLink', 'N/A')}", + ] + ) + + # --- grant_batch: share with multiple recipients --- + if action == "grant_batch": + if not recipients: + raise ValueError("recipients list is required for 'grant_batch' action") + + resolved_file_id, file_metadata = await resolve_drive_item( + service, file_id, extra_fields="name, webViewLink" + ) + file_id = resolved_file_id + + results: List[str] = [] + success_count = 0 + failure_count = 0 + + for recipient in recipients: + r_share_type = recipient.get("share_type", "user") + + if r_share_type == "domain": + domain = recipient.get("domain") + if not domain: + results.append(" - Skipped: missing domain for domain share") + failure_count += 1 + continue + identifier = domain + else: + r_email = recipient.get("email") + if not r_email: + results.append(" - Skipped: missing email address") + failure_count += 1 + continue + identifier = r_email + + r_role = recipient.get("role", "reader") + try: + validate_share_role(r_role) + except ValueError as e: + results.append(f" - {identifier}: Failed - {e}") + failure_count += 1 + continue + + try: + validate_share_type(r_share_type) + except ValueError as e: + results.append(f" - {identifier}: Failed - {e}") + failure_count += 1 + continue + + r_perm_body: Dict[str, Any] = { + "type": r_share_type, + "role": r_role, + } + if r_share_type == "domain": + r_perm_body["domain"] = identifier + else: + r_perm_body["emailAddress"] = identifier + + if recipient.get("expiration_time"): + try: + validate_expiration_time(recipient["expiration_time"]) + r_perm_body["expirationTime"] = recipient["expiration_time"] + except ValueError as e: + results.append(f" - {identifier}: Failed - {e}") + failure_count += 1 + continue + + r_create_params: Dict[str, Any] = { + "fileId": file_id, + "body": r_perm_body, + "supportsAllDrives": True, + "fields": "id, type, role, emailAddress, domain, expirationTime", + } + if r_share_type in ("user", "group"): + r_create_params["sendNotificationEmail"] = send_notification + if email_message: + r_create_params["emailMessage"] = email_message + + try: + created_perm = await asyncio.to_thread( + service.permissions().create(**r_create_params).execute + ) + results.append(f" - {format_permission_info(created_perm)}") + success_count += 1 + except HttpError as e: + results.append(f" - {identifier}: Failed - {str(e)}") + failure_count += 1 + + output_parts = [ + f"Batch share results for '{file_metadata.get('name', 'Unknown')}'", + "", + f"Summary: {success_count} succeeded, {failure_count} failed", + "", + "Results:", + ] + output_parts.extend(results) + output_parts.extend( + [ + "", + f"View link: {file_metadata.get('webViewLink', 'N/A')}", + ] + ) + return "\n".join(output_parts) + + # --- update: modify an existing permission --- + if action == "update": + if not permission_id: + raise ValueError("permission_id is required for 'update' action") + if not role and not expiration_time: + raise ValueError( + "Must provide at least one of: role, expiration_time for 'update' action" + ) + + if role: + validate_share_role(role) + if expiration_time: + validate_expiration_time(expiration_time) + + resolved_file_id, file_metadata = await resolve_drive_item( + service, file_id, extra_fields="name" + ) + file_id = resolved_file_id + + effective_role = role + if not effective_role: + current_permission = await asyncio.to_thread( + service.permissions() + .get( + fileId=file_id, + permissionId=permission_id, + supportsAllDrives=True, + fields="role", + ) + .execute + ) + effective_role = current_permission.get("role") + + update_body: Dict[str, Any] = {"role": effective_role} + if expiration_time: + update_body["expirationTime"] = expiration_time + + updated_permission = await asyncio.to_thread( + service.permissions() + .update( + fileId=file_id, + permissionId=permission_id, + body=update_body, + supportsAllDrives=True, + fields="id, type, role, emailAddress, domain, expirationTime", + ) + .execute + ) + + return "\n".join( + [ + f"Successfully updated permission on '{file_metadata.get('name', 'Unknown')}'", + "", + "Updated permission:", + f" - {format_permission_info(updated_permission)}", + ] + ) + + # --- revoke: remove an existing permission --- + if action == "revoke": + if not permission_id: + raise ValueError("permission_id is required for 'revoke' action") + + resolved_file_id, file_metadata = await resolve_drive_item( + service, file_id, extra_fields="name" + ) + file_id = resolved_file_id + + await asyncio.to_thread( + service.permissions() + .delete( + fileId=file_id, + permissionId=permission_id, + supportsAllDrives=True, + ) + .execute + ) + + return "\n".join( + [ + f"Successfully removed permission from '{file_metadata.get('name', 'Unknown')}'", + "", + f"Permission ID '{permission_id}' has been revoked.", + ] + ) + + # --- transfer_owner: transfer file ownership --- + # action == "transfer_owner" + if not new_owner_email: + raise ValueError("new_owner_email is required for 'transfer_owner' action") + + resolved_file_id, file_metadata = await resolve_drive_item( + service, file_id, extra_fields="name, owners" + ) + file_id = resolved_file_id + + current_owners = file_metadata.get("owners", []) + current_owner_emails = [o.get("emailAddress", "") for o in current_owners] + + transfer_body: Dict[str, Any] = { + "type": "user", + "role": "owner", + "emailAddress": new_owner_email, + } + + await asyncio.to_thread( + service.permissions() + .create( + fileId=file_id, + body=transfer_body, + transferOwnership=True, + moveToNewOwnersRoot=move_to_new_owners_root, + supportsAllDrives=True, + fields="id, type, role, emailAddress", + ) + .execute + ) + + output_parts = [ + f"Successfully transferred ownership of '{file_metadata.get('name', 'Unknown')}'", + "", + f"New owner: {new_owner_email}", + f"Previous owner(s): {', '.join(current_owner_emails) or 'Unknown'}", + ] + if move_to_new_owners_root: + output_parts.append(f"File moved to {new_owner_email}'s My Drive root.") + output_parts.extend(["", "Note: Previous owner now has editor access."]) + + return "\n".join(output_parts) + + +@server.tool() +@handle_http_errors("copy_drive_file", is_read_only=False, service_type="drive") +@require_google_service("drive", "drive_file") +async def copy_drive_file( + service, + user_google_email: str, + file_id: str, + new_name: Optional[str] = None, + parent_folder_id: str = "root", +) -> str: + """ + Creates a copy of an existing Google Drive file. + + This tool copies the template document to a new location with an optional new name. + The copy maintains all formatting and content from the original file. + + Args: + user_google_email (str): The user's Google email address. Required. + file_id (str): The ID of the file to copy. Required. + new_name (Optional[str]): New name for the copied file. If not provided, uses "Copy of [original name]". + parent_folder_id (str): The ID of the folder where the copy should be created. Defaults to 'root' (My Drive). + + Returns: + str: Confirmation message with details of the copied file and its link. + """ + logger.info( + f"[copy_drive_file] Invoked. Email: '{user_google_email}', File ID: '{file_id}', New name: '{new_name}', Parent folder: '{parent_folder_id}'" + ) + + resolved_file_id, file_metadata = await resolve_drive_item( + service, file_id, extra_fields="name, webViewLink, mimeType" + ) + file_id = resolved_file_id + original_name = file_metadata.get("name", "Unknown File") + + resolved_folder_id = await resolve_folder_id(service, parent_folder_id) + + copy_body = {} + if new_name: + copy_body["name"] = new_name + else: + copy_body["name"] = f"Copy of {original_name}" + + if resolved_folder_id != "root": + copy_body["parents"] = [resolved_folder_id] + + copied_file = await asyncio.to_thread( + service.files() + .copy( + fileId=file_id, + body=copy_body, + supportsAllDrives=True, + fields="id, name, webViewLink, mimeType, parents", + ) + .execute + ) + + output_parts = [ + f"Successfully copied '{original_name}'", + "", + f"Original file ID: {file_id}", + f"New file ID: {copied_file.get('id', 'N/A')}", + f"New file name: {copied_file.get('name', 'Unknown')}", + f"File type: {copied_file.get('mimeType', 'Unknown')}", + f"Location: {parent_folder_id}", + "", + f"View copied file: {copied_file.get('webViewLink', 'N/A')}", + ] + + return "\n".join(output_parts) + + +@server.tool() +@handle_http_errors( + "set_drive_file_permissions", is_read_only=False, service_type="drive" +) +@require_google_service("drive", "drive_file") +async def set_drive_file_permissions( + service, + user_google_email: str, + file_id: str, + link_sharing: Optional[str] = None, + writers_can_share: Optional[bool] = None, + copy_requires_writer_permission: Optional[bool] = None, +) -> str: + """ + Sets file-level sharing settings and controls link sharing for a Google Drive file or folder. + + This is a high-level tool for the most common permission changes. Use this to toggle + "anyone with the link" access or configure file-level sharing behavior. For managing + individual user/group permissions, use share_drive_file or update_drive_permission instead. + + Args: + user_google_email (str): The user's Google email address. Required. + file_id (str): The ID of the file or folder. Required. + link_sharing (Optional[str]): Control "anyone with the link" access for the file. + - "off": Disable "anyone with the link" access for this file. + - "reader": Anyone with the link can view. + - "commenter": Anyone with the link can comment. + - "writer": Anyone with the link can edit. + writers_can_share (Optional[bool]): Whether editors can change permissions and share. + If False, only the owner can share. Defaults to None (no change). + copy_requires_writer_permission (Optional[bool]): Whether viewers and commenters + are prevented from copying, printing, or downloading. Defaults to None (no change). + + Returns: + str: Summary of all permission changes applied to the file. + """ + logger.info( + f"[set_drive_file_permissions] Invoked. Email: '{user_google_email}', " + f"File ID: '{file_id}', Link sharing: '{link_sharing}', " + f"Writers can share: {writers_can_share}, Copy restriction: {copy_requires_writer_permission}" + ) + + if ( + link_sharing is None + and writers_can_share is None + and copy_requires_writer_permission is None + ): + raise ValueError( + "Must provide at least one of: link_sharing, writers_can_share, copy_requires_writer_permission" + ) + + valid_link_sharing = {"off", "reader", "commenter", "writer"} + if link_sharing is not None and link_sharing not in valid_link_sharing: + raise ValueError( + f"Invalid link_sharing '{link_sharing}'. Must be one of: {', '.join(sorted(valid_link_sharing))}" + ) + + resolved_file_id, file_metadata = await resolve_drive_item( + service, file_id, extra_fields="name, webViewLink" + ) + file_id = resolved_file_id + file_name = file_metadata.get("name", "Unknown") + + output_parts = [f"Permission settings updated for '{file_name}'", ""] + changes_made = [] + + # Handle file-level settings via files().update() + file_update_body = {} + if writers_can_share is not None: + file_update_body["writersCanShare"] = writers_can_share + if copy_requires_writer_permission is not None: + file_update_body["copyRequiresWriterPermission"] = ( + copy_requires_writer_permission + ) + + if file_update_body: + await asyncio.to_thread( + service.files() + .update( + fileId=file_id, + body=file_update_body, + supportsAllDrives=True, + fields="id", + ) + .execute + ) + if writers_can_share is not None: + state = "allowed" if writers_can_share else "restricted to owner" + changes_made.append(f" - Editors sharing: {state}") + if copy_requires_writer_permission is not None: + state = "restricted" if copy_requires_writer_permission else "allowed" + changes_made.append(f" - Viewers copy/print/download: {state}") + + # Handle link sharing via permissions API + if link_sharing is not None: + current_permissions = await asyncio.to_thread( + service.permissions() + .list( + fileId=file_id, + supportsAllDrives=True, + fields="permissions(id, type, role)", + ) + .execute + ) + anyone_perms = [ + p + for p in current_permissions.get("permissions", []) + if p.get("type") == "anyone" + ] + + if link_sharing == "off": + if anyone_perms: + for perm in anyone_perms: + await asyncio.to_thread( + service.permissions() + .delete( + fileId=file_id, + permissionId=perm["id"], + supportsAllDrives=True, + ) + .execute + ) + changes_made.append( + " - Link sharing: disabled (restricted to specific people)" + ) + else: + changes_made.append(" - Link sharing: already off (no change)") + else: + if anyone_perms: + await asyncio.to_thread( + service.permissions() + .update( + fileId=file_id, + permissionId=anyone_perms[0]["id"], + body={ + "role": link_sharing, + "allowFileDiscovery": False, + }, + supportsAllDrives=True, + fields="id, type, role", + ) + .execute + ) + changes_made.append(f" - Link sharing: updated to '{link_sharing}'") + else: + await asyncio.to_thread( + service.permissions() + .create( + fileId=file_id, + body={ + "type": "anyone", + "role": link_sharing, + "allowFileDiscovery": False, + }, + supportsAllDrives=True, + fields="id, type, role", + ) + .execute + ) + changes_made.append(f" - Link sharing: enabled as '{link_sharing}'") + + output_parts.append("Changes:") + if changes_made: + output_parts.extend(changes_made) + else: + output_parts.append(" - No changes (already configured)") + output_parts.extend(["", f"View link: {file_metadata.get('webViewLink', 'N/A')}"]) + + return "\n".join(output_parts) diff --git a/gforms/__init__.py b/gforms/__init__.py new file mode 100644 index 0000000..2b54323 --- /dev/null +++ b/gforms/__init__.py @@ -0,0 +1,3 @@ +""" +Google Forms MCP Tools module +""" diff --git a/gforms/forms_tools.py b/gforms/forms_tools.py new file mode 100644 index 0000000..059a585 --- /dev/null +++ b/gforms/forms_tools.py @@ -0,0 +1,487 @@ +""" +Google Forms MCP Tools + +This module provides MCP tools for interacting with Google Forms API. +""" + +import logging +import asyncio +import json +from typing import List, Optional, Dict, Any + + +from auth.service_decorator import require_google_service +from core.server import server +from core.utils import handle_http_errors + +logger = logging.getLogger(__name__) + + +def _extract_option_values(options: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Extract valid option objects from Forms choice option objects. + + Returns the full option dicts (preserving fields like ``isOther``, + ``image``, ``goToAction``, and ``goToSectionId``) while filtering + out entries that lack a truthy ``value``. + """ + return [option for option in options if option.get("value")] + + +def _get_question_type(question: Dict[str, Any]) -> str: + """Infer a stable question/item type label from a Forms question payload.""" + choice_question = question.get("choiceQuestion") + if choice_question: + return choice_question.get("type", "CHOICE") + + text_question = question.get("textQuestion") + if text_question: + return "PARAGRAPH" if text_question.get("paragraph") else "TEXT" + + if "rowQuestion" in question: + return "GRID_ROW" + if "scaleQuestion" in question: + return "SCALE" + if "dateQuestion" in question: + return "DATE" + if "timeQuestion" in question: + return "TIME" + if "fileUploadQuestion" in question: + return "FILE_UPLOAD" + if "ratingQuestion" in question: + return "RATING" + + return "QUESTION" + + +def _serialize_form_item(item: Dict[str, Any], index: int) -> Dict[str, Any]: + """Serialize a Forms item with the key metadata agents need for edits.""" + serialized_item: Dict[str, Any] = { + "index": index, + "itemId": item.get("itemId"), + "title": item.get("title", f"Question {index}"), + } + + if item.get("description"): + serialized_item["description"] = item["description"] + + if "questionItem" in item: + question = item.get("questionItem", {}).get("question", {}) + serialized_item["type"] = _get_question_type(question) + serialized_item["required"] = question.get("required", False) + + question_id = question.get("questionId") + if question_id: + serialized_item["questionId"] = question_id + + choice_question = question.get("choiceQuestion") + if choice_question: + serialized_item["options"] = _extract_option_values( + choice_question.get("options", []) + ) + + return serialized_item + + if "questionGroupItem" in item: + question_group = item.get("questionGroupItem", {}) + columns = _extract_option_values( + question_group.get("grid", {}).get("columns", {}).get("options", []) + ) + + rows = [] + for question in question_group.get("questions", []): + row: Dict[str, Any] = { + "title": question.get("rowQuestion", {}).get("title", "") + } + row_question_id = question.get("questionId") + if row_question_id: + row["questionId"] = row_question_id + row["required"] = question.get("required", False) + rows.append(row) + + serialized_item["type"] = "GRID" + serialized_item["grid"] = {"rows": rows, "columns": columns} + return serialized_item + + if "pageBreakItem" in item: + serialized_item["type"] = "PAGE_BREAK" + elif "textItem" in item: + serialized_item["type"] = "TEXT_ITEM" + elif "imageItem" in item: + serialized_item["type"] = "IMAGE" + elif "videoItem" in item: + serialized_item["type"] = "VIDEO" + else: + serialized_item["type"] = "UNKNOWN" + + return serialized_item + + +@server.tool() +@handle_http_errors("create_form", service_type="forms") +@require_google_service("forms", "forms") +async def create_form( + service, + user_google_email: str, + title: str, + description: Optional[str] = None, + document_title: Optional[str] = None, +) -> str: + """ + Create a new form using the title given in the provided form message in the request. + + Args: + user_google_email (str): The user's Google email address. Required. + title (str): The title of the form. + description (Optional[str]): The description of the form. + document_title (Optional[str]): The document title (shown in browser tab). + + Returns: + str: Confirmation message with form ID and edit URL. + """ + logger.info(f"[create_form] Invoked. Email: '{user_google_email}', Title: {title}") + + form_body: Dict[str, Any] = {"info": {"title": title}} + + if description: + form_body["info"]["description"] = description + + if document_title: + form_body["info"]["document_title"] = document_title + + created_form = await asyncio.to_thread( + service.forms().create(body=form_body).execute + ) + + form_id = created_form.get("formId") + edit_url = f"https://docs.google.com/forms/d/{form_id}/edit" + responder_url = created_form.get( + "responderUri", f"https://docs.google.com/forms/d/{form_id}/viewform" + ) + + confirmation_message = f"Successfully created form '{created_form.get('info', {}).get('title', title)}' for {user_google_email}. Form ID: {form_id}. Edit URL: {edit_url}. Responder URL: {responder_url}" + logger.info(f"Form created successfully for {user_google_email}. ID: {form_id}") + return confirmation_message + + +@server.tool() +@handle_http_errors("get_form", is_read_only=True, service_type="forms") +@require_google_service("forms", "forms") +async def get_form(service, user_google_email: str, form_id: str) -> str: + """ + Get a form. + + Args: + user_google_email (str): The user's Google email address. Required. + form_id (str): The ID of the form to retrieve. + + Returns: + str: Form details including title, description, questions, and URLs. + """ + logger.info(f"[get_form] Invoked. Email: '{user_google_email}', Form ID: {form_id}") + + form = await asyncio.to_thread(service.forms().get(formId=form_id).execute) + + form_info = form.get("info", {}) + title = form_info.get("title", "No Title") + description = form_info.get("description", "No Description") + document_title = form_info.get("documentTitle", title) + + edit_url = f"https://docs.google.com/forms/d/{form_id}/edit" + responder_url = form.get( + "responderUri", f"https://docs.google.com/forms/d/{form_id}/viewform" + ) + + items = form.get("items", []) + serialized_items = [ + _serialize_form_item(item, i) for i, item in enumerate(items, 1) + ] + + items_summary = [] + for serialized_item in serialized_items: + item_index = serialized_item["index"] + item_title = serialized_item.get("title", f"Item {item_index}") + item_type = serialized_item.get("type", "UNKNOWN") + required_text = " (Required)" if serialized_item.get("required") else "" + items_summary.append( + f" {item_index}. {item_title} [{item_type}]{required_text}" + ) + + items_summary_text = ( + "\n".join(items_summary) if items_summary else " No items found" + ) + items_text = json.dumps(serialized_items, indent=2) if serialized_items else "[]" + + result = f"""Form Details for {user_google_email}: +- Title: "{title}" +- Description: "{description}" +- Document Title: "{document_title}" +- Form ID: {form_id} +- Edit URL: {edit_url} +- Responder URL: {responder_url} +- Items ({len(items)} total): +{items_summary_text} +- Items (structured): +{items_text}""" + + logger.info(f"Successfully retrieved form for {user_google_email}. ID: {form_id}") + return result + + +@server.tool() +@handle_http_errors("set_publish_settings", service_type="forms") +@require_google_service("forms", "forms") +async def set_publish_settings( + service, + user_google_email: str, + form_id: str, + publish_as_template: bool = False, + require_authentication: bool = False, +) -> str: + """ + Updates the publish settings of a form. + + Args: + user_google_email (str): The user's Google email address. Required. + form_id (str): The ID of the form to update publish settings for. + publish_as_template (bool): Whether to publish as a template. Defaults to False. + require_authentication (bool): Whether to require authentication to view/submit. Defaults to False. + + Returns: + str: Confirmation message of the successful publish settings update. + """ + logger.info( + f"[set_publish_settings] Invoked. Email: '{user_google_email}', Form ID: {form_id}" + ) + + settings_body = { + "publishAsTemplate": publish_as_template, + "requireAuthentication": require_authentication, + } + + await asyncio.to_thread( + service.forms().setPublishSettings(formId=form_id, body=settings_body).execute + ) + + confirmation_message = f"Successfully updated publish settings for form {form_id} for {user_google_email}. Publish as template: {publish_as_template}, Require authentication: {require_authentication}" + logger.info( + f"Publish settings updated successfully for {user_google_email}. Form ID: {form_id}" + ) + return confirmation_message + + +@server.tool() +@handle_http_errors("get_form_response", is_read_only=True, service_type="forms") +@require_google_service("forms", "forms") +async def get_form_response( + service, user_google_email: str, form_id: str, response_id: str +) -> str: + """ + Get one response from the form. + + Args: + user_google_email (str): The user's Google email address. Required. + form_id (str): The ID of the form. + response_id (str): The ID of the response to retrieve. + + Returns: + str: Response details including answers and metadata. + """ + logger.info( + f"[get_form_response] Invoked. Email: '{user_google_email}', Form ID: {form_id}, Response ID: {response_id}" + ) + + response = await asyncio.to_thread( + service.forms().responses().get(formId=form_id, responseId=response_id).execute + ) + + response_id = response.get("responseId", "Unknown") + create_time = response.get("createTime", "Unknown") + last_submitted_time = response.get("lastSubmittedTime", "Unknown") + + answers = response.get("answers", {}) + answer_details = [] + for question_id, answer_data in answers.items(): + question_response = answer_data.get("textAnswers", {}).get("answers", []) + if question_response: + answer_text = ", ".join([ans.get("value", "") for ans in question_response]) + answer_details.append(f" Question ID {question_id}: {answer_text}") + else: + answer_details.append(f" Question ID {question_id}: No answer provided") + + answers_text = "\n".join(answer_details) if answer_details else " No answers found" + + result = f"""Form Response Details for {user_google_email}: +- Form ID: {form_id} +- Response ID: {response_id} +- Created: {create_time} +- Last Submitted: {last_submitted_time} +- Answers: +{answers_text}""" + + logger.info( + f"Successfully retrieved response for {user_google_email}. Response ID: {response_id}" + ) + return result + + +@server.tool() +@handle_http_errors("list_form_responses", is_read_only=True, service_type="forms") +@require_google_service("forms", "forms") +async def list_form_responses( + service, + user_google_email: str, + form_id: str, + page_size: int = 10, + page_token: Optional[str] = None, +) -> str: + """ + List a form's responses. + + Args: + user_google_email (str): The user's Google email address. Required. + form_id (str): The ID of the form. + page_size (int): Maximum number of responses to return. Defaults to 10. + page_token (Optional[str]): Token for retrieving next page of results. + + Returns: + str: List of responses with basic details and pagination info. + """ + logger.info( + f"[list_form_responses] Invoked. Email: '{user_google_email}', Form ID: {form_id}" + ) + + params = {"formId": form_id, "pageSize": page_size} + if page_token: + params["pageToken"] = page_token + + responses_result = await asyncio.to_thread( + service.forms().responses().list(**params).execute + ) + + responses = responses_result.get("responses", []) + next_page_token = responses_result.get("nextPageToken") + + if not responses: + return f"No responses found for form {form_id} for {user_google_email}." + + response_details = [] + for i, response in enumerate(responses, 1): + response_id = response.get("responseId", "Unknown") + create_time = response.get("createTime", "Unknown") + last_submitted_time = response.get("lastSubmittedTime", "Unknown") + + answers_count = len(response.get("answers", {})) + response_details.append( + f" {i}. Response ID: {response_id} | Created: {create_time} | Last Submitted: {last_submitted_time} | Answers: {answers_count}" + ) + + pagination_info = ( + f"\nNext page token: {next_page_token}" + if next_page_token + else "\nNo more pages." + ) + + result = f"""Form Responses for {user_google_email}: +- Form ID: {form_id} +- Total responses returned: {len(responses)} +- Responses: +{chr(10).join(response_details)}{pagination_info}""" + + logger.info( + f"Successfully retrieved {len(responses)} responses for {user_google_email}. Form ID: {form_id}" + ) + return result + + +# Internal implementation function for testing +async def _batch_update_form_impl( + service: Any, + form_id: str, + requests: List[Dict[str, Any]], +) -> str: + """Internal implementation for batch_update_form. + + Applies batch updates to a Google Form using the Forms API batchUpdate method. + + Args: + service: Google Forms API service client. + form_id: The ID of the form to update. + requests: List of update request dictionaries. + + Returns: + Formatted string with batch update results. + """ + body = {"requests": requests} + + result = await asyncio.to_thread( + service.forms().batchUpdate(formId=form_id, body=body).execute + ) + + replies = result.get("replies", []) + + confirmation_message = f"""Batch Update Completed: +- Form ID: {form_id} +- URL: https://docs.google.com/forms/d/{form_id}/edit +- Requests Applied: {len(requests)} +- Replies Received: {len(replies)}""" + + if replies: + confirmation_message += "\n\nUpdate Results:" + for i, reply in enumerate(replies, 1): + if "createItem" in reply: + item_id = reply["createItem"].get("itemId", "Unknown") + question_ids = reply["createItem"].get("questionId", []) + question_info = ( + f" (Question IDs: {', '.join(question_ids)})" + if question_ids + else "" + ) + confirmation_message += ( + f"\n Request {i}: Created item {item_id}{question_info}" + ) + else: + confirmation_message += f"\n Request {i}: Operation completed" + + return confirmation_message + + +@server.tool() +@handle_http_errors("batch_update_form", service_type="forms") +@require_google_service("forms", "forms") +async def batch_update_form( + service, + user_google_email: str, + form_id: str, + requests: List[Dict[str, Any]], +) -> str: + """ + Apply batch updates to a Google Form. + + Supports adding, updating, and deleting form items, as well as updating + form metadata and settings. This is the primary method for modifying form + content after creation. + + Args: + user_google_email (str): The user's Google email address. Required. + form_id (str): The ID of the form to update. + requests (List[Dict[str, Any]]): List of update requests to apply. + Supported request types: + - createItem: Add a new question or content item + - updateItem: Modify an existing item + - deleteItem: Remove an item + - moveItem: Reorder an item + - updateFormInfo: Update form title/description + - updateSettings: Modify form settings (e.g., quiz mode) + + Returns: + str: Details about the batch update operation results. + """ + logger.info( + f"[batch_update_form] Invoked. Email: '{user_google_email}', " + f"Form ID: '{form_id}', Requests: {len(requests)}" + ) + + result = await _batch_update_form_impl(service, form_id, requests) + + logger.info(f"Batch update completed successfully for {user_google_email}") + return result diff --git a/glama.json b/glama.json new file mode 100644 index 0000000..24fa36d --- /dev/null +++ b/glama.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://glama.ai/mcp/schemas/server.json", + "maintainers": ["taylorwilsdon"] +} diff --git a/gmail/__init__.py b/gmail/__init__.py new file mode 100644 index 0000000..55eb575 --- /dev/null +++ b/gmail/__init__.py @@ -0,0 +1 @@ +# This file marks the 'gmail' directory as a Python package. diff --git a/gmail/gmail_tools.py b/gmail/gmail_tools.py new file mode 100644 index 0000000..75afc41 --- /dev/null +++ b/gmail/gmail_tools.py @@ -0,0 +1,2376 @@ +""" +Google Gmail MCP Tools + +This module provides MCP tools for interacting with the Gmail API. +""" + +import logging +import asyncio +import base64 +import re +import ssl +import mimetypes +from html.parser import HTMLParser +from typing import Annotated, Optional, List, Dict, Literal, Any + +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.mime.base import MIMEBase +from email import encoders +from email.utils import formataddr + +from pydantic import Field +from googleapiclient.errors import HttpError + +from auth.service_decorator import require_google_service +from core.utils import handle_http_errors, validate_file_path, UserInputError +from core.server import server +from auth.scopes import ( + GMAIL_SEND_SCOPE, + GMAIL_COMPOSE_SCOPE, + GMAIL_MODIFY_SCOPE, + GMAIL_LABELS_SCOPE, +) + +logger = logging.getLogger(__name__) + +GMAIL_BATCH_SIZE = 25 +GMAIL_REQUEST_DELAY = 0.1 +HTML_BODY_TRUNCATE_LIMIT = 20000 + +GMAIL_METADATA_HEADERS = [ + "Subject", + "From", + "To", + "Cc", + "Message-ID", + "In-Reply-To", + "References", + "Date", +] +LOW_VALUE_TEXT_PLACEHOLDERS = ( + "your client does not support html", + "view this email in your browser", + "open this email in your browser", +) +LOW_VALUE_TEXT_FOOTER_MARKERS = ( + "mailing list", + "mailman/listinfo", + "unsubscribe", + "list-unsubscribe", + "manage preferences", +) +LOW_VALUE_TEXT_HTML_DIFF_MIN = 80 + + +class _HTMLTextExtractor(HTMLParser): + """Extract readable text from HTML using stdlib.""" + + def __init__(self): + super().__init__() + self._text = [] + self._skip = False + + def handle_starttag(self, tag, attrs): + self._skip = tag in ("script", "style") + + def handle_endtag(self, tag): + if tag in ("script", "style"): + self._skip = False + + def handle_data(self, data): + if not self._skip: + self._text.append(data) + + def get_text(self) -> str: + return " ".join("".join(self._text).split()) + + +def _html_to_text(html: str) -> str: + """Convert HTML to readable plain text.""" + try: + parser = _HTMLTextExtractor() + parser.feed(html) + return parser.get_text() + except Exception: + return html + + +def _extract_message_body(payload): + """ + Helper function to extract plain text body from a Gmail message payload. + (Maintained for backward compatibility) + + Args: + payload (dict): The message payload from Gmail API + + Returns: + str: The plain text body content, or empty string if not found + """ + bodies = _extract_message_bodies(payload) + return bodies.get("text", "") + + +def _extract_message_bodies(payload): + """ + Helper function to extract both plain text and HTML bodies from a Gmail message payload. + + Args: + payload (dict): The message payload from Gmail API + + Returns: + dict: Dictionary with 'text' and 'html' keys containing body content + """ + text_body = "" + html_body = "" + parts = [payload] if "parts" not in payload else payload.get("parts", []) + + part_queue = list(parts) # Use a queue for BFS traversal of parts + while part_queue: + part = part_queue.pop(0) + mime_type = part.get("mimeType", "") + body_data = part.get("body", {}).get("data") + + if body_data: + try: + decoded_data = base64.urlsafe_b64decode(body_data).decode( + "utf-8", errors="ignore" + ) + if mime_type == "text/plain" and not text_body: + text_body = decoded_data + elif mime_type == "text/html" and not html_body: + html_body = decoded_data + except Exception as e: + logger.warning(f"Failed to decode body part: {e}") + + # Add sub-parts to queue for multipart messages + if mime_type.startswith("multipart/") and "parts" in part: + part_queue.extend(part.get("parts", [])) + + # Check the main payload if it has body data directly + if payload.get("body", {}).get("data"): + try: + decoded_data = base64.urlsafe_b64decode(payload["body"]["data"]).decode( + "utf-8", errors="ignore" + ) + mime_type = payload.get("mimeType", "") + if mime_type == "text/plain" and not text_body: + text_body = decoded_data + elif mime_type == "text/html" and not html_body: + html_body = decoded_data + except Exception as e: + logger.warning(f"Failed to decode main payload body: {e}") + + return {"text": text_body, "html": html_body} + + +def _format_body_content(text_body: str, html_body: str) -> str: + """ + Helper function to format message body content with HTML fallback and truncation. + Detects useless text/plain fallbacks (e.g., "Your client does not support HTML"). + + Args: + text_body: Plain text body content + html_body: HTML body content + + Returns: + Formatted body content string + """ + text_stripped = text_body.strip() + html_stripped = html_body.strip() + html_text = _html_to_text(html_stripped).strip() if html_stripped else "" + + plain_lower = " ".join(text_stripped.split()).lower() + html_lower = " ".join(html_text.split()).lower() + plain_is_low_value = plain_lower and ( + any(marker in plain_lower for marker in LOW_VALUE_TEXT_PLACEHOLDERS) + or ( + any(marker in plain_lower for marker in LOW_VALUE_TEXT_FOOTER_MARKERS) + and len(html_lower) >= len(plain_lower) + LOW_VALUE_TEXT_HTML_DIFF_MIN + ) + or ( + len(html_lower) >= len(plain_lower) + LOW_VALUE_TEXT_HTML_DIFF_MIN + and html_lower.endswith(plain_lower) + ) + ) + + # Prefer plain text, but fall back to HTML when plain text is empty or clearly low-value. + use_html = html_text and ( + not text_stripped or "iYW%nF4p|BWLBX)Q&N+3_G<4yO$odQ-0x9m_%2&pg3o^d{u4*CM{nJ4UvV@%r;TiF9*=1c;ID<{AX+vz* zBFx%qLZmd9`T^u66%xPhhP6sRyU-qBq8vt3a6`wM1Wpe3#NzSIAl03&QKNEbj`cB( z6ozDE3NkxHd<+tT1v3b@aG>pj>nyI1?f@gadnm4=#Q4u82@l>NW`b_f`2%m8n*x5k zYa_M?c+F;rxQU02zS}%*pf2%uqrzqY(>kXr!8>`*HB%;{tTZTwB=MJ!XTA(kS?bJh z8I5e7f)8VZi|2U#vJWaPKWNq|Y^sRv8v8q17b2tHntkR*{YDqxr+Pn6=*zS(#)(r` z*`ZZZp_J6|8?L|FwN9Z{$VKGAe63;C>Nyv=9l5S*Ne^WA@cSp}=7+m_$e|3ZtJ&G>2~qnqO9QQKnsgKqcoaLA zNZ@o@DB<=MZw2Z`=!T}`WZ`r*EZ(T3SBTW~>yrWp1qgE|GvMNKE?SwNMQ%T=Qq`?J zTQII=UGGBVc?>qK*Lo=7USW2~j8rD@o*{&jmJlem44^WSpie@UWAbn#obE$_0%GUH zh}FTb#`kVChF1nVapit-r+SB+LWEWR=}CW1QFLl)TYdTke&h-3$!ioiE6@sRhYUoS z0y-B>61>wO;5vs1m@;;ZU_HZ%9m^_o)flmqI`iyYzdi#z6(g(jrT)^k>TL_V9cGGr`6WvW z|5L6F|ElhN2>h`F^{N}klRJnv(3WsIjawVjCe&E}YyxqjV857=z5T$Yy{>Y|gQoVd zW5{i~3{LhM2iZDhT037b``!xH<0YNu*wi5!bt-bMvt#2>=1zL)P=g>2=bB63BjX`8 zRHA*^b~BSeXu$)^7x%0WLeMITe|O0GvBePX8;{Dckxk~GzwkbBq9?t;R-C96Z;fQlr&aXZSbslKp z1QOaBxTr}nq&{T?T+PT92fL$^XWW7u>GyQa^6G2Dbm_hw$9;7&y+iRJ30bo!y04J7)9E z;l-a^ zH5)b(chWl0+S%I3XUd_|b-#Z1)R+oxc-EC}^+V}Nz_g=bA!#q!(yh4citM>9{GT)5c>ioG-}3Ri z@?KVKX})*>6s!txaPYiBCw16`BQM^fSK2q}^;n*fZqn<0gZ<|zg!Wn%G9C&5z?SR3 ziqrmM2$|O2j5{1b{6rO!hs@;-Rob6UoN@^s+~5bR2hCiit>?sx%3K{h{PKwLWXBJ8MoU zjWjZfIwgj(=#UD*Jh6F$#J&ugO|kmZgv)V7607`4t)x#lqs?h*&BTbQ$%Tpw3uYBi z7*<*FZ9%J|`kqRRt|(E2awZaPiyTPCz`!B{hdeuZN9-7>c?L<^k_P!E6AkviF9<$XvHE`}O>C#PRw zL>DR3s~*WmGAv`MUwgooBoYc{ zxkDwy^U|m(Q0U$O+laZwbiCYsMeJ6Wh8eiz$Kzf~*p42CFdqO=+Yk%<$oy8`J#4k~Jv41uW=tiHl!!wD=S83hSXxdSCJu3z#7yq^yc4ZbWuratydlLCjgAs-ag4lqwFxXfM3HXo8De&R zW^F;(ash0r+QenI$z2 z0|-kMx$;aUUQ?5a_2kQP17+6bMJKD$l96h8|9ReVS#8#g;%vVU(eW>BGZ4<>0M1+O z8;Vh`p$+xU2>i`;Dr=(%42@{pyZU|376OZO2(pTr0jkoe;6N zW^b!LnCe+;Ul&HJt=hZxn^*MYY2KG=1_$>W8!K-0-fqh@XsX>~adW%@%V2q7`k#Yl z?w{c`<<3ZJYLz_|56lmTbM5wOm1pl2<(=)WoZ3_F>~}sQHRCgNojv{*X#7{Eo?UbF z+UZ{8-q)?R(eI5-(@HLl?Ty=t>Ea{iGtYjU_W1x=^{4OHy|`^4=W8>{i;u4lM|FaN zr*rQH>ZnLpqs9$UW(+RCWjPo5HXZ^to29%QS8+?5If9vH%hPP|?#Vl-gLRz;_AND8 zJrb(8QC8iW!#0nA6&4#ts_iQ+@S>~sx(N=3+acFfXwAB#3S07MvW*&|o#h`D6Hq$O zpC^7wD7OL?mhbEYpJc3?TwVSs7wXw##N@lHnjs1}*-aXo-$OMLn_dj8dmR?q8U#Oz zD7SBcd%N%R>HT@~{k6$~d-PCd6PPWcXH4j%@*BfWXhQMzTdrS4HXf)FG<0z3`n(G@ z9Y(sYxCg3+(K2Nir3-d9D7P0U(+PfFi46qI-UHkG^2R6W=#m^gk??e zcpTtu`}nFX?k)M^ zAu;(2!j4B`uT1pQ;E7@1le9K{e*DKAaqSqWE3-dIpzdhg5c+QO=AhcCrCHCdQ3CqQ_V#9L zbcMd1?hZkZt`yf56;)&$=ufNPvD4$~y)vtu9ku7JV$6UJHJR5grC%*iQ6 z*@dFz*!qR1EJ&$deB?jpi;Nz*$3-g0MB3Ke@N}m1tzOnI1ZHS!p0~~Mj7mXjmwBpx zz93P@YS9H$uNoHvGq*A=IC}cEBqZvpS9X2+uBZZIc=MWZF*p-5M;fzUBb!F~<45g$ zZ!CB5SBenIrBdnhX(gGRy+>{zxsws>7JuqtRIWSc%;{tt$we`Gj%)^N+>2#MIUnNwGRSdR;ji zoQOTo7fno(ztw6PZ+ja4SuEO-(Sd$kNSQVdI1t&vn7amx{%iOYHYoCzF4M|S?z~SW zJgykk!}mdvqL16d!#nXHc?DXkO;!u60>>}?Fj8dO%#t6lRjcbViM`&#PUdHgm z5L(~qq%4puN|;UikYR3=w=8<(B3M3lYtq8{oAvLBmMq9l%sE#>!uVseNu~M&%y8&r z3nJ*s2Z|&ob3ct;(m~|Dk*TzgGx@G{E+g$*Az?4Hyg0VJ65C%I%SrkU^UoQ*>zlib z<9AbNw0wh5W=`t%>Xqjty>NL)=(-_$dDmvaYFTjITmR;2B!%gY8u z0;Fc^hz18mn0pE}BY!|>&Y!VhSLteXC48(?$rUg+ozH7;GL{Ljf4m>%w6DK?PKX@U zg9TY*z|?&ffz%<&2^RD1xaUi7h#1DhH*%k~yN=xd-@OWY$CD!mGL=z(%v zkn?cB|2)_--B;NT)8JE+T{7QG3YL6yI~RZOTd%{LG~lh7k)CSNxWM4rr9p*Snk#Yc zniZTt#?;sq9gWJcMr$SU61TBPhPI^07XCmneJd3-?R7uFG$S!1+L{fd0e8T)IK z(3jee_DWH0(M)K|DE_CQ)r$}wDdM${jK%oo!KNrdWOo#$+Lc8l_M3frwUBE*MeKRB zkG60!vHjNe1><5pUu?;ZecMA(@G79@sl1l>ntU1b zp%nSk{y$Gq`TBF}>_2zg`j33}|JU92AH$Td@@CW)1I#Bnf4Jr@{R4Fg$atz5PzDJ} z2JNQlclX2sKj2ldA+| z75%VP6l3F4T!G;NSQi}Et4-{MidhYu^MXw~+F=EF?6OB0B_TVy&S2`3qLpzOp+2%1thctgm8cuor5H;XS596yF~eU2t~;-IG6;M(Fn*) z*aYKie!U529x^LTjZ&)8wiaxPj?tnJ?M?ymP63!xwL&Z@r(mRmL#Rek3t5N(qP7f; zk;EbzLsmL<0FAcX#e1jQAp>vmr0#jlP0TsoA#rEbppS|03ukufEw8k4Q90c0g#Lz3 z>mn>!+gwMETVKLo&jQq+gw((BAqXEucTz+P8xK6UOrMQcm?MHTcls0|-Fn|%WIN%G z_@%O`0jgi`lT0#c!oXUqo}WsBK;aonnv6xu25V@<&JKFkh`nTo(CNRjB?*}bAlK%K zzuf7>ADO19pBUmUrWou<3M9Qu${ro6Y!(!3|KLQn)unQyzKh;dU|=g%=HvdtxdgqL z{Clu#VN8{inN}oQ@L6&4Np;~1szMPmbT!r}DhS*!HNQdwhYngLjR}2Vm~I-9bNWQq ziB-iPCfO~C@Ep8*em=jlXs#{q%P%vy^YZ)nxch(U$G8sY9VeTu!s| z>$ZnGG4*K3r7FVlh!kG-`Y@sEeOrHGsdv2rVJZEJr&ELdQeLOdCLy5$RcIj-&{MTy zfX15YJj$|hs+^WC(ki_)m0EHDK&C=#(@M-^o5EI-^bAVv^Ldo$FpTBjEIKXI`_hh} z&J7PyP=i|>3!S%Zj54?p&!)PkQa&Z^NjKo_aj{>8Qb!a`Okoyk{bjoBIyI>rmI=C! znn2gTk;2e4t0&YLoW>^;$FH_7(A?mMN~b~TgJfExuf&($S7SHO1us91Y;MUCII{0t zx9esblps8cRI6!Xhz(fS)fNYktg25utr6ka32bI5`^BPZevibOc~1T~Z)jA%$f<3> zu1K4gY{d*XEvZVZjj+J9-r7ln6{n??8vEAbRRG2WR^VU1#ea`l>}uSM%wS`wMZyLj zW3-oUP{NZ@WB_mi?s&H)SIt-Drvh&21qQyCB)ueZ$(Q1Z2K{lB+Z4nY(}O84L#JxM0+vVd3-{+iu?TS(rzH$;e-j;==5KDe%UyhHu3u_la0B~Uv$O$VUD`N? z$-yNPthY`v1Tw=eu67xOb3QG;4>SmuIv)UvTJ{_uHRujE2NJAMW57o}&NxED3C>X* z8>mlzS!L9VgYZDLTW#Ur&LlaEg23N-^)Os2Rh`DYG?=rw86Gq0=D&`(W?yP3s1)eZnROZ<1yHC7isAV6b>t0Qqo~p zP9*j8f~u~;dIU`l>ux*9^xU|;r{Offa=fPsoB;t?z*YDCY6V&q#jT$b$RQ3*JX1pY zdb$?c7BD?QAmIh%iN{80WtZC3KB^dYIER#h*ui++E5LHW_1-#;t=Jh-sx)IW`80I3 z&zjM^iMG*CV(+jjzgg%ky0i15uCFCm_qP{CZL-H{EWO=#Tj5z; zVO@rAeiXN0@=ExT#Z6;>*vOSV+y06nZ_Fh&yvBw-eOT%f2YHH2E;Cpjlv>Ks$zrHm zho#<=5$l2!-j2zok6WJ%if;bNAiY~KL?p?=Sr1*P0Hx8L_8Sn0C)o5BN#4hxwZqvC z3BdvA|hL%?f>^q~ctRf2iu*qp3{FwO(*W}x>wP~JM{we#Hh+qFFk+wgoP z=R~eiS+wU??hHSAFYhd`)~G-0>`cGU$nQhD%RT9C-=RavIMZ)_Hdrg_tTV3@c$(EB zD7r5+&5SagbMDj9t1Gg9u`r7lXJX0_rrE*8zIlZg%r5Abx4Rr{qS(_O-o#(#J^4l* z3J>yjyQG_kBa&m*G=zV?N1TSb=pasSdS}~omR&%OVMe>C?Y?%S$rFm&3(SF=Te#uq z3IqWlwFaV4RK3=NoFWwBUgu9G())S@Ov`+7TxUekaRqb}O7aTD2o4Z#kh38&sEp=O zd~n;sX+JE$!!K+SI`F$((4HpYC1zv0`cYpxCKV8Qcl4jJoH=}LxB0zh~~?e6RZ4~md%Tvw^@3P|v{UxaBg048Sb_WK|}>U98Bs}L4j ziBMh6`zSzz{kcNlV4~#JUEC|O1au&T!HF9NQzrr)QdBNJPd^4K^@Lo3i|bHisO$|^ zw`y)todUHa{R-|YhU?v9BlfTCIDWO(o9meSb*{0k?=BAlec@|T8QmUN@9yoj1KTOIX|B%Cf61`Pj%a!<`GHZ0Kcw{k z%{%NrkkxYP=0D!jFn!TIRcMcH9{jD!$3g-64Zc1v!XIA}tw5@dq+Fxy!wv36=Sb-$ zkY+I@)+<2dhxy6juZ1b^RKvjMIu~#g?ER{QEsp|qn#20-!LHE^+Zzpq^%RmKNJ!XZ zB9XBzQPg1e9t=X)kg`S->XYnN9kQPY2hca7N|iR184pq=kiKLhuS6;Pka+@J2MI0* zy5v?d0Wc6iLF;K?3kp>zwghd%m;^?%%P3%>Wu!v%6>@-%shtEC3uOQt@Sg++Qpyh2 z4F{uGQuJ_6tdto55tP^yx#PqrlF{@8ZFC0CpY{V`>ygBY-BB zjp@SX9tvoafKq|31J``*5P={Bd2$3Br75ZWRzCVTMS^5zpA^M{26O=lFlN8_bW!2f zxQ%B-ka<(~w!T~OZP-H?M7Mx-u3SYMQ0KYA)0Cb;rWn`^rG zpA3jzxA;%Q-x+Ox9a}HMaCQ4X1Qrr%Qo|2TDRMjf&ciCK7uR0WQ|)k0(2Od;(!QhD zz1HQMlBr0Yp(Fq}WDi|*$4S4g0m0T3uQnm1H(H4u1)etefMsFl`xs&-*IO)a!tPI4miB#;>bH7BKvp>d1x@5*0A_`zj#jjy`D!WXPKB=R*Jpg!LcB6bsyAaY9AJU5`S{)*hl+b z)9)@mL1~tmo@TYO)!)=vzM)Rnzj2A=_hG#zwE%Ez$DW<`3>%vB>xR-fZU1hi|x%$)nsGfvrdNT%4z>RiYZs z&-?QK8OPdKRj<+i;8-PI0092~0*2GR`d}skw}$WaIWL7u42|&8=^qq#d^v z3K;NhIjdLFsuCcbY7HTgxH>G$V)Ii7Rxt;b#t1N*1TveAK;JTn$yS@0#jxmM>Rwcf z{u*XTXSvG4*OL%3>gPfgiD#LCvUWBz`KtBV$y4q&+E4++3s1~nAHy5cK$-J@Mr>OO(nU@yjEK zf#7OB7;tB=A&8UUDIUP`cWmrx&Yl=r_>oT?O?*;(s6V|!H8WV|syA6FI~B-5Tyl&< z4+Pg$F!LrK{p^-RH9hKUV-QbWlDj1y&dxdX@wBzRQ+2@}?M2<-_?y(Em}y_327k_w zQZ-HX*>7@&!2`=kwq)SVan!}TuWaCQ1OI%X1R=qAZ0HD?A+Sl%CF;a+DC5$gZ`*l# z>$2LdAs`6a8o^$)Z@1826JU)6s6Wc&>V5QZ*(Ns(sA%^FkJZ`KC`|c%a?oD;ELN3J zpC%+cFm}|Mma)$lI4K1kPOz@Zy3{-@(?y57CNWse9nm zRV3C|_-u}Y=mLia6wTAggJSC^3|KMgN0y%u{vz+3DshU^OeZvT*1^PgfEj0QOiL=g3+eGcc1a6M~IJXH82mM z*I)9_BoX!zCTtVoUO3iHjFHP;gmTEc;W_no@FB|C6@j$Gic!^ zfxH`LRq@{==jt^HtHCtINjDG=bmI^3PXDx9@8-lSdBrKEuGug5o(2b8W8kzZY};_h z*)zypix>cm{@M?|K4lK-X0qnLIUNjoFch2$inhPZ{oT!`v@Kq5ZE;6$#SakY0a4)- zhrcyYix4W)?_O$rKRXbF@LDavjJ%?YD1)mz>hKpF+Ef`>ou1ZI@BHf6v~u~`=Rl+t za@LDrXf#ugIB)uD6t%W4lq03%CEI(JJNjY9XqR;vO;xc<44DWpWgZwvMIL*e7RI>m z32}!XENzD)7077->(Kl`NZK?2G-|KD+&X>A{G86_Q9!h@ZPL(ixUHj0KqNF|78U)u zFipL}tBvRY&sj%TG-r<9e@Q}C#l~NkB4+D&1_2OW6$y%6ri8s)gV9Cb!Xox5NU5Vr z-KE*E-7pDtxgc$86Cp&~X|p$Re^)+!0#Ysy;v>`m;gPOprUHoW3O7YluVAc(V10rC z$4@29Gn8mB$b@?E<8!p=$Lm1JM2}`?TF_19E_3qp(`Jy#8UqmYhCk*OZaoaC2Um9s zkAJW>0Za10s3xYqdkBVl+Hiz!Mbxjx9Qr)F7DM+|{PiMjaiQ)|t52kl?h(MxJDwO` zw|=gV&dVqZ?|C!}da*7XTMt`VSe+2b(%b6tTlCVNTlVH$a`}Iaz}`~7eV+cI_O^ax z+W+V8qW?gnu_`}kl;cOaEj*hMbCcLD4^8t-ZUzP@su&3<&SqoYYB}#uf9(>)_gHWz zTMFkHN1?PU5{4?b+T5FXrgA4Ijh@0AA+vZ6G#QFE-yMlin1)4&D5wTAH*T1)0fHVo z03r|(S;zr_o8yG$0sv{DO_>t8LpdgppWnPPfku-I3x&s7ay}Z zuoTWBVWxtztGZ^g41#E3aWdwe)_#Tp@GvUu4TFTdL@ih;P*asZ7BQO*DdP?|F3%0JQf7aYe0Uak9$jahIEIA174lYR*DY^GZPmtscjM@Z z^Im|3S(S&s>Qx6l_+Fl-xD47GU61Cy^6*MsF2kE^q2c8@;DGlx;$1lf2d4M${n~I> zC#QngDLS2PP6|EoPJin;&Jfeps+T#GdU|#WoAFHuD`;DvcX$<8hmap)xf1Nwpmeo9TE5fQfFRJ6z zA}19G;#J)mX`Z^z`@(%$olOo|7JOW)oCw%|xLbVHyu`crL$NPP_bxmcPfo2+6(SeA z!0BZ4kHAP--^Qq>s?cgm zI}>aD+LR+(SlMdG5mEBpA$c-5@GxteX{LpC@Kdb&{C((A_1tu-f~)GCeDMA+lbgF7 zvvk@IYUBDD+x{z)+rK%?{`c&o>_6V}PwK##utv%#yk|8M3B>#?Jfue?6cAcPJ+|bs z#3i$Bqi^rsUw{8>{F$(>rZtWwh#a2HU}v@$YkJdXiAQZ^>xi>c8@?z~A<7h`p-q|a zB%AA!aj`kYoPATm;CA;Z09g!W*pkd?tN>10SV$3L$UxoTNTuo-s@VO?6kTWt!X|Gw zT1kQC8QNte?~rS67-fX>&1T=A~qzZJsBf0f2kHa zmFAqu65TV&Be-b~*2fU-xm`I@s6&CN&cr)a2+!p9etTh#+i?BU z&0?(0fBDPy^RNGsSWmf4mv-FKwv*MB+-nEb&~KjQBfZf147W4#mp$l9n0Va78O(MH zT+Cf}iq*KO{e(t~{_5LUv=SA$6So?4e`tf4eMt8$`;j^wJ7rrYGibo-fi02z)rb(& zLaeptVt``eOYgtm!K=o%66yA%bZxBrbBnNxkZz-~>Ud-lAwk(q!^S#DiWrE_+NRo+oW0o4$)^>v^zD2*Fr1*i5jSUS0ieV(g?DPbs{P zTptHG43^lCu@}E-+QiQ3IbYm>P*-#rb;xrv$PG5& z833{SjU1|EIwzwQeS8^3H-_M|aj`I@5J^=(cd(cyC%i}x--xbz-#8Pz%G;-MfR}KV zWRf)ZxzrqW=8Nn>K(yOU({5?B)H-J7m!TXx(etgr43yYNk}e$f9D{}pc*&fAYB$^^ zj+Ze5Hll&6EB0Nd!enJS8$Q(%uq8IMGLv9=#qdjB#K?TmG7GH>VPhP^7SaQ|_Ic!t zHX~jg-hJ@OM#CCAv3k+|7B`_aOz_QUi$%9%J?P`|Fe?Yj#*J@+n>hM;aj|&yDsL`u z1Md(uCnP6R1zILYN?QEo#4fw0N~YMFyd^#(W@&e~lCSc^lSq5==gpdl>qj z?lE3XCQcNsDE#8&HIghdr{+mYX=a3bjKm`Su^h(narJ4vQxGS>UBV~-b=2DA6Y>ue zIEZEM3Bef*XM6N_s)yz+-ly)DD2>D4v=8xMbgfEA_5ID5&kK(FmU$A1dm1SveK0^l zE`VNC9zl;B!f#&1D7g}#Yryy8vKt+%8Rp`3>2pu1)atl4&Yn%3*0K7@S;LZ9t=HAt z0`u|q&pvj>2J5l4=F^BfeT}wWlw3B-7p|>K?yxT{G}$hjpc|2v8`Yf}@AgX&_?pYS zftDFhooG1p^#s?#aOAw)7n~wlPG>vL4pG>A5cXIk^CZFdJ^QmN|31pbQvqb1xW(D0 z<I?l&LVS3I-N4RQ#g&uD6mL24qFrEbA@1w63d2-T%I@z;SSiT7!RIoJK5gE~D zG+6K^*q;pJsSVSRSPA^W$6J8kqsk>&DngaO$yUhuBht-&p5`!!ej{JTgnvm%6pm!6 zyem*E#qvz|^_D_L8~Tnii{NEb+;vi(Y|(D3QEFDzL#h^PVBw*1VT;((Oa_dFs^&07 zp6|2UzMIP#u77ze!>~=TQc0FshwG7^cQP)c8_YtLk0!d-IPu(IAjyo%A-_A=Ng#i1 z!u~B;9hyQQx#-hpe;nwEvzenF7M+fyREu1iud-a1X3gYdT&7Cks$iw{E?3+3$w_ys zR>1}BjP*W0qlcE0?w2Z#bCZ?swSu8%`;O{5pdv+oz@B~6KZDItId)4KMM^^j|DEAA zLWo()#S3x*1cT+mc7R{J=z9YRes+eRO-U_ac3JeN8XBL-Au40gPjZKtK&LY1-0H2X zbBU0MvYY3X>!I#~eX9`lrJzpiIH=>)Q%`4n>($|5>$aa|Xcr7#c-*|Qlov*EmIK7T zjMfoE*&kX{7tt?_8f0^}wfe>9?JOptQ(Gpd_sP4vI`+P07Gs#o>tDV9#PlqX4?p-2 z^~YEJfAiM+PYChw$J>|EH@g#4*7*nUGqRs3pMr{UZ!Pe;DV^?lS9*O>@V%~Y0DbIn ze;<+l?>y?(-QTq^aCdZw9m`{)wI1q^t{unjhYj7GP7|rJ;zsEqIBQS|(NG(wC^|Lp z4YR41GdY}Sr5$C72~SyqUMUp)LaQpO5%au8IZjBGEFD{b2CWRJaSBst0P``(s@R>j z;=&h#IfO#>Oa^9SKt&3zGAi4kXfO(5CdHFAjUr%kMQ{}fHcC)3FK~@thzy$6szTAt zg7ZmpLI0lM4oC{9W*oOiOgN!*1kIwUjuP&AZ~MWBM}_@MFF= zgYTJfp4S^!jr^K3<_VayZ>K}74x%@)U|8ELiG`1`wf9Q(2+J=aJzjS?dxUY{S1ettSGUD@$_f{zh7h+!eU$%dQ4%@nZE)Q;}q9#6ATxlH6)nT^Fn1aOn zJqhWM#SBdCjK8zBt@*{bF1mPzD);`ZGPv>&M`$m)`@LURT*=T|rdo3^bxb~DjrM4V z_hwnx`u>VveQ3YyXgIpprMmmgz?R5c>k#Imwms|rFF9n2T0KnTKRD7+3;=-Vzrg;v z8QNHy{I3?+I*R`+u)nDPSz!Mg^{4T(z+T{Oy8M3@*pyNP<#ac{zJC_jg=ZR4RT?eF zD-cmy;!0ibH#fwh34iG||Bd=1XTS9NjDt?4K~iE7lK7$iYX49zP9{oq7)jChD)u{D zd5}h?34Rtb#u|mBxt73Cg6Q_c_I`9rw2`s?BozrZSt_-S^$2Ahb>s^%YsdX@i1ig( zl&FlqWU3w_&(s?BOSvpqfLD!UWm2`2MWN1Ls^UqZ&rkT{C}Ch8}xDQ+>HC~4YhZdcktEOvz3;O-<$l_E_A4eop7`-=U>Xo!N0{{ zt~0L}?@tld*!gPk)r}g-BL~|0f4ffaOcoB81U30wXHvH2Aa>-gisk=MgrYUJGx;k2P=xANQcwUt z6e0V^cXhL|%mIBXp))2-?0Zz7YK%|O#0GT4sZ4-Xz!@3}XC>-nnPEWPBIiMJw`x~O zYD(E)biP(#0FZ{>m6tf>0c=^|4qt%SopCwUN zg&hnDfBMpNtTEg+G4mEjMKfx!QUX0Wl%0_#-&OZTdbfbHui%VU8vHPlZAjUR!@r!f zg#@8C8dQZD;{(Ug%=m)?Sb$hnl?KITZADocIFI5eVM#AO;4cSO3hbz|w zHj5(gU>+!Em<&GUGU+*|L_w;g%wP!_q?Mn}t!HX1nbjD-?%Sc^<+0=ou<>8nnn=oE zmIUbw6_FDms!Kf8Az%gOA?UjT+GIR6zI*W^5Y@W2fe5fSow=!VH{Y@GG(%Xr#_=e= z2!UyWA5wl$av3M>x=-h**URZgu^Kx^N8>?+-eJmQ7L9_^+b=_A*sBaYTDsc+7#t)& zEzMNp(1yDTb>T(@tB@V{^$zqFc;=3m)XEE{X&i73w@6EMK)GwoqnqWa5F zrTxYjQ3_r~X*oPoI1{TzLxzsWNg9tJp}aNkD#C_|7Y+MLNe6J z*rJSxeHK6n0C=}#_guu|v>{Fokh(MNN7V+XEAL>BhJT3Igx)p2+Csu~3p z1riHI^a;aZ5|+PV4D9WtWIKb?2FSz!xU}4?@CgM;{3`&ljFu|;l5)3yHULH2QuxSxg4Ue-iw8{%IwZ+4T z+JjX&>`%C!H-Cm2jUGwqa~0d5OU#`P_Md$;#_S{W-wD#`cL9NKs91DkLf_^%<4ILtXdklZIwtAF|Dv0_^Rv^=KRoD85 zAl|)%fo`tw%yXoF*>(t<;!|PcL<8`grw{8rb1Bs9B5nv z)K6$4Wop^jhVF|3hJOTqZzTUWjUTukHgsDb$3)LS8^Z4mDQD1PW3!A=k0At={Jw1~ zrJ)&->IS(2Vmx5%`4N++9pc-;S!C;O_%D**@AC=`-(B{#qS5j!pkcI&q(f2qlCizCmt z>8tzdrH|4_!}cvREp)5*_dt)oVMET5gL0lL7xmlczsS3{J@r~b0RaF`esChy{~RMa zS-P42Ph{QQ$@M=${s>{a!_LWe|GmNen8c0ap9c3J28!F6b~)v=ik}Aeme*eH?_FJ| zH#5`f&=;F&wgPzgd{Sx*_23mTm&BtM$2&w(*1bKF@S0Y*_JzWdiq+Pjz|(FA2%&%P zZ>QI)P!;m_j1c$b8;ve!Y1|2`Y49SiIGPD%L<0?C&3&-Wm~Dw%_hx_v#ltQ?Gbv0* z?}9*IC*K%5D7FA1zKJHA+?|O`IY8mv;$*77<@HlZ-oQ!h?s`+EmAN=pcUi%&}e*2!=_I{HrpJ3#;@7N9Q~{6y;qfz5U;4?;|&nhZvGb*jssS$*p(H_JU6hAT+QlL(a*2q7U5+n8v$LV#$;1~^`#=-~|@-r6gN z?e(vuiJmaX&wnJh&%8q9FU||M+4{|n-K$x0@WoF*w$q@ur1}NL#*I2{U|n&?FvVi= zmtO%>WgUfA-m1^^{QJ}k)<=6p@u#C&dq1 zTz@6J-4M};O|Y^q7f_&q&?K@ZVxcrijP17fy^SfAVj<3_l1mu~vJ68x7m?TLkc8@|#87c=MRK^@gad4bYiHucB-$FRnfF5&vsP z5MxCDjuE^oEe|Ja!%3tX!(}E}045ERyJ7?RuEO^a-Q`~9x=!S_A-o>z_O{1Zdu0-|6v&4}=%xl$@g{ngcpk^q#)El3O zZ_`rdoUiu(E(Kyks|hjRt+K(R^;%_GwEMPopF59lP3wj|^zymNtub$pX?cpmmI?-M z79e5?-?-TTe+rwmC#taUcWky#-v?R60_Lt3@-ZfjH?8m0N1qdW6B}FV6 z+45S}p~3`m9y}z5l^%`DuJUt7S8BphRH9#dEkUeJrgKtlY1~ zII}A5g^Xkc;hjIL{nY&orx~+jMQ%hrM-<$E9ZMTysm!I1`~FwjE;Pbxs)l&f?F>23oM zk_0kU&&w;;M)?7zX(P*gd|RkVr{jTo&+?=9ayb#;JLD zSb1gg;3Ek7QAA5gv*!uY7J|SDxg;p(LVA896YE~JCN((A^5z&a-!$*uPuzgytq(5S z#-G?=Qi2QZH^6g{04abbl?=GWyZfu*255qZMx=9LBp+(*C9kCL5a085u$~1$?M}<+gy)pq+t(zkunu2)IR*5} zjxn(L%zU^}<1lC@opAk$_m~&-(Cb$7l{Snx)QHl$Pl}TPlIWVuRBb|`nk~{wmjRH- z(sj3zaCRy)^?I|cdo&MdROdCP{&YfHMI%B*(53cA8vtq`nt2q%uh!9Y)Z;V!q{$Ha zhMsrayy%69{HkAyvMl6YEH;F1Af&-=Mj9qP`2{v$sd2GSuwrX&$Pi2%$!8l~oxN~T zbM>UY2zPYr!j+0vZcgWBzw>3Q+QH^{`ulO$vkPvwy<>cujAq-cVQGp2kP0nwfX99; z|4I2`pPpM8;g*;or;@e2*!O6_cmYd&i{GK_#ex}BIY=sr9 zFI^a_#_!g$tgAY-O&ybq-s_B-9Yt?mS531)T|MThO@39P0B!v-#jq*{Y9=$v**j?| zib03gJUIkyf7duuj8M51w=s^4&@hf-_9-p+hQfnmHfA5ez zr?XF3{lI^XANc=2_|5)@>io}Bvsp#gc7p+?Co=zp1@*BO#~_s=(m%0m$e$RyWg(y1 z^Lg&>k0HDd4#@(@tKx^m+!q3-J;@3kXT6OHq|VN1AXIQ`?Y22pxBMpH`(3 z)r@*v)((XXpw3u$Rmv_VR!nucUIFxYp&FaIc}y~8sEYKXO5##FkgzG`n%R7hP(3GB zHp_k8S|XjyfN1S2N*E02K_Pp3ZbpF+06bRWq!u@6V-pI2HmoUeUQs!lX%m^*2?_dH zdVRk^yGB*Ld#!(y`<55`!_^#@+ijS=zt88LtP{U2Up)ABN@~jcHsB&u^X5(U=u!4# z0_D_@kx%ZMt0C#uyW!Qw098rXIIg=+Ewka;<>GZ~4E*#xhaV$SD$}#w!$tbVJ5*@}1jPFc0| zN_W%U*>;QnUzM^Rrfj==u9rBE_Ai^*Vad~sKZJlumt$!J)lgUnL<*p#NdZ+>Wqm5| z125S(clbS-H}f1Pm|=ohRB+Js<<%_eQ!pFKlIQ~F)Gm&@yoSewk4gRL(tM;xjVFju zp>gl;SC}^4f6oamio|H}9`wCOn5{>7AL4IKixS9}GC7Ge1UA>?-q5KG`4C?hvKd z)^3M0b~qE4(vP^W7&*K1uRt$!A+-w*8~|WN`hQHA_;3H)f47CxvH7o7F8vpd`&Emp zKyR~b2dLFTjU<=zu)rTA8{~Y^rv)LK=X+hr3>Q%mCSs%i8wvq?}u0KsbQCo z_$TCk4GRZ;v8$D{=HjI;EaY5d9nk4Rr!Tx{!aSsl8e}g+u)h)}WtA}d4=C4f70HAR zqr>l5j(_~IRmLT_T*p<(JQe<`T|8Wf!kq`6w-Eje0b!gLP>7N3xlTi7y9LRTe@9$R z4n5Y?8fL|f=Ekn-pNQl0H^S4;&cyQNY?`KySx8U7*-h}FQK5p(t516@~%wxZYuOQmbq`0#P0@4?I30v!1M^w+8PZlz2mJY|@;#Q6_g8P(@ZOp@dnY zr`V|b?%Qeuyg(UD#(Ch?#O^u4=Sm4U2cc6F`1A)`3nSD+zT)s$W^&~}Zc|>#R6^mR zbE|3?i9^0{M@ksl1kr>Vt`3T>ya5jw#I5{%Wo1E8pRpgwW=?Bip%`-L=TAY@+|J}% zo)#WkozsOBCWBv(mr&Ml)Y@|Jzjlt2v0 z5UvMYI|!GTfo(uZ-g_*Skowo8Q&JlV_l~#&*8HjTbs$|hY*@U39R=IKkJ`=vMFT`r zLaC7aR$Zt^=GM+PoC(ssZWAC?8v57qNP2D?jI&q(uN{Ka_~O2BA4hRxn(Bvn@FaV! zcEG|&!u(I#b%V5Rn2`tTeTRl{TEANylWpYDv9^g|E@{%>>ToNlvA!cQW!Yytrp?uD zXb%cm((b*W6$&hTYa+V>E4|k_URT$jTWkUrQ>e6=h(y=U-#BnNp}8*XY!s!)mzd7J zOp8<_8j{V2B0w0P;30D-Vm7S#G#&AGHjdA)|k(lZK$O>6+M$DCgZ`?6E{>w z7{S@VLh+LT&xN%bwswrKCwURmlpyh?+l=Yb-Ny^Jr2V#n(GqdD`G!Mr>34ijwB>|f z^0di~=E{j))~nT(lV(pEFf2F;c;0@h{oGG3lv8U&7$9_ zI}7Gp`G8!YVBx45YHqu^@{oykq!8OhYbUc(B7}KQQc^!t470t65_+K`k9yyP1bx5z zaj2;C2-SfSz~|LfO07HmP^oiZ`B2evq^;%GjD`uS9dFYMlVWZ~oaxt%U6q#mb_F3f zIqQ#|3zRzJrQDHYaO8%SSt&8i6rqB4@-`p!2?@*uA(qz+je4rJ%|^JBs2<6xGS_J6 z-ijQ$bDaLMAJP%VQ-I(1%72^hEoyxOXg>;QUzlArX`p^KCqa{8Us{8A@^^Zg->atg zK}?teY$r6InRadwpl^y|-5-Ux#|&x5azc5A29Z@nk#mg(-fE#Xy*0Z`jT*xTZ`FqW zKy|7O67b~JQ}}N(VBg!mX1^>8-O?bpvtt_`!&?TVuyk9eMbiNJFq_g^D>k|ioB0bb z)+gYcg{jHSoyF*##X)z~WLnbGC^zHTe7#xT#J5%QR3i$dRjZw2ir=zVZE_fVu|78D zS+BguK?7l&@f#HA_Dv|SNuUKitH2D-s<18hQ*ICDWgVDiCizq>F8X{t#~%_~C6l#lzZ&;%Vc^ zPQ9-TsF*6<<_bD)Ej^Y|E}c4hx+4EV?q3MyY8_+$=pKGq196QN{j_4$dP5ZZR9#^d zd9gr{x5p5Flp-jJcm9yomvS8JvnIEHZ-Mi25yUTQ zkX$W)wI|;@ZN)qR{I^x|cY(C6?BsBTqKV00n*`t8jY+geP2}Y9g&G0Y0qfb>H<=rW z+#mIF41-S$t>9Jo?W|f1S8bN(!J}7rCA@Nv2g+B~vFRImAo7~$hrbUcM-HP+gt*B4 zNo9pyl$rYp908XdoyA^Jvc|xKk0ZSP2$ESjN<@paH_kA!%%@#;4-&{4^_v4+I6d%? z5GY_vE`|fha4JX!IZ%30Ww?tuNgmWf))(? zPHnM&9LS4+^VhzeBan&_V>T`_Nx7ViK1IYunwH49>%S-}oR~3k)}qb_5+^KT4?Qtx zqykBE&=H$9VHm)(xF(!&4L(;5cMJpsRVg@~=2MH02jShIq^kbvM zB#=+CB2lZc4^?GGb7DZlC^<7pBfu3vs&)FifKxnxv@sYqPiPU6RMI0aJiZ()5}bwt zFRgx_s(}vCl)gbhi=?J->QJXf7D}{Iy<;d-Q6&hJa?HChxrD|3u*I4Yl=k2pkgi*7 znDR-;3X|tRNC6D6eTOu=coCu4ECj%V-eS0_sB0`R%O5f#Zqx!)3)v+W$caWPdz>bI zHl}G?RC+ueQwhiuj7n*OqBe_VqKZ@CCmy$+VS*G*S%L^E&^b@D0F(~`O(6wGg7Q3k zUTOW}G#O^Tp{h_UJv`)2EpfQS1Px;zDb1YEA{W=>j}&L5*%;$cd|-GMPgAIXrk!fh zH%=G83~ee;8bvW84O2|)=n9ieAdCofD9>0n0;VKZ68#cp3|I(RzP+dw(#U(-xTF}l z934Sef>#i8S`n(0s7=3=V8Nje%D8=PGO1VKAzYa+X}XBE|J|HN1tzjY(uBMK+B!y_ z(Q-V3jslHB3W<{7u|vwAy0?X2jc4n=TUH!a9CmG1ZFV45Aa=nP!PYXy*efrgQK1*1 zBt2h>uo z>@%cIQayUp?LRifHp!y@5pCQf3v=X!9mdz%xnFBIG|kky_3%_!HAK|&#RYvmzv??O z{nou@>^4+Sa~8T)a}@<3LoecxSNVG6j+Jp|KV_q?foHglEZdSF$@0}<5tLh>8Aj_Llsp=pJhBIo&Q#x4LzLPzb9hRTTHkvA4gIB3~TmMG=6P&ExexN;%*@Cb+dw>a0^oa!4xLMQ`aylE%TB_$*S z(%My1nWI;WH-ps^;Klf6>~NKBzlHg1XZN;I&egOaLaHXH%A#uZB7aABhwDk>z_0MB z#&DgI2#dV;G&l&#_*P^tnTsVTBmNK(;C!$sf~}WmA}S1BG%zB^DtyDclxYF&C-i}t@^$U>p)NX z;4r6pPDTGct0x*~N6qUw?^(Mh^_j9UBn$D8qCG4Np~F@m2hD?9TPtvZ z-Rev*c(zrtp!gCeuwRimb|bov*J|UW`$#W--nL~_%e;d63F}Zh(TcslT{sle&O`rk zfck4VUzk68-HgktmV^7!!TR9Wb-SDmjePEs^6RHSUvIR*>(ScojSmi-vw$6NHq;8R z!_pHf!etHpR3-l&yf_zMYIj4y^(D3rf$6M^@^oGB}DS_z*bpo4&^}z_O=|zU) z0IiqBl;TvN@}{Ve(0PzjF_W_wJv~E;^6I)kPwCNSKUMxO4fuP!(JPOUB4u_4u zuEoBWsWn#T<)83~NfQj7-AuHjtQ?CH)VwvdEcp`S>+n>*rECU}2d#+QPd}RPy5TF3L4HUmJ)Eon5MHo&%_ z%N`aNKRc9`&KiiH2fFagY0SaoJedl*>)A)vm$m|8?H?~6)6#3$+cNF=DVDjp3_3IC zo8B>-o+ZD!m-Fr^f;lGI;bF_Csb7^*K5kB%OqiY3b16Kw8@y=cP4~(*CS6w=C{MmiagP25X~=mJ~H# z5h81iToA${ipnq`i$*2fImddxD1^7+jAxjyBPjQ^`%oySCfM}tclNg0NrIDZH_#Ix zwsj+w1Y0wPPGfhHj#z2DZ!uI*CPeBo0aDSLN(qlqr0r8NPXfaHlHq8TcxBX>(e#=z z%|;DFe;FRlBkGTRvxW*XOhaL-A*d_VfPzl-?~R$CavUR`3I_G3T0o$V0gj=;og6^Q zqs+@{)EV^4^Hg>Mo5(eJQ_dug#KMOFj5SwE!VscqljxxduNYlTuH#Ba8L&J#-I!K+ zMtGls6HN!$ja$m)$%f*ZE-uex6on&Xr7&PDoh-ifP}=OD z5s^YIQqQ$JeRjm?H`3e8tn2FTM^FWILiIFQsQ0(0JCcHQXJ8lT+K4@KgHTVkz;>oh zIa<_OIhMgjcqR=Nk{Cl0Q0>;GxEcO6QHD^Pj(pK)Lto}aLB_|^z@$-uJvI+CtEX9p zVWc8cB9QV+^Wn+$E}^cfMvN%I!A~rYg-oD_D$S3c6V&od3F=w<^KM)lnL{;|<|w(l zi(k*f3aIuxvAFB+1;M976(hwhEsf3uZz;IfL1rtNeHC5$jVg%LJ~%2LsRJ8c4j1%n zHAAD`2-9Q{0Bl0T%~nbQpRV#Ssiehx*@P;~PLgQxemPys`J^7sgQtjbIYiY|TPGr5 zS}mEK`JYgP0DtlCDH9QXGmz~y-}icyJkbyl0Gu}hAoEHr#i$(&uI@86UmxXyzEiJ%&;?cK z?DJr7xI?)Mp&qgLb$*l0BO5|&I#|^p6GW9K!m$uqxT_ zerMp*bpcv}{&YTCK!Omx?ixbfeMP|$;{W#KzrC`m1fvmPzNPsP9HWKUNz3b91G$3e zDY!TS#_{BaSL63DzyFStR`S)e#~kj)Z|)rmD*ubP$DGN`(8Ji{#@%x>bfFatB?c)r zt{jQf)hOZ+Wf-%MHcUIhKEfWUjnaxi%1vVHFo%I2K#__FQ8BPoufyA;2jcRq1Wgc2)Z1?wzeuyE8*@8Zo+N)dzK!jl;Ta6c6cEONq+C zCtlc2re%*insTkZjuc>_x_`K;uRD?f_-7YYJJI$n;qxP| z&s@H5nGbjxv_JHn2MU7w|_3z~nT za42zs{r#u6T=tLo^-~F)m+U-1s}17P-!nwXil6A97^OuI4$wyWU%lD7z|6p<9VGU@ z_>&01Vw-&W4PS$KHw0?iZ@^8dyK-rW=dG`nOp%;VOrHA<9yFDU#Y1JaQCEly_NiTE^>uFI8U{aW*TUQq&!b*$^nJMxM(s{J5iiqtCdiMaB3A}eYsIj( zKiv18e=iqWC(4U0eelea)i)1HjFDU0zyI5E%Z+jiYvRv%2l8jU^ZyG;{z>Cil_zTs zMPNQtwVs{Nk31h@VUdK4K(P@l+jVNL1-K#-A41GnzGHuL>o7{8Xx12~&T`I}t2zu` z7+>I5(UF1GVI?|{t9tbOLpe}E*IPakfT<;ifbl07M}{D7gmf{WVVVs^Ks^~{J)XH) zi9>)DrLcK8`3Fp%7d++=7&)uu{WOS+1sKXv^^M24)<9;?4t1TtifSySfNU<21g84> z$;X$JXf?sP2v0LukBwXm$rYjk0$acUtSMQC&=fU1qDa%@AruEP3Iv=$V>UBJ&0rw; zXJt!|nd@r?qLd=n&VbMJ3B_Uvlf!3#ngQ^f$IF}6iB^DO-E&n}qy#RFm{BRb)O8kN zAy${BP#m4-N%P)5b&vD$;gdhdvo6Q8cTq@~^yN>AbDxwsFuQwiA06Is`;3ng-rRC6 zu)s>Hb4MPdLrgHrdCMQ0{c1O?%p9EEBnN!Ed_dH{ybc_;E?o66o&%{JYCNqAr8pr|AzTeFfSep)b!D(eDfXD*bq#~%^Fs5LsjyG9t#2>J3ADWuL? zu3vZQ@FPJnp*w8X+qLA?OOzScB|%{l+UUwOG=Uc1@$4>_963Zcek{eEN0GQep)Y+l z{B3Z>?g^EQ4Q-Y)b|Wj|cAIU5L+^OD`#6(zi@SNikGr`ax|n^idb=y#1D|cjew&}J zz2b|xwzh74!uHEZ_!`3giMwX)?f90tSgyW4{g!mk(f?%JxnGW3`IJ{nz2k&2a{rE# zFx35v#DC(k=YqNef3a<1fVk(0VKhUQ+x+&I{$GJMCS|q!|M$w4nVqA}e{$mNPU`p@J*In18Z_&v=+6 zJnV{PT^Zb6+3blsW2)KmMm}x#mSFlODaQ%qn0uI#-lX@P%hWcuy3=mCT82Dbq(?t> z@EG5mSV~?V9*C1Zi{^)`Toq0)OdgSv;QSwl%{y7@gdFA@^DvN_$RvbBzcp;>5yOBX z9nI??Y}a~UQmr5h9VZ-TA_=(DcLho_Oh@@Mm&9xFYbhTk07#L5+UZ1~S#zJiwX&i{ zIHMfb>*~;PoSbF{yin?+o_y^?)vs2%7~Tz2W9CO>4;noTZzk#avlFtX3!d~cevZ>v zW5d{9d2z$o92z-u@)Ld^(qBEV%e!5#tL4S&ehpK{4Ad+7`HTI2lfqtUC83r6|6izzhJ5f}h<}ihHjpl$bb7#Trtu$M<4$%X{V6Gm}q)i{%vLpqXE5 z^hqZJPPX({4;Bk^$GTudrkruJ&C@HtlUi-owwr*mVwds+p8eZPPyq8aa-(KS%#Fjd zTvB+`V6YP7n~5G*I;@|a1UhFXM~LNqV^PU*L$ly_sZM=FD@W?iGscr&a2UfPsh#sm zrj>|90;bErF=ij$xB*%PE-$-TYR0vMr|(JAj>fd!!BR-b=u{r`XbBlJ7Q$^GORR-E zT?iG4IFt3txo2e*SMENwPM>QF@W}b3#-(P*IaoV!kK8r*@@eC(u*IGDDGq+5mB?LP z<(4@K+QzwTx$kJsb2xnrZqQYXm~$N)&5KsveE;i9I+|t;G4XTk{Qh~UwEvL}|F7rz zUswHqpU`Uk?+NXjbr~dsvp_E_EEkMMC;Zh@F~(0uC<+lud4dX>+pXV@Q&LsxdTjM- z^(6~MhQ?@Wr`NUnZ2E+sViwi4&I6feZ&k`o5z-uykw5ARcmvaNqs|==xA&J9qU0!*ZTDd$r#`v6#EpA^k7G?1!~w}ojwECX+|)}O4RRsh z9f`)@D7|Z}PXnfm1g!&{MxxO&eKjR?Mp`WqHzo7<&y1gh&}6d8WP?_w$>7*+cWq%W z>Z)^*@uucq&Ed8t=cQJ#m9dKN;zz}EIz=q73l{dLua^L|5YT&NPaVdbK0@I|g4=N(Y-?L{r!5Xu zE#xW=RH*^JO%*WU-79v-_1)yh}Bw{yti?M4x0<&|Cld!K( za2Jwyg94j}4oH5rNlkf({M?b}8n7&F&67oCa6Jsxbbv| z;fU|Df#L(!^r9EOtGP=+9YL`dPtrkJ@;DQYi-%0r!la1Lk+&5o{aXwi#Dng z;&$W)vBRyBQBf-r0I6Y1Gz{$%RwhQ5M8<0zF`e4VWD!p5?81bONe%&VC{)or!OR*> z+P;A0@|sL8xNYKj#1$-}9miS!JCx-SlZ5m8uWjs?@?f7@;X{FU)*L^^mVjs#%*|X@ zF&CTTRyrpX9UKOC8d*rb!62n;f&*P z#uuG4<7pY3^gm@`!R&kc8>C(I_bC{?TYn-iL)6=W+UxV5J&go8FcMJ zT+YCWLOx(rrM*0Ji|6(Yx;%}z^Cp)5Op){Rs;$Ya{pq1Ttz5`X)(#0>)iV6StI|WR zh%WrNiQ@v$e()y`trMqyRh!&^&EbB5RW~O*We6?aguTw z#qgh6=atVYmIHw)exA?y`!c7JYxC>|QruV{Kj88H`dODHgz4_t?0j81ob2}7bC^-a zZkWG$2*0ce)K~Y3yvwZe(`J)S`#z`sF4iZGG5KjV6Ab%Pv9aRQrO0<@Y?Jomt_(j( zdbWVtvRmjn2#H!0WT_NP6vM8Kq%CU9dPMJ+6-bA1tMNlsM{$Het~h)$z(>qUbc(@t^12 zu=-C*&Of3$2WQu+C^TEW1-|p-JE4Pq_N~f{!BZBDI#&JYlVyGvton>i8GyM0X%TtA z(btvV*bJb)A8_H6Zw0VwL0#o-G)2gL0!{`_ouJjtFoc+yw2nYr5&Z!UhzY}o*07ZetXcshpixpx9dp^oByI7^ElGiDcR~V1 zC@v(X%p|xzrGX7Z7iE&9@^kK$t5M~c{xhB%qF+b5q(B-92r?=Y5{MyW;EL2`n7bsB zQ%cxDS&lIge?sIyh9MKiA@BuAmz-m?+=YL@yR}`^7 z0x8~5Q95+jTC$L4?AzOb^3;aexI?d4szT)4>p-Y zP9l3{XlmuktrXkijku&$;DxRW&T?%$*4bOTWV>KyjsIzwnxt)V%{@8N;M4zlcgY!T z;N`vQyUB>!uX**UC@F8hvyOcMPbSMPZG*dC^tK@ms#;XX*WWn)u>Rxik;1nn)61BU z3oLcF?SH%N{}=3Kqc=8e9w$txEgY6@NjYeNrPh7T}wXn@+Xxnp+Z|}L3ds%jS+&B7lRHx=Z zcjgZEYd-vJCeaSRke$A9PVTwL&b3y)4lbu@e0Jly1g?M!f;)&@hZ9tGa8eMQjI%_Q zpXrZv=SD|kE=K*RgS8vxs8AP6b(6_q|1D-XkIj|Cx}su`Y!r$_h2>ymp8+jXXoX^?J*jHCOrp(DDX zv)6%$qx@Ee=C4G$)xe#|r_q07*sh&rqrpG$b?awlPWc~Uj*a0j>;I@8VU+(-kA!?j zolsf-zv>Ye$t?Q{WAP`^fxHj58JCO>(d4q=hT)s*55C@^S2WS$ey@78%s*k`i&9m= z)?n4J&XGqgz>8I^z_4s2~-!Ekc8r0fwgNwI^%5gWy|SPK}6DNpa)+J9zoB+puX^7FhoC5FA%<& zYZ}c$7IeIIwNExmiT9G)AUX3r6fT!{BnTM0Ytj6D;J!@$jYW-gz%kNfrA?dZ$TT(o z!$g#M;pq(7Kg#JQc{$B?e*_~QIQl8xj{ikU{+?OVL;EpJzc}5E5dRsS{{SClevy2Q z+Zo%T?-t|N_uQ!rvHLts8~z6q$+OP-R5E+Rwxo%QsRplZ;mL?aYCIg-zut8sC&eP6 zjN%Kw`?umu6eGa@5An#y&-ef4QU0?q^D1ipLp#z1y`va|2Uz66THF#wC8C`q9Jg$ULOQqnEfc|8|_%49)18D1>ka}Chv{zE!)Ez7|ByEKWU zoP;N^&)=JgNCO|h2f?wy_X#iTPOYxAnKzWm=slDA9gF0Nc<1q8^LVgb$I0!wOQ%#*6v@A%VlDNNf;;q zfT-C2DcJhIUM};0T>lRW@xSM4^k3HQS9)9qJM%-jz(dn)oxmN21Qro3?Gpu3O24)U zzkS5dm(WZlQ>-Ox7-(W7A`W@ox=vm)zQvFy`-P%?F{fR(uVG!rEO>;dcN}!ONaMS< zjm~8r z%%!aL2>%IPXLAtmmADqZ`dRm)$F&Tx@x+VMW9}?lMur9tSb9Ed_#V;$#1bk4{L34M zOc;&}1oIj{1sQ5~reEGkl!fdC5{|#HC?buN2lKl3n?MOE7Jx>=dGEd+$hKD%U{9zx z07C@=fOXzQQ8n;&lL&&ko79xJ1uD_k-51RNri`?*Qq@mYyw;AwVRykw!J0s-YVGun zry@q0zHmd|oc$U8^l^Isi8f>Bo3%RF4H7}tB1RmW2H|x-K&VVae)Ue0FgQJQ!~)CF zF#wm==Un1E64K>}0&w@q;c4T*JMy?St{2rXzQyHoa#}=VidYf)S-QgbThIfZy|xto z9t^8RUE$Mp8t>+M$J-)L*9&yXZUui~XGi`7+L_&_?e&j?)wbP5fFQnEO5IuVp#6Hq z$%303?2i+&4DM%_?o?hT-?ooH6#sT_XSb%z+~DV>@0=#-5gW(yVgfr=TOK%-+^+hk zDy|QYf(3!ATsh}Uo!n95`@^5p}%vo3c`W}_ve@(Al$nc zhLoHrnVysinmWDJfl9b91SZUtyhA|~$`ay`q#vO`j0lFH2faTrn^y9Wh@~otLO$C$ zD27**l>13iO2Nh_Pau+5@rk6_8j?7B322?-8eUTI9HjbCP5YPB4#MdBe~wH_y03Bg zV2kGU1`;Ra4XheTYo@ZNDaiM*cbc!A(e!{^E3EB(=fk?t*FOnW{S&`HA*yo*DU*7| z@wNrqN=9SZLXXRk5K;CbQ4h3f?MQNE4G1g^Ap>O65;F!lV~ZsBK~$aS4xD8J*7CTW z0Mor;Z^WiqIX<*6IiMQ;{7y~QDz0o$c~g9|NTc`lw9N?ct(X#!vh!a@@;uQ4fIlu8 zQ+vdkX&E!BQR;;LCU^4?L*XM2IqZhc~md(W54zZ>nT{Gfe%@qk|^pDI7c?-&H@7Z3gm z_CctatUm@Bj%1GWOmUh;)Y1BI8Pb=Zeu4}HDSoDQCGV}bX&w@(-IRc4CBuB{Dtf{!gN1l7g@9-V4Uf zIWD)+M>+*|wGq(LDM(TaEnn$03A0gXpIDch^zbp5DGIF$1~Z$ z!Uct!=+Rr;hy_|JHGyK~FG|%#ZY#G=g5AMKK?HTf%oW_xrgUSL2dVGhxD|RYlYT$G zkQlHkXrwE zsDSv$9D2&sNgc#!L4V@Ue3ocmT2GvFp z1v7hiaD>^<9PRX^Y-zI58s9Fp-mnNOP1`+IceLTzF89Rn${Vwku>x-;U6@WSN|WQr zx7B@H?QUwS>6$A%>J7{ycnD-n-%)9iT9~jE3PSN=gs78-Oi}pwwfW4ab@R@!QBt)i zIhhT1ZOXdnB#l|g4#5bY<_Fs41BiU=}iQe$Ov zk6*G$7zLSB)ksMP|CwFu%kK`qpjfPrK>oAT>P1goH$Ocl0!hOQvnO zMoU)`Q@V9VSZ(JKz8I|Mr(t6c3%@tqd%k@q0;;PNCS?kE_nHxu^@OpUm$KRw?Qz0^ zxW@KmlbvSmK0Xa34lJW@H8=g-hhUXrh#XeN6zWAPC#tY<>pjS~q8veVRbz4 z_EkQ6IW|t7TX8WC6o%g3U*RXj`a)9xIzrBQw4CmT2;$UsZR`iH#qY`s?pB`JKCIkA za;nbjv5X{{nIALFPye2nnJF#28e{xByQi34J{LIkF>_Mxa>VDDUQp+q%T8i`Kx5yn$$t(dS@`WIaN`p3pbr%z@-Xq zgrM31zjN3p(YfnL=cDth1*@QQ9vVZhZ;QX#%Z9hiDxIy^yx;#eamX`A5F+@|4gvir zl4<^D%J$z;u=uKFy*q=I0=>)1oseS7!Klz|U z+G!K>hs{vY3Arzj^#LgODk9m zL|C!>q}VCX_-(QvHnC}BOo~6nR?h71Y;+XMmZkTxzG;gpvKJi)Mf{10Y3@ieR zSRA6XEE7k~3<4gLxHTtq7_gkKCeaFXFj;4S%|LQq1xuC;Ok&bT$&o3D-wYO(ASWV@ zRG!E@Ls{u4YQ$zIN&q0n*>FXSvdznwWJDk$5?PmMg1;#Ut6dXE0sI-koLN*krkjBb z0|Zra80oU-aF8SqcaxQ-f&z7T0Q#X)ti#pXieR1i5J)s*Cfxt9i39RUWKb2FM@lNp z+$J@TMTqZ?U^sI8CE>j%6b}&vWJpLl(y~GnVq(+IGoS?QcQ_yd_{_|rol)v&%M5Vn z4@5EfJtw7%qBBOREh9L|ktym5R-KkiIZ7kJKxQ)HNwPJUQ6{NARWCD5k-fb9`yg4G zrX>D{y)c7kkj{>g^aG(aYDg)HC#6v27pgN!Og6+~nG(&7XiIjaBhiATm|0ea0F(sI z(2<-lf!MLu3;81}iq=HxCqJePSgOM50i2{r>*SbI59{Sn!SdQyr1af-?=PS}}$7KPC5 z`AKQl^Oa&g?)xOo43}6Zy*hjxPi7A-+qtd8q$&TB8xbA!`Ot;u@S=|F@SEWYSmslq z>Ka_|1|N4j;xjh5=Dfwv)3T$KiLVDiiJv?+K|J^m%3pUpK9i4BIX^m|jKB~l|gLDMJl$(norH z`AU`$eMj=jwSQaISa8vDM8kc4DV0W$`ac3qKm~ZWBJ7)gckc zpuFT+BYK%4Ivs2pS@>)SIQ~?Gm~Ia+Wj!I1KFsopBlOMQ_0~c zB`%%RSeSMlbbvy8r|7jIDmx3G+?1@^wEsbVFnUT-(RS4N|# zMjCf|*c68=w4YzAR|?ZHcJ1-}|4{ahVVZ5p)@a(OwCzgUwr$(C?X0wI+qP}9(#}dd zZ+4$^`#HVO?(e(z$Gg`2{m!{&M2r|Q#`b2fN81Im7bo|$%+S-)e&di<4(uYBoJQr@ zNIHj43D!nB3408&is%ox z!!E;X{y)slDN%)2CXe{Kjy)z1*MHUi0KtZ-4EaXWuD`)i%D;`Yj>h^9hGu^ckJ=94 zf5W5D4Tn}Z_0Hx?1lowsWj~#o>jDG%0vePwUV5G&1_^!%rT25w6^9hTk&KZ{BR=rD|MtA?Pe{5!RZEHNPkc&S3in zjHQ-rz1IMgDI4MGA@R`m96}Ow1xp4WYGMw)vF5ZiVCZ=AU6>O@8(gXuAs4eqpiKKT zwzqy=^wKr2N3Y9C=Ikobi8u^yI;t6U@VBF^%{n=^6jSoE5H$7=YMcxdbtp6MM_Z#` zR_VpDu7+ntj?u~L-DGb)k<%YYC>q|5%n#b~Amhv1H(* zU1I}1O~~5aclQ2+sv-C3^O59q_uv|+_WnsWLlvgp_H8a}Ih8VdI#X(-Y$L_^v#w~F zO-d{QYC_pT!=9!|XnLZr;q)&6spqF5y~Ou+etrM`zisEg@+NNi%YQ(mo@hR*+7Nq` zH4Yqk80Z>>i9&qWPA%)My7ASo@S27bEICvH;9n;%{8k7e72%YWN6?XtZ=8*s%+2ox zHY$+4cC)CkqvFAZOOyGB3 z$rf8@n%rSTK$;>Fr+kPM^K3*hME?CkV-!=t&e#f#n|32KR3rAjmLSZV@NSfvB=G0s z$sm>m{7Ev*{J9Ogtuh*7BM1*hfK*Yiaxz8%+5J-TSl}#>Q}(M}DGKu%BxLUT8{2m* zzrQikbJy}uX}UuQ3KDV0^dEyDfUOwSsDS`52~4OsgfRQw!P(8PY!k~pZM8N00=&1{ zygsj9ZaI=y+qzG_B}eWPLwLHQ$+uc7(3US>VIw2^ZP!T}1@t5I2YBVyg}29CzkhE2 zZ-g}Di)g&}n`Mzf{Gaq2{`(92uh;(qN!7P(Hdv5-(6q5^pkQ!jJMx4>0WDqUY*_y> z2w(IgPb`U7r?c<6`#P+Vie27Ncp4j?|HmrV!QRUuJKm{py>WG`e({=Dtb|cy zR1!bMlqI0UAxO*11A`CUH_bv#0SvEFuihr=p;D={A6NHF%V@qXM3c77U^)~9>;<-_ zR&gu|>5^-%gFYP4QZ{ZT!^3zHt=q!+MVW(_n zCu?)^q!b7 z;PE?cU%pKY57`e2ioqwfn@G~l9Y#)YQ-0|enM&Y zaoZl!SygJATWy7yphXBUsT^!Vo8eJ>!1&FR;;)!_!bF;ZF5|`_6WLrS7&&dLg%jv0 zQdHf+EE_y*PjX&f;HX9r#=S0b`Oay?^I6Jng6R^qfSIObLTstBJ$x8SzwpCmDGa4! z^3NjU|N7!~tHe5}1i{}#`XPFX?_>LgYhEpt`@V_v&6u!%MnvF)5A>mBLPZko4Nuq) zT_B3@$b07D6R*A1cEzt11bF`FdD&QQg(8=Tj?FMv>`dP^pwqI2qjN#`sPb` zht-~xo^3i1arCc~@g$GnPmI;M2eiUL@B&E$xJ)_oAMU(`^eKi2csa@PPa#Qy;7oUz z=CEgpq?IEMu;o-*!y^T1`3d@BRe)(aq72fn#WP*h7huJ>I%~{tfBjZ)@FkSKd5JcR zcAZMZH~fRGuHpraI0I0Yb&);^vWv*o)@{7;pl+B#^foQeJj4n=o$=b1i}~Kw{l;44 zN?UJRXi!SQ&W)>cl91BqmNHk@&ekbe2*rv$*95^1OUQ?`kbxY#wKpb_Wu8j5_ zJDphN<>Vh+!p03@dHC&e3Vx||evV~3_xu|5V?+T1u~cykM!#xRDE8_lglh>@jON1T z002oFzp;bJdU-G^e+d9QYT;4-w#ocycS@svu^Xq@xv(9SVF=g zWfCcPBq14vTPYjHHMz|U)@VKm(a6K06z{K50DE_6I5s37Em8!n66eSeo=6!h4W%J` zC`3ONJMJG3D^rKEF$#1Uun_J`0u~Mchb)wlSg$~=KPt!og{eMSGD!HVX>^S~71Dc= z@aK_?38er?+*ritOZvBTI*mD48(`DkMaQq5AlNWit(=U_Z{`K5Da)n#MoNoxVr#dm zcshf$dFwJ<_W7QD(Kq#Cjz%#Zt@3VO@1N8Q9?lhgEq8|Lw-35Y_n>oV&2;y{YF2M= zcI}mYcl2|*Cg!NBE1Q6rZ}vqhtGY;Un3?*~A+B+cE@X<+v(v}Mk~{9o$if6_e{)Be zO~+12ab^eS=2e*Q?0V8&;u)Rn*70X?*0g0-OwQYtm*Q!*O+jeKEo0zIvLXdfKZRG2 zqPy$sdCzcY6DQpHIK^eHWiv-+aoca>2D()D{A&H*zfbJea$=a4lDK8>1}g4ueQo-@ z&;~rqJ2D{CTfxj(rHp+%7xlU$&!1es#$Q8QRq^7TAU~&2zD3R@psSoQ6h7i?FD^&M zJo%sf2M)LJJCna;sr|mZTzcfWX%+7tC+_EyjCs06rud^;?X;Y)k|VW<@@cUD4B^Po z!mm6WyCgJ5m+m<1=PTEOEIUvd3PlT;k@FLW$)lTVNy!MCP4&jAe}=@)@emXm|afEP)|5dtH}(ScKDvg)`QuvuMg4`Ecb)y+j* zPbx;n+jh}@e4q7;E9fprHfW^j@dHDkZuz!`+fB%!Wsgje%FJXUOJl81 z%;HZ@YQ%_bd;584#C>G^DxWF*{a ze6FNCM7{JD{)NpJGsZnW*FpSirFEkq9(2`5zigS!ba1@4G-9PSH59Yr3@o;fN}%M& zM!6ZFbI+m;-z_ske&il4@#h7Ngm_}a`zBiR*UlDE4~1JQ3}GDu03m>gYY*6u6miCV zGV<);#5*W276|-SY95ZaSHlm6fQms*R1)%UnXyi7Jp%il4iU@0e(U2o=AvNO-g{eb zJ)4V?yIM;4W$D&D;n351#k1VN5NNjf>Ew+R806Oti$a%TV^X_9abM>9L9tkzE|d4T z#Ih+X#IPdkuiSDRLdu?QI>2%tCB?w6ct9vz=YK$B~{&%I?x?f zt-S{orcNm`_}n$yQLkDPpf(3i%8SMWWZw+y65yK1jJkne2MN2TMO5iB_g{ej9!@N> zHdxxeOP%KL6ZRj)P;Br41Yr`%{60}RNoyJ z#DHJX`7z}d-jM+~C7QOZhy}NBjX}8=_)yjhWVks_VSiG{S&~kSf09dAX4ls@3ot9e zerG_%O3ZF#UoKgHkf!9av0l-!$} z&w?ZjMNYG(35s;)Hu=@O7R7e4A zZ}1qU-ay?GrYwPef_Tib5_Q9*xE`-I3v%@QdQ7?-Vx*)g14_@b( zeS3tN;-8mIm=oz#GKkjh*#18Li+kx^TzHjaaU4Bg9j2;zv{kCvW|6e@m;q>S2BFb9 zKuNew&uFF~Y;3n`iM>)6vcZeYP0jqF5z=RZN;N)WPm+~rgzBp!h&`l68xE_1jzCGm zm||TIg1tb%6(?N4w--;zuvDqfzj`G~L>NHdfwaFl)FaU!s4E5wrXkrJ=m%d%kUJ}c zsA__4VVzP)DdSQROGhU31WBbOzhup|Z2fbWQQ_)f%;|LmD{iu=Ec9jCf?%KSV43>aF)u(a&IC;Qz zGoCi?Cyb!vfIw2Q)Ov(jjX^e%K8;~9bL6gtEUbPE#tlHrfqz7%b`YQH0&1w>3(1Rn zMCRN2p_~O2h6A%7=Q)C*86$`hHP!)_Zm^tra}`*I^|cX_+3_YTP2o19T$08i5M@ej z$jUPy$n_Rmd*Ck#LFgjfCiccH_H7b2&f(u%!`}GgavDi~x&#XMz^=qu1G4etNq)>! zMc5S-3Hr|*L=Ypb)V}Qy{%M33WeY*Q^YeoeB{rhVb%^xnctCJ^x)gt0!BUGH#OPbu zv_y)sW}gDGX8l{%z`+%Y-23yWKuG6@5o@tt8^Q1{!(5dh?j@V3tAF862XX95C0tKT z@z*_EkO09oU;ClIPsYrJFynz`t5C2>r}EohL&Tz;?@NX+1}K%7U5FD zn=i*KkK7df_|_Crv2CvoHyt&yH4bo=POqh+^=8HF3X0b%!aIbPTkNwXkGg~OT!n3o zVdpY48-(L-T%g8RW1Bnw{ksQxpPJ2szEAJC99%5(hG3xM?iaSk2Gsk2HJ^rqk z6y6|GQZ&6m>SNdFb0%01%+gYx`*IoKcWl@mRLOU*nJz?~OGw+j*6k+cNI%8Z(1wF? z;#Dx4aZ77td0mESu9`~gEv?DIz-CRk4PXKTQb&ZHgU+dR;6gb>dS^z#E&1|p1Ogr5 zY{X3sAAmD+4wK2BBGCIO(U+Lv8W8Ps4AUXbZ8JZ^k2Y^;j|ooN0#_Ega!8iT;LUsb z@~+G?U4FDqR0X|0O;(|5cvIg8NVOe#K**o*IB4H%ntPwIY@ga{{kb!o$sIybM7n*h zOq!z~_WtA4Rc@w%ZF}vWWqid`v+d^j(8+CwSCi4eDDSs+?*8XIXgK@(udYe&{F-Ke zhu2Q(J#XaM9eUWjYFVmHUq}R_@;t_Iv;r>Kmf)&95$^X1?y@_x)v;&F%b3a5#N|$= z6jjzG5&%ISE!`TJy{s4~sNsMci`YH&{r8Zbx0Z{V`VE(PBK-fk(Ek>=mTUifHeV_v1J-|#^Xup-xtRm zeL$B!r;lp3|B|)}eMC4C%{;%9&Rt|qBF|} z-bM9d3R5VUn?$EblV&gx2@#7E0tT6i69oA-wW5(k3JSfT_q+<|>rF^ui7TDpXBg80 zVIpvZ==F1SQWwH0iiSav97mJ4%+e$T zcOPzb5^)ss>+Pc~N(Bc^6PmNgsBhR5!$FUCpM`9j<+n)rb%)R}CXZa9M@YZ>|F&^! ze}1=d1!(}N6B1*y3Go^YREn`1@CQ<3G0y2V z{FjBKcT)`!{@KM<-2JyMZZCKrkOZPJg*<@#`eZ`1&KN%R1cyapxD=Kxi9I8Urq(|s zpL(!6b5AsB-09>ch}4WQabh%X@&7aMJ#r5hVTHB@%dMA_uot>e z?Qv_nH#e%|l0_)XQ=+m4>uX?CVUpOeN$)#FYjqJZwoc;7;$1RCe{`~QSqHDGRGc)w z8j_Efadc>2?$KsecwwukEO~Fa-pzlbs-DbFx~e0lO=nl`4|`U-{t(`1YTanH9<+Mi zV4s(ie5|T(HrZw9KT9L3phEPu^t#f*XW8_W-Q=y6=`N!!?GbM2d|LgE@%q*)fVu{3 zH?2DeVrpF1bWqN*40ZcI?&_vG3e$C0l!i7Q53a~gVxB|Rk5-(Qq~mJF@1)~)C?-~% z?|r1SecF>|zE{8MYR11GJ7g>UH;vSd9}4@by^<*U$HffWNzszGKn%XKV8crC$>7Jq@C60dO|}( z-`dMi4MHa*i)@Ro&UM2dLC-`JNqXeE2_pboMdAcKZ(E`mmggEfn8rF}z?82&grAmG1_#zFV71^a;34k5YM z^75QzROum_ezfl3q+lCrxWoFe`JqX!fXB9u4R0wsU)$VOa5C?w_S&FrIQ%`ts`hdC zmyv{$yez^0OV{n&=FIr-s^YFh`v2~_IUd;jkFHygwVgbj+NS!?s`njFkl(Q)ahPSd zCb+Jh*V(^%)R)i!dzY<{LT*Zb8Bq=|nMI*=5w`&sXhIg2BZ$(rU4b9RQ4(=jczWv6 z!+QO|ZKA`rt2Cdx5Eh-1K#vXmD4jWXV`xI%-k4pPz7Wc~Xscb$rOkRdts$*(&g^XLQ3 zFENZHlm)Dk{j9Qym0m!;v(avFnjWhkeInPIdf;jYsXhtRPUrGO{fCc*rH4A{{C?u~MCG3xe@Gcka@Czb z;w_kSZnD2LJ4n`8pre=mS$S2zfOEWVanOkgl;QIikRz9S3HBdj3IB(#+h4ZxU-61t z{(E8!ANdU}y8qo~tjJsXLW&-tJiHhqh|8&rDIR;0}k!2iSklBdBDHb(y~l!yfkkm`D8Ao;d#V^}h?lspxMnK_9d% zShCYRPzTC-G5Oq65`J2KemO*vhHoO7Z?ipAk7p<{wPG@rCS^xF3=xD2^Ls{`E!$4) z{3liwQb~2Z2k1e z56A5;$6_{F+bQ)Js<&B8HAd*qg#N{hqD9~&5_rV12{i!dt7kqi03H;qiI{0a1o4Ye zzKLhK01ED8%SKMfk`TcSV4Z!kqkh}W;+9es^KS4g%D70{ihLn)pdGUj=2f8At8Twf zREgr|jHjLwwj3pqj)5Q-TIfJunz&x#ejmnLk6IS|gDJNATz6_y@3D)uO7p zE+G1g01g@EGs>aBfgUCWoiRRi72o1;II%BQeq;R{V2D8pL_kv8co3D=`*oZ+WRz?# zmYxm?>~GXPf|U?Vv*TL%BE;rJuig3Fvlr!&QuYdZW~P zz1+4}t6T^x0Mx*F(TMDs#G7&a>1NFGk?5<8K`lK3Ngc29SC z=zD%BypaEx$v>Xqz&j%bVr)Iy)KfpkBFlSl8?Y|(&;BxhegT!VaBbnN)p!NKAB2vR z+69)FJw@sCcvEsgGUx(qhwYQ?oyV8#HP)MpwIDmN+J&xv3|wk=tfzOnBFiL+>%kgk zO}A*}OiQfm=u`)HbKOr;e{JH)h>bR#5a06SUN!q;bsu5v0S>cRI>H?|`LeoI;)18a zUqbS2<827tgvoRq2b#CE_=H}(zYMyc01TUWg);M@<*DWw8}>z5iwHqVU1agKOph#f zi#o)!Y_r9!qg>v)B)VZ?%sWb(Qh~ToOGsUKaALLYpjspKdRqkRX0@Fi*oyx zMlV_$ELvzWv5{;997cM^EY-LHsQI>d`q|LiHprN5f}KZio2}I_QoK7S@aA32Py_SO zvUoH+4m^Uh5cH*F-9;CukgQJJ>X<*B;ee>x)(D5H8LtK0@}=}s7haJ=uU@)8O3iX) zD;2!QAZTJClhxq<%`8HZFg ztG{pHt%zLPk-fJ)P$%#Y2_VA|>~#U-vDtzl>)0+=GtTZ0aX{ATZ@~q{MZ4)VMnj9| zrm5QA&qgOQ3)gwhiXhl|t97a44}>G4smm)x%0PJj*oEmNbl^M}bv-Hoo@8Va6Y}}D zMVs|f>^5Y8!q8OgxaNaOI~mKp1{vX6BAwA<2s09Kv0aH}cH~PrVe&YML_nOpN%^yoUdB#dbU9cLYAi0ULKpEwF zMa;Qeo|2?c1kQFU0$@OpbLd2b=;TYtS_Uoi8Wlep?uLLZjgj?Yqul}F9dDEY4J+t? zwD{mP=-V5H>vaGnX|fqKvS>hrrYCb6X&E+D(Co!$_Ut3~0q3>l`p4F0 zX1n_lG3%_7E-cJ|o3I6xA||-SPez(#u;KxaGE@=VVfdQe!ydgYK?6K9cZJp~f})(T zx1V<#ztdgCYmooMxgjJwZPu9jknmJg7kQbHy^z4*mjBW1-)&moVS1b9*1ELI&4E~a zX#s<=g?)Yv^_Wol`OpKjMLY5k1nl%N(}|3?E|?8fg}2U?4gJw>$;CUj8Mag)++mpn zLCS82UL|zp5qMmU(zG-zbU$s^k)w^!1bEG`vGZ*6HPyny2@};8+HLC!`kLlup=S28 zm-`s@CzhZJdY+g(%R66dsWN}?<`2bNc@Fs*=!+{K?D}Ey5$%^^oEc^b8_b^OeTPDG z-yAb*;z9+cwEQ21zxShVWQzR4XDGsFZ~%!6F85}*si+pq=KW7=Y|MK85K|@XJnqBC z+pD)Ye09{AHo34?diR(gUtLSckLtacaIbWEkS?DfRZLJnOkO;qO&zEVSDE#E$2m!0 z5ae&(AOrwQn5@=4qrl<+S@y|E-_i2#%bNcr`_%YfvQI#NFZ%@WE&D`zzbB7Hq9VW& zNRP@f@{}}7?s-$#CsvquAlMX|Y>7qpKV_e0lcq0n zrLGVwok0q-gehu_yIVO-uA8dZZ=v&DiNfQ_w>3ilDngRy16{cF*k(0+de!7Ny#bo#8le(|ChLD1^#N|}QVvlO zJSA3(&wq?+m&x9K&syI#smC9`RLXufRf|RKqmO8JSnSc0jaYSx?wzU4kfpR@eg5B# z`c4f0-KcMl&6elU;&ds8s)7Bxs!=7!y6#TQ`V1FI85&*+l-Hz@?i$p0FtAUFx++{$ zNk|mN8+`uDX1XS$)W&^RH8%fI_USL1`LC<{TlOh>1OJB?nvW)Xuh*+`ITUp*aVcCg zow+mjR)=er5&}ft+||WoFWLkh{-$Ubk!BFHhQno0y4o=J=5Fg=Uoze#r8WQ+_>e>Y&@SI9aAOf;w5v5u2ut(>$AL%$OS|QY7HdB)#}O1+yyS zsI^rC|07DsImmrP0PTi`i(|cJfs;sS%o6If^~(LIvFp(LNeu{Hl7umN=9u=lw8Kd2{SfX>z+!F@Dq|64BM<@tz!niwv;_QPhh~~r^qGV2!f5R^tDWJV zx2et+NI&zH29K5#k6=+n6e8v0Ztb>p8Fl)rKlxE@B^>ABx-43=o#(M@-yVO+9_$6{ zjlaI15!W8!vw!XBFX+Po8SqboZvu{d#D9`F`LB)r=ktFRM%vaf`_13uHU7M`nUt7? zz22Ogk{xDO)nTl{{<-*Ok&g_Nkw1m4ZE9M-vN7WG&AKWJldQy}YsIDsh2!ofp-n>y z@4uEj(B_HNyL7z0B64_cVykRajB-Nsg<@bL;3G)k$27ij$O-yEY*tHtCPm#CQWX-u z#p3Ql_Iy5>3?U&Zma)N#=g(7XUErPoW;c}R_d9VXcUWG4q4Rdy^W{)se>$y^G zoO9)CGW@9(&hC1^_UA@o+Gz3vcYXC1cDG4zg}61aE0XsOrt~es2Uak4} z9So{48n=dZm(D#J!n)k@e9b96awkOvaG<9Cb7hxDp-ddu2pF}W9H0|%2@n`zLmrgD zDD?Ox5JxmH1m<1T!mswX(a=_&9ig#W1RoGKokRusOk1@mMJ%4Rh+fIlqh zrO>tEuM=;vf~>vbgeD>RB!MkT@&xbzc)l>*tzNtc+{CrI52$f;1I@xIa(C=`oDMX7 zfS*t|40ID!2Q|@k&r&}EL@$pFO>x+}T#5Snh#>;LC91q7(bOcNkQeh2X`?VJD`wCOR z0NETht($3Bn-^5~KR16hVZcYxDE@!VW9)uS!K3QlQh;6pbsksVxvnmnDSHzj0G9q^puk5WVPLrl@NIo!<#+-=qCcA`F;c+c}hvszG`DcC=w zM&xGG1g)pobzjiR?tWD^#@(6zC1jw!jOIZ&07dvzS6%Ejqu~Ibp9Y^EYE4w4DTfDB z0n1b4MGt+1Tn>PS=#IBHyEBIug?1<5zi)SC>>Vg{vN=s#oMu=1oQ~g+ytPbT+GcL2 zSe_(IoUE%&v35=&O)woB0P7{x>eJ%}+F&Y3qCL~JXI5L$pU&y8rX)B`8DlDDBlZn7 zN`5quTCOz9GRWtArRf~3(w6p3*e`_hRK7G1C-mbQJfyvvY-Kgf@%j}48D2h45H!QzuSOYq z@LsEAN(A7zT3S$6IjXE#Zu}x@8$RH%WSaJZ>N+QV9n(0$tZr_cH)XS-+ifN)w1!*_ zeb~epnq*3+JC=+>f9E zLOi^$F2M!xK{PESY?q~%LJ5}r?ek)5;fU0o`Z&`uP)Ef%anE{pS`(O+dWU>cnl{Vc zY~}lLs#`NCT}1)9do^_Zl9yQ7TglZb$!WVmnHzyesGX5xLO&wVWf}c+3@-9Ot8Jp| zpI1{%hol1e#2&r+G4{;y2WC&$3-1c#A%J*xLDy=d%gc=ipB5p%=YDn)x1BzcDq zb#lrL)|~Ft+HT<}EyoJ`4n4k(%sY+G6vhXpt~F|l$&rn8pn7J5BGKBH0-WwpBMVDI zCyvf*V)b+Rq-2xypqEvBPO;S+$ZwPDJ+l7Qm=652wjP^Lfa1_?bH_ z8SN7!CYL5`xW@-<|8!`>-LUsPZq6-=ndd2l(*_SPT_mLHs{uV43ffMX9- z%5H(KoAbxw4&^fD2FX}$<@6+bqvS7TOni72`H3f~#l=%pQfyUx zwqq6@I#lN&+ZsMnpTq0!t4W|qQTBBwKrp@X)9lErXJG#m57eK9vkkm|FDbE!n}RjI zQKYDEEdTFCO8lN{pamE9nWyee@5Vkl-lneejUKYlWk~mphv&e@ucyz~q80kV(pAlCONqF# z*L-s2!B`8KbSM=ZfvZp+j2RwLAsjm687qHo^6M()yj86v?{p9>lr*>hB3@x#1RfKb!9r@9J!w`S#SCJOG9fOt&g=zU5WfQx z3tuA(GR~1S8t4~an+fo;<*!1BQRHb30R=zYrkJ5aIjqIp83h$!KZ2h;i+=e@jAHV3 z81l&^A;o@yq)`HR6uHdx9pK>ks)ZUBy8?Q5;(X``Y$#S`ZI~33!AbDpj&#^wVEQA> ztY*eofC7lj+EhQ$ev)EA|0FDpMxd1QxUETqvA}6uI%sIxF0J${nAUT)^miK5W8USKm;r<~BrX|^m@IZn607LfdDzjp zN*jJ*iV5L=;}0c(u@?(e3H*Fu2yEtMT(;KI0E4--;B&6lt3C7Oh3ryrqv1OKcBb~i zwN1%t=^376J>9S0tF!v`Qvc^AfHymPgzfUy@2%s2wmv=i%{%kDK0a>O5kKV9O;?xR zBYJ^;n))y~#r(Hd)I{e_Z2GZnfMHq^=>p|#!_}eNhER60i$FR{r}e~Ro{TTA-IwE+ z*S76Qz0{krcaMeVrOa;A8}P9^dppN;5fq-FgH~iN&Kw5veZR)lN$`}Y z-=$7ULu0B@!Z)tWWAeU3*LqD-jFRi0Sa#KP6awY!Z#z3+}`qlF8MQ8v=}> zLDGPTP?aJw+>17SMdKv|M6Kp&&&%9>KelN{uxwjm_~66CEt@XN2^K}7kx@Fw;_6|V zqr}4!!_xOxAmQgu3DiMbdPds52>7`$dnS%|T=pkDA~8!u|^9MeO5@V*cNmO%>+ z_u8tlt~=CnADq;$koGx_%o9ztr)5~;3bCYJ>3`Eq+Lolh<}V08@P7WqSBE1#j1Bu+ zufd820D$oCrbx~79nGxF4gQCx&VS1XQwH;NxWtnRZ`hhgR`(^lsI7ae(f>#kTC@6Q zW0p0Z#eQ{jMtC$74n}7Vu4?3pdlAPx-EQKz-}ClyAuguwD%fHF4P4<%X)|fHAO2 zOD~lMS{C8hHySZQpOD0qoqCwJ8;ADdf!diTz~zLW?#Uc>Zsqw-}+reR3l5983x#=-0Yk*43G65ghQ#ig!0@193Ng%?|0^b22L>^*rxfo2Fo1 zpE)zCh$XZR_#eGgFtbHqAylu&0B=Ybq%o7{wlUe=1xi(9t`N0Ku7_mV4r@cQOWQbp zYUza@x*`wc7fmJCA2x2Rl{iX{LX%?a3WA$l8}Lpj|4j1c9os^{*b%dTNW z*eZD{lD8-*K{TaUNJoBr_u}j+y#@ll-YF_$`c6&j2;R2e!Y{Zld~`!=75mEi$oqE& zS>|EX7aTt7-u)p5ud`8Hiiq7s;9@d;1~1j%HPxbFTbs5Os(4c| zXP{h9VpO7sa%p~)huA@?lCqV(?qj{_kRk!V_1aKjMnj^!6{$U_hE@6D&iVfZ7W?3Jkv-s&}`KI2A`C?r~V)>wT!4YYLc}l%B(?t zLc$pk`Y;8*cNp*9RBt($erR@nt&;5KtZsOE>wa-%wt(HP++ys)uV)NJ_L&f6JOdvc zPElC2GXcHn{076?x^)tM4K}rssH147n;}@FxlZda(DFNrDZu7lyyc;>WvbEU00v{) z_t4lhYmbSiO;T0RSPGK}g66u2Z>*bJ>x}clIA}iAxOzY(U7!AdeIOrOBAqg+PJ z?sDLEKyUdjz&hpjDqpW;sdJeE-52GWhxfudHWADxYsY%^};$J1%&2%9d08>Jh4T$?FFzi+!DcLLAXxA`;d zboMRF-n?a_L9AjD@~rFT<3{5-%oPociM7RE*H1Ms7O9NjqF}jKEHqLT2l^OttA^1U zR3%YLpP_6W9IE++z?##Jl!!FhIbB+x5B%xqwFyu)5BY9P?hHI!YM9KOoLAIX^}?Hn zKf(_ro56MT)75uAeSzn`f0=Q);`q9a{D$DazLVhpV{H9b)~l>29kWOW^Fhm=V&083 z^9veL;5P``4@~y(2Beclh(lh6zHYvZhS5w-@fG7V_4;)A}ap@B*l9^V9tL zEOMrt>DFv>l#!bJcrpi+{JR4=_nUFk$Z?@WobSp?)qs2K4 zq5|e6a#KX)w0$`0ACQ((ON$bTRq?T70q~@Dph-+11x2Q%PAd8!DP_fcc_5SZ0xX-A z@n8z^(B!Z%RYv)afY|O>ZHnT;rq-EJCq{tbv5itj=;d>!v`r4vr->2p%=plH%*CZ% zlVRA&=;%<(%7gfGBgnI6DHX2EV<1vJi+m*P3Fdf`$?<8Wkjf}2ed$I4l{B0TpyZyo z5fyI8JK4s#>u##*=Q7Uij9Eu9dum+(M@7<{z0MLYR}b{JFPXyvhBh-Yhvw+yw$_*{ zz{QhJ%NyUG<(N0%r`+q-I2!8%mn=x0oo~bXSa>d~)z+))t-9;bcJLa>=I%vf;avf2 z5xFSFu>kC~13xMVs)~ex6eY?~0`L5BjwGt+K6UpW>13d@?9d18HLC~H>qkD!Lv&`AMNP~bSl+r4y zIP&IG<-uwkrPTlQt_P*!7iB$g?W&X<_P0RM@NST2vxRR=*L-!(*FR!yhlIz3G)Woy zj;78m>f(87-L`ShXNa!pZjWE5xYEtNQuv1B@xWbE^Uny}-&u3Y7O<0V)mRs>G9KYQ z^I7_~O|^X1uz$2C;d~J8=yx}9www!M$-aG2M(;bLubw_e8tXv=jjXuqm}#$VbQs4- zC4;hU{gML*1*gdV{0mR+4n+e*_?r!o&;S5H{$aHF|DvAlPV1aPx{6CDnbCBpz`!qPVZQJ%8+qOM($GBtLwr$(C zZQHhX-bL-czuK?r`zLjhN>Zs*PEI}DPj_c7=^pjy%|H(j#EQx)Ze!BoD>7~coR;Kd zn--h-{^&d=$K{xy-_VhDTwQZT`bVIno9hTGT-pL5@EyLP311M=&jSQySjQimu*&~{oUzE6=icw5Nfa1{beZ1HmugBE;p4NTt>tv(Zh5DdQqf~QmSffcCzCk;j zVb5g9`hOB?;944!Q*YinVhANf%u%TiC`1PBLvGt(tKV_;nKxV1OE}CLd3=c^mwYpc zd1sgG<&V~J3L_q=Okm*L?i=oz340(84i&RcC?S9M_%>7Xy^JLxe5z(vB{&7tTi4Ra zX@lm<1fZ^iR2md%jIauUzy1&c)}1_M??Rt*$S)dm`U=N*OCop<@!|D7^@_pTRp@7c zN%6t=nk~l03rZsdhZx|p?)H&Of1ptIuN;#&rb1DiBf`dg(1YcZvL(K!V7F-d&!5Tc+mqA-FSu|k+T`=iVD61xGmOYI{E z`rvXOBdy5qrpoyFfd7bIEQFJeSR%{EuYp-i_X9vtuc{(}+d+s#iz&Jprf?yFo-riu&foIqiuL(QMjvs?+ZR1Mvdl+r{v)>j4!%A~lXj>wy1FG+FdX);N@ zi7rq=l0xU5LF6S-Fnp=2xI_&lv`PITWPKL_!9&o*xQn)6H%JyRsIBx?(qD|Ep`0gu zrQBY+lwMYT#H#ioNi}6@3j{!_r~Eq@80g&_Dq^Q@MQA0p(xNOeHWjdy8;X9bbFoD_ zj8DqxqNQtm z8RX`@m;Mms;EHj=*X>{f19rT~{6xd0kn+R@i}xaXv} zn3c+}5J@zuP_mRtqK5ts;haLEw$=4dGmFlm35-3=ez^^$LP|ypx!r-rv-Zrr$`I@j zfrFBy^3m}TF{re0Xi}!ClQRSyiTdjjT=s9^_%EL|Kj{gnUc#tXGcB8lL|2A=CU##=Ig*!BRet-nJsKd^^H1v zl3VucI4@nSAOxBVLjuYykVMX6?(?3mSCiINN(7(;vx4v{MTbeA&*R0rqYY%m1!w8J z#Af)CO-VI*@-3SLGWNtH%kIU(mo=q%V&~B)h0G){uv_WZFz{YhBSH;t%0EySh{{b% zi2>G&R|dAmk($WH1T!qf(aUG@fqk46&`VeU=I1#8(qWx7%^Ef?hi};!3oRdN-ZQ5SynQYYY0djaD9%e zB6DCR`_(FCHo=M8Eo9TQ{AuaegS7?#Jj?D$g^Eh13dLfxN?I|!4jMM{7*Pel@FdFP+GjfoE^g%G)q+khqV}_tQ}H`%b`!aapzURuavRy9V~X` z{tE;nnAOkz9>iwp{e=WkGOD~?a7%Lq5*PtqCNt03`fLVmM4>=3ww&9DJI{#l69zyy zbDPE<1xUD=Zz)`c5!R^xC&mH!IlJeIRY_S6TChG2@uqmbPeCx}0pK04?92Q{ra(%u_nm%CoEQwwD1&lIFiop&-tQuU- zf)fr4yL!807~XKbq_HZfU0X6`R#N%Z|byl-t7r3A~E(E{2cJ;0N zQYZR{NtQTZV!JB*lyZJ$`VLg#bJe**BjitCbSn@M$BovY_W0|B5H=1V6KhFlwXUN> zuf|;|LirMYhNY)WKjvk3Y%A={@J`jD_9e`-+&O0QTSI_>p&OGWpQAibnWwFAwybte zB~8yhBZ_-2wkpU20;+6~xb`56vrymx-y}L5YvaudXIU%%vbm-j#T2$PY`MK^X&3pQ zVi)`N;DCfCaut_rnl4+&0`k@Kv-~N*2F7w9chdoAM{mYXBm@0bqCM2(9_M2~RGE zJaKO{>LyqkR7aVp_CIp%+Tb;%{SWhU?j=%i`Gyh*W!y#y|H`xmnqXGnOe4+U>Zjs> zGi3jC<=pXtgekeS`n99dTku_U_1s;ArAAJpn%!O0qlxBY}cloO8THX&}Ku`nYMy`|09-#@)|+XcaZNWlPL#LPUlC3 zDn*n2D^(Wb{RalK+ri>4iBUFf8@Ksqnfr}vHZZhAvr zqBv(nl;iA{A__m$wOc|L#!Hyr4pOd2P$xg`kK$jJF~>*fv#d>a-&S(Ubeh+Tj=NMe zOAe}%BAgUn#1w%xVdCqxLprO3MDd^+B}#WOf7~Zu!h6ni45_eqB%$ID9y0Sx_|(w; z(DAs6wK}Pk$?3gWG_*Wg{C$?Z01qyk0*M-m9uHb~vZ?~IW0WJ5%djGJ{}2Ju3r!p8 zI?|ZIFwy^t=O-1RE~XU1b9xh)1o0j!7O=Q z8|C>*mP#q{nkho`zYd(WXqkP#S@AwE{1dX%kX6f)D5gOA+Wg1=32v?1sMgv?55?3f z0AC$p1agqvIQ!DgYxO!eoi)GU8HKOkfZp4V=&3}8o#Z^d!Ed?Tx})_z5t6IN^Ze!XhQJHP$EW+uK753qanT|X>o&3D{iQFq ze6eed@B7%&@ktn__oS=)#Jt+_!yNHd*|TQt;>G0Q3ZeQ%qqn!b8s;T@EDrJ1+;O{q z@jsn2(zQ5?Bz{qafL~PMf1?!pFKl6f0pW|D*9F_h=W!SmlF;5C8bZX#qw_b`F|k#| zr0FM10*abYZ0vN(`1tM5N05p6te$2U0XL*s)Zs9+mpH6_m{o?hwZ7Uy{EC2N1#O$%NDm}T7aa+Cx!e?NtQ|gIN%or zz@#!%%jN?{w{?g63}OXHHagH$wvo_aRnIKHR>TR)!5$1e;BbVJcrVpFC&oxrYQk)k z=BP;rLnHxI;L>ayBEYorWgrBopjAbx)CNoXwp#W4pJ^NV=GZ5RkhdOAj8{ zuv1Jlw~LW$IR|7XV=V>TYuDHJ{gL~QA(((lPjOZx!Vn6h7b{K6`C8a)?;bd4R|gns zm9(nGM_27AW9?otsoIv!JyDEd$g&uu>^LbGlRcuHWI5EJ^e;Yt_Us99R#{rO=Bj1s ztt`#7-`RcBd=aJcWP@CxAR${DnBG!BthK04j6&@l&G2%QEZS!EFq8G1jcY&zH(s6f zRJE6@`@B*qSS#JII*k0ChOgZ(^d(N*2THJS=x$zPCO#PU-Z{OV@cCfzS%Pg!>oRVx zc%sfJj9Zq2<(La|ZX^G?w9UL+=iqLw->Y_6tUP(){=N7(@T}n;xNyY%xPq%&aK#M< z*1E7o60)z)srrQm>~zK!wwkbi{qx?ppzTriM%OO>2g`fv2%K|^4FKSi4fy|`f^7aD zWWdPE!o{KB!*w4Hfaw3}dDJ5kc$ ziR|}>PG>B@D^V;GU_WEY!ZHX2P{)j6-Kq}2&&#zFXZc?;{?o z00Jj9JQO~%F|6&oE(X8!5bOFa*XSNiv5Y}Ch|QVXg`S_U_auDozb*-{HoJpk&v{HeMw(7)b3z=NTuui9vHR(TpH(ac}ze%(W& z+NSRUchwY`u-@goxboViuTK_wr20vVxdv0%51C!5v8fGOUr11*$Xm?q-q&=6{@!%G zz7P963xVG@J)qq?n~TD|`M|=uM}9}$73AEBX0n9Xlb=W$)Qxw=CU?^m5`n;X{R;n_ za&|)*4#TEH7`rAjX?yvz)*78bf=xld7r26aB8A}vtu)>m6X|6>0(*RVFss52aScdP z+Pd>Dupe?47sF`E^Cr*^MB_Tz^LzlV3^$_m*Y#;EO|#`$nOxI0F+Zs!JWsWf5xt9@ z>2VvjCi7*W?djw;fozv<5so=K7lyfRR=L%e*?Biz^QW`H)KSJRD<`uwn3`1FHv%;s z3f)c=Dy1Wn)TTaa#G9g-6u1%q_I!!LGjw*cb6dEA!p~i}$O9iZEdm7je8(1kf1q#74b;h>p2HH03RUtH2vpp;#!+CeW;1j`GRkrh)-)2-T8E+A2d3wCy zmn{yFKYb#7|3NJ-l>`7mV?-Q9HDdE|MvV9Q!RF|A1Tx2IRU_~?t76~om*GdVsNsMv zG9{0N2p~N`YA-vT6G%X$6(^w*@%)Xac5!M=(v+)^NfgVS^(5s2FrYFn3c3N4TS*;7I(h<#m9KLJXxgR8M_{B4^hpcqCUVc92iSgR zjAUMRG@afN(;@+@?1cvM!5zRChAlyk8XfQvhW{B=s=Y(v%H=qg$&Y2AQ)$d;xh50B zOnFVaUkf(x+VgxPV}a!w5c?z#{}rnY9vW(u;->yk$Pz4HvjOnh*}@EvYj)fptitn^ zND&C)or&OBvqSHyzLGdGyAz0<%zD#8W+F=5`e|Y?JUVNJ4uOV>h}^d7)EIad%3q=& z^EfJ~gklC1PwvdIpn)sI+cT|I3wRunZ%Ti~j~va12u$hrKj6YdOf8HipGFUn`S(ea z$-DyC-BWy+1RdR<(x>1}(i$JBn@;mTq&y$G^RzFTVRflkV3{l$-#U^WV~N%pq$WXv8V%1`@@<>{Ql~Dl76;kw3shwDQ=c15E!VFMQGq_yw#tI6{L>#ZPeH;m_ zFT)N{q`B?g0}~utOY7`J_6COO2X{vHBUtIz`p#ymFVtJv7b)$cCkz`#tv|_11f-|% znEWaA(?kwB!@g06C&i#>&0kMevi&0{MTG8AR1f@?Xh5nNq;jT6T{$%U2(=QV zTZdDvPQksgOanu@R6c|yJCc(LUohrbY<*;3JYRYQIUb7uJ*DH5ITopPQKNO?z?&I! z8Rqy0C#s27WE#AOQlv^89r%~mUQPPmWycH-1UngTZz5A$K2%f-z59IBCJhv+X9Afh zj|9C%q5R$gSYL1{pe7K`5R)kzHa=wNZ6$K_vQ)Hov)++$q8w#z0`wGw0!MITLB-h( zM!}iP8J5MmZxG^PVL9qci?N~kkWJD0+!ro;oyKGpiiuL5=2Q~xb&xJ{Nagb(1jo6L#2d_ zLDVfG-T@Mv)pMIe`$~F8OP+`Vj{<5EXnaNbb)HOVCuGvrx(-GPRx$NdmO_<$bDes6 zrwc?kPZ9X265jG%(y<5!ZR`e>*{|$+sv$p-{X`+V{*^X#VdR(Ym9lR!g& zzEg3J)az2+GiCeh*>TtF$+)`ax)`H60`noT9xMR{cA9H-n>|IpIss=)&;mH0(Dx%$ zNr-Bt{)%21yoR1iRr?lY;GYr(?W);P5VZM-29M1EeQpNVyfID$Q!up2Rh2mWuJo0O zS%3=GA`I|Ol!jg##2=l*TNzF@0C*Ll5E|#}0tyB-PUcJQ<(Zs3(pwU5A0Uc-XA~f} z_K|uCv=O*}#;aGJB`s3M`Fc~`??QQtZ;58ZunI08u6dfcdX#DO&ip2i4@AAF&qBq+ zu@Y~jn8cv>#gQ{rV=T`W8J3%hAs+8UZsi7(L_Z8f)8-R8jjI`2=NHz3PF2&P^A;-R zBq_4nalwW>29ZbM>oBOSyTV$8F$7Fy<#}>VhL4g>8wKSM$9b34~SMeW?Kh>&wNaALOO9C*sA%g3+Q zX?h=`ZWSXenh(4gtPBBV2Yo9`qZ@mrh{j>>e^-eXU~*IqHYstOXzR<16iUWGRkti3 z0>ce?g`QU6b8cNuSZ6Pnv0b9R=@BfOmzhLN6jmUuhi0E7t21zr9gm!u1Q3xI6hx7p za0*WXGjnvP_1({*_eECBu=XnZqZiqZ`GY~c6n#<^B5(5?PeEYRHq_YCt44tIn(x2=EDR%BFJf+-I5 z&#r~*8+AQ?d;BSYINi-XkqJabXuU})8GiTzO|?|Fp|+W8mb&uJLHv0=7bZ+X|F~w; z_zr@L-vh}tX$AN}Z%@000D8-KH5B+JC%oq$AF0um}G}Gwwb3uX21~`YxkXE#n@j{#cJx<8Zkkgn+ zw8Mi5Z;tX=;27(&@h}Jun%IC#3cI|lA_!atSDQ_u-w}VF5=D^UU6iC<1JdV9R}B2qe5>A4sc~YC2y*aKfQ9cTVW`FVnl4BRNaUB75Uo&R zK(Js?7BqrzHe&9gCkD3QwZ+BXV%FS-G8w2a|M0rM-_OCZzaBJuVSV}lrS$*C*E~MNH6j^|db@+!iqj$076N>2QURZ#O57zcpR91!+1rc5Zj{ezw!oZiQnq7-FT45} z7ubFTU(#U-_v2c^gOXx7a00VY(yd_>8KKJozHH|#*}>2F|K198UWtU}Z8+k*+1%Lo z^C+1zw89d8=X&(m6iLi>A;^um=|w%Y{d(Lu*Ml>CBwq;wRQ1Yp>V`I}_m0CHtG2|Yr%Fy-i0#d^itcX3!j|YAP7{D@(nu0c zyho)%co@n}*#I%~uC_83hNX*W>`NJvYW>eUOUti5S5HElM|(Q+!p5dk#dF>b+C0>W z*qY)xxAzf6XyH8hY=}P|+@gaMoxoVB&%}a^mE~2JGOJIY=&Qo4cY|^g#Dq$y*V`i1 z)*MsPE-NrdR>lA2Wt(&o!GBDh|DEB!-CY-5;Zb{|@0c@9b^Ygu=w^9$eyp>|_HP!; z%bR;&N2e*6y|BsM4JzEIr~~A zGNLbH=_=425aMz5@5`Se*V!TrV3)@?JgS1qmn{hv0;lRCU>{JLYw2!AWDt4tFn6ykh3|(mtHA-drGMHl zsvuVrX6u~U8i#FtQ}<^G%<=r|od3?G-Z%B-9{ch*Oco*QnA`;%y>U`Gc)`Jp=%>18j>5jU95ODo`aZ@s;;p5;t>mfi-psHJfc_ae-wubR8V$&_%Bt zw~=Jl(yEs78EaY91Y}S;;FdZA>m>}-b#U1&x8$)1%#H^rXeDbZ@k5fNB66y$DscB-TNk$mOCuhC``^8Zwl=f9QY z9ATLaze%lh7?LgRfmC_2Lj$M=i&CYN;1d{#2CX6~e_HfH#wA)r7K2w~aYNi6d$Q-v zw5=_o{7Pz>R-H_33h?SoUc|9-7wI6hSF(YBP6seqZLFP5O=lB**h%{LASS0_LBp1l zeHmDGTT_iu9SaM3s6s(udj|QS#eyI>ae^#qTRM6%bYjaOjf0!na64M-kB7FBcT@$D zermdZt8G4Iq7OIIWrK~(Ol1U~xR;$Q<(dS_7F8+9qXz0b*R>gnL?qRt=58 z@WczM>npo$wOT^D!ZoD%sr2M8>}zb)8cw{##NM&b{vIqxC*)y4FHHw4w1)Olo|58m zm3FL<7R^TR7*dhwRjWH5W-L@OKwY_%kaIy@sDKojrvmF#{ouy(m!3n-hvk|_&aoPb zm<(1CD7`(_r?kWR#uTBX7Sboi`Z9NQ3*AMH&IKZp9{{E=m;k z7c3m=%SUhin{52#SGLU(fd0O>;ku@6aEN98wo}DaD_$r1)e6`|-C4g-H|v}>vc7Xh zQ{%sldIY|9cx=+MAwsC!35psLr?lVWeI3mQ_=&>#(LLN0O%-H-KmX0%)Ts4&F#3D% z{oR-LTfSqyr+Q4jMUQp#S=N8sC4Tbm0|{Q17ie4fLcoT zH`GeE?$bV?pQKf5lVUDRPNCDo0S{mk)5hJW?y}9_&s;#*bYV7KPL+weJOCSEcQZn(zX72T2wYo0jNF_Po4}N*woc_hj9OnzQk=Q5LBgDjUzt=fSL9>V zjnmfEY^WXOlq{H6v3!I?flRe8Ixbf# zs|%rMYrY|S@5DVR-y=GNP%jcWhNC@HPXJHwiVYa?s_3@ItU8$aEf-5;N9LOoI!f!u0S zWLJ7E8Y%&WWBeRaY(hNOHcA&-d|Unq9NL0tLhhNc*Kr1z`4-BkR&C!0;9^QX=$TrY znqL~avg5+<<2Bz(D4P%R%r1qg`hA93iIn@60lCrV9#m%2^h36rcLAbo&%65V$e)rXB-4 zidm*fiR*oTdG29BFS4kBqQNOK_GGUTT2`&Aa27dbKuJ}DYZmGz4<*CvVq&7GW;WneeF&Y0osKD|l>^q1&*>I16Un!6khU6G z>loG+3F*X5MP9Llnu>a*^=1yAAES0MZRX^>+Tl&-6!z}egdinxq9Ef_lNe=Oi7GwO z<987Y@+OeB&&$m>OtCz;?>32d-uoZTJih84!K} z3F2}^J!GzE{AV*RmxMjBP#Q}o1ijMWN^uNuU5%o)w?zkxADk(AK7P+}zuw`^?#7Y! z=Q6m(;(-*zVSSn^7It28XK=%*ZPy8^Ecj==u4(;iUkRFO`$Nw+NzGS&tv>>0$2gnV zpK(I&EG;&>s?~ds3Ez!`MfUSZ?e+F5nr7D|_x1ZmfWldpUP#9F>wP32jP=b zq;XtN2$-?8!H00%Y0ff+CndHjz+~~H04X(0=)En901Hw!88e2LCE#U$>jO7x+=wtg z*x`;z);z!Iqt;j?r|A9irOh3Sj=}B-V1G&Wy z8Mofj>j?A~0n(0d4EDvVLz}}yEUFDDO>hc}nvP}Z=Rb$ITM^@R<1aRSEuhqDz1x>k zt_l~+D{`ZR8uGU{M>`g`J374hf-3R8ICmu-k)f3tVIo+MqeU&A2{$VvaOi!`xFhAS z047HbTT0|K-Z18ZwX5@hFaR2*j*`eaA5tq*M|pazFNx#9ql4{~N9=eSvcjIWgA*K& z*KMF$en=1rmAnIavM(WF%m`>!g^zY=*w^Bj+1WEppGHa%6ddT;(H2qkSXbt`4wt68T+ zpp(O(jw$fHZcIp}17@&7h!?Y{r@llGhqpI&eOfm_T%Frqby97;0sMJl!-obgloHtc zSnR4)zGrKZ-@}VjjCJ@Pjxc&@AfI47U=3<<@KRr>BHn#NX8MU#7 zBR+Q0=K;{LY6|{Flkw|JQ~q;BjOKR>;`|57V`_HObF-f$^EpMUx)}6BranmJ%?3BA z3-^^Vi!cw|0K*R)6rQB2a&wG4Pl>0;0oD38DhFvj_FK z!Cx{uLYkE1kFU!nPP(}*M}B?0sB&ITh7jr%teA@Gd??l7n!m;bPQQ8x$NFo(=%Im} zKV#$Ewq2kJ>5?+%uF6*px?&Q_>x&qIn?K%k>;;5Em~~un^MrsZ)MP+y(3^gbmXc#4 z3w4W-#Lf%5kPC+|LXVdn>5?g1rzb=S@$CkugsVHh_@b%NNN7 zTeExu>_w`ljb+=f!^g0mNSp)DdZvDM5GB@c=_B^y=r!ZKvS^OJCu|`vbxoX%H3YkY zW{e5*DMYf^$#mDj9m*-;l=&(GICOl0b0`iy1bPjm}dwXpF+^rT=eI#f(!@KKj z&orq8}#ThTn-Y z#a2}}0<=ynw#q*7X-TLQ&zgDXPtV2jm0?A(!5oo&WHU0iFJ-ebem%)^A3*%xA#{Jg z-zw2h?*5UxoSmLY6TZz`$XX7Ne#mWDw%Q_+d7R4AFBfxTh+#ew_VJr8W9yATUvT?s zzDI}Gb#S#}%R_?o@-5cBO&hxH@-ACGguI=R+!@)5|BK&M^ZugNR zIO&+4#gUU&_fJ($v=Xfo4tcKUbxhnWeo4br!q550|BNooU)FBz{H3q)f1?Y@{+Hdv z|2Jp&{}R~B6V->pFkdN^LM6J@VmGSuviT%ld6-i@2ZbRO0%1rK z&e|sU_$w#HyU|}>_}81-|CcIsz4&&1$FAdUS=+}}kRhm5aO&`{Ky{gN6rq`vfQUZ6 zB+GpX4v*Yc(0t%L}2pC)RSRe9gBGKz>F zl6%0CxI!uwAlh(h)_8JV>b{9w6_kf_ya_SCczV`uB2jWy*O5xm$~v|O^J?XHA7Q@~J{_wq?w5s%8CXyvg!pN_lL8$DEH`pIu)$KZdBDN8;^kHATAZa8 zcJv?`4^V{ku3ey{U!l#XU>YNCTp^qX#bgWxd6l>Rqi3C8(VUjj(0u&fmiB^O^A%Fy zH>gqES6@L^z$RYm&yK=uLAkz4Aa>+9nvD3uRpSyy)%wjUJ%w(+?|r!P)qdCR%*{KZ zkvv>AspLXfi0u;d0;kpvd-J)~aelp1!FR(~nAW0q+VZ+Ea}X1)#17Y{mHlCzeJPS* zgZ=N@M&O-zU!A`u0O*Hg$$p>%7eL8 zt1%`TA};xH4bC_5wXw4~uyg2HbmedN^#834C#xIug#V9AJTxEx<^OvA+uHuMFtIdn z_^-BsgpL1L7-4$-D|AdZJ@=G4Xtl@Y#`~C`W?9&iM{yF%Ai{{}(z<@_F}oA&L`)P9 zBk4sRIp9Pj2-^bhhybls|MsbijIX6Y+o9LRtsjWs5Qrd;(C?6oXW1mA=%$~r!Zi3K z6ulY1p=$YX$SEM$HsPQTutwVM`)wMy86%?erd)TdXBb zFy+uWK)Q%r+V8AzY@iHgpmBWXu7^ZoE}}Tjt#leh9^tG$yFTQOPi~TlCe`U3lkWi7 zV%yJD7zKLU-nhE&iH?E^#J1Xi4bsH94#GWXo5-rg&!3m%|D1V0xB4Lk2X(=7FYgRH zTChtvNr~;d4MsXk1fX(Y7f_EQVh{$vAsS93NUPZmUzQpaIp@nlLn3WA7P0mrK?w-8 z3OerU!Y=VbHv)1|yiDt5Cm9_;AbY?Bu^u*G0jA(yCr7-@E%+~-iRMM#1=!?GhhLXp zzthd(d`F4M{1&9-^X2z1cYh3?h|VZmaUJB(u$Isxyzqt}?%o-d_mN9~M;AHvbs16= zeJ@MX54OM_c=YF|U`sm}!V2n>Kc}7pJb(ACHMBodS=6;fh|(?d2kO}kNX-tPaxY2s$Bf z6o?U{FyiEz1eZURNM4~Bi$xLet_7v_2x6FY{Y;dZOoqy(XP7eJ1G=#h92wh~;^X>w zBbTGl}dn*V}}#cX@4zAbNuUaxjIHMhE_w(Y%gW8@+;`pRvcdmNcwiQ&d# zu*&b~>YcO`%?qeNEJTZObU`7lO?3ZS24+ANmdo(E7p$t!M9Fr$zf_3{yB@TLB-BoF z(hTaSNULR{*FIdT382zSyLXlTD)kX?#Z^v}OT#ywWE$$V590_mSmX=Jwg;)eE?Sbe zUyJcLa_1_nPa!W9l<6bUwWKd3Ml)l#cW@Afnf@5Mvy@2{J!)$bRV4(=M)Y82H{DOg zgk6!r0mTNcN)+Eh?9IA9m)|7JVG6Rmk2Mg0I#i+Hn%>fLaxU&5h;FVTSOPvSftaI5 z291TtR2_4i(2>d0xYogk>bv{`?J^E8Wz)hYz9kcDI&-*5VWleo)n`@@KsiUnOz*{X zgISNW6YrkJ%icSKrgd{a>!cTP@LHcnj`8Uqo4!mbup_2tPyZl!-yWFtVc}c8N=Q+X z=<%C!O$C9%scSS*W(cWigem2u;7ryGyYwx^6uO)TV@SjpawP_0e%WpgGr5g#INyT& z#)0-5h_ZEYBJ0e$+{|k5kp;b5s)h;QH`|d$D6w94kcTXWm?_ZNfPK3NXQc}qdAYUp z5lvoncvFwe!j0v zsijY(U!Qw^?*|Xilq7sQ%swskJKA6)=EH9CBhusfirmpY1ZBZwz~#T|60c-G(gP}; zjkVp0p-qu|cmCvp?jFMMe_hX}XrMwU!A)(<_cEh;5gjbpZ)>A7qTX!xYL|@L3-i*BD0Vo?4W=;XN z>A`dgvv@h!B%rJcbm<9gQaH5GqZ|Et%q8x%cy|%S$BEz;3Ml_?WBqsF2gm{5V6%G# zO}&_?8ltG5PapQC(8z&|HYbtD2xA&nCaS> zf4IoMBc|+mj)PrFW7Ri?iH=b*=g@v3Z|ZrFg8E$Okk8Pt5SZ`*`cFFht*EZl3gls~c{)4q8n zWqpB2Z!mY>w$?zXOe6nt!*O?q&-%4tPCgu^Q8&xHVZpmnXqJTBJb=Q&K0H)6wWYXM zA?nq=pXP1$^&d66+>@W#=52P5W!K#dvl(AL&*U6(>oohjr93w?>yk?6?$@e5Q0YN+h|al5c9_f}P(#YX`PZN~NSv#r~>Gd(h9Dc*`E z!x@PfFKZjK0vpOnf~|Zfa%^pPI+JWO);~9Wce0MQgIw%cPzQ)ZQ8}ncWu<56K7CC) zj4mia(TiFd=hyZl&Fz&hw=SW(h!Nt@pDlU_)@ffu2^X!2$fS$Z@GxhgvM}oTo)Ns6 zQ4v7vfCP#LAw5ZM;}y=m8$uTL2Cu0(%mvEwtzNfq$Gq98Z5jW5gS|0XZnZ_l2NP_A zVIVl%d_Z72p1&=|);(sFVh#Y;_c^4QO3%D8=DAdG@L2eu#PWF-uVULzX{R!Q)re!B%~;k0&iPFr zT(aa?Fe6vWOwf))6b|8)$EqpvEh^-MwC&PTd7MYc-6Dnfgpz8{LpB)4W2in?zS?p; zUiOSx{2I1Kng~9@$r=Gx|IwJ#2z!8wY{dx5xfd5xu!d#uYaV+AF~U8v3iG6@MMCX4 zj;4)s1%6cf;j*LWom*cO#Io(5y1`|;awSL2UOwakL_zQV0-7sj=P7x#E z-E4)CJ{5#RH(h*|dJ(=y-(+y|sg=jk?u6O1X;x)EYwY}s;)Y|p=bcn%aKjm~_ecA~ za{y>`k!(jIZb56%3coCu5^Qlyx7Qu9J%?_P35((P93J5u27?{rIak0#^!1%I5}Ir~ zU?{~OeY9@5p4Sb6yR(R+jNm0}Ucpf@rKqUq*O zG9GEI)0M48%ebzzLvGS)r7+@56r<@mt9y1;%}NatuCT#6$ciNcZu{cy8G=xE7PlKp z08io;HYK)dEi;zVt!-q$s~U~!tmn_im>&{{sWmf^15m59<7-X0>WyKFh_e%kf0n4S zF;yGJ!#f}%T>o%Qyv++}uZ#a0pR8SOJIy(XR%@VNL}U6i>oQ&#YU!}lUX(_BQES!U zFYQmZ#WbJDwi|&eA}H{kkxT@~bfoY*Wt&PLGQ%LvvbD3EvL7kJc#RIPYk&qqm6n!v z6{pOU&qh#D8%M?5q2N9vm*A)jardDI8M?i6iu>)? zh^i21xGSknaUIE)xYEDoE2sE~-`D+3{Fa!|e5;hYb_#RyqYqykd7;4Xpi0vlZd{x^ zKa|Z_)@ku-svxj*wwRT-`#GuOM~}pFTg_ex z*`k9s$D~=wtk{hctz(6}=WoIeki1M9sAvW)kW&5#H>9Bba~wf7y%9~mI9q>Wjy40= znc=vpd&%aYh+)tG#afEs)qBaS%TW^F8W~Q%FeL(DJv^IJu#`Mrz@n;Y zNL`;jqfRUH`o!ldIs2`5?xBUc&6r79xWrA$14y{mXz+Nv%GPod28&pU()rY!MY|Qn zEFQ3qX|#5eZU_a;fQg~`w5g1fRZo;ym? zi{j01^eySxJ5nU%PFkANQj&FFsluE=ltvQU)XZ-G=g^s`ME2>vJS$d~SLw6$+ak^z ziSdua8MerLjFs^p>)(@tMcMfdI_HIpBt;$CcTHl}JUn;j$CHf>c3VHLOtg|n{Y0Z$ zRx40{g{(me+LU$bNmk`<0C2Z^c8F!tFy7F9s~)huqb~Ty&Ol0K^?jv?Mk?IT&v4iK z_+y{9nGDKG!-QbQlsLbgLnAQn;h>FWT(6YiyftDM zk*mZa|9ENn>g1>MwoLeGMzf*V`mlE-BDv(7kVaoS2r9dW|0^z-#JnAY` zYjDXP)E@t0zACj|Jk2A$y&1;HkcOGfeyGJ-q%5m4%uO=&btgkCE2qd4ArEUTX><1m z1hmNzHWY+BfQJVq#*yr(M!0@Rlqv(hV)FV5;295;@Gg=_oINY2!htw<_u`3z&?gOz%-t(@1OJ z`E~)7m{EOubf<2N60O7mf{76PRJesVa&;7fr&p35B8{;1GN|=H!^ans0urzX{*x4b z0Vw=Ih_DjP$cSUgQkHh*(a3P#T%{JVclm&u(PhbrX#yj;B}{A_w+Po6mij`NG-~w14}tvKXF*F~8@L zNv2~$>+3u+OST0O$?j519&$5LlSCSZDw`u(n%x->CZx174ecq1^EH_`yu4KjI9^t+ z7(QtMp0KNtLY`0Z4NKJZompZA&Hd55!dErG+ZusCy$H!zx(>;B=|G=hpt33W`M%Pt zNl4krH>2*gPr@Dd$ow{Wd(2P))H>I56rfh^5c;RCGJRZ5!G35npFzEru|}xt`G)-8 zs0s{cjKcP>Fv#~egYSO_RrxPq^?(iL3%%w~o$exWhYb`8XuBml?f=2qI|k<-ZQ0(j zwPV}1ogLe@ZQHhOJ3F>*+s=;dH{E@!PoGn#>%DcqKJ|QDYmWJ!Ys~SB@JlMgnqhCY z-%CT^QvWo7FR~X|^DWxm`0vadChlRC3HnFA$ znVRcRBF{Kaub|~5h@7yDxq4u{ixy1K)Ke4d0cj zlJkvl!Ac7Y$Ozn}GE>5%GWHI1tK~L*FaheGPC7l(I{6MsA*rx(peE~%>CS89v{PbU zv@qK5*Hfz~K*1)i9t=S9@hiInrcN*vC zd4k`W&}JM)t4j=};*l3qG9P(>a>Zp_4dh$GPQ5$o1EU4iJ2N$!j$L}|b@hrm{&lz7 z;J9?hSeo2GAQHcsF+SP8Am~X>3Tho0ij}xDYoTG|8I7!H(RJdDTF&)ron|DtWkNJB zaB#3m!(|B};+CmmS2`xC>4uGU)R@4+q816)dCjJwV)yBp-F1gJOmxEb#rnh=56(*} zQcBuW{dg~lLD#*t2fp>_;$=E_d&%xyz`Of14Rzahs%IqEH{Hh}3vA9z4R6Fb{Zc$r z9}oVk(cmkhMaP3hXy^AugX<21L941C} zipl2lLN~)6mt!$dj<07GjQ0queeje5rKfX!xE4Hz%9m}vnU4Ksb4p24V}3OAtvMHR zW(ZxQbbz{~0f;cQj%*}gri^Cqo3mxNmiIOTrh24aib;jOc2lD75K98(sR^kg+S|eH zc`>vi6f5J!0uwlqFV^e>4E?$*(0q zW#cw_EkQt$4#TzTxgV_&58eUA=}aS_JZ9#zi}lJ@fF+&|-|Dq-w5+5j`5& zRr?oJi4MlKb=I9Al+vaPaQQPh9gBkKtpjEWH?0laM!}>_I1jnu7_mtP(N>G&;^F z`^z9?x6~*(r&()Rv8K2alhx#}?rbm*f2}lr{)WIT5d;2N7c}s42iNjOY^GMLE)5F@ zLIx{%6(S&*4&qvj5}tmI5n8RgC$fVI5U^CmIoHzJLOir!74_U-T( z(2Eb-1&guglm=2DNNvv0j4sT^+@>*CLDZBGV-)VYa8U?NWJTGlX4fljN~q^ip)}q; znGPZau?_r%#5vM!lzNb#9v#GDqYt8rP zWi8e1F{r1qfk*PE9;?Kc2KRR{NGEabkS6LloNpKvk15u2k{)J@htvdOt{S3m41qA; zjn(tN*h^g|AnEu=F+J93g{*9nA{ga?V1tOMXwZvGjj$1GEeR!S~bp`sv zq?5u|3*oNka`lG{R7bO2(Q>-cLlrca#<=19r=HUGIP)qwjcX46h2bp26^dR$5MUv){_j6lRMXQYh14v{N&jgMDnm@p9iUpOIipYQeEFNW^mKB&B z%Xt{f=6^bQqwBUga2VVRBVUU#Ub^tmcO%W2c$s(Zte#jdVaN^bq_(!jsu8@X#Bz*3 z`BBPk+)5*!>w^#0o1N$rZ>{5j)a!%A;ZhE6dsWhU-Lu-DLa^;-$nF!uhYVhu&h#%m61GlVnSnxkmS#0f!ICwAvnK+qk&sQ!3<}%^7E1IKWk;5@v^z}&-w58!36!=hVp;h0eu6*e>~GosrCJb zPJSlypG;Y7fC(gKX7&fqt^XgPEONn6EaF$dsto?-VIoc~u$tJ8POO3tsm4fPx}M66 zKmCq0rYUb4+SvCx4N)X;uO}BdJb#yFF_7A%!mGhDqN{f$FT-6+`3+j5Us5%E_*=T}KXpir+p9xA` z)ZZYW04^RbKdI(LH*S8S1H^p*L)SdUZ44d75&yn8YtljTcr_18)l9Wnsch#-%X}zYi#7cpq}#rFKSy`289J(!s}wjhW?$ z$r}4(0=7w;RKyAca_woGaiZ|{9DwU+sF|n346qB5ujJ7S{Us?lp?!Pr?2o*Qx@V^% zgGTJoJc$c$0|TsxLi=x*nfA-W%>wB*?DLiBw*kO;V5sMBuO81)>H{4fI43nuS@>|5 zd051Swh4EmZdR$h4C5+ikkIMnK$cmglCjvsbcJU4QsE-pKVERh)y0N#FnfHzqfVdC z^SC%Ynza*uy}Hdr;oQI~(-Iv1k#|=(h+T1?7NVS4eED?FIoOdr6Cdw=xmR`!VgrNXs$QhUIe3HXo^p6Tgl6JqB;}tyAmnECq`CHWce}a#O4idv${+o05338OE94$6(hrP8N|=;Nn` zuagx%y2CZ9uaY3al&xJ>jn9UUvy=ILo_piPMjFi*Hls7gEjtyB5j$r@{wbWYgE1k0lDq<*Jv`l~5ev_pmg; z5NWm9+Ls-*`YxtTgaLZFY9qYhyez@2=Qf(Y2ge1Se=x1&+$^AGwvv~rYNw8B!ThcG zjg{%aq~y0%3>nX)%JB6lCgUv@r^>~%>WYlAp;*%QwS)2kg@g3!Bw(fL`Qd=34w;p$ zvkTS!6!#-1_yikpSg|Oh zOty{=p$f7vFd?TM!48Op@Cn7#4Cfx5JA`bST2eP87>ctf&yj3Md)+-}sfWktEhaZ7 zz+wj#Bd~y$(M&X??t^G#1he#R{3{iwB1y?<*@!SI7|zdNE*1r}bl3n~UDd={yzli$ z423RDL5{{4ZzXv%wIeGW;=>KXK;}G8#;xv-yR!1p`LK|a%Kmo(7p>osM}Qj@*YO*< zxo2vj0oI#yhg`S#?G8cX!Z7*$DGk)gg8b-2lds$P<9qmEUG^YH{Ml#q&2VGM_-)ki znX+{x21=E-_P`UblX~Iq{5Md?REDIX5I(b-6om@2O2jW~yI=%J`Y?mP$b*zaZ-g(` ziydSGU)EWuS7zuL6V03Iga^Zt#gn1QM^R~#Q9{(i>)K=8g7d$4Dv}%y5eNvuRx3YW zq+ifwrM&l6&H-G{oG+4A-Awd5WQUR=M< z++5fW(1jRMEts8*xMYiPQ*G6J4&#}Of6rspUS2Zrt=lFvUZ}V{SVmm(4&8s8!bd!} z`22nH6>`M59>7mI|u&oFu-hARxaA!fAzSYlFIrZET+fy+e~=xzBFAYS3pp49ah^WXTB(L=0(>| zu5zH|zv&!`YlqHOrFK`henGVKdeuOHtvcd0vq2B4=?`?p?ZzVKzG9Blm<`W9ju+u&$0!1Q4UneF;-P-IC2K4iA>Zp$9l&i)#qiKI z7$q%WA~fvVw@$_4-|pJLbY8E#Pn}jZ2@=3yQw_~+X6j#0#O}@*LxOo9B+DJKI?W+7 zM@8!yfW+58eZ(8f{6KEH4}emqN&P_cMzc&K<}sZ%Ndsadd^qWCpjg}|817dzYH)4U z6ZQ&f&g|DbaoJA_1_`Kj5-dpT9W$ntEoIAECjZG#5f!>yZ-tt4+%S-;0n)GrGnFc% z)p!QUXZf^jS2yPS%urr-E7DpP70+K{Xv!fz%Kl=gkmI}6!1(R9MK}=oWI-JFXkp4x zu6mH|El=aiZsg!_ARYl=I~qBT0coV+k}@Ee({&B5zkc4E(bw9QJ*|Fi-ZqvIt21d0 z25AZ?6Cu4DLJRA2(8_&Z$W74<9_mH|PsP0uo5wxd>v{0uYR%6nDcLt9F=r#)hDOz; zqIzMuzNzhNX~HJcC+Ow#zSlRYTZt86)_6HEzNUHbZ1{-X{jNXH^Ez=V8DP8YwXjS-*y z&yZu-NvQ&2n)8~cp2V{P!kXI4l_$d<1bEovrXiHE@JewgQWiY8LBpIGxf!*wGcJ1x z0Ln}B%qoWU0MTd_rpo9zD?|sUVK<%xV1_~%fQY`L>Hq;TG*S);6HoOUf&3N+yRxy^ z7E;O0E1nnsZ)JyYbJatEZi-g-O)W1hV>l9?1J)6i()koKPg&1!$w;RxE0~q|8k{5KbH*MQ?Q$u3?AAEo7lV0?EX#sy)6k7^Wa;7-w)9%iE6#l#K$FK$875 zk%2ieKRf<2B)JLhmuG)3n0b5J!)}dO(P3;4#8ofVN}74)6)daFz-;YH4(TX0661`K z@~$VIW_ge(9@?ey?<+!S_y4SsY$NqZZaV(jkvX_Ea$|wB(hIGb2{Fh|M|Sb?N+2$# zMDPl&6K%N$n?;5?KfN>xhI!GdnL9hW!HzX8{PRKza~WPRg0+DHR!MKxYb_t^;mm^* z6XJS1)U7h>2rx6n!Y~o%I5u~$mRPwq<$l_@QNXL5Lh1jC^6=sU_MbbY>87 zc?HaqLN2KYB>;NxTSxG1BP3%YDEh2xc9EaVssd;m8sI_hzQxd-ype#g{y5yiYD#h# z7O5THXwlI^R+y#f)05m(gQ;FkHwY6PR$v7Qs#A#;X`;&9qbHyaLFAWIlsx_gGRuzT zfULT~tc9{~uo7t~!&~$()5R6o?7~kc{!DfZ>gIvb1x|Y)YYm;GQ5U|b-_=NDb0tT@ z;pA~NpDK?M5uOD0~x6LdRR>|aID6F-$h`>O&Qrz1!3_|br6u+ksgar5S2ZS zpJM}d-s-tlCqCM8w*6Y9kM_7>I|SWF1voBlF(w(5I2a1{<+vCt<;e1XAPVjX2r1PY zz~9dI$j4=4eGUT+#UGj5-f!$DL$6QmK_1G=CKV!DK38lBr(nF7Y}7f&_t4TtYfQU} z%!{NgD%olZwL66685-aI1@mf2QsTMsQ;BzfD)IlrJ^SyHY^^X6gGGSwfi|r(!0p%9 zqV#SLg@e~H?K8umCUS-8t0oPY zT3RdjQ^A`Yi{lvkKCl~bgY%GoaPUn>RwB<3H8?ZFmny2E5GV9kqw*#_bitha#xb7Y zyZVTl({vs6q1-(N7UFs-a9|4vR&diOF_X<^fgQWup^2Rt_z!v)02>4| zINRFlTa_SrJwB}>*1B{6I{Y!n-3K6uYVmZ_ukeD!i3eRz;lX6S1lambKt{v7>oEbf zjP}_15AbU${9%*79GmZhWS^E<@CD+=ih>o4lWlKSTn-&_1IH^guQs~&PCed*CrZ5+ zt8Qs*ZVEgR<$8bqnT9L_%zI7gkgAu2o=^WTXzkiFi3=8eE>cO!ri}+8r8kkXcc(|T zk&{CBdzbt}gz1R}q9H!pJ*Nhq846=+P)$+Ij?`uWN#kzk z`oYHrx&JTGf&VDX|1{6}Npb%#+cl=QN||GpIF|3dkBua0Q4P z2T9_0jy3`apj?qye8F$cY;(jtXV=@m2KCVUUs)akV(huyY1^009j??x3u}wI#ro*+ zu)zk+Ys29XYyCu#0Ou;@Vo2wvLtm7{mZu+QscT90zLJQ2VyBMrw7a4==~en+D3Bb< zk7~T$1PRDK`!}--_`5zW>Vtct4ROq!UXO1vPzp=?-kpOg0*H=RWKN(>{JE*m8Pa@z z%pilmH3YY;+QU=L#XiBCwdTpm1W;@ighzpdPju-W8!Z_sSFP0>pXwA-#dV7-QKV1r z$Rtvj%^7-T3}((YgJW7`nZ-aO3-J!Xf>!T;tC_jHv~J9An4u6AHH&wKgDw)Oj0Qvo zf%KCJ5?jSX1@)K8F0g=#0fLb{naud48w+#GB=V!`?Z_bq3Ii;zISI64_kvZrV%^ff z2JM5i(}v?Gu;Q7-zEH}r^0JPNs4J9#a=c;n#u*USs{0?pEbAQf?1(j2CJi|nr~Bh( zBvSzhFZvRPFOsN*kihzbAT-ht9QxZJVIl|!++bL@!4-rY1qj<{&OY)N-6cq2^C}cR zs!Rx?aCw9*k*mhR%~s1P>vOS+Yv~j-vN%_oEd#aqmrkHjK!ZlMqS5&TK9Tziu?`1l z5*6(zkl}oAe#>Pp#F>wim2t^crE2Sv? zq1}qm*91cNVT%V#m?r{i$IhD^aMQ>gthHwdZBRrbHJki`=`nx=ca~kA6{%0^8(XA6 z3?X-5#v-p;4(Rj1c8;YXFO)g}P5I3T_lHhz21w8eg_gvn-@W(nMF2Zri|EC-zSg=~ zPavHhn*Spz@Cp?hIdhj#=9h#pAgoaHQCTM>Z9>H=(TsR!M$)TTUhuqBB#E)oGKDxZ zoDt-L3Qe?K;uGJu%@mAD*dC)%Gei=mSR|e~Vt%wVbdPmb{m;;(_Y1&toRH6w z99&W_vwus0H6R+A+U9%oKnWs)d<=P07trnk@fx#>p_ke_pIK3s6@QX!F2JOoC{G}uYvD(@36hdh2ef+;-=WpF zD2E6tQbn*bYZ;f(l$VU`D4`Jo49rj7u!GRer;U$FG?lX=<8G+*i&Z>`~5B)8F2FsX;jj zx%!urfvyz)BZsntgUKql&2ZgYEWNZgA(awV~N- z$TLem$Fx3|PSWGURZnHPO8)dfya%nM`-Y32Kez~ zJ2XCJ-V{e0bbOixuC;?)DoIZWAgM*+r+4`+ueD zOe(zU9LZHVMO|_MF`yj8RfC@QBeeQcMv;P^w|l>0g6s)qk1xfx$l|L^Ge!3WY(P9o zsfj<4Fa25PWo!PXOuYd4r5h4;BSyC*Z=jNK(jjA?2jU(1$<1yyj|ss#(g z2J~mfmcBwi7nwMLfcc0ZDQ4U5Ct6t{F(SIkjvz_Cv-De*BF2z|26ljNiV@*BIGgTd zIspvBgQnQn8aIH-J1_sxneNteItw-ucTWvaL;BMX1b!)`+_mrz8?iGhv|@_TFVS_y zrOcu71VP7phqhZbRhy~gQxNyhh1c&8$`-eX{AEHXJ01B}TcY^hcWl+`CWUux3iH_% zDw)fk6jSa}V9gQ}YTUnlRO@v~aWr>goYq*tjg6_f0MPpuU z`S0}vjNVBVzJe7z$OKv_Q}RtKW=m+0Iy=gs=OSzCNNt~f=h%VS7mtPF6yX()IbP#! zADYUL^`I6CI7r6~9<|~j0gcJV{612=(IwN12HBl(Ovs7u9wryx%i4CFO2q8q=3oBg z@k`vPzC6JktADn*1V!(A2P7Gcx`y>fgf8_J-Th$G+W@#UTG6e2;auWEA$tIIbkXS9 z)@Ul_1XgJ^sX;4o6s*=(*!mNR?Qj2^1Mu)GyJ}vF%V*LkOcQORL%6v z!NVPzbZk~;er6i2^J^NY4JZ0Ay}7&l8S~8A$I)N(0Wvjkg24y+ppL#(kOReV$@D^gWNFC-i0_n$ zxck5^SB6>kcUP{J3kv60Hz2{wi35yxxT^YG%c%=X`*_CA_4NW8@E?u`$&N?#N#+}^ zjItqluRh0wJa2Rj*3Osugi4?@sm#5RQ|$v`@aOs7BS28 zv+Z<8Yjh+-b+F=h-m`%vGZ!l`OQ$Gf-n6!;1BJ5bDu5CF%%160*hVFlnzRaaub$z0aXMfINH}nO-z0_IBcy zaq)(uymV%_MU`|5h^d3g1tWTrsg3*49lD6R5x9YL+Vc@~kDH?;^(t{jcBZJ!26D%m zv|(Jrv3(h|<;Rsz4b_K8Ie=ydwR-oov{0>)yXO958|PWG_PR>f7^TqKuN)#gv~yRr zm7Lifv--FW8l;uQ_=H|tfX$HxX|#*Tx4q5bj$3~w9>g*AJI6yW8uA7&J2iww-NR@> zZnd#oQ8iuDrEx$u>a;G=*wWSL7!2Y=vj>&a}JpGwb{|CdE% z5Dloxyt4N5KIP_K{$_{zU8#oAWhRnkSPK-vMocFsnYIF8r4tC){3S|_u|Y^nMj&TO zQ>?f@DwG+c`4!Y>0ZSZ`?P*F4tdxjm^xFa=C`go(*wy~~%G8+RMq*8V&D(a!X2({N z)4R@9YstI9!2h|2T-`tUvxm$=p3kNR>lRy%sHY#LuBJY0Ku1$3~h=A z99x1#CClYFXA2OZe_5K|vd~wa-IX-)$9ylNSR;p+eiJc#bt?>LHt=Ieu9VA>w{jM|^a@$^c36k~{6n^|AMXc#kVe%37h}W)uwl(FM*RtCvUc1WtxOx^26T3O>wByS7)~tR!0e4Ev z+2I@+Us!%=T5!S|_GWKSZJ)k&Z}-aH?O0vu#KMaYS#j3csUYxo(R7y8wh*QzzPkN^ zej*Dt`FBdzh=4AGJn2-rD8dNe=(2v86U{7i-hqzPd#Q?yYPz!Br)gSyEaG6-?S=1#+twS-^#Qfefy!|?U!qd_ z`-Wv~Wzu`{PFKa(Tj1Npn_-6gd$La~-#6peA^RhtNYCbTs(XW}^_%tT>sG)68}I$H z?)601x7L@>hNMtW_$u_&y#pRxNXvYzI|u*(KcHLyT5ESZcU?n$Lo;JKCRzqYx_?v) z>z^OZZHyh<|1rlLQnUVy`gP%bK+aHzB1lT5>eo|$;p zJeXlRk-rWSE<$YDN5xlIX58hW|Zc8q+xQQDA@i=*o>trW)Lu@U@jk__o+zRy(qY?aLxIYP2PX@%(l~`QcG^lD$eZ zYV=S8GdOzeJC)}u$n(0G8nVF3KJDxM>gh>ZAhHb0suOFK*x(bpXwikt`{pH90d1A| zF$Bu?t-`ZBn?2*-!Nxza{DpqB}NLo1J!z33@NW_8`C8O$o z0HD>vM=DDnzhIkMv{2h(f4hxlr9PX@^GC$#a2#z!Vx z9b`pzzp6dC0W63MnB$lTczi~HCE#(GO4WD_5w!y#aDO7z`Z)1?n_^PJa$lA~OC!XNEVGyh@W>V8cYWA`8i;(FZ|Z~IG~-+& ziTF5p-Vz^vOl65TR}(7)SYCu9yvXIb#~!pVBUkV6-31w)mEF?gvb#OVKJsjih_4c& zCeGTMU+t|6SsoML|J)&@LYRU0{!CV%fA0PN^_~CkKEe8jlY|aLIy)HIQgara&1@ZSn&fPBLbqU6VZDHMR?J&EZC5OS3 z(cW`zo%cEF>iC%B&>f=$|2>A;poa5`SwRuh$8K3D%K(I26Ukx*wmhS-A zND9QjJ{|A;%WNNngv5z+(J0bY9ux_I9~2seSujHQ2!EEGC%lsoErEV^SI&;Z=TuSk!b)oX97ajLaPjpK<>DYDbC&J4QK07P+au$~= zGCTAtsi6uH{UkMz&UT}riz%g7-^=w(jO&^}Q7O-mmU`N6C~Tb+FOAzHC5jb1m++;w z$R?@QVe^Syf$qNHmVaSUd)>t}e6s(K!tqIvI01P&(XA%9WirjWVH0cO7>EXFT>QQm z3vP5bjxydsx#lt}t;5*Fh>f}GcE~e-XgtyilvSm85v;Mob~?7gazOwfo`TdFy<2wK zl5vN)a{gtm{S~ix-1?7~@5l9ijm1+UtgxVy(#B`nM*RG|lEd0|N#*ON{CNrfxN5X0~D+@`v@4m-VYo;VVan9GibVrk8_FCcTS|Mj; zbI$?B}{ zkNYvae+vQvu>SwZm$8$+k-n4uzj41`YO5!1u>J?j(<8^s%i7|AZKVEBO%={cOk${W zv0BpaDf$wz;38})itp#kVd1!aP#|IgNPCic{%|wqZr)il<}V%Ul^>F449@kPc&gB@X%n=q=hhLgyN>5Dl=1Ixw9B)uR0v zozgMdu{{o%A2;RW2e<6N62S-uA$&pSiwO*Rn|jqp6t{L2oc_-!saZ;;2b`bY3#U(z z^M2RazFB%cJM|J6Km5xDsds^S)h&!#1jV-jlvTVDar%p2WF=zCnCF>%eiuzTRv!rX z`cH0xe?PHR0K_%~4DVi_NT)v%Ri7`qH9T)TX0F4Ckj7vY>^wWO1Yd_tu76uf7if{)fd=V$9$5J2a?q^>05Euik6k$^fX z`Hn@AA72{!cc-Rg+{~E#Fn8Wi2mBp9yi3jse5x%4tt`UJ8$#__5!9%0TQ1)28j+dg zHIEnI-Rs;G(I8$U{XQ?^@~*E3V72+zEc<|#FX=EAF-B-DbG~Fc29VX!Ffr`)0J_I& zh&h--^7eqLm@RUUanZYc5cco0-@^|-IQxT*)4HS*-l(xoV2DB#b~^rGx5i+K@d09y zKH#zEW$~!%u)UrBzO_q%xgVQVB-knCB!2?b9ki`kGByILun>aq9&cbz?@FX?&fOi} z*b`pqfEaKMH*^D_v{wH$ybtAZc4Ls>47P#Oa;L0s%1^E@!alOx5Wsg->5&jVG7GGO zn()vC<$Y`%S#KK_0C4wv@w{`Fe+=_rbRyhz86QN zCwVL*;C)5_BaLCZxLx`?o?rTF zmoX9e$Xp6xT*u57Ct+gBWYot43Q*w`|7be>rJ`pzF1E>Cz3+^Z-w0?E=2>J-M|_LYo@c;4Dms)b4!p8T zSCpc$AUIOijC*FH6dCk~ZJ+(u@aTYGo}P=k89H$d#f(eOn^2+zgg$8bg9B;~e&O z`?C{3J5l5^n%&KOoK!9N17gFRX;8f$?BfL8{usf$9PKp`gK=; zOYpEuw|Bko%R*Bfmng??Xyv8i=M@Nzv&(D+l5U*7(ecIsQqn>%G!u+b+#Rdp`zavX zF#_$tm_BWVv!k&TR-Ss-U%OHtplNQUcv(JnZ~A^Sz}ifELTJc&3;5o`asq;7j=ebO zQ~vVH6f$dE#@B}vRbKHVSA=2r52u$%Vzg$P7+J3Ov2=ouDcyC#-SG2S9M#9m9VB0n zn@Sqd#VO3UB}|n?0qk7G(#IKhGLuvvKch^0&1b*~`bAxl$AH{3Z-LNudHK-Rfgrrn zSnyNBadw8bkHUrCE+hZC%Gy7QCoPokg4bsZFbNoW2VqZ6k+sA0%%lEHpm1Ly)W|Zw zjxu3Y&WX=5s=pzFN!g}hGAR4{fcz8^>!%vvrlW8zE5Wd+lf9=sjPY)y6c9vL2haHH zo>*gqt}qDE>Z$NF(}uXYuYwRAzov>(GMS)?f$_XTCZZ2;lqwj7$0`%I;v*$tQ%v;S zsNJGk-8)9qm`o2prtf|}6Sia`(S+YnD?-`RTNGp>vXITwWzRm+paCc)I-M$FJ_Rl8 z^?P+3#>bRh$jjC0@`g78u2&G9EkSc5yRr$65uBfXw@pi;q!IyJ>(i75RPN7q<=_=Z z1bk99qbkWXX@rsdR+O*qzoJK=6`!<1NmI;KD2C@vbXa)N$4{rQh+Jg~&w_F}|NClQ zYN2EIQViT*7_5WsFaz1PDEDCxp_qElJSj)NEq;SQGre>5<8v@PF!n{CXmc%>fx2;} z0uFuiCxxWXeXMyop-!Mq$Z`fU6At_%+C`kmFd6K(ID53%m7SMS!A-@}%4HwFOw&0N zSQroh)!QX#t>W9Hv)>JRMa1+*Q#LYAUS2&;nKPxCsT`NDkdIpJFw@B z*cpQ%Sq^Eu8s7@+y0h3`KU~q3$;#v`kiVbhKE>o{;|s4-9_c)A#AHhTRk~RV_IoId zu4cayR$V{{^f#C0=vZ)KBt?{&Jo5M#w1B6U0J|cy>Q7>hS-EurfKB;`I&d?I#Bc{Q z^UDF|H8f2cbiH~%5QWw-?}ojA86+4Bla=M-@dt|p`TT=IYwC4 z5TYUF_8ATfPq)-A$(TV#f1^oefJL9zv(fs2%vDxRPdZQevC`Qi?*phXT9!&FL4z#k zn`#ad9i2V2{WB9M;5hw#ZZSr*?J_I}rt-@^hmEEN#f~B}AQY2-BSESLX}s3Ncbghm zB1tMyMn2B45bUYDQYx;2Fm$7|z}(DC!#uVn`rB3RDD)R6!~C&`hHy@~Z14}CEwYE& zNT4TiQUC(oc^wh4N>P`zL?~RM4pa7^ z?o{2(DL3L~akPha(TuWnsBXFKz~Lehv}5{Lr0ymem)+((6@1HN=AbJSfvHdmY2mrW z5hOzQFE6YY5MnD`^e~{UNZ@BE02%kb`Iap-YUOIvdf`2noTViaLoN5~!G>y6#I8m~ z0tJ_*vwr#hT>Kl=KhK*1bFYEqa2bVyiJ?YWCaNoclg1UQM$ep+HSMxA@KmeW02V18 zCx%HP=?%07Nbt%korgL7A#Ps<9pGgG2xwpV2Bo4k7f0VLH-lfbICDmIm1ZeqS-t`b?biU9=EqM9 z44B5uEu>q>1C`>rY+O1G*F=r6FGoD z4&B*Q>zm+Mh2s#^JjMEy6b+=}94n>13dSl;_`E!R3pubfxa!6HH>xU)Fh=^ip6g>h zY=_~%|5|Ud%B9soOX96s2H}{;4nR;cYano65XhW34N#=}W1P`lIQKf?gT>%LIm0Cc zbV>(*)Ai3S&7OMtbh-go6E~8=!i`hHi;U3DpDP*G*+tI!`2)7YXtJr=?y5i@#n^&d zPk!mkz%P5e&-(_|#7uRz`o|M@Q`xzyp}Zvydov3x;uA_qga_~qt+Nz3!xT3ME3wnO z^`7wH76!toZM^wi?}T7oh_!o8)}WvYz5&cqCquPVm%p;f<`uck5?mchY()9Z-$A)y z75NXajxr@QHycbVLIzO=(mT@yUF1`4uVG7uq#zLte`HB7(9zTt11-=+A))q^mjaSX zLt%9(?MHbx_LCffZ7qex{>t4Ur>S6__=WuN9gYDC)I6!uE|Fu5bj$Haxe*pLZJY?_ zM2U%Wn#O{a5N^X{;?3C;c_zmknlo);gDn`wz#Q+6MOZr~v~q}+nZK6i8IhFmeP}s0 z12R8vY@G5Tx{+VZRXqKIdiNU z%I|4zsG^coUdd!hHO_M%xbAI+AbGM?CmMG|VGP~_1Wq^M&GPwWZBHkV7@q}brUPxb zbV28nj7xKzQnM{KnYx8c1J+$V9gz+VjaeAE_L?$55T4q|l*Q2!d@g(hb;Ay)}E zLJ#V;7<>$!N~RMf*TLCxBUOjNM1 z--Y#+Fxy>myTAcoSmZKK!ch0uC6FsAqO30zY&pL4u|h6}^~8BZ|a(_KE8 zS~cbb_)wn1J9|er#2Et=@zp~_qJW(9d(j~EuE&`}cB-?6aC(;-ORMV!0y-icw3{9s z)=E7$(ArUO925Vv-`)e87BF1Vb&uT-d794j4aKv>wrPigr4lHj!(;3+dfJhup`}M= zcrM9iiEAqEdI8>d5$5Ob*ml(eWJZZq)tL|yiOhhV0{LHQq6MnUu+hIrqNN+jVc~pj zwsz6w=XZZc&hOf-!E?YYN7v>_Vis(cv5$ZB^%Ec#NE{f_++)BtY}sIyqRZd02y_-> zfZ+yxenA3)0h^v0P#3S5$p`+LlI@cx@dEi}?h}kV7`rDXc8h4O&>C7T4&xb(%dvKi z-gZk*BVk)1VLM-Bu7;UDrgW2FbYV;4T#+!JCpqF+w7h;Hreog zJgUrJ))9wPq$^{zi6P%I@d>-KSXsSX9&u{gAaF6qIkyMTsivEZmj!-_ODczpv}fF( zS?D}NXY9TT5^G-tH>GkEqLWmcH%$mOc%leqwf;I$dox)>p-z?$Sww>(Qwr$(CZQHh;9osv$Z6`anlgZmX-823F=K5y(dg`DK>a41M z&$`#Wz-$Gt!&Jc^2YoY7fHu^i5Lj{#Sqntk5X|*>x_GbzkO#J-&qc=L>E+pfD4cMZ z8YTU!>$-X2nzCMWH-BEUQyL4wkaaBSn7^sBNk!o=RU^-UPR)b&xN!r=J1snJjdK*p z=joHn;?;J(_PGzgSzkAH36@w;cWA`ScXB1Qb{PMd2kM3Ibm34$N~F3(v{fsJjLCUS zK^p{5MQ%&N{_wy(ygC;4V9wO#))RUKRM#+SUT6W7G&654GRoVOU;($(Sbz!mek7#Y)(_&>O$}1p&?hN<(2Z(Iu(b@WqoH z<=aa&j_1+h^R-X$d-)7>r*CXI_;nqPJlZvS2koFCbR7M>9=lTCjoMz`{QfIl{p9oR z7V$$>FMof`qj#uvo3odkQ_vVWKkBn_438pm6 z7$Q>H*EYN|*;gYa`;o5}cs5&I=No+#VVjMgkZnDO&b#HVkLAU~^vLE=prDn2AQujk zvV@TZ?Gnje8O}=?E=%gW`Y&v%HjCN zl6;~%_>#nf0W0zgMg}r1CuWJxWJ8A*PT;=Sz@NP%wM2d;3XmaoB`yEhnsrbme_sm; zSze$BJ`a?#6Q%4DR?soRYH&0XDAZ3c7)iQGu|L7=YK9LdLsHOrDKOx2^AqU8^;oLV{@ZmhdBcM1vB__ep7Rt zfzU!ot^EzZs7Zl|exOp2RuRejvBxK=i30nRXnL0eiqK5!$F7SBCYwYE%o7j&ua{OFIHe$d@Y9WNZ zzFMlaU3>pNbRT@L>|VH6@wV6UZ|i8s z1Kc=?_J@(br?=d(((m+g8=y=PA$IK1K%1=ICa>S$H%{NLy7s-1GO8O!NzY&EB&r(^ zdh8c@vtOH?cMKGY0vpvoDYkG6=kJ9ly>7WJ2djkh@AOwe)Fl`{nb|&NQZOUTY;>?H z8EsCi)s5?97Oa{?nA~ShAQ|@2seSmQDE)p?f9mf!r>LIUW2Yv1wl%01rmaNw@0ZyR z!pV%nBSk_ru2K{Uy|kw45s64sTugnG-yR>46%zx~k|K|fjF_&~bl=^CfF-z>E!R|% z6tGE@`>`W4n&{1}!wVn1i`%2yRy?}LDgw{-1{S?NTlitN=&)9<^ISq=n@ZMeiBPqL%;FDr&r(?s& z!#VGL#m4K0x|ep%%eZ*+O%Lt9rkgNgzwhpaQ}RpvVB8u}FlPL4k}uMZzH`z`RY4)k zWY>BlLxrIFTqF0HJ1fkT`~DZ#>n|v<(@*dpM~x={0Mq{~f&XuR>VLR=T1zK8+e@7- z+YMF}-;_TqzjK^L>YsjFtq`uxql$M#p$73wB@YObfhJ0x!+AXC3Mrx-twvZ=)t}!O zT1hL+bL_lM!~02p^I?>LDp=l_+H=yK;HwLO4+tcxiS&;}3o>P;2>uO}5oM~B5M{hbBiqOO`0SRy-^wYMv*B+(Rq-n+KbK6TEgF)E9D{474_3qLY^6MF9A-@NB$fFW&{+&| zY68g}N3A`+Q8)$Vq5jzv<@Zk_*fD#>Un&lM#L+jnZy&pd)4o4*M6#(e_9K_j`gobz z$QH^V;Y^x+A^^9kPna~MG7+T!m8gwmtpzP8n#G0uLu|;mff(R~XvH9)_dR=vj#K(Y z&JRzmZth%;<%A&RyQ5CMRnx`v^KNMl5h>UA3nUVtDB9VQ4APgwa9eWjLh-->x1-~huBzNi+=`_oC;LLSxs=Z zlbI^|Fpp5)(pxo`TX2I>K#3w`V*p&hfH!U}RJ&o*0aEGd{Q;0qK^tDAI54D{`sb_& zt@Ax0sKUN8%lW7X2N`f#0p6qofLSG(DGZ4yNhB8dqXJW)zWlFbW=P|CkzX4(JjE-m z^6nhvKwj#hVHYCBXMh*}c&AsgAJ-lJwK`uykjBXTBbtnDKT49JIK`YJ0&mWpA3##4 zx{C3-1OClZxOEnX^*HW}z|Twcn5OO}$A0Y(F?pcnY4q-R$yW4=cfStghF<|ZDa(En zQU+J+I`!8(-51z0y<;Y6)85yE5PtD$P7G?iD9S%d9S2Bg1$Itg*$E*?>p6d>?TYDC zXnieq(oauG10r#*y5Q0B%SN0nd0w;7m*7~^Tc2ReD>KsbG&XdriwGN!&c61zHUYb0 z5DG)x1)FQTC4x+-ln^=(i@67jWLzGa8C9 zdp!6rW9~Af<9JV!hC(bWXlUaitwX!R>r3HhdJn^T(FhO2_B@x73O7EX2m(fq{`_!4 zl{s1u{hEHA9EKA8Q~BBmb@pnO&nYb(@gbf(R zzC+k*)fCcTfs^%CQ{OrC$jV%7Hs64tDmJk&9Jj(xuGR0S&DDY7J@aH*S&z0MRcRmw z$Uzc>Bz|VeFcSaRGeg=JL{RK%h>od`4adJDi1s*u1HKvQrAC>t7M!JL>80<|3%;;4 zfF~au%X87i4m37YH<_y^So@jl#K+i!UA@^(UY!y%374RO9?!9;ad!R5p`Vm;UO9Ft zNkfl4r&@gVc0(+qpQE)7-tY1yO-###u?~64+R@D2VWkci-T0~OAAu}nRq54+4UuD% zWL3c8stz)2V57i!N8#s_^-U4E*iu>Q>)aLd!@q;{D8u2jXab*EXnL5Nt$Bdq#J~ah z*JW_H`(WAtXR{W4Jj<_@yyW37u$LgT;znSD0X@25eYNn7>*BGlpxn3eQW$QI!Zhh^ zIk%VqN7x5|k5}E;RibYPU7N&anzO4%B05T@n=esx9x6_ieBP7CO2muNXjBNDiG@}E z^gBdVKaL!%#bHLGI{z8*C3@SZb6sLx**YPD0_ApbvvDCa^tsty7h`{F1JC=~Y1BjzK&c>ogDQ16@?A29wpQU>N*)1>(97Nt(AKi_O+{xrX+Dy$ImgPDfm_i$UsNRhMHB8KuaQMn+j>|Wh4 zJ7JFkj_auh<;zSQCqCZ5_0_=j_OO=J7>11-zRj~cW}%e0SF}nv#+V^VRD$*Qxu`pm zXqGVWj;qV0x7^WQDysgE`O0b)<-8Ar=b6~kQ*|4WAOBQMt3{i_RlCio|3!KN&Z=~; zu;wuXIy2_4SF1*g3taP4p##uyzBVA;V!7`yt7G3+M@?FcKk`JFcK^*N=ZlWecBpEV9n*_+eRLB@RTDHiXUx><#-%cN z;vP*etymU*viTffI}-?iT5MIIt>Jmg#DUtbD{a(Y!ue5l8d8{DJ}2Ysgg_?V6Pn{~ z_~TpcyfHWdk_HEwni(=AovLGyh^Gfys#Hn*JTJJuIu#yQ6%tow?12JDnV3Z`t;L{W z1=hMs<*(y1bW+>V-E4kY8gYG`w)9`s<;q4f$06~08U>8~BSp!@=+4?YaS_sVt1Ldg zKFqn@JRR9S?lk3`a^IhiGjT&>Qg5Cx?6DK=JqRCZbGK3TfihV$xb2JXV0=P6zoGl; z=P{q2UgE4eV{3FKIIuTA0RHQ!Aisn9o%f?`A^OSC`0t^K|C|$4ek_BBtS}$wIo&qh z_&yV){0IbPhlvFA`$b6RjxM3(`~n~URm-g@PZs!)?mW-SMK9t^q{K>UcA`?fmb#l1 z&BxiXOAhj@dt!NK#yFLN+j4=p(7_M0Oqd2ccsQn@Om66MY}wK95% zj6}noE}DUaSgza!l|cN40i1r_;BlHWLus;kk)M<+$hn$SN+8TwP-V!Vfh>!lN=U4f zL@+P#r#NYF$BtR~{;LO3t9U{zwUy%pj6)qI-a7B!nJM*5K`9|uck*qAv zG=dspAl-OcddwVQT4he-vZUjjlpwRnucQ<{5FF@(k@pU38?5^ySy;Gpols)Vzdr() zIe-O{-!@(dX2+shpv4Pe-tY0?(0m~QtGWS!_11t@{(&%r36u4I%iA{MLg6TGx6j9- z+I$hEI_FruNrI)4*FZz9moSsTKrp+8(-jzi*Mmo|4~-^W0#(y4r(2<{zhfF-#wE;0 zyg%`|Y`k`MIcT&qpBg4VK>_U+`E}T=?weY9r63!to7cn<$3EapSZ)lyZ;DaMx)L@ z(#f6%UNSrka+pWrT(-=2*3inzYq@!t7_${Cq9oBNdVK%IC#ykowOc<3>-!`WX!@#Ls zCgpn@(_ zkzayK%MirwxuD8zgYcVK(VKui^3~5KA;3N%!bqeM;6|iG`)l*a6d@jHxM3)k0&&}k ze?wJdKoB&LJz{$87D-we)c5Sn3$&KTNeK405DB=X(c|GPV1j}UX-EV!O_>Q2f%-y} zQ#qW`4F!^q;tl`e#o9#=PimnDX#zbTRjsJW$3_R>4^v3DvdKR?us8#qAF%`#@tw)1|FMI2*Ula$-26-E`E&M*TY zY09=h7g`r#qy+_OBpf%BS8(3xO+Ct|1;!UPsiP(+9BA}#AQW&PGgUaBWz0Pg4fZ?+ z{RTuqX18`-IhBGsN_rg>fEQW6W~r+Zcl3mVv4k?;VNwzBoBzJj1b9!+{Xxn`7c*s@ zfJVF4Ls1{9v>YvABilF>xH357kxI_q@1e)HDh7H2PG||mY6kw4JkWp*YxML&uj->n zp(nj_PSk?~YmIHHg=^r~gm%ah+Bzpi0(AD`&W)C>r_l}SJFe)8A4H_(J$MYl%Ro;M zaUW8xc{x3QO7)w(WQ3Xgyjq~7dNMQP(nS{e-orR99P35FJ&utQPV|CsQKG-8x8+e)rK+_3yM>)w5{bC^+$NXaa=c3zr zUGvw9hm6~`36_J#){*DbYqFAOI5;WYNT&Bxoh3xcctl6zvLB&$jP$NqKvJ+|`ZHd2GW7VDgI?1hW1VoizluY0%gQb}o z@D|j2?7?W@Gfp=SNy3ytm?0&A){+i12$E4VD<=d@i?Fah|5n|*F3jQGn3d67 zkVe%QALvWH0BqVm?3$^%#G=a8E%s6eFiFL`%wD&P5bN3J!( zZd4>94^x8h{x&P1XLy@aBlbku5;#3qQ77`2R&OBSeHkf`U9zdlu67}K%1svf!L zKyaDSt2n`nV>54-ugksGG%?dFyjQwKYNkNc7EJcqMp(Aj1yoYYrRB7P9cjc@?kAZv z^9z&yod8Af=QaIPD3+Ufj_JWd4QGE;=;uqs@i3Qi;q~k_MO0mZP;$t;smoFn5*y(S z+Zi?-iCx@ZeaiM`;#=o=jjhhO`fY;WYM!-%+^xb*O)eBn(`B%xCG~Y(L>^8s7Wzg@u(%$mqqn3ib`*Wrpy%m<@$aiW-$$iR4 zEz);u^(aRulunQ4?Dmhxd2=%1K~c(H;-=TrQH&7S_u#zK;HQ6h=bx!+IM+GCLTj%s zioul@?l`W7?MGcV$ol{t@S^;nkR>1g)jg)wi?`|d!T_r;E<(CLaIu#fxU|${8g=?^ zz%ytu_#(<5)S%4iDxg_K;h9hPCJ%am>~n3sh3^l#-VoQIvf5*jH$8S}?`0wnOGE0cFNy#<*hR-^yr;BSo(1 zr**3H#P>!{$IM;LSlKz+Ei5x0H&h~)4u~3cj&EDbZl2UX-)1)E9UsCb_X-HN4sjWg?4QeGSH!lZG%S{?e%_Lc8Y92`?pC!j$0euWp0`AVQ3vK+i z>j`6raLZJx9$s)#i)_?_vXrGPA8~6IPC$@hQVNDE21# zpz`mu6ICV~?WoZR<&JIn13`bPZ=G^9y&c^c^m@8p8ob}Ll^l@0-wtZeU+yq{c)@dK z7E-it4%wbp%A5dCEc48f(eFvUC%klrZ=48)tZiO6=`P827*0n1MSu=04#s!&!{`_M zSk(R>Vaxx*=RY>Je9`PvrETC*xby~m1r=mi4W9}3-S}3^P(6#BhSn2$jV{72Z*7ak6sU<|9id`V{ zbs{J$qZ25PN^K~?P4$!>0(kz$2f`i7Z-i6IGmZsc%%KIO{((YKQ44ZQsWFrLF{y25 z7$?~1OIE{^Tfy8lHop}^875aC`51`Dr|iiSakL7tl(8G11H}|nN~?fEQy}q|t1t*6W#ZHq=VmA8Km)0i#Q$oMZw{88HifSA zyt9@vgo3O-bQPu{D;8!kgX=Mew30@nae`6Q(a$D^hgiN)pb~P{<$}{Z2`B2Yp5|C9 zaiAVohaT0>PacH6N0v}ZnkW;ZMJYRKYI1`amr5f<-|#0e_4ij z`P?44PE*0Z4x@_;5&MhZy{dO&u@=gkho)P$A=s5mzd(D;3?r?@7jvEZU9Xc{YO{rw z&GqGWU^2MZ`D#X8Ki#G?NM?8LgYS2BvJUTs=(^!!%7OC4d;aS+$$_tjS1)$;pu_Cu z#*4bHb-f0^<%{0UC4={Vl?VMhUBNBb$C%EU2c6HwOSKc?pKQQn0ho7&r7q@e&>G=N zMI=d&ItNVHBvx8UJ=AmMig^UDrDu^F{O8z^G@tq}m!EtQX0`}1E?UYt$v4`zS1fO|g=4YvQ=xZ`B!;%H>z^na53YwFqkI9G(SP!RG%#Q#eUW(07KaqzjzI&!xhnzCRW8z+lti)_mLqo4 z;qS{&%?}Snu}(n1bqFX`4^idRf}-_*)1J;vb)cVNe5GsKq)B>C0Rs37l@J z<<|s{xP0*=)(M^u+mr8zi*~9#_^bXHuwsL@L?z|M& z3i4%cV2yr2EMa|Fre}Q=r)C8w6zBMY4Q?d?S9-0)G`8!ivl8Rz zm%zX-p2;Yp7qyu;QzKwy>)!#>H@G_wVyR9|{^mlQVlJ;b3ikz8aVWnmPsivm{rmt8Z6tlEg4U{jRawuM za~D|7PjhBwzw98d`YSQE*U=0d%6PkD$bHEh-;J%xcl-B+B8R8dTNpA?>UDjf>_F#M zw}6i}aQJtDdjo)?5T3E%pwQpsDz87Vw+vWu4v}tl2fa#B7V0)7aiq6pS_Ube4ECzGja z0U1Q2LHRE3vr*`eEhn0XEixdlecq$>W&3#P7(yC?9aQA$Iv!v<-?tMpG)*nOEO+!3 zL&!_;2MvMPK86UupVEPep0RTM;bqK|G0t3tS#X#s#eLJ3*UnI-f?I{L*tOZlv!JT+ zps^`N(=ftGe_W+4vr0H9EI;YdPztn)gq0v%TpO`TV?PFKiRswk(UVS-Z^=3vQ2DILIp%Zy)zgS1?uGU z7A~HSCkx*S0ZR_;PRTgC`H`&1?{I?wQNMi3Ig~IgGD6S9&1%{6*|w4Hc%>zp+#7)f zFXvOqhQVE|&J3Eqt zwE59I>)u^l4>X-?q=WhQlDJ4}Gw1|3D5>%-ImPs0;9OEqT@+HAR3gP(n}Lu|sg$uliGAoSkmSfF(4-^3^>FD@ zAQ2z`BPp*3RamF=M=Eme<@yBdk;P9!^0;Dc&-BQtP&d-+m{6XG!z(9iT}&gMN}iK3 z#~X-*F8~?1;P;XYF(e{V)cO6d9I!m4tH}?zto*qs{`;52f8tD=AH7Ji=udUfvb56m z$d(B*MP#x{g9Hj;+GU4~ATZDyS;0w2f@Ar-Z6PM=qpi=unR+ z^@c#Kx?hG?b|NdYlu>Yswl)M*n?>HRwhFbT0TyM?hv?^L*@tT{1UD2Psvv@3L{X;I zLVrb23_FG;WA zESOe9phQrN(2p6x>bkEyRHj@SRqhhuMY$P6yH;*eu8%AYiBJj8V8DtM*w5edj8s`8 zyEb~l(MAH4hzuY0OxcZ>6`&PGZ2?jdK25Nx&g3>Vi;AiAe6W^Ch2zj1qv5}QZ=4%8 z;Cz=pR}EWk>0qg`^7#>mkiBwUf7eZLPJ%B_blEj?=9YbS)p+vCvC9o2!_Cm**-ms_ z9mvyC+1>kyY}3liBYiDP$K7AD%83b_I2dpK1QkT4!K|Bl;n-!D<$UvcADIPwzBN6K z5DCm&(|7kq>*jOYj3|m#ZHN8Trz;h92R_UYe6|m##C`FJ1|!yjJKYJSfPBODj4ZzY6sHfcv)a9TnqeZ^T7e$kQzM8O z!V1u8Rn0^KAQ_dCyb~O&C-3n?=5Q%~eHS1(p{=DR>D@p5)TUF_ep=<Y6ZNVIqb2pu9man`mHLfWM^YzXZyeL z+FDva=nmzJDlnIoAZ30cl4Sr4goYDEtFut%xz6wh-bJ=c77HlaZqa=EoX8gocRE^& z*zU9Xl4r_yWnX7sZ;~JFu#ZVrx_Be;=}n@^d5rBBrK7lLKxr~y94|>zD&0vGrl&TK zr^||yRY^jnYb=(kMl&agE6h8s{=M80V^}d3P1$QASzkncA@!1*F&GF;&0ITS$IHV| z8K`x~sD>Hz=q@K~;4VU@nmZH57Gb(qhZ^9`JW=j5wcH9sXB-j9NYq3gf^yGffkqt3 z?2g@k);^?vT)|Pr8A_QvPfVbgaU$!=wzXH4lg#CyM<>V~Ep<#{pV)xLfkDrvF}-Ly zR2~qD{6=Of8?SKU3L=q(qdO73hmyy7wB7dAAp z!pByhk>*4A0PJ5yMhMZ_Sv_Dxp~F?9rR8sqv(V5)vbM8g4~@Dn*5Md#=ye)Ep$W^o zLXYLxq}m_$I09|gFziOuh7zNkwGF}8mza_o2$AipFu-w}A9JEA$$=uUnj*leUmG|8 z7p4m?z{l>^^6f4*a-L4cJ9Rw= zfp)_tRgpUEpT>CWTLL8rh9ZPz;TMt_WpDjRDva?S9`r8nvxh!BPnB(_luZ&bm^*=I zWanuK-Ud~(9gB*RHSlD5Ws+Ji_u+m5eZb#CIqst-lp_5>J{MC`v1 z3-c#v;u$b5iMa2d_Xp$4KK_dlNcm9U;D8HEE$Fs~hY$qK#OJs1f@5;hZTUf3Y>?k9 z)XgvXqXJ*CLC;NSYsl~69kGC#XqJ}luGU5*{jdmc3hX4Np5n-^A4HWdq%Sl)y|Dfon8Kw- z*qP?5t+0eN5p(Y}prJR8f5uNu27m<=dsjbRhRV{yA=aYDFu$quC`>@=`cY&E%NgeU zSAZn*Fs6y#rHyD)`P<44#!(SIN-#|y{$MsV)FBNW^{V5D=-~@pk@-)NSnVbEGb@~D zql<$5M2{_7!U)u=;~XexdZWsg0lJd{eTi%TrcGuku#O#zPMY#-iC|YRCqBl3(X}X| z@!%;}>x2XfEAX7(+}{vP7{XtLdA)I0A4oO@lj3LzzXY#8NZF#=z&yH=f6XO5oj_b6 zGNN@itpv_}9$t0l6~cE>7ePj$Q~}7~IUsU>6Gm*Cqjg(-{M7-u#^yKKZJ?LI4u=?% zOm5r8IhkT*v;Rlf+sj$ zQ!3~*W?ay!7rMEsbD1)JPPKy`lFnsC9d}ii-vx6$|7f$5bm;HzL{HyZx=%IxvE#> zhE59BJG&_e{z;U*svEC^OzsoMu?FYFKCU@!_giOz{K8wNV!_BJNRVwdi|j{b zx==jgHfq}$d6+W?bn;%!uv!pxIutLL?C_Lcxtw4uE@DIH``fdSrua7Nl|5v|PO>bN zRTV7Ai5wKHbV?6! zccVucIPbcxscHf03x}e)K~^KzGU?zVwcTQ3v#G`joxA4+|G&x!urbb^K!2d*<&T`; z|B^27|Gkj@HzcW3Tl9k@>9`vWdid^^(~IbT5N>4^?ujsde}_=J7c+~9}~r|K?+mO5>k#! znxY&Kj4HLjngZNd%kkQW=R7I^?wtSAd`_QbEqJRhk^jGdAw;djwq{CpjjERxBZV}O z2n1N=Eg}E!(*O)bhe~y@{{(t)stT!sQGfoD29qCQ>R69zKne~1?Ro@S${wrGR9eI# z&Qk9~eHdij$RyM%7=hqg{28F5pSH6Ui*K<6njFC-=Lre@gOdySnLI$XtAZ+7B2i+i zB$rH#MTh`PQ5sdi7hj8O^KJ zAfL!k4-J(KsN_Y0@z#bb$BbNCR?92JW>OiAM(4%@05kdWobcXmF`O)gsWp{{^)LIWJm z%WjDqc~xI`@EWbRRz_mJw_3*sU^X5x#~k^3x&x#5-L>6Px8(jQ`ylSZ;a1zWX<3l6 z`L~h}6{ODYEGH#xNr4Ls7-S?XEsTaeabd${ONZ8K4C!=Fq>pFMj$P+YnFBZv?6MF# zdYCB>tt)Cw3*!oZ?=Cq(|FJiqOpby%G71K2FV~Ahl9YftMy`zCbw2Ng!`U0AnecO& z_a~<7zkia|B8JUq_qis1V{!BW1V;AjQ7#UHvqpYIh>Q^l|Ch34o@O`d&dyG>@ zT(zd8HE--PNH;K7GskQ}0@iMB?Qp5ztoPoRxh-w^%KL5kP9oR#?q8ps=!NFtoX8fR z9skqw;Wb~X^&Z1FT|3Kimb z$!Oe(KHbHK2}pqS!4rtf&-#sVQ-vpwxs651Go^oZwPZt6 zdeAUZo)~BJt5j(&1(qHMI;+HEyH7suGzO0~5eYIV7Ar1Ey(DxKv)$C8QjjcNip%vV z6^7=mB5t15-+4Cb-tEI`Slae{?~R}8}o-_MzYqRYI;9ECpjo7EJ+-@ z7~5&IdVrr^QHQEEZ+detDmDNQvGk(ukC;j%P3TB7)@T&0SDb#mqnT0$N;?ro zocbxkO>rC=;!HsWZBF-cf~wJ%Im6Xwc=T2>?t_qN7tN--N_W{3CY4WXQG>9qJ(Dzm40wPBlM~jV@F)uDU&s zNu~CXQdy100(S`jKnnvm%V^%se3S=sgSjt&EDg{@_^Wpz@F~Z9Fb!Vw7m5Fng%>yw zP{asW5m7~G135n;3nec$$T@^QbSL%fTzK9P6>Rx^7&kw9Q=ll<50KH#)3*iw8aBkk zsu8XcF9h0kK+^py5)$&5);3?~*(83m8AB1J+|(JnJRiY?01@AK5PASC5>khT5Gz0z zJ9}51Vps}?)gG53JV29rPY%1jn(8K1`V~Z;HA&x>rqjl~@sP!^gmku0KSlT*nVqD? zK*}NScF7EXPea~hYD{VE9%)vokV;s_c?-J_TBPI}@F#zhzp+2)iLkRwhF*NjQwe!) zS4>IW>4(iCNb7koBL;pU2`w_iLFNVzK6Fz?$!@~(kK+9JhV1QpF(w`GPxyr1PEDz3 zm*pSGzyKf&t!md#HR|1 zrEqL0*~_zxq^F|Lsd36IJaT2>G4v)L6VF-thu$9n5a1rK5WVt{yuy2~pzPPDI}DsW z;k>K}FGJn4ygkb<&rcI$%8J$;_DhezR^Iv(7OKP$H^G74jgbWg?=kl=5x2HPqmd^P zkSCUHtAi$3Ib^AgFb%~T`yDVNA0SMF_?d^-b}t0Veb_C#nIao@+j8viwbbIxSMd(X zG7No>Ie_Cfb%W!4h#~T|{!;f3kPXfX{m>IqVKvUW+aExKEjD%>z%&^j=pTy%^QDuS z(wJjPOcS0UpbM7+B~#iH`PLbH1h8P8gLC)B$LvB`hNvzTt5^uv;s$V)6Gw{;<~mog z;sqrDA(_V)v@&5s^3TB%YGGZqcp@KG_U_KY+=mtRS5`&+7HRGs{G%&^vzv`(H@|pp z-Ny$l%BTX_PJ+j{=aq}sM0arRM*~9lk2&nB=00*of!4&vl3UFL%$_y-kIFy-`5z^m zx)7xR*VJ)OM6WU7oEzzHLT8W)zb}G3lr^363ELuRojdI3{Tn)g=fXk_w`|Io4^|Kg z;bFnM71n+7d-*U?$bGmMF`%|j%keo(5u2y3ZkXpY<(`eD94VPJk3r=2WvxBUpI30L zxyRkT+ygS(S1-BC7RN4*WR?zgqDK19CLsnQ`Vj)m-8(nk@sKP~QODPZ+pabag|=?i z^%#D`vqF6QvL@xa3?kKujyZ^QGo%XLS_n?&66Bps=xInV_BdyG=4$I zNBXE#`LLUU$o}p`?(O0J78`7&`_;YU{=Jl7aL>af`_-|`22Qs@I4PRUW14IYvn{EY zKc8lwVP-cB<-X3=pu*;=@Y0fzu1e>RG5XxQ`D|2Wvpb-Se0u0JK&eiWXQNMauHnN2 zMBy(#g2IF7SY!f&={+wy?x7_40K%y6RP}e?Jh3Q`B)n@8&Ti0L;3mqRi^E5O;Gv7L zrhuh3)z7O_W;oP-=o+rFCoT&+-faiA9ATS+EIy|?iH_f!1PaM|G8FRJI zOUZNE(enLH1VDuBaa%>Q z8gZ~MePu`bQ^moc74IOG33FGrSPYQ90*gXedYJPI6m}?fOAnU}b1|Re%Mq+G3nvk4j{3&#`5XS9NXQB>9Gh6yO>?z3)(vL6-YdieF7Se zHyZw0uXUEN%Nk(r3txT z(f@ot)zdMz)-o&#N9Zs^Ycy-Y2|m7gzAsM+^;mCRX+M3|wLIr8+-`Mx-mcMr?&5qU z_rdY<`VJz59v;hQ4wwPf^7gQuPE*Gx4&fsij)o9r1xER)6FTYA9%BM#pgnE{hF_#^ z&COi@MmxW*-Ts~DGg0B4^!YeNM;CRXH*bdyP$$w1NS~}Xna8ao&R%=l&LYyTZdW-a z2>#T{tS6pwumnqw4!eyGQDZrf2>-7zY&#VOG37@tjr=p;{I_7(f6g{4KVg9X-ny1H zyo2BEARs87y$d9`(Laq8ER^h=l0V*C5>CHq;BSr# zV_CXYMS!F$n8g8>PYPz$16gr6;fp4zfVf1UNaYwAc1&w-L5j39Es5B+sBQCMp#t_V zVs?!-0uvf0%-aK6r=8HOanmwaPcygJ9quoavi2CQ)S^VCX9E&GHvrfWFl)Cw`wOo> z%`pQFVbRj2?X=uGD>jD~-Lf|B>(L!ezBU7?yxWXovWQ%|tcJ`#^6C8HfJ?^kAc#{S zV^nA_YSP7I>k8I7qz~Mt#RQ+hCXZ#PgAEU?lV&>)z6C~I(_%K$OA8D$PQjh10ZzSe z;sHq~(Bh4$14AiMkEsr|haTuQCNXhxyI$PBmlZ>UB(E(TdP!ofX}=<|r< z17ZU0Lg{9#1{E`TECk~lEF;mOizY}0Cm}6#C3q{`6DBp(biY=CE=Ppr7`NEsz)JT=TQ(m9zD3jd>cYwOM$M&aiA;;W{0&ua68mKrgzk%x2DPKRZVI8k97Zah!=oad^r&Z0N9oX2O#=C&KkB(_9jNo z{~LqscY9grQsFJ--%9w6XsHX8OJ(E|2 zxl7$|+a6d!ukyLn^8 zvGXJSmTaNj`H^AE>ew^ljfQg0>P|e{Wxq|+hDg@f)qCbu%_%CKUU+ZJnO=2E=b7a& zK#ncS?;^X-DQCv5OIBtAU-7tWvbd@jkCJ?5-8%z;oeWyLwF!%cg`>OJav`>>r>LV? zQ}2c?2I`}|6Fx)5;Mn&E_^*5X4vO4vUcT1*z_CCCjt8^u_ri10|`cMzm&I-W_kwUhj>yzw_7E$%e;f(wP|t21+tBO^LQ{ z4=2tooe{36!dW)^Gy`;A@ZFB}Z;pmv>@rJb?mc>o?i5rPeFw%qzMMUg2DlMP!RcriS@ z@jVJ;(v2#b%v?jBB)K$wJ(lJD+PJRTddM=2d86C7TrrMoz@IGZqyV=N#q)!#UHpZ5b{%gXQGC$>y2G%2FdFrHT5sVg$=@;oO zVMm9a@B~L5$O#siKHFXG$mzRNS3wp@#{p*d*U&ek0;QVkFuce5Yy+7(_PNc zV%BM`4+*Mc`TFqSByrl**v8@Ll)Im7cX_@HFL) zsiTLM9ElG#qD1k`OmEE^%xS@yuZ0yBzSWhPpdW)-Ha$5K8Y=qxW9H(sPM29D+sTA@ zLGfBr9Y&MUYLI(Xe6>rlUI4ejTiMDpyx?7>xPMD^{!3`Z3xv=nM5<-T;bUOX_q*K1 zaxhti^x&oOKG{-nm|=OvkY)d>`K_IzYSfZ2x4An>N#!{S4+l*(BilL$-j?Fm%5X9| zO2$az4LKp1i9A0niXrc%D#L7UY3JHNumK-8L`^%Uf9deKy{3}O+G5txg_T5eshzxO z)`ApL)XW*pyt?xOr%$aKsWzxqZK0W(uWzf~RIpHjdkM&u?GO934Y>{GF(pv*2AXggrbcm|YZTVvG9Cen z55NoH?SF6!SZ6%aXnbE?C? z3bMGFROdrF!d|kGOMjT)e>*slFo#^-=SR7LoJn+umsOE{3A?N4NoT1uWwihrWEf2` zsn50owUOd!Ao5b<#sqxxL%$a37gZosBwj4*JW2ZTA0U3t=-g?7z7U2W!4iZ)aPI_6 z7-%HeKL<{#VBpV!_AkX`RMS8_awSq04w_3wHqrmV)jb7CwuOtLF59+k+cvvw+qP}n zMwe})%XU?lZTr?*>zsY}z4=AX$jFC`$d@_B_%)MJN%fO#<@^s2n57qlVq#+u1m_8Y zzBz)o<(e61toTXfyfj(d-4HASdh14g@IwL4bek zKDZI@|Bq3OO|nk<(mRL=_sSR{&vGKT(QAd*u&{Jgh)QhViMYT;$Zo$5>Xh6GT5CXE`Zp zevcN@TtP+`VVhoi@jk-%Sk6_d3OEWzZM^gD*rsg*>*0l1|;isCUl5yC6pBep9l zfN5cTPkitpT&z2>07V{y*cn1TG-`i%e(7!$0+>3b;aJ~Eza-&t0s>Jx-0h;1lj3wZ zmB5|oJcuA=VCBtoKV{|q%^e`G{a6X9*=`ifh;5K0_#47T3IvOUBQcvydXNCO>>VMe zITwUI$X^H)ZlH`7uM*oQSIkBtiaY6OFGn=+bf|68u89E8G*vAn2Trp*rUxFosIJ!r zzerUVYN7%{JMI96XtdTuSDcQ%4+Roz0P26Eq zw+R88WXo7Q1Q;pIpII2GIu%SU*aC4=Bw7e2E%bThd8g`mCu_+P^(yr$Bb}2N_}N+Pu?>LP zhpo(a(c%jt5@Q&c!`iAMEjJ!3ZQ{I*koTbXpf@trEB>*|Zb$X(KidO&2XO}x3y*`( z!H1xSpl9!0r&RwVuF66BGL{r?8bX!J(ud8;0`5iqUQkgJQ;1pp9!#6HuhG}lC^n%p z6d?~G?*^`3{U34VO97gS`{u3$1(-&iz}QUy(hU=ku{{)CVAC|T6tf6mq~=6qxG#qR z6*n&zDPTCo1}GzGNn#cs3^7^ROl=hG@YF2!)J?XM&TsP<-F0H8(K0>-)C(9&z49w^ zX`rR}X!jl>L^*SGnA+FZ;n%3qrxo^1ESYjmvQuJ-s|I3~tIrB&!^bKUaBUD?fx0@u zTNr2c5S_P6wvl!B@KR#C?nKmK)ewiVl$NX&azZitE3!1}sgVnv#Xq<6XH(H_=$V4WCd$s=Z?-hQdn$W6iH&i}T$R`? zJ}>8SGW$bAV6iJ-lSj*l09p*5>HfjDDNeu2592 zxe>g4B-@7m4f=a7a|2sY?vo-PRQMDh{STkaV8P{Fk%ODa6%MEaW5PQpwqQ%cu z=`E{e;fi7k9LT$(H@#DLPH0O#c|BJk{p(zX++FiAJ=p8RQ~ahrL1?OG~zGGTh+=J zH>K=!zuEUn6si$nG?l;8YD_5T-FgwAGLUVLs8UdAL5r9E>i!!UY>+A^<-$f*gF&=R z)m6dFm-&ahD@gacf#iKoc4a`Paq;w3&+;;mBim?hmN_cM!NxGUVf&|84ahhkxW)z& zC0J|{VlAAMvdp5^)7eOj?-by~&&{_bWqc!%)~=OY4Z9({@KsCKol!r46r)hc*u{#p z@o-{l@Qa)K?%$J;a8shH^np)d zK|r!}fj588%cFE<2+v=HmWso22Q={C(+;qv^%}^#yN-4MyW#bE5I|B<94166e}@W^g-Fn>zi^xdhVXf!R;Ad}&E@m9G>UA2 z!Fk#)<*0x7a3kvczOmmb2P8IFWJID(nv!xtQzUPqRk#dBFn%`pn^#@=?d`+)X7Mir z$wRzae@&caR)*@!(&I+I6((q?ASNpUjRTbtFj6RDx&;`C1yqP_wF=_t(cXR;{IM{3 ze6WyRUirBKq;kcJooy}sb}qc`4g~{?Nn(I~vz+XDv_KtZd@&_Tn|_tE5GY1aD+~$- z;R~O4aW8Ch7_XdTE%LcJ>+Y9vo^(jqqyhF0gZj zBzTnY?r$Yjyw*LNsiYeXAj?n5Sy<`X{vF~tYpd1Kvb*8@Y~U_s>TI))}le#RN1lvg~k@jLh&FS zA{TSV$E&x^UYz&GV>(KrBkP2pnS@eNdelYtAj3;#8&PrS-wPpqXYn|m%VeUHbNwR+ zkg~Oq>zaQ^{PHHbV|HLNsOUyCO;Kpm4Nj%iR_%mAmRM#;k&!|=F^o3y8x?b*L1B4(7oBHOJ?xP zSmNFke_@Hr^!Htbx9vAAKNTxg^LZV)Im@NQ4n)1LL(&LVAx%mCAD-M0Nmxe4ASlax zF*mJ*^Zq`s%qvs)E(D!)1E42N1(;``6X(m>mma0Re zW3B9QV^rSfO#s@T8I6qmZt<30@HM1{Yao=N3T6?IFql@-um@KmmvzI2%1;r!0v?0`7+H&<5NULz-w2Lc!cT=Iuxw? z?D8UMSHqLyBUy>%c8@jsN`j95gB>b8nzUPS&X86Wi~Gqfi6t$o5Vbw|GvJg8YPFac zgFK&04!}#mf1U#4{Y9{hhZZ2*tcPyd%8UYZ2xro zo;5v=Sh6v<>ml7l9oX>z-V_oD9e5!m3h*N|iFdVEQ$8;lmg8fz_=&M3OHkUNV^*n; zK5>d5mT>3i9UJGN7g)WQUb#)PU%{4fS`ax)*(?0$Q%veZlZUU?ligU%{$AH7gT z{1}2mIvoiU(|2T01&|9MUTZ4yLIWG7`D`(H1ea%M+?!`2!OnQgs*3@WVg4PZ5y=qo z2Q6A`#3O#Wtf@%OPy{-amr@q2KDE7>QdCc=;iO=^I(XNiu&2HVNUFeVaqWn5WS1v5 zAp}rXC-xnqlevh9T;H9fDY6=+P?b#q!B{m}5`qT&3xNZ5QUIc;+#tCmi!qv@(B)ZR`0w#pKU%`OWYyDSE;QjVT>}L39>SFziVX+&7IRE?MxxaoVyS za~aX=BANy5aU?IXVQ*?L9|HZsAalpAzHTRw~=P{_(X{qv;6D=C=rEhqUTVuv}>*_+yUO1___h zccgYNPSm`Bl%a^G5n3EF+VNZ>pXAsdfi)J7nYYQooz@pd={{Kt+cxb&8>6 zK&hpq7PfwYjALX+xeD9tP6~D~J%mt)+u_$=!`rd~;|IOoriN%z{&c}30#@)u_*t~o zj7G~#N)NabJJxiALo?b3BiwsZ^?!kpup>c`c{}-rL%%EsIDUq32n~KnA!i7x=jLSh zaK20IM}OG_hrM3nd|(p^r9F%Z**@jR)exg;;XRW z5mkk*5Uv8T)Jbq|YdQMpm6>lU*S4Qg82i$pq2!MIZYP0IgDDY1T8b|1Pb_yeE^~mI zEV6sJ=GwmFm~Gi#K87s7X6JJ|@!CdS7gMAcLN2eJO=nSj4LfWU6^%rv4*d{Ho0#*e zy6&Pu*IMQnbbmIXu{kIDmbge!m0<(5*nds~_ic)&<+XwLbT1MIHm>6?n;3Sk0?jb^ zBk6w}bva%pa?k|e4q=lmt3}WSqct|VpBh&rB?rQc&(1ud+z=rKNfac_T#~Ckka(n! zBl|$mL^tZ_uSi4lZM0&t-Qq5|nhRkS{O9aC$D)B{i-QUB4B?~1BipuT^9DnSVRt8& zEd<-i_;!63X36WoW33ls?k|Vr|@pEOru_eb{>4e!N+&ArcIK;uNLvxr$WU8n|hHH7N ztu7yXfDSZ=#;M_?#KH5+X3BK7)uD2Z7Jga-{OIk^h;u#ppWi`-yH}$>DS=PmdG0B| zt1L{g5|U0)8w9bwek5=MV!VtP^&VjM0!cElNUPj{*0$`5cx7akCx>}UnrIQV< zttH!jBFA{K=h9=Gj-H}>oSfam_TxNVqchnu;nH9{^PRGijm9#K z#Z9h04e$Yd4kTqJhCt0xrw7WIiXbA02%-Wgfd70LK@;`&4E&eWMBWW@-|v`Z0Oh{j z5P?bIAA`^V=s@%b=29NQ*Z*55N_3v>tM}V_!}K-NnjpEX^prF~vr9b8%XyIoghse5 zqtBW=B%T3(zi;3t$UEc{@q(~Qx@4rmn}%SzJ7mSU;rpEG#DBT4M@4M zf8+{wI?39@Zcd1ltb*0c&l-}`i(EHersMn!|8d9=bt_a(Z+EhOT6Q&wBYm8&>X6V&0pI zo)?d$3XpR;!~_zk72TG8Q@5@A=Lxn?cuN&^U%yA4FFXC+_|hHhWk!|tVfrxw89|I7 zMi8P2apX8tyZy|4{>SI-)kjhszOu@f3C|KBfM>a(Q{qkm87Ta1*KV%ymz;dgr-b!Q zUq^ro@HyBrY&G`J&eOK8KL{2NLzp4-fBiO_d;Vj=|6&nT_F#QXrb*xKdr@VuOn>C} zirN`?mabyNFTJaPjqf?6=h6slU_05oz>=X4FaQt?2my=$MgY^75aN@c^B=JJSE==b z(WXZjc91~4f7n87B3$s{&s{O2@c)nRgk>}|e^7M}6*-xz2jl#k{6bkHu-5jpGt8k! zGO{Ft=Wa~(qhO@ji++TvJGSF%lqbJckzkE!G?XnW@?mRa_izUqAHXD>U2#@pD_D+02>Sh8%_8l9aT$E)}K>BpUonw3;}U16JT$nOvq) zcS*`Xi{B+%+?H&U7q*RA)^NwCv!m`FQx*I|S?6ohl6G_+*OWk=p3{>8zw_(M=xJtm z)$8aId=4KT@0KXGcxF(O}qytjPDG z30PWj+YwLCiuWf*uS>?jk||!oo6iK#z?)P0@C2!aVd}QCVNbsaS)MufO+=VX)?Q#W z7?)q$VdI>gL$0*Q{M?j?A&g^vN8BmihM#xj8lt4B0ST#ndP05M#`Q0E_m0g|@{m&p zc2e>!DEuL}Z=!=0Y7Jqx2o7W*F`n}4&5id^vkN`NeDcwQc?ii#+Mho8508fFL_P`k zKLD#u;jyo%rUXg?R#f`Qp*b+YZe( zv+`fQU&r`rD|X~+Y4~3k^kymF<5-TrZ0*SZ=BP8vo@8SEHWEe0>O0FyIw&w3rNy<29;51Nwb?1-Z%C!XevlJytG7bTLe;>gi-X3V*`FE?%Xr4g z{9%!-1xo#zpxmZ>^*0jnl`qrFJWlsKHjf|((i{&LH~2XTJ%+S|ub`hAU2{L@8~_D( zB!fDk&>wzG!sk(Enr*eyogRTpSzWl*1I#k{mX?{`E@Wb)g(#@+Oicyy>2;%^8iRE_ zD&TH3sWupR=CHImOG%zp0IHES4)c+pjj;VB?y6f3RxM#BQtRO*6&~WJQkbojoAv)v zAHy(4=NLj()c=we^PCsRJ+Rsk>mP1lzTS+bmCVtYYp!D8U?6~FnT8TpT1i-3*KCo` z7ZyXWMTj7RFsBFV2nHt19360vek?3wy_IygRYMJm^dJ{eNQ<4!*qcI8L#reB_V;^2 zUPD0LBo|-_w3YN1F&N59qH6;<>(um_NKd2UAgsx6gFYaC_mD(sw+FZF`oo^)m+vZU zh&{q#NQ4l+1f+NcI`)1C1yt|fxW@0&vLr&|)@8_$W<>x7KSL9(1GnUVC*W15(v^8i zsxc=uUy@YZ3cI2p1!2L`LQ9a`>7rfMtlD^nb|Ls!H)fg3fY;!kKY=*ukzG>Ue zay@mQ|D*)5FfD~~aZ8Z?o|+f;fr)=@FMmbRWShJVis`;5FtNaFJ8MDw$Vbf@Tl=V+ z?45w=x~k3jCgZtQ3J6adm(eRh6bJI{(=??4a!vOx5F;}>`amPoXGeQmn$1@&y%On4 z?h(=n`1fH&pM~;5y>W4EG>jG_4ABa|p1X72IXy6mpy|6By(zC3aK%ynIh^Rb3y!(F zn$ZlWh<=0h#*J++QiYB^8%PFPC5&_#4=#099A)-8^VNvQ+2o=UU^W-_MSBDHvB)SH z-t>7IKf1ZsQiM3oj&BZHY*4VrK?=WF3E(X32fVck+XEPej`RQo)P~)vbcM&r7p3N{ zsm^vbARFM30~2KcsoCEFq6EfBQjP)A@)NJw5{~L10?)O-1QI*of*{^VZU!U*QBoux z2}x^-j5wFv_4Eo6zZxp?JnA(^Z1qxaaRnv)XpMlvKM@gE=FHk&&&WPtO;_3%R5q>m z*>=w!>}#+P^4Wgx&I4{s1u85R#1p$nG7A=G{1qjHVW#eVuHH%!csOm<2AY#InzI9E?uc z-dl8dnJc^tRY!-J`xg41D}C-gEp)<{UE7hNvnMUEv$w94nYG%wFckOgSxU3fdOt4A z^nTv?`85w?_zg(g>f%8_>K*J<6SpaiH4A$yE$m=i)S8Drq>?I0gAdonPqUcM%=t~CT4Z05?l(AJ z*x3$-0+3_FejVccguD_c8%&OUA67Ny_jTfQ7((TEMYkUfms&KQj2j|z->+)OJI4>n zNjK?JjahHbLZt((hS9UX)VKq;Db5Lvol0`s*KMX74baEumZ%hTao$!A-WtUU|Z){+zC^ zX02sk{Q&g)XL^1sR^#A@{eC^95yzoZWIF89EQ5d%y)v z2DMcvaCY~S{|lVIp~>Ul9Z4nu&$uhAfI=*K%y4iVgBXcfJ0-z%4@t z56a8-X>(SQmol@`PJQ7<6~`gCa+`d*^7!l=&EIqh>J3YirU5_nVLL26Bvv zy)58ViPCFSQCtAQj3 z@6Rmt@@h`0O%}*3NJVvx8piUUNBykc9pFxI{Pk+Y{Y`B5-s5tpT;oEyh!3v1O~u+I z%$O6BYD4_)wE@S@F@~Tuk{$=^(1Nnod5jEb5OBoKf4;a91k8lKQ&{yb+H~NULK3qY zVBPrB;Baes)vA>bV_^5$V9F-17=7MHrDpIR3|RP@uGcnff+dC5_A;gIZ65N*sy{&s zNIZ7~P!d!{!gZLxde6*8zremuy5*vUULZN8ix!3S>XtOllM5;d!$=(i#>=~ZMqEY_dJ}>NTV535mc@DzDPZOyuhi-~8J}x#m7+1Psf=?=(c55E za)s?xdFnhe8?W`-?Z(4jVdwr5=Bmto7?bGlnGxZUb zP?Vf6lvvbfWUB`}bG2SJR2WnVO$kj6s*kX%c!DCS)^AmC#vC}DDoT&a&^?bv!V{rE ztmlVdlZXr*EgZ||>gR}lF&R@Rpub*Cwdl}=b?SYy_`s9`n)H{l$QS63=lMJ(v2Z5s zOWc=qJ+#s50oz;uE*l3_MlZcOVP6JbAUrMp@;Abhk;0kMWfdV3A#$yZV|fW1jvMw9 zDd=^j1%u*(X?6M9%z_v^4vPNQ40s+q5#m4i@X`2x_}TjG4|vB3vyiKPACf=d-HbUj zb7xr?T1UT3I9~xu?oVYbg9NyTx${G9z*%!$6%C55Tov6+5e^Uz?B?Rvdc{7*J;niJ z4hW*3Gjw@7ang4;Bn5zlfYM$sGKYI~UsLx>wNKpjFP4YZNQb3iSUh|Su$B^A_D^~7 zu%4(X#)u?2%2<{Ai$XlG6_Vc(8WjT)SLt~S6*4BUfO(vdLp+QVBoE|t(THhlo;hvS zQ|E;zHA)!CzB&jJ$Z@AYxp@{t?u_fbisa==Km`%GV*tJVS~xj?bXZem=+qSFsg0cK zC&omPp~M=)u!9hh7?`?CIOqWTX5wM4j@WS*(PDyxwliSEXDQv%dKPRG;bm=DaOlXz zLJDlKcMWoIwQ{x1?uv88FDm0c3%QGM?h6E64l{7?J3x zPs?4`!J$_XCthjM7WLL_T`M&Ug&lEMoYNt0)56%G71jGwJnqa&MUSascblm&l7W*OD_-DEs!6anpfHvwq!RSL7!LW!?v6xv3^}qB7-m<5~k!jFkA>lh8E(|uWtIV*YjE%FIi*G z=-6?fcobng1%Z;V8k;la&V>kZ%QM3`m~wQWj1J)@qItB35CCq`mCAKg zlkYqH3FLda0KUubGwPqTE#aD%-1joK!a*0sNEQctF3_x~JV;gXEQ zdmwX!Y5LxBmkIwyfXWSMRLr@$c-~LZd3v*ZXb;a&bIRV6F2(48${x~xm$In*iAJ^n zalT(zni{?wUOGGttDmKYRTOHth*79W4W>9(xb!6Qa4>FKus>9q?ZTzP)l-?f#V<1| z%!QN{V)kPACB+C_lc<#WDJDb%Oc15J>|Rddr6bzl(UfC@X7#+NqL(tVKt8|q9%Z78 z;Ns(;SYq=t>|03jOnJ%Mz>wAe%8TK-t0!*6*u!T$GJ0JfJ_oC9?^tf3#eG{hY|P{i zS;<_hqD~#`khFc>a17n7J(V3q*riWFJ3TZV)Vfh^L*}nxZM*1Q1>-xGq&GW`Eu!X zVl$B*nD|whK2srBK^>P_Q%7OPI5KgAUr?T|WY!-cNlu1wpQ6diWAlWEld#I`VU6!o zGYPip5S-nRHP;7MA!dKAT9Lmpr_Ea9YLTTc_Ap5SP$?@ONU%ZI%G?@%wmD{Ugin(& znj(M54{GZ5lzX4I|BM}y=280W9)&O8vf5NDw!VV^W(rO*+SQm+Zi0F?@US19q`9d~ zOU#keP5_$2w79D_z_qVr0CKDl%##;=c;p#yTm-pE8VEMg!N^hwianZoX=NyY~)&2vS@Bx!$Np;;mI5QuaFmS>f^w|({4|+ zVK&!qIIx14F6aQj!D2;Vyw6C>^0>Kf|KNL)U92qL3$G%_`aY>`b;@7zNl_*fQz-cZ zqcX{uI5H9u@u;zGx@|en8cJ2!8ViFIimb*-sir=WeOTc`qM;P8A(rykq!YOij?MWAQgkcH&}_*s zx<_)v5Y#UV1aUyC9Jxi-xpP)8*Tor-A5+7z(7evmPQtag^NuqL@FE#bzaUwyJ=R_1jc5mUt@sZhn8n1-7pqI)@DW zLUr|b&~^}<41Y25Bru#D1Ef6*b~l0>g4^m5t^o)fG&Zys$!)7E>H8}#SED#-dDbx4 z9WK+#+562PK`%k?->`o8Abc?mg-|K4y zI7hwar8*Uf1!ST42kC`tXyz^1hb5DMjIa#+`WFy%?BziPik$d9q%ejs#vmqM8FO@V zOtTNnG0+eeH2tNSxd}8RXTXi5B91<&sA%OjeLM6GgfbOZ@EsLU?>I-68R~ z3+$JPyQw;I?`?RCF%sG~&}DvM*=2zr6wLEB>~mM;&826If2@7qfOB8<6#{9DZK5ql z(fL8*nRT+I>%siEi4cx@>zF-rw24ZW!kKn2z3TH^=&u^<0Yo2Uq0sWlNOtrSA5OJK zpe~#n2h9%3`&PQ#Yw(y54x#1`V8b1G5AP$vP{6qC2tWmFFWs;0m!w!pvE^%if|C6#j|ft#NU_PVCfyLBi~lAe{eGL`%up0KiJ{Ji0)w9ph2u=kbp zDWr)yV@yYsQxyt&ZtdVZXrJGLukH0Tf?%;>s1)_{k3a_)8k>IT8{vNrZ0qoRR3}Wx z3ArVDra~0$5RQK0S3@?6?qvXC%u&k_?se!OSwX7)6`6-z%Fqwa?di6x12Al|AxrWp z9Qy_(ru~$9-7{-k5vA032XHNHtF7D`*hq9if5s)Qun=0am;HlD{b0ugWXh!p1Y=F# zoo|-#7cMCUTGqFc;A+|rucqB8HC_50$$nuqKAGd4v+~>KfsP!s+qX}Sh}QT|ZGrxc z&*?JiMQ+W!_I}ydO`^B7D~${qM}Z{^w$sSJ8*-Nopuee0pCM+dzyx@O1ImI0&VOQA zU5M3tSCjFgM?Mm23$W`RH<6pOv`Y)4$b<6RJoIWx;j>EwTnU)B-m76nHUzU5drM$U zt>RAl#2|mHD4^nuQrYOq+`OUGyGRj??k&_=+G6N*w zl+tHW+8&Qk>jh zDj%tw#g)pX08ZfK76b*xsWHrb0R1gE;chGMIbO>(T3h(-HqFGFd~$PWI|hRMfsSs; zd*j;zg#=`LnJ3P7Ue~zW_thzO)^jcF;B1Hm&cq?grc z9y4=7LB#LL&?7tlGLV<3BtRsLt37v2+dgLMsT?nfm33j%z5Acmr))AkzfrV3gG_O9 z%e-GK+{I3PTP-3_w+lkqGF#q-3U-5gQ5pXyV45yGT`qdLD) z5QW%gsle`-bJkQ7vhS(9jk&(cXI}SfukLXS9misoaSMwOeNLa6ttyJ zkJfY;8e$7Y37u-aZp2yJ#%@;st?K6Hh*l6NxfYIkOeTEoE}y`Ny4N?@;It^Ue=_tG za%-KA9)i-E4U;Cl5|n`exW^FM3Wz@;5HOUEFpz*4Mre);#c1WfQB9J56~HSWC_?fe z6^VI1PD^S$zhBt6Z*9R}Ka>4GxfCz=h5xG5?7AnUuMO2SAR62 zzNq96VSX4%{^6+v4P*#pxH-cC1tyY^r@4@(*xf(1SBPFSi1d*#R0KbD1;i$jp~~YJ z4b%#NW+#17_Z9Fj`oPcqGKX-3SLraF`jqfGbL}+}124%Mf-V_elh@uF_7LJKK|(*T^z?>PwcY=e==AvlFLkj&Ocucw-(*`TU0u z5~fBTw23x*2+h~%u0_IdY9dE)ojc{zCK(74YV?$rm#Qr-E?a4(%_DF!Ei2-(Ex#J; zMt&v3x`#SSaW3K>3DX0O(fz2ok+BJ48O?_D{%axz_KA_E@#Nh0l(1UunspKnF7Yb; zvOMV9Okh?=jZO);r0G6lE=Y{P%00?l?4jQR!CXE)Kl|fCp|}GT8l6lNt>+LzcjKMcbkPl9A@LjK0Xve&p9UQ)Lm1>)&{L z(StEP^lpAW{f@b$@j)`(6I<(B51Av1%Nq_gTw+~~sM6aXVRb4$v)LW-%#wA|N3Nda z;XHE8md%E`@C@|_vYe7j4V6J3cfq20FvpKuS^=wNeZn3~ZZy-J^Wt`O+gRZ#-ueiQ z1c5}M45@r(q18-nMa@@k=Z@Wj(`RDm;Pm0Qf9>}|YuLr%T7{Y+>%C0PZdCG5_(pt2 zu${15_3b<*g*oK&L+w%6uA9d5ow8xH%34v%-EZo5!=18VX8u^~%;;(~@9>(VH#Q8JJDN-N ztu8wTn90a4iF`Ryg*?Jvc8}2ADfAEO-8TZm(CE}e zByMS*Jd!E5Hg0Y?+}C9JWaJzB?RSYY({o<(!s*J1n@>aF2R0iN)fSOWu7;~_BAEAs zsD6eDjSIz9gD%e34d%TdQ^gKU!H%E9(1e0Yx(6x04SR83P6v)DRKTh!GDI$
zv{0ojp(K0iFR1{M->BKI14J^jhbDMDU5o8+cc%>FqO4r3-H%wYz32D}EmgoBuJ z5NxD>!}OI6QL>Q6e8sH6m=NnRhfL!!Vg1bWA?I(scZ}4Aky^^~w-Vj+8#A|%l%l{F z75D>$p@qsqF4xnTPeLlfm3pNsC<49`z7;mP?aPz^gZxhg4zE`hwuH;5BjU^;$-|t~ zQ1X(D5WPXuq*s(~2Y>Pgj*T1MhV(dnk@4ZU=GWV0Q@dP(ljXqb;?x284HUz*_ZxTj zY{s^*ub?=unL)jr=5$AG7Mo$R{VI(KtIhr1ab6_~yNy#8;eyH*31i8FZcMRB!d zx~78`B*w_Q>R6DFP_d2ooxxKij!eN<*IU(vApM^Ye&~7ow8!F;0}Um9oj@t6R(Q8M=a&6i5c>2>AFko9qsMOEdnbG zQ?~u?g#DIzxIun4`Ztnc6J;{`kX%(;uD92Nlrkf9IugLZO~K;7>$NHS%=RgOQAA$n zfXAB+=nSC+d7WdmtxZ$s$)#c}(0rYddbCotmeBWXyeUunak!&VGlh zPjj8U%P7#jWSAIiQX>)L8B0V+tK5~Y(EG`CN9_lH>K)f#(WM$t5Gmm#GMer12=N(P zh+oS^aQnRq?NSRF(dA^B=HyLV(i~{dm}_mBPp%iK z>O(H#*(2gL&k^ZSMXiI0)Tklm!`+AkUFFaCGw=fx_&*1$b}^T zNY_7>hy}J3Ki#)hkUs}{HaFYZG0@d5aEYGOG`Mz>>g1T{6D9)wU-*$j{5`w?^c~}u z*%ySWTpp%Hx~CaS8`A9)#L%n#L;ewf$iQS^*-{)S@h|_er`r96m!cdIGylad6_#Fz zos1jy3%ht7Mz$T-3IKip?#I@3%+ zWAISekI_`(7M%LS)s@sDae3H!6OlTEey)yHN2TQxkB-&)T?aHY>3rsraK`X?LzBH_ zfkgz%s3kZua(mWF0J2@YLm|aF%*7R9brRAjk)nz%q=dKKm={nZc(8;a9iOA*F?>R+ z$Wu%VHDLV-q?TV2j;R|kI+Y}Bh$+1N;g%`GR|Jde5==Am)jU|+=t(QTP*?tbFioR# zI3|vmJ;WXnacnE-;i>9+WmlKyH`@OO?@um~sX%neHt~c%^2noHWUqbAQlKp^>|K+@ zRa@HOd1*X{aq&Ym#ZgYbwXblC_}~ex?&Nu845b=~2_ZX!5LOrZ0Y*+U>b3eL0lU#} z`fMT*_|Rie@*LyuKXy%hVJ8RXgb$N37}6RE9U+a1-cV1bt)2@sl5Waw!v4ZTZD|Ny~zw8FOto1mTffY!@JqC6I+kwsC_6QE>J(}=F zfm^|U5l#pG_XWZ99`$sfGgk3Ju;pb>XfwC9m76^X50e$UxC((t00L+*+dqU*jQAm7 z-2Ac~_Z4z_6BGc7K*gY=(J*LlSavOh%!PjpE(DiB%HNms$XZtv622hF3ZqO3&Se)Z zr;cKi2H*VWXQVNW6O9uJ8qdH$pt)~sqcz0NJ`FaU!+tgfoP*I0RwVpC-j_LsVuUyU zy#V6xiJbstgw_3x_Xu}(Xxlky_PxGhS=rauZMf% zcc130#Npuw(8i#%$0t(T4l~m=io+QHNp%iOfcX-U4hoWHWGbcp9wYjM7t6j#s1HR) zqv_isEOGY!B(~hmRi&OXMl;_C)dUVz9fvB|%ug!4GxbyADmMi1Ul&~1IHtpm12wQwC{*A$7q&a7yZM>_}^gS*^jdrc((a| zAO4Xr$n%y1f4~r@Y>QLbKA#|pYS7eY9xw+~1e#05qXOacANR_eU%M5BoC`NC&fpn* zF-hZNOo1&hqFQ)%$%q5U|C!3Do2;Kqi0}!{SS`1YzTq8shS)KhLGx-^Z48+W`!SkJ z3q-lv$Q#JJLHs*DT7k>ZdKLgMblzpS1{ZWuT$h*=xR~^3hJZqP?XNuRpsT(X0d~doYP0BF^W&eo~z5AbN7Rud$uuq5jUW)fV@W!B!j==yyjD z9?d^RNYb&{uDZkFW3f9FUr3_+|Eg-+|U zIw4p!7|UjcJIhP>3Cx5pe4-cvB#Ap=?c?=O>GiQq8=LRvokDi_2nl5tUWr`vfaP(wkW>7%kP$gqx}xn zHZ}_Q$1$BfJ$|3k;E$@sk^$hqjHQ=b&+npx7^N?V#@)TdBvwA@PvDVoL+I&e%g#-n zval}iRwhUbo<8<;pfb=|Mb#iQhzo_o9)Ggb7boFo(`xizDT&w;M}P+ zj?g1XeT@BE^uZkOfBd-6qkb7ay1HXMZ+S)X4|1NEalQ;uLmAr3X!~xt@(_5krI-J#qUnhc>n8FgcR&O#v#+Xq^hcOA z#+0cZCiCOv$`IAwUd^D~hA#^8hk4fA{Lg0RqRe<^X%M>&k}6R3=p0;6Oc<-{Y6{z`2U z?`lqVe5=`jicifHa|538-VAJ)^{d-OHt9Q_kY|3DclvyH^GOipe?T4uNZw;mTTW z0lt-9qtZHT=zEPb8?GCa4Gs5A(1A|gU2z+ASr_<+GCW?Jve40(j?-tOPB(3;mV4L7 z{V_|4?wgqR8eSRJ#>E~HUeC|dk&=a+ug8b0o_$jf&i9<{FYmU_7xuSnyp)Izyya~Q zQP;*;Le-l`s2?T_PscYz@g0YO~; zxWHBR4B zzpge35Figvu1t25$&})q;FR^h+pX;9JALG#YfaL-ul7Ta;}#BIC1n?BdBqUffK9U6 z9`>bHLWdM-hMr)JMP?b6tF^RXu~E?^iX;^xdGopiq!~s?0mgo$1)4!p5HxYm<@Jz5 z;30_N%c=^HeWuMG%G1pUFxo%KYLGl-KkrQcNLG`$$_&pZ|07^ii+O{qK+@Zf-k{JA z9YLX8RH>v_1ERhv@}t`5im2~8&>vO(gUFv$_>-)5$Iln%NnB$2(=ylEL7Fqg356;= z5FbR;LI*LQO;8a5_8n7$DhCu+HlY)}BqA8E9jvAN!$+`uR%_mMY^$kMNeEs+*e@SR zpytGfqd1s5JAyrnnS0jfH%@V+UXC!X8|DJ4NC7g)qNwE?->?d%HLhiE{K>Si(t@tCk+zhM5A_zDlH|Q4H*Iq7*bSK|CyO5GCwiRe&ND+#ZT7}o zMm9#WF48$1+y5l3d90Id9-%sEZo>|syw-Xc7$(cd)7b(?`)a&x?WR8R;I;gu-{2*n za#brn?D`FP5w($tg-9%HFRW6N0H@!*0*lV9{>0p>E*1$d!|*746Yuy7>HWbd^H=u{ z9n-N@TZ7PWR7&{Z2nC|;O`SxI*nT@-Gbua$DhXMa-U^?y8fsPJSp4Pj{x5m+3^VV) z?qBm#>q#jS9=U#lYU+dkD~rsZ6@}4^Vr2!Ph?Y@e&#kvgWq*;9kkU_41>= zM_K!?^}q<-*nDlW*@aA?ikG~ka1oMCLWuiy{0C4`JQ_SX{ zIfw`zg|Gf2Y)rh7;M+?yEM9y~Y>2uueIO;|ZoK?DpOIPoCTE)xk~0Wgqwxs5oqI4u^F1ZEp> zM8^jYrc?kUIbAsBIshxjY7YHGhTp#SRAM#G<=uvxep*)U-g4An0p4FM;3`toU5Co4~nmweDe_W zAozqqDk3t{O}{gMfkHk{Z?hF7L_!kVEU+n=X|k!VRa4i!J0Tzh9*m%uu7bp~5dglE zEcl7Dc0_ewo*n2`lSH6c%!s($Xb42v536X|^TbySFNd*PD zJuG#a(f6Fj1Fi;lKUE-kij|ua!`tf}XJPAcpV1|GhWV&YY#CLX-UXVBjnsmuCY3X2 zTcUjVm-WwKe`v9%TP2;c**QMWmASP_A?6iw7_J{g)@(UW+}-) zyGi#^|H2?1*gcqa{fed`jsiQWTpJ~_5E5gzQS!#ldPv_r?#qJ=r^_JaX7_h(%k_1u zxt8s(B}63p#TTP65fQM50fG0@Hom|nsqtjifj*YK85jPxcLCx+~m-kIRznttQV z`zQ(lY&7LHi36&DX~w`J*$k!B+FwHGVVvmw9gc;TQ?W$EK1En>6(MBF?9)4l|_Zer>g}e>((0W~a62sN( zQb{f)EcaQf;Bh#CeQ7~SsA0rL@lguRMfVHZcJCAA_tX3O<3!U{6gOgL(2)}D3iaWY zmL>3~0t!Fj(}~msh|>e*Nq#}ehgZd}bHT=2WXlmG^6#8JVkd=YFoV~7mw3PW1zS33{0xl+&VA24;Wveye6H$QNc zN+$oU+Ww;#sdjT@KL5KrSxm`MJ31MHv4izCFc>Tv0-W3y;(@9ESc?wC&_s1+hrLx! zsGM)#AM#Z!EgTd+7%|$IcoYwassO_a9}gEU;Nj&)#r;bSfjP?i>-I4|iZ^Zs&`>fK z)t&UkW*|n?&tIho&l0i>KZrLClyqz{a?o$SZWVT=-XAXe-$}?pyqZAwhlHwrk{|xN zKlgt^kitatA|1j9T8;^sH}BCfC?tWMKQu&C`{h$KL4uID@m%eiPf0>hQY`hvY4ysI z(xr)zUbfFS z26vDjKaV1Gxc_$%~gTuhEu^96ZrkK#d(DXq`zj{6+Rv)f!`8TA11d?TOx@G;G z>p4bwp9yV~{j}NOKRl<3RbY|hBQU+|NWg*#PN?`|q=A5u&TZ}{S%WBb$(W_NjA<-D z|J!Cts#JSY8I^#TNhL*;mn;^XhtU~ap7k7S?)(RWP(3U-_hipVfCSo&qPxd@xkWEU z2IrvO)$_0qePt>&Ee&F8!U-N*+Z?`raV2Mfs%oW6$Bd0{`0Z?U^Nbdm8y7F0)v6AP1}nD>Lai1Ns#w7 zETL#{{q48s-Be7pn^4c*2Z}w3b|=vocR}sz3g8aijb!ew@tcoXA9h|jBI-QHHMjdE zUjapnkFXu5-P*%_*hyE(&GQ2wuVh^F^IF#nUGK*NEtN)sEw^l&>oVt0E5Y|qYlCL3 zkK7t--ZoIg7H&Qn#c9bwpX*WX`q`TAZYerAN*ofmci{iZ9WbJigMWXxqeKt@fc!sk zhq;4|^?yn{)%;%w@^+!FmV#>0jcUQ@D9ETQ&Z;}WH;aVISS;pO81MRHJiID(=^<7o zB(vv70i7&x>+Cpob>SL&rA-y_$3)Hq)k$8x%Y^gYoU7o%$wkg6)$JSjykyF2^L_=U!!Lv5Ts{~d)k+_#10cjS!sFc(I#agp*fef z{uZElmEQG--kWHC96etRsKGhS&W(FWI6=412&OT|AT=GNEZm`QG;@SLVd;v5e!|3G zv?#Pf{014d|8m>o*!|F<`TOv$B_f8(Uz*3P$`PIdA=B(4`RtS3v9&z{9E=; z{6np7^0r3R@}_5!@qy=_vw&~Q0~kn^p3&sCabSl%R4MXnapUPWU0MzbZYC}W;f7xT zH%_1O8;+7K<7J5@gO@!24~{d}I3`ty2`rC&8eJ6zro9ZT-ZRF{Uh;-~Tmt~%fI32& zvi~oBfGRm;n1*CCldK)7{&q;*+G@I-qB=#9QkbP6zJ^TbiJ~;j3Oi9}8)lM4^&cHY z#{A1;3n(j#Y8BMFco|ibrucn5%Z7DsG?SHJl{AcAt@wEd0)LGycj-`+->9C@r{Y*$R1}##|3}J!{+8n3p=%aSuwB#T`7i4SBr}; zg42oBKPL<^B0zJbd)Ve^^vVKwjFW|vThAc&5|5TO z`>Tj|*HnT0^Fn$DgSVBKJ<|a2R3Is;V}T_iW>g?g+YEt(wbmYYR_FWu#Z&m2il_DL z2X=6W>Rrf62C0gQo`sh}C{SY&86jY;;AH_&E5T*~nRMUL&R zgUqcvCg}1WEliV0m8#dcjsf{|W`oYMY&1)>GQ&Oevih#_>1`^vBMve=wUtI}NysAq zGl1tl+=VsbO#jtR48nuhDDhzy+_loI6`^^ob3n9OFa8wj{=r`09p}YI$9oJK6IS8KQxo%Cn$_;TN)=x4IU_T>)N+$@fR*P z{dwzsSX6Y+?y6S__Yq{rDfWj^46DGGeouTJx!kh`;O^VM4QsB(>=da-mIqGXk79Xi zYTUGYZo5U@qHuY`m5vXRvSF+qKx#2;rLq$KHsqMMHo5pC12@aSSr(IZeKa?*&%bN2 zZNJ<^!RJJMGLQYAXLsN3V&VPQMReiir2B?`YZ~0Pf1Ac%cUNVg5t3R9s_43vt}cF; zDgWloFm*dvQPjbEVrwHi@H8YL_Q;L$5d3U`y?JF)_%*aJSI|W#z4&J7?Z}G?j!HN)`qUku@2`SY?=STmj55M z;r}L>>J65kpwlWc18ym9<7H{Td_I-SVqv{lBq!0Z5f;lCSW^Bk?uU&H06#t7J@))j zhr*ijo#+888&+jBWG+?Mxd$>OSB&abEjNXfZ$+aS7-r!c7*o%?;bYvWh*B>Z7ByMl z>e7VyIg$CMR&=7aKvYUyc#&CbNBu<-06#Qy@nFl^X}j9YIS>me@e@jnazJdeO1J<- zb0aiB0iY)@Dh{;YEE?b(geUP#r1j}5kW2Y9+8V$B$)zuUfX5ZPgu{nNBFXi__)E(| z!qJ)D!~nB`G`SygDTK?5z-T~d9Jq#mlL#84l~cqjT#tfk5de3@v%RBaD&CfU`7Bsl zE+}w#_$TnDB_wQr^d>h=frI&X`N;J6aiM10dn9nr?2o$H+&5;$)i>VSWt2GVa-nH& zr}Tf2qt^^!`fs9BY0(z(_bJ1smqr0{F^RJ!**bb3kq6PwzqJmS?9IIJ&%(>WDaA7@qt z9fyP{v1>HgHGTe%Rtj&6xK{%4m8j1qFIJ64%f3M-C}s?XTGx4nzcrR~)6(>R{C zlgqobPv>X0@z&S$_b~4_=JyG^lR?3MrGMI+7<5!!e(3dmKf+8d__q z#uH>^@(^1OK?UZQOq>cN1dW&2gL~a}5tTggnj@fl`x}R3BR1ZA zq6~jnS%JJT#Sx6?KoR}uEH1mVuHBz78jic*bp~xnF^tynOx2YV?c99E7u7V*+z4il zDnz}5ye%`TZh^e*fB|?74?{63Uce|x*KoQaa?JgSkE)_<$4vLuIkB@qp!JnCyY2Kf z_4J$T`~9M8;Q_oV$=f&LiVG>sE`&BB-YdRMz#&;3bzFcOGh8NWiK!`2z2dd8-V|Jg zz;az42uNfs5mO~YijsyZ#rPvC{C6$57K3?NMr?2F{DB18U?Pm+-6tTczj<<;e^9WM zG~8L}i&w~Zk4t?e|F)|dFRt*cc@xlN|0wE`4AUR0VGvt@ag8_*gCb*!CYZ!iwFth= zoCJ2KmlADn|9jS1n?QI&URZfKpIlH~I=;OtVHMLL5m(=!4oeg{-i^Y_M$NI*7f$q- zX(;9{-7;Y;-bQ)I-FOXwx4y@F%vSG8!?UXGods2M%2H0i^lU_IfGK+hdtM0DT(GNd zvS7%Ttw|VflixwxMGT*O?Qno6jR4dt(`CCap9J*Qi}Sfm`Q%e3n*F8h%V5g++5BFy zy#~bE7=0}};Ub@0B4{fxWVTapdyp4)vT>5&$i3~U{xjgg8RgiKn7&B?1_WjCr6!O+ z_Od+kK1h2kDa zY^sz&R!5GxNaG0U*|S%RMP)SDhUC-e^}C#oBT-01TmsEU_#fdnyaxLA}l&5R;&ncU7zJxf@olxA=1Eu`AGm| zSWUjB?2ErP&_nglOSc^MOqk2(DO&YmMu^}`?PWQFUf;vru3Sjq+=HpPA0UKUoct(- zkx(k?K~4iId`xrN`)$cUET``!u#Z5Pi4v_eo2n!rYLl7QK*G9Hjj#!0ni0=hvg}d` z+_1_oDY1JPQMS#{yN?k%kr80o9lx4B)sUIouf{aXBw?`noMED0!`F%fqf5SE5OF3C zA?TPZvz!Ct1uV};Rs|8d8vs0QFfWnEfoX`?yCp)00uVOpfjpj29K@L#XOW=i_;NBq zb^^5`svGP>La1#7Xl?w^oPm4ot&1LCRz^$A(qHF#$X2{#k5Olyvqs`C$;~B2(^vd2 z-5?v)`;LSpl)E|DNsI}oyKdQQWMasM^w7*j1@U0Lm>%Y4MFn~LXRDG+B*OdxQEuCy zWSyCjDOun&FY!Uju6xuypOSR3s5;m1q3licMsXjm30m2Qr9D(V!IRatt&}(MT%Fx+ zAM~d%;2oR~+a-%Swi%ln?{f4@(8uGg zxAn23;c+TOA8fBr;lYdKAByaf%|lg4I1ZG~xztTJNvgY8*o)huch?I33MYEtJpBAb zHMNPd%DHP2_fIcf-8scvY8}NHrF3fT6BK)>RonF3?P+bLwzy$|K0)_lcU5%koNHB( zV|qCicXqWNWA%=&=Q(rl3yBWk;O*136ZA-4U7Z_psB{X$!M1SLs_VV@fq7G|IAcxr ztMomJ9o%vBvvrH8EADUL=rtaG?RG8Fxs)8ytC+4NdT>2DEmwoz{dIZ2_H?-&PC-zm z^TN}2?pa%Y{iQ=IEVVA}TOKU%HujIV11#Sy-w*_96zI(z`P5J=oh&!%jovseS zktpBS3{|~;p`7weSw5gtSz8otMQ*)bRwk`kF8k)L(CeZJW(YJRUHFz>+vmat0m9O| zZ5s%6AtcY77&X{`1&l;liyXG}hXsk!6VQ_{z6o2ne`ql3z(I!rJ=)eUORPS)UYiQ& z_7R>mKnKZ_3B;m|lz=`8n<>GZ| ztkBiNUe43AD)lw~>waCwWN(4OgWE5U?6Cg_(u_X#4hQ+fKDbP`q@Z3VfRSC2QyVPx zErYA%I>o8A+jhn-zyGzyEy0j=BmM9h;SZnvf7iHw;+gzJ^g12P2U?bv2K;%F1}$hf z5O-6`FJV4$#r!G*tmJq?t&5(NX_{Z9UIZcsrfdZ!$+>;E^W&ha{Hg^c%QBHTlC8Ki ze`ZuycgZG95uBc6uom$nS^EbO1MPdIALb>InMwyaNc)5aie}X(5aR1rf#RZ({E>%+ z0e&YmES`yG0aAhQ`-74=O^U>%gI^2Ctm;Fl3p*~3D};08*Q*iZe3tsxbDfvUk;Kfo zR)h`#yg&Z-`h73hc4A1zaT{E%q2{dU@?P^o8R^i^p1dr*&mfJip?0(;80zTMQDej- zhgfZ7Zd9#!MaC4l-fVg8f>_Hr3`b>~Q~8JAzkqi1z;;rvkw|P!@c;n@ujyz|5$Tb4 z$><7sm}Ixvpu}zU24Z0l&Z>iuz+r$|a8Q(#sSk(#Hgj>hhRbjiQ-qp4z_d+tXY4E& zw(b+J`mx(!;!QOcC;JDrTXD7li@Cuj)#7w9_{2hctK#N@$W1NEDxrBm@b{ejlT$7Q z6d;>S+3cxinebfqkz>-xC9CmMkhl8HF{aB70U9NHAY;d2>ySPBK zWsP{QWHWJ&&Y7GllY2VQ1XRmw&fE2>#1JobH^u1NY(0et-FH2vwZ8!Wby-RM#qvG> zK_|PPfZG3lSs7XXJ349n&yEmf5N}8Gx{>g@4gZ?u)@-Hk0-%N*LZi@#CAmC)S>sjg zcQs|EAccf4-F?v6IUT&UKLD7e*u@e-uV1Zl^|3g(S_2E*pM?6ofc3*ujj* z7-B+Pcv^6turcElgsglKwHkywum!I-CR{54#Zs~Yf8^BB4Z`L;mg|5&)WDrxA-uLm zRv%F}jxyMXNrhqcAam9+VdlhA^&BMvsIibbh9HwhZ>d(1=2oQboUQG|Pisg=9~~18 z3Dv|ujwHr_ba?}Ze(K0yRA%sfcgG(p?%rl9&Bh(s2Ek<+T_)N5$A#@{+LzepWrT!{C*8B23#;dK@YrQ0q8%Ads(0CnKdJUD( z{ogCjuJ%lA>6-5|BsAK95t7`C!6HR30uP9O%g57bUks0P0Pofl4`;-_0SBGk;I&lJ!5Ns;4s& z-Ef%wk{}x_gmI+!I}9Vj93kGnF=%fHNC#4{z4L8j&+UxCHaN82Dlw_`5S>5c{Q>f4 zSPH-dz*2A_wJ2S9kpZ9fi%n zU4F-7l3Lbgb-Xu53X@8*ER1c0yV-D#I;=IC;fC0dBJd4}HntUpQl$lsjlt|bE1JcR zJ5+Jj*Em0~=E@!-l4pm@#Z5R8i1j`;PIR5qf=@1eTh}%Kp#;`a$P0KME;=f-i$=mu z5KXQl1%XH>k^6Xk(BW7@&yn|z9pEhBK!PK&)kvJ#_Ez)FoF0j)TfUy}1=n#5|12)C zh?dl3u(Gg7=jWZ)CsYgFr(tm&=8X#CZX?-hG1zrIqQHq3Y+?{wy$-mH3q6o@oL;t- zJ~sRBl=f~^Py$xcDMTh3kQe;BG=8d7vQHVd?;B5Xd^H<&PCN@40s;-kz!n4dA>tunvLLr9>@shIlSMRnor4dJc$IKgP>c z_zNy`*CZB#hIt517C89(4CKP!2H-AzmarO`JgGOK9buau?%+958EWTF2E{fK7?i6h z3FwM)T*i^y4ct6%P&xeokJB+P*Z})vhoS=&{|~&6!Mre3-*JJ9n`V~YkEm}h?t>BS zM1|N9dBpT@KK#19nw#QC6L_aIiG_*4Qr;!C^7@x~hj)@px%Tbz>q;fmTmW|3(n>Qs z_j})`!##@ia-gWr=5+;bDfrEbqp&-&Odoi6N{hA9f`;zMiObJ;*cjfdw2SXzT#Pf8 zD&C61d7jgCrf3cKK@&Wh&YWABR{#459?XKV<*5YuXAm?pB|EM#3ePvXiHkvb0ZEU6 z>^15Bf#)52n`cpKTlAOr>w8jacAbg=dBDi&I8D-r*~7)_O7HHa!ZLiS9Z(Ms%QS3* z;qA%_x=Ddblw{3bz`M`C)yw7r{!4}*3{(DpV%Yz#m;b~t1?h-&x}U>N93BQ6oqb~IfKP0$m;vG6koR=eyg9(rvNvdfpin;}c00WCg z?pHwIPM{s4Z~p<6+-EMM@1d?{ZEj)o>Iwh-j@BrJh7^BAXmlx8Q7O^y4B`1X_}}F7 zc?BX#Vu82Ha;U%;&j#alQqKg!WSmQ3GM&4U)c3*XSGN>jVmpbp;fH!snInk%>PKal z_(JA7ao*=v6bo_wwiNs7#Ui`O#Zao}n%fyjwQZA6lO)h#o+u)^JDNcddJr_i(b4eY zB1kcs#^s7gw>j%vmtv!twbm6Ey-*sI=EXvBuP$NS?M0hxjJvTo$&3(lRQaTcTXp4Law(l8Y%(Z!(-DwpJ_S#yPzIh?Rd7H z&hm7m_kFZ$-ImnRJ5eVUa;aAP>4-5B-E?PjFCZ>_YozwOIk?f^%1uRHiU#|N5|MM) zC9~`6-*)Z_KP~Y+e)w!_8VG>$KXte)ZA?tetWEyY<{h_1%(_UtkCE;M5abTdNDN5J zm#y#qiuV48zlnX{CgC48q{CfxB-D6&u8vWko^o%O28ww7hShk?Ve4S&xC?icpU>y} z)}d!o;$c_G^VXKtwYG7^!`7!q!=ZK48u2+wu*z$R?CKG6B#p~bwV27Odj{H)Wt#*~ zpTYOb<5yQ@rd2}zc>Gdjr8BE7{dcCPe@Mz$xzv~!cF%L;Z)e*u+F-IIS)I))o4X|^ zd%^B6ONV90XRYpDoymw!Dih)Mz>cNOU&StA@S8o)?CVsz7fV3an>{VlXdOx%9aFy| z90z))!CE>plvqp@KJLG|@N%@eym>rczxLNnmZ^1?dF%W5AN{_?p@fv10UbTXl{tm> zbh_1wbmaZ#Z6->5UPV3tQ^^69wv%5V2FUj!xZn(KOyWqP17W`=Stc6E=Rq|$Ne`($ z+dGFaVtA6NY3)zwwU3!Q0Z#(4T05_nkFy*asS&)nm#AY^ zY4q@5xIDh6P^?{C!d_*Xwdd7@qb4>qqEKRV#Q3INx-HObX50#DQX)sI&=###w^?Qt z{xFN=rj23w_+>i>0-NP;seuSX=p}C7eF})#XN(SDQ-mX^0>I=FpLXFoVHO$c&F_qi zeA;#yP&aH8Nr66-jWNe?Uo8W5Y~cq$Bwcr@MT>Att%K0FuI=X8B59i`J|2E_lg*WL zD`1bV4XdL>eg7Q3t8$!;_h_*#7#-i#kE<_1LmG5F#&_`1Tc{jOqYTHkt5GTGK!G4& zp-+KDj_wx9LmE&*5LOBzx41rsn`t9u-+bTG0WX&8^l3LZC?L6!2Vt9&TbAy`jV)1| z!wg=Vdd~qKh&A&nZVR6St?oR959IDP;IVA0g-iHt?zAMAjNh*k((qdi6rCgpo&K`< zk^b?iQ}?j*2EgA5zZ|8RyEp(0CnRFI!jR3ZZGKz~Y1m>a6_y(Pc#2L9SYS>xcZWer z=;4j}`o^H?6v46`g$SBLA`E$H&RC96#yQ`!-SFkaf_L&>{^OXhjDU1r;koi3^4LxbL0~I;YdX@ ziTQ(BH~jL00u)e`1qD!*nV- zdw>rHMIbISlHLiEWznMGQ%_L9m&4oNx*M7A9`$OK{%d_J5zU{`FH{q>COs&Su2|4# znvEWNFO$q42ag_a_^rBCGq%yK<-SFKv4dVRTdIB~ImeSU_1&_lJY2i3Db4YkllfuS z#Pr_d^Nuv20lyVowO+Gv(rd^xxF5E?(WzQcyu9o^;( z=cdp~dRdQ5Kh)4fbDhUm&bz6Ggcyy7x|mOcGX<(wN**v$6p|CA!I=TKAALMoA)rF( zM(B6>9bn#oEJP=gLF6jpj=)OU=Qmb`^A6c_0f0`A1(8;p)dyIj>hwfuR5qe;@9^J(L6U z>8nsk`7Dd~hsR>`YFo?lE+?DXf^FGsen0L3zCZiHKl&#O<unD z_^QKUCtDv%G+k|rA}eYck=zGIIA;0uRTVZg^TstdC%tB-;>>LHg>d4NSTQ?0=tLrl(qqkw`auAiwqff8hyZl% zBOcL5DgMki9tO#_lt-gEM>K}Et*e?gebJK^RpRpCri!uRE)V;@laIEzm6K#tJTJmC9*M64=Gy&NV{Xow^hEnvQ zw-4~`!}6R;%wHN`CX}Y8pGhkUlz0XdbkEIZ^gV{C%OJd5ckJU?OF7eT zX8bh^m?{P=%+Ks&X~*Kn4KF>Ecdw+Kjp!q9YeWj&5!q>WDD9s;#)j;?TJa!#MbE~? z?IDp$g1A^WTIwu<%9b9kvB2E#EYjhazl9A%Q!zDHG3Ih`W|2y?5+isZJ=F8l2kYd< z6PD~tinDUYSgPQ@i8)D&WSpU$leRUhHM15i(=m$Ah>8*2f`B7!(lMctyq>rU`z&bU z6>0PW8i%QYbe0kk=c9tij}%%kOfrimxR&>lq=qGS4XhD}N>KUNL* zgl)1!?Tw=9`IWZCIcf(}5=$kj&?8ZgD>H){n+1S@dc3dh;G3i`?upOw7j~%-!oOI4 z2OozU8Zk;;b4sC~$wUR#ogc|yQBbdmn|;3dAZ~6CuumdPA|6sW=xA4phV<*c0_5Vi z@XP26kp0!G680)`efKuH9ub-AIZ%OZHzJjy$nfz;mz6FtCH4qEeRP}I3frUt@N|0U zs|Ehk+vddcbjCC`>h0!xZ=kFFs2V7k?03yQIJlQ4 z$f5p};(a5riLH}q=K|813RhOjA6_@&%i|@VDek{%jD18&8m9KsA@$Pj{4822p$Pdw ze?X^B5$y{n!QlD)#P1xlwGsMs0^F~~8sF5FuL?Y}f=RDkQ|zo`;)_22GR^GJ+~;Pd z_&6!2l3t8Anyhc?7&*s~sPo_Y(o{jsNUX_Y-FLt6Zc8ZT^W{ZyoG3Om`)w(pM$0j> zE1DrPrOD`?Tp~E{5Q8(`kIQP!RR})skkWgDG?3h=kgsPz*{?Pddk^uw(Xh>kEP z^6tDeMlO)D%>uHiC#7N|?q+N0-caC@@rL4yc1n)g%0}+D`^Sofwvyh@+fIoTPeS?RO#?*wqEZZ{E}il8A3@uJWZfhNBFPkLcq`%?FW2{V4MQ$#Le)(f`a2_BnQgCz@)&{3p&i! zM^6zBCdF_tBU@OVZ&8w@FS<@~s|V|1y>@658BplwdYwbk-;F zJXfFsuFS`s%QJ4CJC|EnJ0bfxY@EBsv4Gq;N^eycNG`Eu-kI0YR#)lG5LiyUiNbtw zqy&Lp;V%9KnD0sET_1hp5yQkxMiS5E&}D$oaYHK?$|WxDe#kN4;ArWDGVnwtjh03( z({d*|U3#&=v~AotZ2eP)5|gawLNTg;2CZZY;eINq%1ju&?87FFGR~$1>zb6GRJYbBo6~Nd_HXXbWxi8V*2=NZJ-Bq)v>j80dGVo8$zvxWO(#dSwwSY7izTh`Pq@Ez z?=gTX>m@LpaeHcoteiCwN651rC?@{?9}H(1r8fP`tbrOO@+@j$z`GZUNM=U~@#OJD zIL+^n&+!IG*%3Y2{=bXZ5{_9nQo^Iknabzu)F*H9JCRG}br&5adt;zVQu+~H72u=7 z@=9Iv_t=4k2H2em4;w)A9<>oZcII`Z?oJC2$M~FANh*FrxuZN#fMoff$yMt$kDl|m zz+@}{0*QtHLCD8WQy#{4#^D8l$5|SXyG)h*Xx3=4km^4eFJ__i_&WcR(B4N2_74^hsXqq&4<3;KLh^c9oW6gM463-(KxpRs9J>V=4nfZecf(KOZxp-AXz7xXR1 zMLs6lzh01dQ6xe9bH%bZD!j*{1V5#=J|f4Qb}s^+p|?Is-DcCp%ns-lG>6Q>JW)s<7L69At!SyGk0-%YxQnhR*h3)nuX zy-7n34)cI-L@|IVbs}U1*op2tcpc~wy#d1rd=Zjx;9*}7*%9aZCPEy8jgTnhF|_v3 z%B9hxqS8lZfdZ?3V@@$_N(SnM8*3(@lZ*+~QNCUK zvEY+ca`okkE1$NX^RRa$(G#vM^F^qa<1d*BwbU!ucaGQ0r&DA$iH4Tytnpl8Buz)N zKcwNBFBeb2?c#kUm0}gqQ7czdz?knSCw9vh)U6DJBik9&FA^ljYZ@}YBS?AIm91y; zxL5cPpZz98Mz&aT68wpHW-sjST{?|=V5?hM1Xw!jSuEK?7UUrK^%dkL%p1-Fpl#BP zk7mmk)(?;o_hVy8jEj;pehGR_OaD6M_>RxEDkH@4EP80KLA{r<}yg} za5%6tt_UqXpZd_%a=SCS1|}a9p1adFwnYA>MGX1tYQUSoTwAX=%TE>wY(C6!5I)ei zO>IwcB@$xGLM)3xdppaRUR680}7{}Ft zz;tI9P;GB@;iRC_#C6E1o<4p0Y)>8;dRd7 z)Py~VKcl+pkV1_yCyJ&3C&HO(t%77ZxKr-eH8@%UbmzDJw{L-2TFdy=rvetZC`}rR)@1pcm6t_N=S>Nx3{DdK zgg#y+`ljUOtLT{Fd3ki}$bpxoU|65tj`kL z{XBTnGcqzf`Z;1kFU42^5YeHPj~Nf=*=wDms)SmJ7mbzSIkMU3@*IHMyBNhibJX`j zp30yX<8bMT%Xg`?-MXXP+Nj~{cx{;W=0f2qS-i(_GD}vLAsgZx=+wrfxwG2hapu?5 zyhhk?vX^LI%pS0j3kpcW!sh602q*szY{6bUT=qYw1EgpT6dc8*^O}E^z7`z8=Rbc? zWSu{3M)9QAn~vvGL=;mID}eoxv7Gt?uKsjTtSH@{)|xMYsf4O3@}$60`n!VLox*&# zG>Fq|clavpd0a_*Ol5c2*vgALfDEGA&tD3e_7?`5^IZtJE!TQS!uv~jdram2m$M7- zo7frRLYa_6Uh&BEYb|uov|I`SRsV=kq#CbHy? zk-jkJKul49wkUPLbbI2U611P6Vk1D~ePVivc#S*hZe`9i{7~=L!<#$2Xl~kZXz!|m zbeV4FJ)3~WOV2H{$uXXRqN#7(;nvMaKrzzM_3^bX2%?^B6}RGJBAEKQueLEqN(n#} z=P=$Kt|<-qiao6mM6F;=X7TkO=KJs*x&Bmas(uQjkO`EuKq<0ABzh|6bm4ioN@yXi z!d7M$ZLyN7@{7!7mFdUl4>uR08~r++Ak!1jH;#fxhiey$1&v2I;7f_mH+u!@CsCe2 z-!<%xM?6yttLmgEa=lInCBL&OvaTtbwCcX}n5#}-t@a+l$;kBehs_*#PWU)(;Cdug z;24?c)w3P6)L{D`Wow^c5SI+K!HQQ^(S)AG|6ZX$Ap+wmBiO!rbgTb???N2fWX!dD zg@=QW1zCpHLkyS{i}llL!c$gJrspD9FT-%$U7%hS@@#iv5i%9ed(7DHaEBs%^6;38 z&dI@n=LlMhO)lF*bo_0NjfR$f$>^5i)1~|~W3|6;!3gMSqUD^sjM{2wTPRC}qIZR1 z@67GkGK&OYSC7%?LKJ#sf35QSo)Da%#X5%d0%_AYLKrH8da-<^Ir?V*t*`&fVKxVk zA<5;srbb)yc@5seK054+z>(-cMG3cUInz?|*>k=|(S4QkB0X+O#Wtmt$MR8MN4j^W zV)a=1Yb1D}pz{Fsq>50DUah|Dx4K-cfj@Ll9YE!2QK(g4b6=EQ+7@;n z!^6yPylJ`VjH)^&Q5hlp}+$J{2Wp<9@XOy9C7L^h@7DpuHhi&z)vAm z6^9(uTF4bdBJ>>w0WEF11@@&`C`{c3gJH_hg*Bz$0yZti8YpU{=TDn3jD7}K1Fe-o zLNL?Z=+I)Zqp0RSz=MkARNYh9;+%`ZDC*T#V3F?=H-D?)_sR5z){uq(S@DM$4_#K@ zaK3_1R$)))u&+4(D}sZ>sQBQsy|hZ`3&TsC$-G);HR>LmpX^(7ZcnO#PyPBw}s z{q^WUGoP)VQY*1TyQ0Mf8xURwIvC-Zau&Z^*<7s_XPuq7Ggsn3lZ_yKED+wB%@Y-8VpF8gMAl<{GQteik|+@j zFQNu0H86|NSfhjOXH##Imzo;yE);4T08`Bmcwv{eUxEG#a=(o~h(SJDzpiGJGd~>Cj^K398ebh3nr79f2F#R@#*cY zgwY;yC3~E@F21uz-scwLpKGG1L4r(vzLCZ)3&RX>kt?Juatnq`sGX71HgHglTr={` z$c*0eTqq{f6|>?RyDX{`CkFNOJI0P^!1jf~If%H!le2QCQ)_Y{;V0O&ZVw)nbt$jr1xM4)KA<7gkPGkPs~tCOg}#1V3ar0;?;Q_b(FQ8e0{= ziF|hWastVouRnKaQl3*3DHN7E(*6lYUU+<1I3e$hoPFFrNoJRq!iy@vOpnOqw!AMJ z%c?9?>T*#%!pnRTUUnGe412tDT^Z;LaB)eetjy$DIKI1}t8X4Hj?F;z0I&NCAqVQ# z2XcsE;tKLF19r0R(`PD7Gn?yr@N(KLQ9Grw;}YBP#c{z9bb*SCY%jmxS2`_m#8OpW zew3P3gdfo0J)=k6baVhek*k)jh!KAFo2Z+#F{vU_mjwq;-vxW6!CCy6uBaJ_7jQUXwlZX-VZ_`&{TjRgWy0N?I(7Ke~MQToY&n z5=z>4%o&xT=jPXK{C>>$=!G@+boSE>7YmaGTG|)LD}8^wjr+O3Jm5N+s>Y97-ink~ zqvUo)voxz5sjjX@^k2Er#lr%&AI63|?Y&Dhcm^EL3&7!BT04;e+rkreB!LV z<;`;cm9Z7Zx|659%N?A3uIUJYsfjZ~AebwXJ#5@}1)nMHcwx*xig3o~N}VB|4#Ef6 z*_6-!IGIW%x0D<cK%J8pDZFn$RBj$BehL~j^K^F5sHiWo{Xj`c zx24%ifc2yVKf0SSpemOz(E`Uj)ara#Y{Y7?sz@YWQxci!zRy-)eYUZ4(QAq*%HXjCbcu zijc{xCuMNMdcK&gy!i2X*tY~2FAwB5lWBsgu4O0VVe#%3Iwu?t7Zgq3X)sNdcwb%A zK;`q_;uLi;l^^|UR6yVv|7ds`0|pOBFj~c*89USW*^%3U23Tg{M&x%&)^7~ub~B-9 z!1zqz&^=GecLQUa*B_I^nfbQO9AnAr`@UU{oY)!Qo_UPYY5G7wF2P++dmE%doyXH# z=>sZ|&nHQ1MkZ2hs6m>-Dn=UJO`rw={hXL$EhTU+Sxbj|eOgs6k}=)gmc#Rc>fo44 zFs5faW1Uwz0##J5Yq(3+7u>cxH{8X2FL&*`-D)Jy@-A_6Mmpt}dLw~#RUN74*d4KW zZ9StW6}Z9spt_O|k$SZaiS+0Gj9Iv!bcoaQ^w>BK#WLbUDGmcJ(!Fd^G*TC9#Cc%> zKAcG47vA||PWM>uy$sESy9P`OK*%KBg#9A}{^3)?0pih*fl%FRhf0L}V`G%|*#Pl&44}Q($?0oKJdZp%y19u1;^Mlh-h6UtogsPsf~o zV!E6h$>-Lj#qX!`rgb+!k8Hy1ofqCsKB>9sN@gU#OXE22KrE-?-i>O09h+-_^kU~l z45s6r1y+CUp)FXQw+NG5iDJMCHfEj#4!8!xUW(p2e)?@t(RbhaGF`SHpBk?m#idt~k7>oLKo8A1>5eA_ znXKVcMt~%%KuUYv9s@h`fs*9~t}W1lEfO+ftY1OP2}cPM0zqa@;w29)zMhMRG}Thc za*TS5Cr+EleZ@rXDEx)G_7oA6Ukb08Mk4d#vFaQdvVV85tpL)iZ+RyBi%w4PFHxDX z1$EZYKEF?=B^>NK&Vr@0`cv>#hT)&7*p`qTCq*Mw(M2K%VSA#7sj(~hnZeB@{8A-T3UZ%_ z34tNwl?-j6!c*O+;?I$ys!01F07{PUBMX@5ONh+e{l|%bezZZv* z4{h`TYaXuLIZ51T!*q~rol%U~CeBsooJCL+xX*@MM||5J{~8;?gZ=peU21?SgSQ<= zEGrR4e=LHh=_Uls`ZQ)2zw)axVt!kMK|w?J@7XN)eC9x*Fu-2D#x!onl@$$)k6ptH z&JKX?sjFymP+hB1H zbd1Tmf_vW2hMsIZfoBd5dSNk~%F_J#fDO~uh`~+&iqq}BI;42Z(!)*jk%?e-cx`!( z4$43el}V8xg!BvPX10nsakx%AQVOHMDERzVzxO|-5@9``)sQIJ4Mh5HxsRqQ3txyt)&8z@==jBrRTrDqi z3DtfHX>z2fTDjhUB?-@}0bBZ9@hlOLo~~L(nBfy0(@>c%jfs*1GWRPQ?eh<{0nJ35 z1@zxL_OW2s-^zcf`B#SFYaCg**+TWnUffq5;?ANK9lae=gp*P_9d*rvrZm@NO`{+5eJ1vQ{Qs zn#WwqEPDUJZpFZ}Y%Rp?Phc8BLn)g|@kn=>@gzb(t#X5gb0SLm4&3qtkSwtHiU zR3vSTN1UrPWq36r*bC!+7WCwkX`xbGz>|9ZLRu_@lMdkekny5bbMT5!RVK8NPj*;1 zlV;5cWKnZ1zirLW-+Adikw;mYIYcWeFMi(^ve~Q%KW&1jAOhA3nrCMyCSaFpwK0w! zm}>2`O!(QWz7(+eNnU#M4YE-g-Zh2K6aKf=)v=DVAi!$?!a8RK|C?))Y2=;y%Lh_r z*}|GiUL)b~fs#x=s6N4T*^^R6tGai_;fWF-uR)vYX5BjG-Qx2pzE*`acn<&9LND43 z;GI#?+&2qE<9@1Q>a6t_{1gTt18_UUKXrP}K7F{+Vf?&)hASNFL487T_X18?>EU@7 z&}7{0?)~<9zi-A~_<+L;F6Z}fWoK_&TIFvvdB9-<=?J9|%&V{F@W6g;4`x_^DdyQA zI~+mwMq(zGT6K?JIVQ}(MZ+N^Vbnr%?3i&bu+f(cI!7sHf)wAd>ZECo>k~k0xp#!n zdq+DCsp8l=9>v$7(mu+DkRJy4rHFs|ms>pY&E}cx>muz2zben?W#(z3=g7i?=cniA z^Tw@mMQ*-_$A7vCp=B`6*Zb{uE(ke&ueR!s-J8BoYqwV~jxrBrCHQyd-F6UGIFNEw zmX7UBgO}g;e@j>g*BI&B{r5wg%Goy| zM+ww|>)c#3WB}ByRQA&M3-hbVpQ<|j%|!+lm8FMvsyZRRoiHvoHM}6Uaf++;lH2(m z{m#2(Z9gk&NK-wh`v9KP_#pU0{tYl{dDbiBim5Ix&D;%~_uOu+b%6uqCL0mc-s07Sz43*KUWqbS9e<>RqWhmBA zgQHTaSLZB&VM;i-$ew!8YLW^WE02?Y1XqA%CJ06lr`GUuK+T;h2Inge;wnO!b2qSp zkU>FI+!68D92PK;I>XQkAsG?}tNyEGQUMJJWL)>MLzD_<2~0l%=+6@&`2(aPAp`c& z?5p7`mPuPUQf-veB2qXQSL@U0 zQcG>^ikaTn0Z?}?!IRVM!w#3+Mt$!bdz`^$nA2)r-nkwu-d@sD2x09-m#+#xj93us z%J4e1tv=91ri7VMO6^>zC7hAqq<^6$Z#qEa_TcmRdH;8p%)>ZOw2{-+3Z#_Dx<|_9 z{n{5u>~D!n(wAS_gKTE6mxzv3B4@V9!R>sCCvC#pa$^ad8a6NoP%d#=-BR;oAlIHvC{ zH^;eMFhDvSUfgf=|7_e&=WVOF{m6bf{P50neM$F}5cpyH*=Kh;^xAq{w|X>S*mT&e zzAb<}Qgq!dZ{DD#bg2I-_Q)V9 z3;Xt4_7-{HejN4I4H=;LUt<6>;raB)|u8MQM3zB3O zAn6t)*HoUCYA14gq8b!8+xLC$n(SWU6eA2(>PnO(A98>^M*cvQpLv^sCv`5X&tMO+ z?Y=#JRK&+NZ+ecQIsMf-DDvFHv1irhKw6IA^-fZ+GmXr9>1{zuW(Q3fEbuzc~u>nUd%zZrn%5O z-S2M2t0Onus7|R$1h=c)&tNOW;1vJ7-fC6Tk43X2J_ZoHRKMoq2z}dCuVKZx+%xEL z*?AmcJ*u}~bHCC5qV!uWM`}>1Kc=gaeFhy|+y zpn>yFS-S=O`o_HEk}S9{TriZ3ADO=DZ3r`3rD^T6Z0kXX;on_B30o{|F5K7cok224 zEn@<*@esGf|LTS6jqeFULFPCoqJBf#2I@~{JAhf&5DI87@$v*QS`a;#$yiv^lgp4cjdZU0z3qYm{ES3POJse1;>++slH4{%rKkv|ShwD+OTmLH55 z#nO_K8pR22mv%)sWAnzL*;9LL6x9r?XvC=^j}J@l=_Z;LmhrGx9_w+C;?ob9a4#K( z|JJ=(IG5!NBYciwF^mx9SXhIRsCnTbQx3EsKEr#IV}0i6PrJngna9v34du_bL4nTG z*?^f?E`derNv)~XcJX;D5GA_`fWMDn^gTuWYo&nbF%(_F>AT!4ynje1N)ueaPMB6a z7h7*ai!M+QEH@@7mW0-|tu&42b+3N2+q3{qF^Weq9K-B4TL$SUGy0Y9L#cu)UEks0 z==}hZHE6adXPy|wK3n3h2>(8IAtG)zY^k)hG^U_;yqRl1S8miJvIsYv+SxA)!?X!% zf^KivLvvfq0Lm+G3M{9zty53nYJ~HIXcwGxn2m)*NUTZq&aR8*qm3(j3TIMmti zAcH`zl?F$3A@SisEIbS}0j;E+uHTlfPK4y%cBIDF6HXv$!bETy4lWQ)LRR=v>cJuS zGV;Qb{BX~cmNz*&siEHu=`}~?O=PRddo07ul0h14oEjs=gRX5Q>Z?I!5vR}ThbQ-6 z(v~NzfN~yk*^KjA>GKn3os5khcqew2j>=kHw<~aHh2zRcpLWp485H;C{OWF<2lzED z+=9KYqaO$TlG5V4f%(M&2Ct&boehX%vIGVvO|ITI1et}Y}x%fgdG+JTd3&!H?R zJZ7F~PWz=&T+%Q&%_5C9sO@5%&isx+q#17@feoImb9|(*z>i zc!Nx?I48^)qt(DNlvOi2-GwsPCZ#YU8gCh@jlDo(6h~&VI4mRdc0Dv1^F8Lz9*6g;w{$NIsUe3YUnrYbvO> zTkFW-(-JA{V`(IGs#-|aD%Fq`MyUk`74D-1`nR=ds{JzAq?rI?M5+;?j_|5vqOcHl z*nT?Yiztc0YU$|K-Lq5})`TI$8kgpMCbm?L8&?gc6fxCO@;4Q4)22?+&)gNNEK?!GMceU)IUUiar^qPwi) z*MPQ)K#a$W;w;F}PR|7#?f1>brt*EVhva}wKitVgm-PW6!E~2GPNaQW&Bnuk_D}9S zcgkM5sFm)kL3W<3iSBfhO;;D4ZmWT|9$!`0=i}8f>&$@#jyp#T)BeZTd%xU32d$nS z@Cvh@{^H9mb8NzVTq#?36St1J#)W+_i`;;6j8JfFn2Rjmy(<0R z_Nbn@4HH|$&72pvxX z!e_@B*I91bek%s&TXy>!zF{Mnr6)F>pU}O>0!&jurkSl_7TfqG9$u@*9(7kf=Dywy zZY;Zc0`^k~88sfs-VeUFZvP5DOU^twzGfiT2q596v=0B42K%*Yxh*-P^(e97_p5zB z-$LClQ>}quu^pT40OS$bap_2$XKSyd2oOTN&u$ zF^clm0`dVbX-A#%0nP&?p+K9d-2aw9M9ydAQ6JB-?uu?wp@Y~NZ~6pm_zf76k>evE z{J2Bk(zy*Ooh`^-am%hafC(^uc4mU&zIcm7;2zdI}xBUjNM2erlRLXi;9|i#s z_HQ@*fd1?6O7B~;a~c{5NH-A(i0OZM@OQCxwy<}!Gcs{<`rl;lbjBPHSW$d2a?r+} z*=zWm9;6xlWEhz=4iDp%VZasQL()Mo5tFNnKd&u*Z8mG>10=au$v{YFC&EM<=YW2$ z-~tgx9ByoG9vC%!^NG%$yv#F$i3XfNh8{?hnUT2(9i-QI7G+T~paM_1_XP_r!f*EO z_`WY+%%h2akq!|>nlOe&?E>!zktpE~3$Ik(Z);H@w<+WG{*zNhC*IP-+}x|m+t5Jh zSC;(tP+Vp}O;E~Se}bj=BFp>tL3<1epjt?&LBRTw$ddcy#!G~u7y~M?Vv#%|1+nEL z(AlNM5neUuLjnq>Zh@B0Or(oRLB>qL{s8cAUM6oqWccX9p*V6+W6MdML^KP&dEkYH z_HsPyb_VwGspyt;FutO`aR{YTG|s*XxS;$U25m<8A-=@)6h%{5cO@f;4*P-(rFg1s zs~#)g8+`)p7vapuhqzbp<24NrNutP11IQdjtO$KJ2+JUYz@*}K8va!0DA6vWDu|Yd zdj6IZ=0`LI3f9gImy?-$dJv@r&z86+g*=u04bI=*)&9n*lu5T_2$o*~(k5L9^~iP{ z5xT@)FiRK(>HUcTO}c1ht=>jom!J95iZNpkjRzfFmke?a+XUlJ?u>4pfG`Wy1{nLW zs~_w>Iq?-B>|q?&r~r*A9$%O6*z-b#?za~@H#OE ztEg7gsZJ=)Z-mIt+wUl3LZtr-O*@I9SOLHnU3V2D;s8T@)q68MIx#Gs(&Qf6$Gs82 zq}iZP&JJqC`uiCDbsk}dLLG=Koz)yzKPjnM2*gr0oBISXk-?dpfv{q*jo7QHFFa+NxZm5snpTOiq5a`iUm6ZSc}_~BrpXuT@^w=uJX1urjAedVR_+Am254`x-RaUfnqci zX7-=aSck6f?=3lsX?>*hr|}Jfx=tkcDA!XaM*v#BxyazJfJk!MfrxI$s|Fcl^;191 zpY;OJ+$&iAZK@VUwMJ3Ftru>=$&Q5A8Q%+vd_o9dO!*8)jH{W30q^BNf7f{*5RUCQ zs_d+*D&rzK>hQ`-+^}JJI9Iut0*wKgn1XiBMa+uV$4IE=GnJ9D2`krg$tXnU=seO` z0>e=%C{yO_E_bS0>Atclp)J-m()67x6SM*0%JS2pi}bH^C)I^m z9vsT|iN|cwe;J&{_qKt0kTrHN7&Yi0q*Zmgey21iE;aKeJykh&bdbKP8o8WwQ=26m z`ht1fBs`rDJLcIrdZo>r5$2(Ir|d`z_S8j1zLczE z_j9`E98D(s^4f|r-ERy`xtGIf-Kb?>5I)&IKl7lti@?~YeR$aiS0@zfIjIH|+1u6E zYtFt*Un)7E_0X}XT}+ytwCu0p&s1+&^!(8&xROOySa?lorHE+L6T6WFb| zeQ4#H_<ZmxH>FNe* zB-Lb%Km8$>+BgM1&vvqugN)UzZj|+j#iuAC{bu1yZu5UwWf698*RU0tO z&K-K#8&T2NCnTTuCw+0I=#L!>VLs7dUyK!|y$|G+lntj-yS7n*y|Q>3B~uFn(;E_> zfcSS+zl748kBRUQc&(_#Zp$EoIm4Um_{ ziAIUwkw+z;$*{oprTZ_Z3#;P6OItP%WlH402%(5CoO& z92oBUY5KEAvD7^897(H6Sa8rTDmszo%aV@l+o<5KO@H)I0^dFrfpm3O?VrtQPu78a zgu$Gb>tWSH$YK?-qPf zzXNG!H-s7z_x9(8=89%w)HbU)5o*#ty0MeT4&`*R5%s*h$nSszke!gzTxeV5OG^yk zCfWSiU;Lr{+wBfWLe3zoQ?}H$`^|jE0l|A^f%~kLGn^d&tshAW=_1WPmrGnOZ3Izd zS^se36%RfKWN`~hP@ck{=WIst$r5*bNrY>7$@;EpQHKr(ib>J8eCDAf*8yIcD|z5= zh>1rkUd)TWID@1?#E6sFd@s~fu7;NsGA!5EY3-RId`9B804$7xCU=jiOcPEUU}e&6 z6~9JC=+wRa-3g!Inm)y_0rJu!Y6@LyyQTHobegGVO_(4QncBFxf;Tg7`-Q=T&0q5@ zdg0&JO_M%zgar`&fd}eWcj&;qqx(#{rK^H zMfU9?-vJO=8DY{2^juEu-dkb@t^aK}qaqV`C=B?b=XjAl@6>iW2n>#A?*k9kY?)xe z6Q>}`$^D~og2~8?@$NDMaiLGHW}GZ_o#p{fn5XT0uj87(LyINQ4Q8 zf_5&=LL^Ec6xl4#q{xkx=O{%^-lCWeVDAyi5+R*D9As@GVm}#81teAr0hh>C{%!)AGa#a)gS3kbBwRjMzR+%BSjRHoc8Y>{v9TY5z=>kJ zEHjuLH>z0lccWA-vvgWhM?>`i)`Kba{}U*}hz%o`gM#g!m~X5G+y?~Qs)dtoC)6X772b8#~^(i+m5fEw@C%#-K}He^oj#c zs*7p6cIeqzz==gt4_ozKwMmNS(LVz2X04ZcqyF77u0rBt)AsAtfiJQq2)hK&@$jl; zvrqiv$^8rWq?F>x;{;X4E%9gKJQ{^}>|OWKBzNQ`k+UH64f}=W!DfGfQ*MB-aQUj~ zT*ZA%isv~5`PT(!ZEfKBFz-;uNX-y3ZmFh=<{y~s=A6e|Od^aR5e!XRe#NJ{0_ty> zOP>#mOf`;ucGtKKPqZzC>gCX1FAC+docJz?!Nx=!kJ~tK$ZIR6f%TjWjDhk6KC<6# z$LF+M&JPVJRyoDRh-^aUJ(7tYt|*|?zfx|GXQK%R70-96E5%^p0J zYC}&43@&*tD6Kp_-)T|jb9U}HW-KvjoN`~FBzeU>yf1C{7VfDCGbMa}X5Z|7dZz84 zlRf=$d@+1rRA21!J)12H+;5N6aDR1+6b`#Y-Qf`UsdMzDAJF}|+n%Kt7Y$YC(v^-)+y*Q1_rK+Oc9+(gU@P8I z3nl8CWbDjmK5hGiqsZN+YUtqI*2_GXem4LKg~ceFu|$(<9?HzY;=!~qrkVHwqNks= zP1sG+jP5L-b2If3 za%6||Dv%d^$@c?4Hl|$m1f0@MxNOp?)Y}0s`SgaW z+f?G$b2^it?XV4<_PmfdIrn;TWC~N7QJGex7F4~C44&KBuqHj9(T)t8;5_h24W}J5 z1Km#9=mwKso~^-?thF%zMg|^vyY^Q^lq(Eg(}I`ANyjt^>`q;nFV_XYy{?d^_l-U< z)cQr%MwS*o`~ows-=y|V9I%`4`qnMb$ARhDdw`AEZP`spP1R^X;(#&PeoT@upaZf0 zRe(3j@VK2S2)zQ;Dp(WlPTMG76bhfEM`ZHG18Sgi-yxB6)^e0K6IMtbVm}B63~EDd z@^s6|8?~}mtXi_}_6682sStfj%UfM)BzK$h#-)0OO|`ryEWT9gkZNP}nU)EDD0mE4 z)WwwVOCz`kE^5&4a&Ht ztA3Sz)+;&wU$s}V6G{NFJ>nNJFjxcyV1$F9RWK=yScr@g`Jb*!8Q@n2Jd34*6sbWC zSx-vgMszB6s!?QS5-4hw&8iKrJI!5TPIeG6YKWj2nOvZFxEqxs>8aH3n~n@@^$f;& z+Gc?*RKF|)X>u*pSK7;QpYt&p6Ei-JY6~+ZSgd%mrv<$(#&%q$qqv@zm(Uey*oml) zg2p6)n7uklip?nk3k~S z6;(9^w{|Rk<`%U!{Eq$23&ySaFU4BLT1KbnOl>gO=-^no=1% zCxB#^T`Yu;xa!}h&&7)k$m>g!n1uzeynyov+fDH!|_>A zs@{t><%CJ&41K|)tEW81^}BX};WjXvaNa?^MA5cJotCTz1)5+BgVd}oMMehb$4{<&y@TztMtI3&0iD(i(kIrEG9el$~9AbI3;a_SyiT8v`<0gPMFeu8|Il zAP#(swr_-&MFNe(bH#uW-T^`vOH*v$C|~nLPDiC4O8*1+m#6EylYSr8L`R+`h7-+I z>JTor6LY|1e)>+S=251t8+rT~OhbQF3?{*5H2bJ$4Bd35+xMs;Ic8yER*|mcJJW;k zWeLa#_>hSvHo(0fgkD4nwx?Gk(k4V~si~{f6UQodqC$cY+^%*Prl|!!C!mB5lq%se z*WK8GaI0R?5*b{>%)*bA`uPEzk^Mi(EQZb|oVFmOMVMm!2Cp&6kRz!GL^ zSTa_OT$WS_tch`yUSn}mVJb}R;BO>R3@{?sp(n?E{M4GqBffeLUiivV^XJ{;=*x84 z8ByhnamJT_UW|_aUNUcVY3xu~&YeyXue{BMMUqa-W#*l#WVzIs7H(EuEI;c&7)C;b zfuf|~Q_~6mOyBL|*+eVyPn0tgj}M|9@(p^!DEUmT)_-v#6V3r7X1;cO|+BU~iMb#I~N#t^CY!3uOJ>S|}_uLU%@@7F3Uwap_gA z{O~lPR*22C?0qTQzUqMv&-oI7y(J-V95Na8o1XqF*j8#ms7m-BoPn#H&nPA%FvI^L zHlt>C3F;~}dZC*rfIW)+0+}P~`Z1r2N7BtRdqt1)c?mye$9LA$CnulTQF8I=Zv*gi#mmTy2y{5FGKl;8&yYeL{ELnyYT78L+743+r7j{klk;m?-K_7lmL zE8LK9rm&idC!g_&MB89!fwkVrq55;i&5Xc0T55by0bj4U&fYqH$scY_xYirhBMsq+ z1V#-_>2fwTRSM$fIb1Uyr#d^=eihk%61lJWv5E^?gk?w*5(qh35--y}yBnZALsipE zA*HBIlR)wh2l)=w^G?WBr%5b>_Jj5q?_-&yh!zUGzTus$5u-hzIw-{;M3m@xk3R4h z3;RAE<*gp4!g&`E7Vk$?{Z#8s#pMFgu_+M2^}$~({A<0MaK^5`D8??1`z~9%ZT{k< zKamM7DDwMwX7ZFvfR$k}HxIn?v+Z=WaLAcAVwd5xf$-Qf8*pj*{^i@1fYG~A3lA*- zdP#QHiH)?zSe3MC+N(4M03Xa6{IWyHf>7dXaNuS5drQtmSYh+L;^opm4NPfNzEh+yiz2OJ%pb*eS=k&A?$$5S{7siQBP%M&&!UEy&s1DsZVx_w!(Z?C^0Vz*hbj~ z1q-;MfTl;!54B`}gyl*%n)1mDp&tH|AzL?nRUqD{7;@{JxBj=C-4RdRezmcETy1~U z*HS>Ji}?;9s{Z14~6xW~T|Yftj5Xf=vFx7i!n!AUi$#nobDz103} zdVea^9ag^V_$th&0Kp5P$mmewZfs*Sr7WSsF8JjA?=8*wA6}ODN{$ z)j3=1Ki6nFYCP%rZ|hG}U}t!{|E)hY{8#h*f6H(FQ}*}|H|xcJ$Q~LmEDuOCVs6&) zh{!an{_-YX+^u_mB$A6U#uq+r*r~L^B$L&1jpCf9$Gk0IY~8!$nC0`B=!pfk8s+AP32dr%R=3vnvu2 zbY_=FQE3T=B*xPVBU)E1reHQm&;`7ZyZrTp&2{{pjjB0B;h!!bn8vJD#_ozQEyzHI zGkFMzP@^=mSH}zXZ)em;l3v8R4;)I-XVg$4#SGyD;6^tB?S@N+vlJrFaugd284ANm zMZ!#h10<{QRIN11Pm42^J0}ez6?>_ME~kL6@`*)*h!lix{H^@q0Y}2ki~s;aqM|o@jE^iZgIoWVI5Qz%@Lvq(nQR1|RLopFXN$0aJT3 zN^;opx=iGKH@Jwiw=44+Z;+lC+09(lS<=Qvlq`6~mNbMn99v10Sj06JtR7pI55nDi z5xIK_W6e#AFwt?@V3Ip{&GZrLidi65!wo%dphcLQY#UQdII^!_#$iz&!{-)0YDOZmEN^a%lH$C%AK)c~W z!}abL-SWfenVa|Z`}&}op!Nf4PJ4KSp^WyOF9nMrXXW+xOw@7(7reoai!be8e8;uM zDYFIdum8sPZ7{)f0hmBQyT3tz82=CXzO#wD^Z)dVUg2#x7PmdADNa|eTAOB1PT(2K%c2&4d)zt(+0 zaB#ojl{eoOtF=+{i)1^5r;k`zl=!68p3+4ClfIul8d5^E(Z7_#X><0dk7M&V8&DWfp-}ln9_yx z2G|LovC2Nndfo!dKKI8UsG;~BT>AJLxtPGFqmbVQe13rD+*_2iTlP~M0lAmG5zj8T z(>JCf}^O5p$UxH#`Hi5`JL)e}u+PY$e;)2LZOe6Dnr~9NtBLhHl zfpn*#DE?UF4UUMf0}Is&BRWumeib222=UbBuQ028jm^V3q9jr~ZzqXwFR@}UQI7X-85T-2HUz}oL1+@X z)9_u4ccyTVeWgBn5CKdtInRcLP|E$VA>3x;=Wug)BVZj}lW!m7IB7x;=_6N{8~}>MHUK z)Q2y;IK|uXc9OzNzt;eBA%=L6<8Pz@9XjzLlP(aVaN`bqvLtBY&FoxM1oP|9pJQI& z5blH^S{L~5y-v0r&NHjzcKTo02_>Ge4^o@)15nwRy!?dZhMz>S10Xvhr$UEad#Z)E zZGLUw1J^(y5sDGJerRc+^Pu!Vs?`wuFJM%YJ;^A1x_@bxD)c!L#ZHE zo>};ilMts25LyMM{mw<@Y{iQ!#%S8zb`TM-Eh+L z`iY`NX!uZc8d^1s?F~Svul{G~HVRjbv$%J=hCJHI zfhN-0Rbe4Q{~p0sw78LmaaODh^wA!BPU?*gEs8Oz*dK55UWO`gA$VK&PS+<|k-#I{ zuuaITUIqd;Lm)i9zlsfr;9xCC(B{qARDXg5)?L|ZTj7)=4M6?4E=42N-zm8Mp@<%`w=t?%I<>K!rK;8bHs|X|v#{!U%aH?lc>3Mo zyE+hC6)3QHXF(Y4?O#S}4@vzG%HAnRl%QP}9ox2TbB%4=wr$&9W81cE+qSjFcm5qa z_C2wm&WVhum+ps-itetjzRb!L8G1)`s`w&N47IfPG7%E6@*^x?9L;Jv(lRIPjyvRb zZ#?0Yd-<0I^@Mt{d=L3Y<~S3j6W|n#0YICh6N#y@#xM`A;!Yt^5(i7~GCCC=lMs%N zm_47w(GHiQA%gMv^&V4ja9p@Cf%Nn@G&(R*ept>ZagFVK2FRS&PAU2M__mjkk%5!s5=H;td-IA`%2WF=);-Qg_^U2Ur?gJ65vfUvzBv#tNta2 zXRzWGbtR_$HI1Keo(69@H0$(>i91utIQi(Hcf>8XVjccI$NYLrbBBCv=_%s|k}FbK z7`DvQ))0u^DmhS@y%$y-O+W-9T>Js-Q;svg!BV)-%UmS|!jckDiRiR*QHk=Hk(fr7 zYZI2rk?4Do((y%4%Og`E3a#yUhxQ>#HW(}>J(D-UQLak|Vc`q!Wcj-au|Zz_Dox;; z4{k#ADp1J_a!NV2{sW*BzT+-ljoIfL2t>MQDs zGFiX&jw()8elw*37~$B6I&*ETz&#HtbM7 z0qvi-Z+uOl;lLVRS}s;c_HVw`fp4P9GhLw2TZd~&-_UVevnDTRPC!A>{%gQu>}bBy zCz0*+3f7T|<727jDGAa+$45W1Swj=oz*_D3beDlXylTeJia%`?2-KOX=_a0_df~5& zW@EN9^^d;`7-zi>USzTydkK?9qixinT@%BcOr8<*$Ei-y>!Dxe;01Z#{9!%7<8 z%DJqpfY1NCuU?WA&%aRId~lrI{U}DG9MgAYnj&ngj`*Q;yJ2)@zF;DcOWUN6>e;rL_g`EI*OZb|BxKGX)pU_~*pm$RX<9AisojU@k z1D^8<0l z2`SQbbdXh(z4`v!Kz@i2bgLsYyY5p&$|ZBHc5bWswz^d&qf|`JKwvR2PY5XzEcCRw z-TY2Lw}@BN3)=NH$_pCtql(yxQLT~)bSn!q`=Vt<{WM@GS{Z}9LEgYw3ly>sag}Bk z?<@aua7LvnD*IO%R=Vzdg(k|gsatnM=V!K)C29%~Rmd=P$ao9?P>O5Y0-V~00YC&( z2=(=hN3l*?HbuPv9`@1iFnPB|(E9-PCMyceJzkdtQ;5f6uqF{oHkBjGWF>>)N|Se? zBQZLFv?X4ZkvEk?3br_fIFOzo~7*b-m1wwRojdk{`I zb(9X$m)ze7kMJ9)b6;oxV^@|hyYJs`$j^)HZW*NQ<{|H(g4E=jFM-wZIZe8w^^=5j4E>QAf8RE%N<;>;VBNIxcu}$g?!v&f{jpAWa-C`V-o#aR-M2$~ zq~`UOLiNEA-HQYwCjS7E%pKO}1qHa^^1*p+Uao#LMtk;lCZ&mub?O|@B?L!sZ2xzw zqBu`bNP^A*qFp<4QmP4;=Ut9#WR8UUbdOv(eXu?Z?~5@<&0Zy;&edQUuRAbSR!ozP zQ{d8RfZKk9E8AX6Bbs5I?fy&8n><7k>xg{ynv9~2;#>_|EKDHt)nse>9GY(+jpO2q zS5fJ=Vh>{7jBPx58MqkkVjB4u^sx8u)K@JWOWC2yeOEIbjbU><6;%m5l_Z;Dnc1w) zP&o(`ycSPnrZM?Zbi)&nwZYVHtj*D(13vO40L4ZgeQx4~mCJ~*gZNSCI7YgIi%f(k z&a(%JkYI2TQ)(`bfoJJqtbp1`Vd=i?&;(HO3#88L#L!ucJmMX1-DUVHvg7BU!O|UGj1MI(_?Eu!?6vb zF#8L670MFipE7OCUP@)r%`v*E0#`Ma@%+}jXb%gs#NCCH#d$8V1fFY?S&JydvRyAw zDj`tugxE%eu$|3PiT1@8+^<`*n=ZCqjcB2yG|(`~UP9C* z+Y^!?KmHIL0K{I`{av6#Nhzr#ln<#G@yjC+>7!5vpZS&m)cqTh0mL?p%Q>!lNH-i3w z8HxJM_xCcn(J758W=y81TY#zjinLe$;?rg)_O|zHqnS8FVr*}4&((klY zw!Oavx#X0QX2_$IF$QhEv{Z3%>7Yh%jl>*dImFrh;^8At!&l+JSDnf?WMRU}gB?`U z13m#l#bNDn-38AY*Q|l&M3!YhHVua_+EyMDF_h&V6FR2D1nqg=$V$gBNVL3k% zk2~M?2>k$F=seE>D!iflzkG0Zubbcl;`xZX>jfl`Ro_r-jNAi_$6gr`*)+##oY@!~ z`-O5+NN3_x;nuTZM&TK@3pYX!4~!D|vE-ye22~)I!*Lq0E2*{K`sUz|^h`~iIaEc> z$IF@>B5eGTu=zmb#c>6ONB5phE=t|#$`sfgFeN#19p6*_#k*%kBP9bF0x0)mTm8`N z$CL0{5-PVfg;Srs|7ZKV2i1*3k~r^9==%?I#*BEXpH`c>78$n_WSWA|4&=BD$;|po z-JKjanYhc}Uz?Xx7*Uau++yw~mp%vQ>FwdE4{#)(EAURzeHWoF8CDO?3NLwT-+Cp# zB>=9s*TX1VqDts`F;zVbOB@ML2#Cu8!^iD96!%55h;3(VlGTQ#GvY58P;6+ z5W+e&5u>O=BHfzWJWQ~em_H@2Xb@!BudL=|wj33QO^l#+u2fjkMbej*$#+G%FIaVK zN9w35rWsl}38Ry((E=VH_*2~AhhL|0=t~7<+(oRbhPRKy>kTV)JNiz>TA&v zJG-?qr$jQ1-)bU)1{l<#5hO7)_$UYxC%>t@bjVV(P_hQM)no*&HDO>BykiVnLl~4y`PH;*8=4?CGa*S99J3a&xJj zl|Wxs>X@~|VM1x41iRCHZc7fVY3A6A0koKPoVUf@jXRFG8$iH?98IYBvioztJi0#B zu)kI%-YWq|YYe%7^vOYmUhCMriohU>6tFIhcoTzrYO-wtV_Qy9)Sp`gniQ(0#dT`w zL5xXzFO?SYCeDUdMmwS#fKW?PPNk`%hWfT1!Zmn9F6CUI{rCT2afWfg z>Z&fc6^zFJHPvMRrXyLWaXO&VQXxT!KD>}kqw4XB>6*1Ya=l!V57=#F2jB``g`N26 z^KzBILA1pMQ0pMZ`(<;{LaMz>Dff2BBcQ8{@C08BmGpN4;Bf=|4Dn>YPYC^Yz`gip z8bgw)t9B!G1kK8mlIPD4XpaX|hqR!S)lcVI{fI86Hu*vrj5hfKn_dm}TsgA>(YY$b zz$y7eH+*yPJH9#|_rb~3J9b^$K=5aMCB-0z{3%r7tJ+x{hz3at?iHlUbTv-d6lxI-Rx9acAMY z?Bh9n2tR`0I1Iq?!s5|(7$G9Lk=j4Hmm~lE2Dhl@X$dbYuF_OII%qe~eI>H8*WZRR zcN3?k>z(!1Rl%>>0H`n%EA-J>`|HA(FOx=`%2UK>u0&WN`h$3@Ch|H|M6b-#7Q#Lb zxfM0bI;~nCNc>^ywgJe~9m$H6Gz6rla7BFiWQ%%Gdn6@&AdDogc41Fyem5}OVDpmX>2Xqgn z)*|j8aP7~# zziVUH`~n5 z5s?kHukAckqFBX^l%1B^AZU!S`G@pa zap>})tr+wVI;90(Si^U=F#@Bztsso+aH39pyn+Vx7(l=v=Bt>bo^{2b7C}zR0g;QD zY`a#rOlh9*UT$w`PP$}kn-Dh_IZFgn9K`if?@|ST-Zw;q;yXY5V$7x3H~R}$e*iM$ zbNXexqrC)dR&q1(Eohw{^@Ps>GgYj{2t{S^7Xv^x_tF}qx^iT z{t2@hv(ahfy6>!PWnZQIN4k4Qqwm@6$J3>&)UxxIdC9UF---IEgKp^I4om0z^}qaL zm;xw&Tz`2s8VG>@H*Vp7h_;YleetH~FVV)uefjpi_CMjzA^rRIE0)F*DMuu>@jpA{ zc1R@CEg>*QLmo%=c=+Y7I>j-vY1O^ruVqGeqHjgp`ISt$Gfg=r*LZ%H86b$ejt>HV z&VQ|AuEaNmsqER-gAc}6ZstO?$5yo)iw!H`HnjFJ8tIog5;yh=q1pMh7J63lx|U1M zU>Hyfv!RoN4zh{}(zK!SMjW6*QX;9iKv3zYoAW9vV~Ej>8k3HA@-|dprD)bYurp#J zQC3M3o6GY#6cvaWcM92Llqt}NI}l~rOyx+(A~Z>X#gdw0q2y}?p9$@m%H;`mDK_I# z991?(B;D<1PGQPun<;KHq;}-#^2oU=9VJ=!J3?7Tvto8?L!M~ZYt7lolbRQKwT4Sr z9LuTjEiEE$Vah79UROE+Jf)NpZ4}sq>-K=H#jAdH9QM8FrXqFw?0Y+ zAgX%Z%21+uiwYuh2$7!0|Am;$Aj)emP+Z6;EMW~IB$p{ZVT!6S2Sz|s=;mG$AOtxN zQ6|y|5)N|HP05n1q?M%IdEik~q4q+<&aBC41TIxzg~zj=9v~Epq6h#m8F0x6fXiol zQ4Udbs8B}$vZ>T=sz6fywWa}v|8l?8a|_q|eMjImahrJ0?B~Fp>cRjT*}!e?V^0e| ze#y(5o7=luzwfBi!^$_~W~km%3?@~~cE22=Kd?b}D{_bL{3dpNlP+uT9_i{U&G@-^ zCy;ohY_zpMPEIMuF*H9=8fKo{IVTp^yI=4pHhh|oKqs@6+6j-X*q1g(k4!ONdJh9P zA)=m%$E2ek8KG%Yvy*AET#H00t(%{QM1NuqnbSJyc=J~_zy01nd8Os#`28;7p&9h( zWFKsQ3DT%Wx4UNW?;h`(B`0ANl~fZ=UTWyjFY0+7S2j*t$^ue`AE)Ma#Swx^436Rx z2$u|1&THU?+P+@O}WFSv53%oudUR|zQF2Y#L2HD5VN4ru@XoALZh4#@#viS z`TWy@2L&Uvl!KS=@Au8naj&zIx0?Q&dmnZiGY#2X*z8dd!7wd{X_N&pC-Gr@4%875 zj7Bm8+vIM{(*U;cd!SlL=;S3I|AZEv-b4EMGtaFvHt9926|R_3a5CP>`}Y%Z=-dzX zhl?04-5w=B?Vj1BD!#|s^U>T;{H&wHxwJ#?&6!1W{3gy{ezex~U{!wAvuo>Q^IkWu zap|yQ8TSjGD-QqM`>)*>&nmUE{RZF5_Mtm2n7B#WozVlMflu}+Xl&rX6{(2RwIIhf zbTPiOul#n{|5gkn{4=2dr2qgJ{Q~~KJ0@XcU~6Dz;^_2$P^3CqNt#_$A5+j}Dhe!L?*@h0nv0;oNR3yjlj#NKBoqz}+l#)au@gT*uik)}?t)jb~ z^X8vF%gehY@{a~1K zZoBr-i89hhfb-bVK#68MupWav6 zMOsVoy-7V40Zv{YzSG6<{c{7#Qgic2c&j{Mqj}l>)y@5zQAs^%PYZcY1E{ zeS5OvgWoNZK=eHidLnDBI>N4*ZiNlG7^R^9PAS%loiBW*ZbnDCystj7;{9Vg;5P0v zp~(NJUJ9yXmh*y&@8PRpJipzdpNfipP|(3l*ze)!^4m6A?j9h%BRstu`uDGsg#Ocb zxbGt!p#;ph_aEku(5i(ZZ+90Mw}Ot2j@W@$vALu|YTfZUn^s89>=lhzxS1e*cbX7A zs{Qci z!+KmRX$Rkj28sRCbR*w6IivCkbjF;E5ihGl+dA45dh5R3W<35eIB@SB=)oUPfyCX? z^^Hk(!un8eDTIY(8kjDZGTW+}E#yorcfY|OU+T6h=!=X-wO97AVHWci{h&f?{ys1R zX=fs$+c7f)+Xnk%bi!`snWZ}A(z(li^pokd&jZGtCf@m}f=~&Ysv5m+UnikMX@vJM z%(ycV2I>^&t+|@Ur!qE1!m9$;m+{b^I_?Lhf9bkN5`|SGJ4FuCOA!l2SYB$u=}g=2 zHAQ!YB*sZgMbrxWe}VTrd=cDr*?s(opTA;Fe|$5misCI*WH(yY7MjfjcRfFWPNK}@ za_V(U5r16OZ9Zb&-&_f-1<#^hGb&kDfA!d za+m86Iq&mw1m$t`(f}rT>ZLlx2;NoEBqlpdkD*ch$OwcsWYCHlH!+izR@c=C$LIVdbj1%>hZmY{$5 z<7itzq&*0fidH8WP6%*9K(k-0HIR$EFbWkng%VF1>K@@BPL;RLh;es%mng#eIH*-RR}@)lCIfY@O7z9s?98vu}KH*9WY4F zPjuYpuR@>~OLuA*C@B!Cnlf_;SI~dKilhGyYoAqcT66N5Sj~Y#&4HHd%imny_?L!k`B-Rz`66Y<)yeEh*OV=5x`+TB7e$DKBOv%RB zu(M*Bes*2bfbVuke6<7FN|ShI5Zd&dgK<9=?EqFfs0qIx_PY-2ie`h^)jadhO?9E9 z-M<^!Gp=8BM6J3IHyiqILaIdLqOUnP=#PS8X~-oLoz}cF1lhkCnCbtbZ>)})IaF)$ z$;rP&%n^J%TWXbUJClcBua7y{3_>*2j3cck6_l1&p|GC((a;JWm5wF-$!-Z(x1%38 zC+!eYsy_R5s2Eki`%3!}dcFb)HG}I|?o9bidC!BAOGW%z+G{G}aj$^Nq{2S`yueQ= zn}{5kNZXuOz%sytN4L3+B|Xvi;dA^kh|*Y1xkWg-MCEPKlvPhnncrs+TUQhOAr*BZ zok@DzKmFoW^LHKKju z<#~exT*dq%RpIRxo>@iG5E-f@|LQ!IDDUou4?13 z%XM&kr5Yg`@%f~+8F&F!T0y9Jx~2!WHiI*3bGx4&MvLw&SfkvI@wljrV6bheJZ)-j z7Fb-_WesI>CgTO7_@ijFqo#IY@|L|Yy1wvbfroGp;7S2yftw(j!d*%YvP3=grnA56 zmgMb@g2mxk2n}v*2evlGc?i}FnQoD4z0e)>h7)qtKJ&<2<k{gCC$)J8mNown2DI#zg37$R0zVN9QDVv|rl0ULh7u%aMI5)=bJf4Ca zu)#pZI)MZQq^lRACl=plF~NCSpx>K3sQ;vof0tg#)jt{ATqfIn@t4iIqTmC1izSModRI~;SDkas?7wIgn*lt^P)%$C7rq8l|~2ma;} z99>UsOwC96kQ2oCCil~dWYS`FnrEe5 zS+00SHTMI-%v!*5O*ab0b2q-(y0UY*{?4BYoEC-DPOQe&^7{QbB@s1B*BWXqI(rV# zks6YxWf_w|E#RH}8;#=9?X-UeJji6fkWi5=-E94wiU4Q2w8~+z2^fteT{sP!vE{{_ z|C;6#-*=u8yh(bfaFO91b}qO-g_j$>G4J|lgka~JzZ%cNlO4wPwcIKZdrJ^JzXR9X zjFkxM-h5%E#U$u&#>4kyvsys^&eo=9gRKQqzLrrQDf6=JY zze_b86$gLcTqx| z(qE-eln#AnC$~ePtBM{JfI5V`=4Xz|*!KOps~RpTq0)!6TO{XPyPEbTz@5X(I(i&%=*a`zDJ%H$po?H zwk1L$p3NO8_KsZQZZY|Nq(_??`-pmolg(wR5<&ARVvfpV8`x0$q!?}*G^tGO@W>^p zhK%VbX-oPcdHHytoE>$6LDyFWDeIq8ML>$`~{Pw0$Zo0QS2Si4q; zgCEykBR9X&g>wbD@g!WcecE6@c!`Sdy#lln=IG@2=08T9?=CWdK$m0Dp?+G=_l%N*|wT5+&P22>aM|IwT#nqmbLmGGf1Ye6B$5QDX3$*#z{Fw7F$lcI9A;oB+O zB7&mlH%myLe<7JzbXWSPuzna_-lH}$R9feobdJpnOP;9g0O)zql2X|&Ia>1&L=CiN zLP6>}&{J!M@HYr+%E}YHA;#r9n~rXmsHalA`HF@jsDuZG2-HPZa_efnt<%(YC$+&`O~QLUAMD4vhiWAbd8!{_%iUy`mIEl!Jx-Q$YwdS&O3 zo$(X4MQZ3twl+5~9YvuLQOExlmK56>i_d3Pl8jFrcagTSV8PU|jo?r}B+nhyF_XGJIJUg-ykVY~bF}p(dR1){qJ5^vC#GB^} zmWTpcFfYnwOti0&N|mt=?<~uiP`MG*Dr%1x_LdhN*e0fl3jz=os4MTfDQ@pha$ZGPPa z_6H^mzV9K__mOhG&?8-VX_&VX^Q^(6Ef4yHr=>j_RxgEd5gA!izBVg|EyJcGQ?+NSnD6L-YJ+P1oJZKP%Fv|$N zBwH!}<&X^_>BZN(&S_#t>v)P%l8Snsg&TpW!RRNx<$P=d`E)BUlREG3e9Lf!o{D1h z&~D@|(aO|$v!pdHhsub8i*TXj8GgorWq$wOD~wbW4RZFW;qM?>wWT&kuHLO8kI(QL zz*Ae?ri8)SjX;aw{{aqGc$^d7^VsEb6s%>vJ1j69Fx<=F!LwsGw!CvuHEMRY z_P|Eam;SZEFA_ZYF925nW+1W>CXIWM*hlX54Xf~NlT(K_pjK1~5L`hFw^YdXe#G_E zj=J|O2bO~?Azy+jA=Qv;$h2kJGK|_Yq=Aoj6X5n2D_=O=mv1Uu_r(I3r0M_gp->Dq z3IsfagHD`;B{=yX2OI-_1boR`1!-cdE0i&+E2t}wP$jgBc|!;ApL|y_zcyH8BpyTG z*ibR%Nho{^yD9y{wfDlkv84i87W7)Y7M;AN>znc7LJ(LLiJ$4FU*l3yTuNcJ*}J&J z!_|OB3!$zr*u9V7n7Dqi|3cx_ZDA)u$ZJ>m3Zw@TfzTT=&Q)g0VdYW|P zG8U#JxuU(IU4zGl+KcYPxp*=w7t`;<~?#oFb!c z6Xs28CE+z3~ajRbSC0v~g;>xXwx_C{rjxuLn<`8lK-@IQKC z*qRd>xjIslpmo#gw6y0kRX93wN6I6Isu{o~XzN_hSy(LN%Oqe+JWJh`5wXDIga0Y> zy0;>HN+DJY$%E5~F@qTSYxk)I`0*KU%q;w{$qdkz*%z<5@+xUaSLI~`a?Hji!JFOQ zReucwSk{n~;S~^qB{a&bz}Ppr8$>1Fl=1yg#l9KQ+W-arrU8tFh4J6VO6vTR`f|w2 z#rgEuS#jSuJq{gPPC^HjIBqx1_ga$fOM(lG^XOM8~82)CMG1?h}XUi-`VRqBnyY>AVJJk*hR`jxi1p6IpHyt|%+9+Udl0i_`5RZeZ4IhxH@Mr9za(=p za5E-HxGZ~;z=CUeC4)68%!@g9jfLTZPI^Jq;LWl`@+U!}m}7&L)5?JUA*k<>Oo}6B z38m*Z`t348tw1eiYU6I37FWgKYJjNnIKyI4*bRDM(WQ9c&^tRK0~F$9+5OH-=U-Bc-II)u|AbChC>b~hYwK+`8nh(-RKln8V*|~vN>#~%|8x$ z>l|r00Y4;ErHP5j6&J;KNQf+~fWGAaM5>vIM1CM|BW@x}b+oXsK>NGeL9u4oG? zv>=om7y4D&zWS%Z*#J#i^RhV#)hch$xiw1!D}N6EWDIm)|L(Z7;e#7@zvswxUGk?r2wA%;$ z`De3iN9ashrZ?zf(vfWjC&zh*B@{JLH^rT*V9M=?N{59*US-X+A1NEV)XlNPCP zo!ruasQJO`HWOR@0b}ytxkCe4r~r{KP}~_$@2%S~0(QY1R;|~gV(3jzRPC2-bT`RG z>vC!RfbPsV+Qtx8^UBr9CVR`RyHSY`P8xuz)vbVrD5aBB?x3 zrRnUA6m&&rvDN6ZsYK!C`L%}`YvzK)cLwsJUpZ9Rc?$l{{C<>%n$sw}pqtMfMz6!n zY@9%lr~*rJ7+7Y)!IVr%!O0l=1>OoE%=biZyeotq7Tr=^p0AfxL`_koGF>fFscNHM z`{sxNx16|Ij{YJuH{!+F60Q)#0vGe}v>+WHI0JSV-7cj(3(TdfZh70NqZg8C!CtwJ zr3tcq+PbOBD%oAhK&3sPjI5l~WA#Bsp5p8fFxOOItT16u1unwb_$4!@7jchXghSxL z>O?&f%$!h&p%7_T49y@(htnV0c#1jx*ilF#?-4Ed)0E9*iYzG?K}xi8=EiL#WnQ=6 z@`9Hod6*=Ba<=Q#DER|2mxpeGazUCNsSbU-on!Yutq80&PLfx_1X;S>M#$AM6x#YW z#uO~7l*~#*5-IK?myFED&V>~~OGI-yjk#Hu+{m-OEZH|V+&Xj=(d$bk!es*$Q!}C^ zy_JD9zeOpd9*b!bRhifGj5~XXuVo&n(K$<^hq7p_)1%E=|26Dl^ouk`u6Yw=ROJgPl`}?)FAz2xEtryF(+Loe z9Wz_fyXjft!omWn46fyG1l6K$oETn(*&r76%B?T=N#rUY<`>Aa0>Lj~CMXD}i)f-P z_$W9ueT_t6?H9gkd+&kyurHl5dUYk}-7ZJkAC&}w&-T*qXSX67Km$?ZnRj!T#0{td zY+0zULFtkgdZ81ojMA*6FuA88iySAr3fXB8n8L)QbTDv^pamb1UKv9 zRY5gm-eUvT*{r%Xz=qy#t6iwLcqZ4ZAf0-o@s!G}7*_OeGNN6vvbHb5au)U8=0LJ> zOIrlQK=+N@o+cKh>sAT^sfnFfzB?%OyPjWXfm0F6GmXW@NW;o!L?pnUzLK2QTI{aMi!PscZAnn%`Li5?euWvAzZ4JjiYu9IL*K zwAPGyAx@=0ygoBMof`B3Q2P$6C@X@Kw=;oibF7|36XD{w&ABG?!ObAXOgVfWJ)`E~ zEsO_yCY~Wn&>~H6i`UvdGBD0*;S0)@VAdOpN(oMZ1~yA{5ePr zyPWb&gY#kDa^>St0C9ia!a+*UCL%w<0&J6 zWzrr>sa3kc%s3_|fy{8<+JK#kbXGBr|G4J>*w0C;*S#|FkU;a8XFWkw>_JeLmojYSe^|P54l)8wfaaKW2 zLmciGQ4Zl(aQ7%j0Y#CUoho)0?f6p#x-0OzhC+pjR{h<5D zqy4sP`b*4agC2sTGpWTGQT>BUY|=6x*b0ZPZ5vz*VVXwxCEa(woJ;kvTlyswk3uGZ zRVwCFbbE>I0c*@m{n+D(v1n=BC2SH7aCt*&9P3yv*mrwaVy{Yxu5hS^6;9MfASqWX z5=vf1zq3$nmMKu-0^AqT-L=wl7ZEeQZ2pMKPgc8wPZ&WoMt!EathMOT-_eTwIp!}0 z)U{Z#cdu+C7S#Q*pUHWe7|O1kD6PTIkU~Mw)GnWe7!&H3Vt}3rgv||Qa%r~Yv3yT8 zDd{sUX7m)jqJu3C;DM@l3KhvX@};>M#h_$sWUdfwvtYcZ@R#7(@-`F0#Fm6{bOV}w zf#G*5gOL(_(gNbq+(RB3_*o9|Dc^(=-teG;Bfe=<{YFNWGv^Z`5fEa!q_E{ z>|ACLqyK;7ELRSP*w%vku$X3|Yb*R1K{YhJl(gFCbB+wa@Qwx0osk~^Eq_%{eI8k8 z&RMw@|9Z`%L>H(CMv*N$x!(&&i$1=3miZ9C4DJEK3~mPJ_X3z1oV5?*%$7Lj;1_ng z!p$78K#R_7ln(ftr7k-3-;o32U^u*0%*9LTN5Q(qy2U=0D;9M?*LUeFmdN0lUh(j_ zY}(GSYxAw`?o6{BN$&{dgEjMl+yi?mG^}eZ>2~t#%fk}!S<$Qs@8?rUT7Y_0Fc|U@M0XA@2iaP z9B~n|FA+2d{=alOfAtyZHw<@p(4Hj1-{cw7+*v@ni46_dXJ>2$i|E9P{z62CL7EX} z@Aig8R4yK>C%RJ8J6s{f>STmlOt0^ILuA1R(6G#e@uO`Vydh+x(roM(6cTRi8-j!W zXC5cvTY8dR+qQ8@o3z(P)Y1n1CSR&4HSt0kndu z!&;;5dG-T*Lf6c~|7#$Dd%+CUos>J!*P0EnyVPI`D)9X_yf_{HZv5SfI;pnCU3Rj3 z_q}$@0q5WtCDU!i;~J*S23;>dFOxNYZH~#VbrJC=JJav3BtrK-T{}TZT_8SNy_I&g z9YZ#A3X;DGNayzQVlUdMP`&tZsUnutnZVX=l0Adcz8{2Sze3$_JhH(|E^ zpYgc>s}|6bAbD}yrbk5k;0Fd;)(Rw#-J0Rg)p>yulOT}ODl3U{OFQp{xUKlYhK7+P zQA~{Gct-U}({0H}E^>yJho9HE1U)0-PSWiYn}O-oSzXUJBgKCxOO6F4 zx)eOT{@?=Twy{WrGn19d?DHN_Ul$Z-ax*4G)0*fGNMjTck&r3eHmU0}$A2ReKbbl( zKrqYM&P%2%-cdbaU`xcT@e)0OWZyA@Zwy>Q z>f3|Dlvv?$xHz2c&o*AIIxNAcaG5hH_o~+Ax$8Q!W?o|2NZx}Q1jqH^Zd0Shlihjl&Esa%JzJ7mI zcjp02fYvBaglO&+RnfdDtW^4`A;)6*slVay7>@+1jlT}#KQxflHK7Dm?+7FznX{5( zM{iwX8W%zV>7pU4JpYgQM$A+mQy{cZIqFMaUsyE`4%FY|zGzm8koW7bm*M;v$v$^^ zu%$Jor=%?{PHz#{5w4Mr8LMsSK?9qabnq)9Nl8-HauHYFlIcX#8dlT{&DcubW$y_& z&Gy*ayg#P;A}fN$3p*c{{W#fKGsGRLE#r(4sCHYbdc9d?sxG=QvpSzy@<;rkbkw|Z z*fpwFAxjQ8TU)`zyC>(hLG|KSO;|>VpM_a5UmyujiGE}AH&~;I3YWraoe*drXi^b| zbp&EAIDwC>S~y@OUFnjek|-P*o&xl4IDla*!?tK^^3$r6NrAUD)mYg)asvO5KJB7h zNUx)S9@p0994K_ViIn8Gqccu%8K)SzE!~5SRihoR9GA?;U5zb>U>R8Vw-_ksv$O`i z4`slFd)}&j8y&Z!3I<*y;;zSRHEL-W;O~Dpn#)(1z)(0t3=4r^CTB~T;*eVfqw?OH*H|85Ck+&&H&33-ZW9>AMn z+GLq0z8)vRETT6V3YluzHEBC2LAm8$P{3f<%NH2t?@3oIr&D!ot?~ip>d*21loZW! zqAd=19oqfVOUUIJ95jM+4-2hHSnh1Ca!+^N^6kq2s_k|8ONLJ0gLvBvt@pMdu%{Df zX313|^rgt9ee{V~A}2pTS`H(Cxqj1Tk zn?zFVf}|Lj-AcRGZ{=3BOu&+`+NKX2!MtYh}k7-N+{!-8zYrxt2 z5w7pz6bQv)6zW;Dit^?lR$9i-Tv8_HDU+Z^dX^nK(-1iGw~TYms-^&DaXNx(ZfaWn zc@P(&nwZ-vJJ6Pv?&%4onvCAZiXZlLtlTRZyM-k9e%94tH52I}3ZXpN! z!qNHjgdR_@Tx)Lb`)|egj`Mx}*jRe0HtVb7_Y=o5#aH$H+Ux6%-|L0>eMotKrIfz; zcJcleRmy1s`&}}`?bqSE^@kTV1P0#Vj)fkmr?a0#t{KJ#udtHu=YK^>MVf!YHvXC~ ztN!1bFaM9K@PEY7HY!@Rhr%%5^d4~2TlA+f=p?~I$X)*O$Mrm=0W6Rbo_SR-y@wWV zYKELP%hEpBA0O7QLSpuPZ=TU=H@eO+I~2K1m)r->-T_|&Uat>UYMcxo8A!wWGL{d9X-&?npL`hm~-RC*G;pk`IM;IM907>8Fk_16{^2N@OSe~$r;Je{1LPm^S2LbXf4R*!_> zX^2tD>!%TySXGY8p*0zaFHE5T?AXn@=8kzR^zyM&wsW?VwzIa=rXSsTd4TDT?Ut3P z_4*pO*&aDQFHyPW>puSX>5Ud1bzANEekc9s@Lv|Hc1grNMw$nofPloOXJ)L!HrkO7 z{^{ys2hAe4q4vq!`M;irz1}!CmJd+2lE;+VNmoBHKkgCN&0O}L`aiE9bi1P_Nu~(R zS+k=)Ch1RXzxD`^Z_2#zX6+fL-OEou*qW2OqKG`n)y4uLtv2gD@&th`(j95C>v_~% z*Z$rIQ#t+mwCxi+;tHw)7EqSnaU?H#YEB`Tx*k^SF}05k_n0N+UYsr1QNsTpXYUwg ziJENfrmadFm9}kXrEON)wr$(CZ5x%gZQFTs_vvq+(|zw9qrdZijrA*5tmmB(F(=(B z1wOBGvdfx)a9eBJek4qYMcHx1Se-^(aZ?W6p@oBn$k&GN&&zg0&zj{DL_jA{y|+jlzBg&TKZr9$t*2rAfj4L}riT9F?wY9~+ zY!yeZHT~tXoXz@30vk5hRcMMxB^`%ElzwLuCXhqyR#`y>)plOxUh~z-NJK1GXYi}( zk|fNC`m}FHljr*;=k_bS@FWIb^}Xr2;py?pGMJ_cb%?|cJNcXp`RXTW19x^R6NJT+ z)d~G!4`Di4gooKOXY@;Mt?dYo$KF(T6{?f2%K*IPE;>JY|?K= zhDtb2{m)=bjkUTGtR))gPLqq}BBC1%iU%&^uSBrv`>-+Svk?!tw2AHT zRSN{e=!TGXIoEFv}iLW5kWg<6YfAAanzQiSa-CwCquO4KNF1Bp@MVxCQP=d7(0IMPzR=>&dLfR^($Cmqa@>K7(LSB(xpB{Z95gpF9we z3iK-o<_J9zzw;*4jDqsw$GgV@!HBD_;_VQtI1(64)IM;q9R1#i^ zgy)*a-ZD`W@*d4kuiwiZ1JWsYt;bLpPth1aEz><&Eil=>Cw$=-t9sQgL0<=w&b_ur zW(2wG5rbfE2X7OsaenpmS6n&q9r8q}3CMkE7RrK}obmj`%f-1Bq-4NwDBO2};nF+D z&SYHdgJ1nMrt?CZQF;V;|{8m zenEFpHAVa50!ke)_Sg^+&4#1id6QNrc`zq=iWn_^xZof7Mm&5VtJ+BZ4Sl}?&42iL zidU5JKR)ucAcTN{YR`NMh20pMZ)+_6hHdEwx-{8|G2fo~Q4+A+i0)|u3+*=|6y$qN zi;jRyLc}ndU}`cwO$WnNV8#Z+!-Z&(8npyK%tndWJ3@(5dlGVi_>)EPX(uRhS`N@0 z-QK0-=OC+IYsnda?Jc!hiJ7~0dZQFPy^h0nSD_ulP1AN5Jh8e!n$7L>j=*?J|P_; zIqk!=RQ7mW!A(z^(U^C_9Zu4N4B9{| zdX^AkBu*xUMBNu;Tx}{4MjL)S@Vpn;wKlI0L&0et<16Z93OXK$)?<;zx+m-zdXI|o zfebNz?EJD_-8jLw12PH$E4K@gXiHm26Q{8VQgEBFQb%BGgxMRoJ_X8YLb4>qS^Y)X zS*EN~S;gJkcE6=rl0W5Qs{@9c-)ByR3lmNnLtU)jpaw_YkW3fs=oP%r5Z+hNES-d2EP@MV zqPl?16rC4>Aub?1bKz>N+)#$IQKX6-%Tc8n{u2sZVj?BP-?oSG!c-D+3BL4r{OQ@l zeoZBcwYoeR*;-9uojA07hQ!7Gwd}SX3C27_vLa^-!?ojKeYWm2P%BfLJ7q+uiRwEI z(SCSQ@i)${=`Hy%+`!l&Dyk^_o^ttn+*GOXony9!T*9TMQDR3gYm##4iP_N!KZ{9a zE%*tG-4(E^2Y5Hm>1N=X?ZtXpNYRp~M*H?!pmg#5te(=mhX>av#6xMER-Ex&m z#LQHaUypc7jHsPOUCE~vl=QfO%{3pzTE?z-d37k39lFpi+pZpPE_c7y; zJ)jD1fBqp;|CZsino31L6)FMnEY21Ho1ljhgX?NULCnv+2W4x06%Gz`M6B}50tu48 zlPK-Jrz5GRoQp7TDs{h~KLt~C@a#vTI7obYjSLx9?p^0*tH-Hz59MNg#Bj)}$}2`8 z-u)(ZKU2lrmR#ueQj#;dM_R>!Yh`?!YHz#{&y8XFr>6o<#GB^SV!n;4GlOa#oSIUyEvK2ta7!M`9+V>SZwN(@ zt=TGY?(LK*HOG96i!?be>;*C+Od8*Kq(Ur&Lsnz)d@)RuySgN@Z#6leK2CF;?nHf1 z>m8rhX7ny!zjU3PNL!&(Id?ClpJ}V_C**{S{X8%YcEzT!63y(4R>h_;|CH0t$BQUj z6hCClFu{rpCqHNca_i4W4;{eyPs{1Ex|B25v+)d=)IS+o*r_c-dL2D4))&Gk=GMnI zT4dHrwclO<%nw#Drjn;gTY+Y>WXW+py#_SVW0mt1OVI+&|4g5D8IVi{h(ZG>kH(nu zN7;CZQ@4zU)%1ydb6c!Qn4xz^Bq#)B%;27XJ3jVwSXFJx#2i%=0T5~Jjmk1AzgQHH zoO@Gm(D!m{v*gN?-C*J#p7&!+&Nd!H)7jRHcY1HwTl%>!Wd$VG?(O!|2sJ35RR7)} z0(E5|ZxNNmfJxjbBF-&*xOO~Ycyt@;0^dmxx~pT*&2$fmzeLz=MH>{GbOKZN0ypXg z0P8_*eua+CMl}VJ+jrDj8%NT(ixjELne9 zT~FUtxP7RnI2BhPsMqQxSWwC{#|bqcr7NNlNw)&9w~+8Hr0tJs1bB_9@bbQ5@=LRw zo9Pzkm3%B#sh1H^97Hw%xzt5BVwAXy(eY1Z{Sb z*oYRDol$-0Y@tX+GEle`1U40y5#`({Ezt6;PKrD!n=1y+!Y<+7fqROjP~`dYTC(E| z%WxTuCBP5B!0_tYm*NX@zZ#|I&7z-$==dn|8j1@ZFg^z44e)CKlK~+mxa9HD%BRE) z<8lvGlT)1|atmkiC>J^A^Yx_Rc|WetHam|K-a2uAc%cTDWm+i}-?MMKv502_dp&kc zJj3@+IuLP$Q2RYQ39i8Ox5JrO=WZ32_+%JI?jmDS)mvwyR713~L?`T`O_gFJj&g>? z5gA@(aTQoy@4GUae$MC0?JrzRxT3(b4f{_WU5^1cRG*P#Bl}U(n5EA+O18`r41g93 zFRnZ{om7PG7oIVMz~|`0ydIcx2N{+qU?jKCfB~;L(RiEE#Rg`IQ6Yw>5+VoYi2vZ% zlpTA0zlLtI)CbPoGtVHBFx8TuEpdH*g-9nIl}}4fU^2tyNZq%Jpq3<8z$`^&F(bv( z!qg!oRuhMgkGnm@K)869(hAjy>eTkyE%55s!;E6Xg>1gwK6U5&&_#*s?0*+sgQPmF zN+O|ec0l2-2(h-h0Ds-xacgw9|ImN>R1xvlxbyyceKI~eWd3jj&DQ=4_SUnLX>}@r z&=p+>h!HBB+^xHJFE5hR%kdkoPc#`-I#M?`wYmOXLDNXZ#Ps-f@_zVM(f;$-^WW;3 zmcn?{JRi(^GEeZYZoI#f%S5c2-AQA9#@bj-yr%j0@5Gq67WrjV+ z(XJqDk78vIc49O{k6x&+gn}(%i88bjxxs8wS;PVfky;AX5PDp!d#Pih>91>gaJp5! zhe?i9g^9*_K4MOP2fo#7Wa@>JtD@lE< zr4o^k!J<|y)(P9kE&Y8)F~MBGcHbN9YRtl$FxjMSln)#?qVXtD8GSa@*k;6$&= zh>EAs^hZ`;+TKt(%p^;Yqua*4Pww@@8&cD(;g>*aQkJQjZ>~Q0Zn;nFYg!7E6&wsa`L3d zD26|CCQ&S?W?$mW2RbajwlaefFzKb+xptq)h8`sEPKXIK^Cp~iN0c-9P5EMa%_A^S ztgEF$*biX1Tu${W_dIs0fbOQO5M(j}9QT&Iv`w^EQ7%75WV;h=P2eG1iXPp}^@i-7 z^I2I}v^U4DlATQp4v@S;@pvAus0*8}>^L`;?$}cNXD?*9!X(;!9w;ba?DtHjHZ?Z-h*-)bxuQz>kXauex=>doTMKDSKUR*Bm8>jFl*lWnGm%9U22M3OqFQZ-xABBVIc zIv~)kNLQ+l2eTEUZAP77-4L%lCiY}n3jDl*&MePVrSd+n&vy+w*1PJAAJ*ul%A2&}Wc`jP!XhY-E&5rGFXL$N@BOaVuE-?*E zsG`Wp$EQCE!i7cF9^7aUX$0B`Lv}Ase8MsrOzgPwi`&hQXJ8;=+JA)?s&WL^20$7N z5{4Y1Q5ih~P}(wNsj7?*B6%H=LDH#M!@zlVtWT8TbVAik13leWiWY*#M`((K5gA9- z&sc|{A7m3F=f|~Y%5>8pA~fN^#~6yVrn{z1aco>k!$2OBj&4KM@|K!(20maxC>BJZsy}*yfVBB$6d`#BP%5Q4KVt-b5vlg+bf3(_ z&Qn<_01xtmHa( zrqMHDgKmT6HDR$~TTjaQ)eXKe-RH;3s-q_?ddLD#;3$FO^YKb|mB zmDl-nKNRw;V+t&$_Gi**tovF5CNMo|7rERk`u@IDm?V0jk0bJT``99^g#Mw1LP~+I%*b$(4OdN5 zXY3&cMMZcxTQ!)N?3|Ko&Qd>?bKii+&5Krjv#qnlr=!BqlWiXEsRjHz4U*|vs#FAK zf)yY0r7&S8z1weW+>1&s-6MH=c+lU2GV*i$8!_J4uw4B`w;W@R+-yBum5c zVMCs%AqU3jb~}Q4zLQdLDF?PS{pi(2d+wGKn7GagoFJPIvtj2* znj4VBb?o)TiSJDL!->b&$}`NKFP#Uqwg23{_Me-3-b&Nk%e_C`;TiAlVLj4-V<9d#ctuG@QZ{@Pclvr|PZG8S)a>I2*oZnyk^KDD= zpCy_6kH3w-DacSkDte9%;e(bZc}Qn3db0(efJoW~FxSi?x;cm`G|oZpJHORDnrVIx#v?j~S76xH^bqJe4_2)zbQTvAD%=@;fv zdHi-=UP?U7YDgx8nk#K%{;q!lKLJL=o&r?^ILU?Mx_EU!AL-KTL0q-6g%m$K0>axmkg0i-+@EftG(^!Nf>m%-Bglef zg>Dp(l0_Hu;v@ji5c?L}>EaX|=D-Z!L6gKo1%`Q*)N0~El0zy=_@d@@XK~Ko+{<9-)=OScHcAPp_D= zOuJ>)*$Jyj{m4wJtD{9gR5C1U)Cj7i`{GR`bhfS7d)L=rhuxDDJ$JWNJ%XlZs9;i= za7d@yy!tJWRgMUID?~Ots_>WreWVvF&J3tD=4k$wE%!iwIlxwFxsM7D|65ar2~P(A zFjm!q*`Qpygvlsj^8~wv_K^<@PhJx8WxI4Z5hdGIrT4`cYk<(d#p<pZO?cAMvrN8Y8~)=q}ZMb_r}_~pB@7jJhsA$9g`^7W4X*QWgWYf{@uTh>tTdE#YK z(`-8A9ehWAA*W8U`4efnmD>1_*MZ5?(#(g0C2MPPI}3L6)OV_i#FqX21sW%kOWuH8 zYEf#l-Ey<$l^TzRT}NTzBgJK*%RB79Qcx@vSqJzx1?^!00C4_i!?6EDLH34rPKI{> z%|Z)mTmN7o6)5iZq$QO8wB|hFAYCy!{1sef7!YbX7OXJj=l)8LeeW%tUvhOw!$}tn zoBn_|VzFDE&QGxopMQ!1bA}AiRvgzFk~M2A;$p`j2~7X=XmaYg<}39^?AXy;D)tkV zCUmh%R;L8*B-Ts+O+$2B#oi=;(~uQh zGl@D=jOO|+z}Pp9McrN?q?6(IbR)5yU^ZsV0)9EN}W8qO^+%i^anZ% z2dna2AEH_1p}jFZMVH{RS3v-Fs)4);kM5R24urt`GLX-@U9su(xcyB7^l?!(_s{k#GH8CJj{phAeW}m4j zsDhp4NxW@`h-B-e-qp?f)+I+#UdTu^>|wC;%8*k;F!v05cmzGUg&5QtLhKy6b3UBJ zvP(=8>P1U^$XTqXu-@P1ChQeH+OX9ALss}iRIk?nGu3%X=d%My@~$AR+^a&{(+%*U zDzmB-(D$Kc(N5DZ+D)BCn znbTy?R&=Kv4TD-bA)EjCWZRKKpdql5WxC{(V`;=6cQj2U0f(i?3&9>8NN=RzVdm_4 zQ+W&7`hLTyPed!s)9Br^nX<^U_J^K;OZSXWTN9F9x{fxJN+3Z2&W}MI+*7tIClZ8| z@76dTgM>)+XPu08X3e5rU}v1QhUSMz(aTu&we6%o5Vuss{mPO?!Xfm$vb?Qj&h$Od zYd&x^$RjWc(6^r}M;O?7AEYz3akCLXGS7QV)^9j89KQHA3W%fwDB&6)IsCx5k>UWI z?vEu{6NKQ499Dpfb-vsOt9#aBvhGe_$X+a9cs>2|28+yTl`^-)9P`Cr$3A^n#K4}i zyj_oIQK!hR);qsEGX^+uTEFU=iqM=x1Y%$k4iUp0Up5#%NBOb^i$rndRps9d)VlsW z_}9Hz%&}iL5vMLm+#0C8^Kvc>M!2`?YyJKloN@%Qv)THvTmNWK@s5J*Uk+9LZo0}>lkqDPxoqYze7y2x*G~+{IIZSqJ8+pK@p(}ExKsC_eM1Y zIf!iY_mB;wzt^n0&$|?g9_YW?uM^7H?rJ~+{B0b9D)m&2IiA&ewY4e%YW?lEEr%Mn7ucDO>|z!!C~WKioTpzmmXc%e1D8Zt*KpnJL3t~2x^t|*~9HZ zZ)3DblP_bTV#}@mOzDSe!?)o~HzTUIdTdj|q z))5qK-^$xXtCotTv3m^rdMny%rJlw84*swGRaF{U@h@CJ`Zi?z-}TqOp~3N6IzggG zct^{w$LPjuwdEHO%h(1IkXy4NH6mrqcLfPt_~g8xQV$i@tdMhTEoy7~7+|Qd>8N<{ zJIBvpj>Dq8k21`NGFF{zsp)4m5(J4CgoDZ(>x0UtV@H#mkOw}@;|fWETBFYnu-8lAZoM>B4D2Nn1N}x5bBgv8!YYl_T3laqQ_Z!(_CP1TQ{^ zsK72unGgt4hPz__O`w@!g7>T^3o@Yb<*ZAVRie4C60dZg*7chi{2Yd?+MVc%?K^k- z6P=Tt--i!8(QRKcx?ZERU*RzYmzI{Lnjg5}%xDdo=TY!T#G-Bb3ftFgIrDJ1lZ|d^@PYtX8pVScgH#LwZT^L*n z0^EtoZFxRz+@OD?OF{ETj`A3xSdU0(q#;bIq z8&K(s2bNW_$u6qvt;pu%^&XtKLo4q;T+h5OMon6T~I!U0f`Y1mP*N2(FM;%lapF~5+0V~eEC z)Cni->9fH0#D}!y>Mb&KqLyB}6P{et`drubGaXCh8PY8SLPA=1c7uZ|*5+}kU_^@@ z65K!7AO#A}(J*C6=$BGWlb3O?_wJLfh=iG6#yW^9xTr19k`+UXS+Bt92q>aRUS0OG zI)OWmsrV>{yyt0!wZlvH>&R^d2Y~n%=?3&J(%qL%7J~BHmIlxx@di~xTH3kjuB@0} zHI?j+MZ$oqLsPp{U3I=t~qlLEEd3TDGr6)L+m8lyJYM&E?SG$Ina$h~R8TI&gNFVZ2`0n6DLs zpga%>p~rYMOMnR>USl$=5^ZnxdAcSp3QH~Y5V3EMHMVjy7u$xIgX30 z5w8M4Ouy={;b|YnmMx^Gq+%2wQUF8%ghQJ`99%dQka4@fz#6|kJ}BZ?Sx9fsXcbo3 zU^Z;1PC%CbX|V%lqjKw0J*uH=-eaTSI4d|w{OQqiCCnzPR)3|oh9UavfA z{NTGyBd*-!(Ixt1D5RTzL^1)VKQWohY`DgdF!&4m?j_0XOxQW{coE`hoBFdn@)k;bHa>b>uuAa1Vx(yW|tqbR6 z3S_?CqdDm4Hz-=@Pw$o!jbT8%Pq1|C+l8qHjjxSs2nfy2ueHB$gCzOD8?#8t%9Jvp z>)^j|Bh40A)5;84muS^Sgh<3!79 z8*6xtOelWodV~F`IgyysUSC49lV9hPZdGc${_P9kUvUE?$Ii(28#hqCapQm4ng8Yu zONDVO5@_ zK6V;vqa*ewAX(ogaU(4Y@mUdl30X}f=AWU$KmoLJ#>T);vsi^KOJ!snL_rhrq-7xK zRLOnlg!&VUDBuQ45+T~E*#O6k?xKptesTQqC5bs=Fp-SE4*HnUz*>Q$=?9GPO%wpI z!O!x6iKQqOP5Sk(Yxj5QMDr0@fVqwg5GhoISjbIK688p){3*aCS=fXrP?w{R&R;p! z^X53~kvLoD6bH99nU-oFK&<~L8&HPvKG}}26})D=yPhp^{L$|-EbZU$!YuWs+VhYp z(q?ULd<3Cg3Oseogm51>>$OhI$*sjNGQv1=&CroVnl_q(gTYasvJ6EoO71Y&QA*$m zPx(>6pgGL|(A2y158NPeL64einb(PQVU1wK&AnRD%VZ@oP_?Crxo)x}B6PfEA(_N+ zPLt;I4}1(Z&Xlkt7e`@o(_g*#T+u@7i;A367j4_viH$siJlccqk?aX6B%2o&K|n;b zF*dxVhYS;-I5K7Z8FM|~FV3EQQVnw_h_j63Tf^eLu>^WlsIjveJ%5_a1-HS&&!N|> z#gQ^ss(rl?=zR3_e)J;v%^Y!EFEZJ0(f`F9OAk!{WR5MVn9CiwjBa?n?Jb-RlNMC}G_K`x}F zfcg$l0M)Q9CgX6EM@ACNL$^J4@!&>DQ3Y$KdDPVKUDjUoCqdvewvT4q+UCQOxC8@X z#bpCaN1}-pCr-khvKW9NYGA!ICRkK8j0U;ASJ0;;8V7BvS>}W9LK-dIPINY7o)7z_ z|85%pvG_Xzyoax;c@;n0*^D%e|J5~P(!dDJX_vJ``B4XU3y)2V4&oS3HK&HK%nmic zUEOE4-+lc^{htwk1{4nOGCCs>bqz=LIuWHrCajK`<2HFpdNb9jkzui$$Tvu_T<2H= zELX7?bHkP_ZO%X(O;zq)CLQiD8nR5W-&umX=zBxUJ^poI=5NbmBk{Hqd>ZfW!ueh%2Eqi2GjG3wPwwE_wPPQ2{e!bA>e%IyHw;=p~-E#k{ zF8^-1hTkoBjqdx2m!%bc6>Qy!Oa^s7udR+eWY-$8G8TtF3b~H|wV8+nVqzCllcE)A zWIQ4&ryIEEYH5rJj?ytVD{SbTH|@aHYnLaC?FB_0-Y|(_+(}8U@@v$<)T2b3BRUMx z2&3@_=MT9+{KjI$yaYKwjGE1FU08IUhf^m$Hc6DxP=NuMV$nk4I+Q(+QAEVNOm1>t zkw^r0;r|TC`*`7c(p2TMg0M@rwwL%Nbddo=1!Qm)UB|G|_^8h`)sf z)YhEIjSn!iOO>tKq(dy@G?BwWu>KM0G-*e+UP?nfNoi~l$+fzX2{R&@@ufV-WK|#| zQTFtJW@YG%jE#MFR|%07?=6zobl7;Sy7w%9^;DePt3ka?+JTXpSxvwbC)E^@l{}!s z7A^tGLwbZa^)0?JIu|z>gmxT>esidn1aS zWbiHxoj=LEvv~_+ldPW!y4T@AZJ*ft?#1j?Nq|*eG}1 z71|7`*Y2_O+nt9li_P0hyVe_bTC~J$(P=!VcAC2_WFdma$C>_2@MSC{-Ev#^PGVcH zly-`{lV^SYTPXX^khzrw_;(1k)PEQhVPj{lZ)pEN9o981|2h8VX?I#R99*^D2!rd5 z`OUZHsY(ygAhcpZCXY|fdKvT8#Tk~j-<(pHDU=zQSLv8A(tec1iAyZ^C9)TQRGH5@ z^H8VSWt<_BPc|t&2|7w1au-|s(BAhLLOI_jEupv+4J5s8;5GVOGfk{5S5vBDsX|hJB{2dZxlW+zrTZyD_z0#SSdtZbYdshoVj1 z=Tmf`zzb*|YG^lNhR_-yr&gxvD6-g6mM|g`k|8n{YFIyAtQONr7=CmZ3 zb%J|U>F(J9AsaVwm|FkA>CEX=Mb`>ws3ygwB4l+GslALgr1T^hU6Yentk18>)!J;p zJr`L^e+n@ldr~!4H`-E9bqV7{*?QZv$3L9n~#GNU=M;r*jD%CYum3a3%w?R zFs#1n6_Yo6Uq$^vm4ZA8U}O@d6`cBP=g?}D;)wEDUu0I!JJMHGo}t!wwvuNjd|*K> zk;o^~!=&@eVl_sM*sb+x+TPYPi8y4U}1`4K^s(15YkGhI*q=Xa}c|QgNW$10~Z{i8k2l;IW zEKZJvJpnkQW7Xu4=35Xxa=o-rX!VY^mWT&=bUO|5mSn-mq8MGLYr2LnO2Cz0L_93G zK5{uwoq+}h#RTw~+@^(LEewh)iXEc88;87AT}uT$Ur)mx`PYi#UOxS9$oy4$)a|_S zEALBXwAp7atnC{UxQ|U7g)ME0R?f`w8oxhX=R9%a%rEAb9FsFyHN8EjY`Imd23>w1aBgrTIk0#A8Jy z2m~Ju6TIr#TkF;R=s@{c9f#AZeRc0*JKRs-Z$~lSbF>0KVj3Rw6v4@{qtN$pi1#pk z^*NB*JK-jFdTzO&ZbSuK&;>l06@aB;0?*X2E3mY`?41iD3|Z-5k6PbNEsOwQ zvwER7rqmx85WszmLvjy`)epBdjctozaPJ6bH4a7(0UM0jOz$brVv)D}=bm;J?16~) ziD5idhnx4l|0-UmSP8z#`wm<%42HB`z&W`Ds2Dx_GZZY3F$pW3%Tj|daK1mrg`PWy z@)}f%ak~Bv8{HA-FhC+Cey80KIg6@h-!sn1Q*9pNrfPtVEgDDe0aQxlPc|xOGC{L! zkV_Oz1nor)LTc4#ua9)-fl!IL4xwn;32S6*uVF*Slp+|vc6P{_DvT{p{mSm!l#NC& z9jT+8kHldZ9qafLE;r8|djH|;hU8$Y6U__n!txyVo3*k{Gk2=aN;{|+&=S-XAM*{3 zc1yF~T2A3>ZO>=vACnE=ozMqNKrE#dKlpSwkQu!m1$1q>k~ssYU%{Hkz0vzF6%P+d z>LO8*e+n_DmSV7vys{QFtyVzqHG+7(MZn8I6U3QdkZ3y!X)Rj+}C;0+!K<_Eu_(Tp3#odk~w zQm(#aar5xN{6#T6fGVt23&C}h&i=jes|)RVIymULWJ%*nKqbeo3D%AiUf84>Acuqibg`#+P1H?M8)6{)Sh zBG2Jq!QLN-wVS+TA*ua6@l$CO-1G*cg|9;`(uFAzNO2P<1)7o}ei$bF*|anyFKgk| zFKKb&!+FY2SUI=Q(6nfr0gDfmcKW+icf^nk*Bo% zjds~vzcV+y|CKtEn=0s0zp0b$n>rc(BMJ7O|J{GHrk4C@)EpneM>0=(3))i>)?atb zkTm$nN-8R(uu+z-kWl$A&SR-FVmV!0o$7C$ZiwHHd#xomVq3*cHEK&)EkXvVVrVKf z^?{pqvi%T?&|Z$Z98HHYJ@7G*7`XhXRbpt8a1j%m;{xSH^-OuEE|=}1%pl|hXowy< z@Tt<;de9yT8v<9l)!(*F^$deC;xt)ybztN+WfK_GaW}$-;kAsTE^1Wj4D5()$FR<} z_+A|qn@c;jt!57Sm=9yp_BhUqEN_grfkgy!J`V@QY>(VK>ce{Md;++go5dU%7zLG& z<~XQ9?g8O{ZAV(7BDMeCB}-UPJ0&7Wm+U%=0MhppN&4fXLY`29bgEUA#{Si2yJw0U zn-pk_qrHR61HUIpdhBkic@&v~p_~Sd&wal0o4WqIg1O0_Wmu6&IKDt{C$j+#MOc!- z*AsQ(JYiEgg8_T4B|_70^4VvHI$nXIt|*!cF0!liFR@M|Q>qC70yW z>#`%~(oO?j7aN?$7VGa)Zzr{cr-*N%pFMp!Wn&hLcFHdUSESTG44gdc3+P{~%wO^K zV&R(*@jL(k=>L-{vvo9dH2j}{sHO84AbwEaC$HuU^+Ke8kRmbmO^ z;ticI6fHf-ut0KR2+Dyh)_6A&LyOB zkN7HHB0JGQ=y@-KM$EO#c3n6;`ofhdZp&XBl-j)Fp>Rfuv4g0HU&n3jjDXWJUI7z?F7 z>)RC;W;q(KZxAoem~B?a#69RxT^rkZy!E9~eq&qOpQk7b%37sXu$ ziUEa0{*CGAPPt^}$cYN7sY!Pc&{6TsE;Dt@%vAcEhKm7fjzzxNx9xuIWxbvUCyf`b zf>h)wghPC(5GuJGJ8*KX=OBzaG-`Z8E81BSNXMVK_v+^(>mju!N^3RX%L~KrGdv=kgX;G&%hr}z$4LE0JmDH!cH8DArK=)5+Vtvq{=sKDL z=@`9}StLZV1U0;@D9&*hp+*bP;{t2r0`?x~V26z<0wqWA**-evG&2~8s(*eFF*@|Z ze3tk3G6lEz`@@<6xlpL&Y^*|_F3*grBBda3sa&I(TP$t2?lPuwX)LPrHUQ&J-DVpY zeZW^3E83{d=B3dF^-pIm1OTk`8T)m+=zIg6|a-Z4Kbur}8mF>WUspfTJQ+?>Yg0pt(GF_owygd z!8=?Q-g1bQrKOuF9bi-vW>>}RjK&4aPBFg`Q8y zBwi`cmNFiSZfkeAEV66som^`*=By})6@c>B38Qwy_EY*kfT#HkA)ajeRR_4Y;6BH_ zU4@xeS)V+{06)UxY1v#&vQ|k2W)DS-aY}#{h%;{EGY+bybr6}Sm^y0>;XVS1QUEpV zW49eHwZ{nfQw?r8h0Ea>i0+WL+kZgxt&gEz~4JXyi?<{3#&2Kv=$!e6(>=XtDo-A+&5d)?k z-UJcM?YLduMhc?}hGG4nZ8?X+2vT!8ArvI*f*F}U5}1fA9gim)KPCK$T@OQ*ytjD{`_zxSv?(=xN!Uuis1@}>W)=Aifp!_i#UW&N zfE!xB_Sx;x@_bFu%??pnA zvT*>YUjA+RYvU~OwSRjj@)Z5ysEXVpA_DJKp-&XOOix%{vd`sGq&vC(pLEh9nRN+y zd+_S$-yB0+7?fj1fRF~{ysC#WlGi7rqW$OWmvkvMUR_hsw0mn$(H=HOfu|TA94{5B zxg=?mfmB`gx!Xr0B-hmzqKqu$)uas}M(=6d-8`8E)-v5iSK1OqG0!X=GhQF(5lm;wxT!x7!}NjRpR8q8iti?zR;G!{yco)Y( zi+x+b6qcRadq^LolhyP+bTx5?hC^+dp5)b$%9*rn>bB1Oq`3k`O!GVL2t%=iW9B3a@l!oCDCck#$3q)qMbN0(4R(xy zi9}-J-$)boKRov?X;NhsZP}H4$i~jg@nqrkGJW*||5ahS_v+knHpvOqWM z6x@xP;ZT5^UK)dsqfn&jn#VBw_=l*!UH$YhJcHg<&evF&#=kb z`xkNQvy0jY$k-jL4AYHK8oW?m2G@lHkF}y%%B-UI_rK_z1_S=O&O$T=DP93_jlDzW2t&%#aev>c8&f=>KxNHQH(Ed z-ig2IGjm!cQ(*Su@M?o?Wt%Q*ippq;^vO$t z4LO#%ltK+2EKwMK{p2pzy_6(n=WdHM!Bz~j0Ya2CJ4*68J)P^LHKle0LXs5qCQH0S zqTPx+#O>5Vi(LH6-n+W z*csSmd^VfZMr(?P@sfWB4u7pP%{mz6b6%ZsqL3S}j~V2C#*wk7 zIdlPBq!ycS0rxsAc4g~JL=d?E0XNcUnEg5r#wR4%eFamjVa9sQTB2Ea>)y-oQxiL% zunBPOaQbLi>Nl6l5ajE@tOvC}hnsL`LM-%WQqKVZ6K>$AJn4f*4-y2sz8Mken#eO8 zW0OEJ)EYwyB&>6P>QZ8+KT-JHb~I4^>nHZ;_z+HPNe|w6upk+XMFvG4U%dN7z6A=& ziRCXLsDjJUkH9}n_>S_`qiVg9l^TfMR6()b-(im{UY;I8aa#^A>>n@Xcx#TJm?jP- zxLU2=xtdtbqQEKD91bHs{k!CzVFr|7-zJ#_0&1MKNpHy8S}=fVHFz>ogV&h5_Fxa} zP#L`MhS}GuoZXzFFly!*+^5b;XyFn%2yGa zB#kR&6O8W8=Ma+xUYB6MCn%dpDXm=B-lq8N#QKOJWG#66P$k-d#DhPq4`j*~D_&)K zX&I1t*#T56-|>~N=?jYnq6_eLnvma9RIyc=S|oZYiA8?bOaO#$@9t8924TiiyaX|1 z!2pPdIW zq?=5hM=)KRx%IxzI|jNE7<-Nm%lgL5{B~^xTVE7#u;QcFIKWvP+6wy*%w`+`o{h~! zr!lV=Mce5eqG+tXodXcIx5s;5Sc`VT?5b|Bhw@6P@j~yRIqJ9k-E)5lNYD=Rq(Zq{ zwr!^~u&8w1T?G}8d;-H(7d6nW2ckdX({h%$ISrZds%9)un9H|-$HJP0mW^~s;(5U` zp`dFVFPVF;0SK*{JNyeQ{EYHlJh7vd#F&U*vXmUp(HWFZuf2`je(#}0@vg6B$ zi-@cfa;I$&J@vVc?re!S%6KmH0k@O;SXBKp_;y(n-#aeIYN!bGvbn_1I19F*uq^s& zu(u&6LzFYjH9=N>F8+clFM<450p$EfuV#L0zmwR|(8V$uNAJAm2xO@17~y2kgVUIG zt)fz*-K9a8@>L=Mkts}^C;(+3F;MCh!2zXRO?_zY?AI4XyqB)7&Y%KZ1F)CN+9zO{ zE&q%8Afus1z`CAFU827{QvrYhkoq}~#RJf&UC1gI&H%_zXn@{Br-d>*@NokqEx`)V zEsN7Ud3S~Km5Z!x5i&zT2I;PSc54dKA6 zV%lJYuu4{3cJI2$E0~1dl2;lrzsq&A&tYG!$94i#EzB9PjGnnDd&*P-wqA4-)Gqi0~Zo$lh*?#>iycsPO7CU zKIfDY+UCWjy_E>84x%-ICAVAc41y%JCu`!P-&pwoi;IYOu{R$gy>p z8}c$Uaod28k{yp}H$y0mc3>}Q{+v0;$*ZY~JAonVd`Va|~akQtDKmDf|s3@Vti+R^@e-stf1@w)2n=ph;zVQ$Q!=om!P6`rMra(nd6aP*Xk!iNv1PGi}FJ$B8XOC4URU|*q9sCs7tA$+B{ubp{yuDwUd61;GZoKea#EFvv@2A?|+R=d71tuo{GgUUL??x+PmNhFNFNoh{E;>|DZN33^kFfv>>WJy-J}lqqoGOg#S01{gD2 zj6o*;K)c}#G=m1D)BLLwqE8^T9D<}OiMFF~)S;@kPiq$g3G-_{adIh}DyozvLFv^B z%J1r3^rHMHlEl{@a7Rz)%z$iTWb2b_nha7O$_C3an_m0XDcI$&=bB!NU*3?zxjiN5 ziVx8ojUvp*W3o4 zJl>u8!BNtXK{Q!BU;kqMZ}$^&(EC}7a(=wh|HHxaKerYpFQe$F3AtQ;;hfD@=rE@x+*zk1dp~dxybS@cjf4<=T$tA2NzIS**W&2xE*4G09}S{L~>37XJoU?fB( zaR>!gN}RR|1;{X~vW|aZlSX9~e}5}6Xxq-k_&%HkO2KeT2;ld^9D=BK|9MYZz!A7 zF(I=8nHpOeE3H+*i_P{Mm8ur?CoWgrcZ;z>2gmN)yY!P$N{>8uT3ZMJUwvKQBMt%FZT_7)^QmotgbzM7Y{p9`3_+V z%zUW6Z@jO#=7nlI#sq-5sZ)NAo!mw`I|_;bbW_dV^F*OO#92;7F(`0SP@|p0s8#fI zz@(Eh(R(PO$Q|xP3-5%lc2nBzTYakQebGyUXxO30-862fQ|)((39s#d!#WC3v*4r1 z6Pw8j%gWMk7d07@+_l|G$%<=3_HK{r*7%c*V zDBT6=63`foZ3@gjH?>BKi9kwiEO=mx9zCttlIXC(htqmrTql{4jhSvpWmsw*RixE}6@1I9v7Im0_Hr=(%6n z#=R~0*toaZnuz%cnZF2}TKGPSwZ5eJc3-wxIpN+6;rHBpBN|q-3aq?%=nktOJ2yYc zETWxEEl~R`o)u@({p-C0k`ORn^2cA;kn(d``*$`)Cv$_JWl_(@#Kz9iL`xjCPJpgBun;x9t$)E2MO1Q-EjA^NN}(mSkM|~Iaz9Kqj?8>e$Neld0Sr^e z**b%KH0?+azof}P&&a(!YHg|$iBuz2BuA0r3~BPrD5`f~B7YxthccN{VrPj&C8S}* zoYTlu)LprTdN53E7>;n~i_BG$B|&wrY42_bo6IwgwgxqA0#Qx@TFZ({_!CmPSf0-W zwwyDj{2gj~J6@}j)fHQbT7!MzK9Rhm z=@pz*!nulQvO@aN#>!CzXR$^@k&)ec6~Lspj$o@FS~%ih+CF zX)xyjh_TeI9X|kX#moR7|9-3I^hNE(U~{NMEqk6!zBSq7VGszz7#2|wWi@N7 z5k+QKdieNN58EmFmUy6sE7Ct;W}9fGf7T{>iZU%a!w*~!?<}D`xT)L?KN}38OCo6Z zwrG+kG@{}8RkmVn6FBpSBVKp>L-{lvhFMXJvRfC^<6%=B%mg_ZF9**1G{P#Ln_ zjhRLZHYk#yAe3hJ28o3-ssmoJMH^u*9EulkiKy8IoN@}bn(9BrsUY;W;{XY;fV%hc z37x4Q#{1ob4!abD`~1n!E2{=PMfbW>Imwme&Isa>T%*`_`Y3v zO|l%@&h{cIV`K)JfU=p6q-s&P$F3R9=VKeL&kWI$KJElxAH=+iJohYGi)Gb*Slm`@o$XGMgG&k;^P9*Y$H!szXoEPRU4Ts~cfmXozo}kd($B%-QS?jDiYP8F z#1NMmx|LaWmBQ6rDP1cydyJ+g`pL^9vN!c2fn1T zeD$9Vc+@q29FHc}H+nqRv~RZM!C8yh!p5$4Jwks-vu;C6rlUd2_81t|(h11F@c$HX z7C4CLqHP}_kl?#cu{r1bIf5qP*-WPtJm`k>ps+=w0W;$bkNduAhR#sqH6~<@p4lr?= zr$5>Nb{O70Hofdzn-)=Ormk*7oT$e^(Oye+`VZ|)1+PBZFXP+tUeN5Qkz6>FxnLN| zoCq-p-43IUV{gIO)E(G5i}s*(d;tBfoI|K&IX5gU3%r=Dp_!sA*aHyFpV2c} z5LT*kp2_E})yVkwG&u$Gq%PniP1!Ov^Vbcu&^Ocvm`1a19NJeHGJ^^iC0($|o2mq~ zPHEYtz3kW7OAv80qnB(ly3!6~a4fLkj(D0A#R1`V-6w234lFstwBGW5ETi#M27G6K ze*O}HN-w1WS7{i^ilu7DOHZsP@B!DqUK5MFg(i(GN$FuZVJE7ofX!NTRIf`5UGbh8 zBFG=3O!(=|(%iRkT~~qZRO+eCmrmAMmZQd7^Ui0a8B>8%oB$Qj-1TzMwqnOwh942N zITpYZdnIx|V3ozAN^BJqU^K{7x%u9PQLN`;Z2GOSI85+w{k6^xA2KykoGr$jhfg;r-XUl3P^ysRucSUMbqX563lTLfG<7M$8${>my;jAl|Z5%4uhmjuSy zA^kX>+S{>yWlXQ*e12WKcDp-%INV*T3H;a(^Yp&Fb9&sR{cwO|549lKPQw}k zLAP&mwkz%oGw8-lx!KCj)X$LXbQP}KE4I8R3P1i;$$uVv|Hq%=8{x|LhTL(W72C4wA!q~}t z9_AoUm&1*itu3cJJyfADq=Lsu4Y)dSKw?t`1USLg2 zTA5>O3bR|zl}qyfSddh!29AX*g#%C@!Kwgn7?y#=qZznTXoxWGDNSm^dyvWn^tu@_ z0in(MHn-3}tT`KAc3A7AhCMtiO=M#d3@E-fG z@K@ksmuDN+Dg!HlX}#`gu8aD|`}af*<}qd%QkRG4lT%2qmrtwaN0##&U{#WmPpn%9 zRm%I*kkImiQlWWTfB7bPoR%q>I4z)gIiSVstgN0lK0hd=R3|QOHcIBQ0muz2o$Y#4 zKG55*e|T)vrborE=Vo=koDlju-IS%h8ED(67$nrq?dXJM#aiZ<87T9lJw==pB z#XY=p^V2PLJBAI-Z#_Q^BxbnkQlavKD{3N`?Ko&>aNSizrmm@_hceE%o7II5S%74O70lS$_@cKYta)RtrUD*Q(CmZmpv zj^h<5y^xUn+PVTMy)|Y-#L8o}aHhsUII=MfR z?hZ=wIuF9^Y?qACjoIJh!r1eZhvQ5%j@sRQH$Euy@Ee`WEZ!@ZR9N!*{w3CToM)yd zhy(yYiwgw6_V4c{M$R7g|J{wXnYhV{^qG=;&zWjU+G>^57U4v(y0KG(Lk6EE*+34J zX&{}zC=nyJe&1&o1W;EG~aYRlmMG zt$u?&#=;gI$;L(2+}3N>fM35I=5cHOZfz3sK?8ld@LGbAInB*6eJN^5_w@<@!R?G=QyC?h7aC~D%&Jqe<-p|#UT21P{W@~eS7)K5YJF7e3fq)>qYB08pl)jL%Jyt6 zwfc|Rd-+Y)32&BP*A#RU{NIa+w^*~?3#=YOanz(mic=*n#(lFCH(p4w4!3yj&37#S_u1_~uQr_Uf>~Q=J zo_;#h+|=sIeY=%iSMaoFAHPtTxOeybma>38ZcfXtHo70q(7u}wH1gc>Lgtt}$0lKB z%HhC#e#5Th_Avl?Ds9Cc0R0&nO(Hysfsalx$KTcza=+hsEj!tSZx!0nPl2Oe*br*+XZwpp}*!kbsz`Ub}A*6jtJ^A-9wpwGJ zSpX8m;KZ*f<;Y-2!sR#nbHw(*p29DT-3tPWdZR++gpTGcvuk{Z<(!e}b zxzh9cOiXZlLJHVIA_-1|8-69_(~RXT-WGT{4iYRld`{HY%uTBpn}G;RTOBKvXzFkH zhXk`LN+|*cDGOBMtHe`^qZD&OD9%(^MLAumrCY6LTeXpOh?)ZS5L@<`s1rx8D|6IW zSHP0r?|uHA;2L8Ma%L76nfd*t?|!XwvGAgO{?u_7`~ElQv%(;&f3i$qj~C_bocHmp zQ~4^Cm}Srd761kS3?K`^e;_m%)?>EC8A*sGJ2z!!)>3-b7M|1$GT42pFI^z=_wy@bu;{ru_X93gM$6S`IJ=}*`TCfG9U$r3RD$JA5=B6 zRu!sSVCfdDq$u#67W^pRY5vXlth)@+3-Nsj8+i@F8iW-Hs{t<3W&Xil7sPL*KSl7b zDUGt`IrmUKvVlo!-6M5ipmTl#c3Y4B3urGT?}CY_U~e&R=<-`wjdqXUdktW&VV=OLB?EnxK+&`R9Wcm9~^ ze+(kSaw~8FAeA6X6G;VVm5+8s%FAjm-U~fHYc6&v&CQ5aWxUB7ycZeJ)0_{4DhTn= zyC`#3GaEKB)vciG?&fpnk6&3uA*cI$iG3t)b{t5}FSmhDfgb^XK7aAf)gzs0k$?Xkg&|P zO54CpiapfdRFP3bX|?5_&FfzZ#8#j<&PqK**Eb5W=lK? z_#!DDaI}e`0yrXZj`?@hl>=xNA7%={p+^}8S7I#jd`U1_+s0|Bx3wa;q7BJ#rB}uq z>0PR+Ovg&!<;Ot%dVH(I=w_Hn2QHc9lE90bIAJ+#Px{63Jd{tHa4aH6)ZUm(9#*sz z8BvSBN+T73EnJ+R2cn|=*-r?jLLK||aw(~d#H&uxgN5`|BZc<*5YDy`!0jXoiQ=kM+`ZcPo88K%u2`E1CgEmO5m&s#p*i+?MEn49YxGgT)YYi%{ z7MZS&2LtPM4cKaGR#6rn;cAqlq{MgG3P(kzahbpl+!B;uQz8PydnwLwS*TC^`;^4)=ivgj} z(wv{f>w_>b#_WcS(`HcUIJHLHIoNXb-v-~n_W&OL-2sQWz-#^M0A|41u=Df2Ij}8D zJ%IHFID?$onJ(ndgd#r33F=GjX_9c$L&-^tEn;!6-FE_~Hd$l|=IcfnQxtrION*#J zsgpoJ`tWe|;32z?&O`3lpOPp~Z9`kQ7Wu<4%TRYA%#7yLBe6qfgUAArSt zJJ%*e)uvhFIMc5Q1Yshada)8S*k3jQwa4=eW}lW%FEIJ7K*rh!TsO=whlKzUO9Va| zTI_?zi*S^^@_8B1n7sB*oRVhICFM9dMKvTrk%!Hp{xLj|c)ZHzRzH_~-`J>WmTZ@Z zpWCtZ5c{d19m@O=F_O?Au}Em0fQhZ%>}MHKTiS$OD$=DfS!W&fd+{OL^+mkFjH|mj zi{u8}mQ1xq#vhSz(p6h~u|}#yo3vdOOyqGKdcH&WE^PA3JyCXfkgBV~;4C zJ?aK$3{y$uwIk!R@bAp>3?eu~M(5AHyy&`csAR~D=kF=fMM*xRPkM55#M5#y@Gu_o zpEf+#!hQm`SR1f2^CTJG<@{z?rvsdrHn-<8L7|O+H(3%U_eg}>LO_xE)19ZzKblbf zoDKqn3DRyRlbwz<#DM^pLml#X>VFb6K{a{rTf)(M;m=onc#ogo3S=yQw={(yRGRT$ zP^7n1k}UhG8!j8ve9{-2#Kb3Qb7Fs7KCGeur57#FXfzw?yQkPL^fK1Wac!z;=kb_o z$splf85d?+Y51z?X{1J@fHI^CD5Qw)%tq?J#i2=bFesm!Cc`!De7q@Y!f4l%wKyo z9&0n!GQ(fKt3l)kw*}zi#;$$3nzVnhc`#>k>%!O~ft70^I zKln_y%ov!fnHgh>y``Du?#$)KJ1{6!{6VWhg`<7EhO~wKMnH3ZzV$*uGjC{qN8gN2 zb|5pZ%+9Id-|PQGj~@oP$C(QY`T_nthyS~*YRDNN*DU3g(nKDRui0Sc%o8 z&tp}h+WTRO-!*w6V3sQTnk>o}TdqbP9l3j8!5&*RNhBpnbc~OR*(+ab!1&|B7OX0O zp~hN?vq6X=ul?zfyxBX>orGiinEuM@?YTtEtHrRHL2TM;>Y4e@Gu*dM6_|GLb$}G_ zTSo`@|Vknoyhg`c~_O12;q@RL-|A~*|L^w(S{75*EFD-kmk>1ZdQ?_uvl)% zDn_J$BN3IKD(Q)jC8qa#hXd1Pt8Mj(E!veE$4~5YFDusDD#L)R>93S)nz$2fhN}Xp zF>ENaL0qGIAEI9zMTcv3ZjjWW{XoEWWCbik9^dt;pzRwG%*xsgTqBT1KsCQAew9L^ z3L67jn_3+`hVs!WSRqhf?rI#C&D+y-z|U&*vd15r`b%z=?*cNKFVI>S?Y9uk(sa=# zDLWdNT2R%%@}A`>LA$55Hmq8|x;9{j{Z>3D!Z3v4WdgdGPcynHvp+UJ9DbSHcz^-x zAWNoV2p?3TrEXziM_n}Ur@k|yKVXNakgZe^P$(KcXgrYk0=(k;dR;7pq_8|;AQED7 zcs{lfNQvVL7zjMR`;T#!ow^_l0vBE9mj9bN9F%O24Kybw#{)@VyfKR9P_K|t-nYzI9DW6*8rnZK7|t8O$klop$l)f zyUcNCe>-T`C5sw(Tsu!~fo}F@B1x&?d zI48Ee2-nGDI3p!G`!Yxw>Nhcr_%k(mtbCN5YvmeABOnHE6tpr>5gZTI8)UC%tH&6e zU;;|2|A}<6$e770=n$LUTyks3|061FHk^(Wbgu zm-WS4+p#+XOy>H_w04+dOm}uyOr`ha!F9)as{WJ-rJ76w?@jwx)5LviaG4B}n3}yk zGS8!N5%cDdLcvX8DCsAuUy3J{dkrNgRV?$RaQ*Zvs$Gfr1zr2a89~BKru?{GQ><{Y z5d0)kM$;mWZVC(Lf=K68$vFdfMvdzeU!H?rcGX}>EZ4Y7L!S~nT+dPB81p8r211;7 zvHS)qO_k&kgd9ALeJxBXWr1QVmSkYUvX7LR*-6QDA7z28%_xzw-du(J!ZQ&YSnr|o z{F6d(HJtcEGEoO9VsoX!n2x1U0}>J2jwuW&*sq|7f#G>>`i3v*Etx{1>4lGa7^o{t9fPnkF|Cj-{68*#8He+?Rb}MdIx%rbnn7-ip>T5;&N>J zU4uxt4G^0^R)MU1nMa)zpSYgvn%00%IhJK`-J`{$sLy~`icwU1gce!?-mW#rSiczNzcwv?(?qDTE} z;5#Hl1*;a5cd-~+ALukZYC(q^_m3n4LN%u|O_Rjiw8ldtz3bVWw-!})F>-wUUN=Lx z>qY%){`rFU{dce1XQEe69%*mH6_3~R+U+50Q-ojqP4HM64mIo3wJ*H*aa=VDU;C~L z8cFYvxOaQAsqXt<{4J$t65f43a;*Q49Q!|)rT$ZmwNaL-yJY^cxM;k{Jmk2$TE`@g~)2P&E9d?@>NB0>DqqS8@aGjfK>^x_9~^M zO`)_=*`5DQvc%313c@A?XGScHI9zCt=0sAjZvb&tahV*&9#D;(0K}Yxq&PE86qA++ zGbV3U>_#)6ZwN)F*wm&sG>4!yy&|$aP!#d7;{||VHX1)W)XfNh{m~k5%x9?KH=w` z?F)RX>`}NsqntUGV1ajR!DT>D;M5iOQD4n;*9yBd6>)5FvitHqVI3Dyd%+FscUy4q zrCw}WMtjo*U$tkVbRCqO9=qm{al7x5v-*-sY(JgFS!F6Q{ZxXu61mKCHl)E;z?t|s>(~S2WLECxXH1c?qYpB)! zu&D!o*UNmyW8tBR+n0F#Vw6rh056&TJj|3<3NyaJSDhhKV)7L_Z>T=#)7PT;Fb;-v`vWwd{niJ%$&{ zLrEm^SLvFGN-&utZ7JafU7nz4k_u4%$wq80c94E`bs?L+=O{{0a$th2B_FH)ZXBr> zLj4M6Vp94pY5OUK+KRkc!C!wh$9l~R9O_$ns~<-gK2kn6IlWu`3zni3pZ6qAw zrhsXojvi{8S6A#=su%Un8?rokPqARe8Vp3NqPgOv0V-LlY+T2NfQ>*&9x@WrX6xd1D=tFkVepE2%hJK? zQJjU$rT7~Kt5+NsJcji6S4KxO!sO`U4l6cd;Yk+1indD`M$hV`xIqg+cT<$gD?R~i z2D+p?oRVz@pXiHXTd-s6=Ll_1n`#@A(#QXhto2!t+zekaiA{|{n!xtyp}rJk(;390 zSz4j-(-W$M$AACX{u3oZDiO?LJuUkOM=!UnR759Zkq1fwt#m#B89zldpPX3}R?BNr zY;7>}njKv6qgtl_(Ugw4IgwB#VQ-Wa`>V1^Q&X9` z7eHDJ#acgQWqH7s*74w4k{`V&BM@)oxWmM4+d0vEtdA6~>w?CST1rj>lBR7WJ4;>U z1-|7?OUVX@XE zCejc08fyT0x<9vQ!5)xGgiwYd4l#%yH!}QWrr7Zt;6CZ00krK=KupREsKxWy=t#Dj zmm^NQrH@7BgMEF+;7qm`@1xxPu$&-{c(TQZ>=jRD2R4}neC6=_;&8iBRP9b5{&?&N zkKZ42x|f?j6g()UXU5v}iXH+(E8a@>)ck6C&sBVm&>ijgI&syc?C!p0NZG4oF; zu}ihiO-2lcvaFT^sNX}2PLC#^5>^&hg=U&WhEtQXXyV3RUVK^JFU(=tpniYS-Km0r z7~lQ6(6&yE2izV5R~2}^Jy7p$D9uH2_1qQTs_oS^`5t_=er9U;F7=7U%Wawe`Tin2 z8;zQ_utxuGmg-~Gb7Fn5j#)))DwmC~rt8}0dM?s3f0yC-F=J?jglrC5>$1d-QowB1 zetA)V!zjvnF8dBjMC~9c_Kvh*(rf6d2cfg>mN9>XQhxt&f3aV4qle@M2Dj)>K*NE& zZ`hN;lrS*k@7LPz?X^3nzlYY2E05-)fJ;caV46kkRwdCJUXpEV+z!jfG~XG(2iqIUNsnhcC{OwU+K8`~iN-gkC*n07qj4i?mRU-3 zd^@jC6ZFcLndj5Jkr0D(k>V(p-KK)P@}-6YLl(hBB#;j(;U>++RQIMV&`a=5eT!8u zf#qGNad>_m2CFBBJ`in32CXYn1AghbJ+LUR7cn1|{2>(H7Fe3RG!od9I9WR}?2tMG z&7}j!|3ZB0G$jaS{~3EMf2_U#gP-{SoOk}&f%Xr|Ti(!c+$#$ktvE!)FKvGF-?nXT zFd4*>j7^xTA2-g7<|OiP4V6`RDmH!LF#jk)>xpNlys1uQEw4q)_>U4a9b`x0)?s}d z4S8D5qk3QyAn`B-v8zPTBoQKn?9jp*Cj@BCaS#F*MvTDfrX&W6j0uTS%l{k7@%4Y8 z9AntrN=9bT{%L!H_xjBvf2_?+A_-EoxsQxt74`q$P!6>pCZ*|E@3VS?J2Lru#VsPld5B-h=jq{DX&yGtydDk#l|Ffjup7=2}73lLg>6@Qdc^ zhVt*(wB0|CDU}4aA%@* z21{<%Hv1XppKK1@e?`5%_oVwO|9Dkr{Q&@2|E)p8!`8^$(azSw)8xO#jVqn)#O!ty z-;|o(+#K9G9j)~qC)SH(Dl5I=9c^POkqQBx8Ww!QVfPJ+KCjt?VB*d!j9X^>(!1m< zfxoAxT)S`k0E^$Ms}7Qrj!zHg+r4Xj1{69vHH9`Qr5qFF%w{T-8U*grQ;A1yt4F+X zZh zq!b27UBS#e|9G#NVdeS~W05TAMIb~+wi4|;fW*BI--EdR(XEmrnYb0i~| zKq%KVW+xy>EWajf*Vnb}7%yzh$4}*-hPCh;`2H>(U6qkgg@a|p zzWiyv=jkL8jJcqCP7MU`KUs+Xr3r!gO9D`jCZp_PC-5@d5PX4Lm^^ejE~KCNVy7NO z0fd@gwrE`zW(Gv8$NAT#x>x>27XM0CyhbJH(T|)M}?cs-!$|CZdS}FuhJzHw@3AXH2&%`X!5qV|waQ{zmcUY~eok?@5| zhi)@b$hjfjWshxkleZzq_IR@M8xCi~L*qnK_ZeFv4)p#2Z3w>XF2FeGw1#cF09jLY zEqh&sX(wefIKLdVS*Y6->37(C7u7&9`%f;k-hC9sHI{j16A{S1{{3t58l@Rbfs%$x zqA5nWvsS{vMClg#3-~H*V2Q^wr-S#sL3BZrzvn(ag3R9fXy}OyP6Ijk_9=LgbPQO4}T>ycm7 zfHXB*?mUMVx4t4xoTnh@!2O$~$vt^}s2F{$5}k9k{Hju{5D<-j;3`8-g0f!`snnVv z-};X*aXPb|J-$Zio6LbJlE>`><233{ zOy^mIWOE(HTIAd@rlvI6iZ9Z?vQh>HD@Sbe7Rqrkzm}pQtojESwS_oz*cU#<{AZL? zkZaE5#cSi)=VL9fDl-iM-DRJU!?v8aQ028litiUAbcXa@4lq$AU-Fv+Y&d!SNZFe! zekIyB`hxTbf5I<9>`Vo|oUCxAky9!{cK7!ObS22*&JWq%rfU zXvd?LYp#R#q-j?r>6sf+@zp8{aa^Ys}5AcrYN^J4CpL6s-}?%K$xJr0)+1HO{7 zh@uX#exsaisAAMy7DP%9bd7myB(CAiT_qi0!U~~&vVIfVhig%d$ThQXNJW4)yA^hp zHuy?pO|_L4<>n58yU4PQU{di6cdl=)%)5A z)iUtOPR-~=sDn`P1zSD-+c1Fp0FycT;W49MXj;Rp{xgi&l*x6pMF-DP1`~Bast420 z3-iG=+mU9@ZUyFZ$+fGP)3J%f^pH3Bj5uc%!@~2POyKt}3XUwr+upGe>ZZl}?uqfh zwXPc!v~KCNIm;@BT)M*`bJ`i>r8C9>=|0+LiHnU?)9WnftCqwK)y19Bh&N>s%NbZj z4CkI$dF-rhNFY8gZmxQ|ekTefkZz>DhL(P2IQxqTD6IiY07WWdyXLl_oF-LTCJpAM zJ-_`my@gGH)yY!hl8od-A8A0+DJbB7Zpz)=}>&0=D6i zl9jz|d}qEc3I&P!(uI^`?RC+6pL?12!(!|nBcv|;y^>yoDFQMlzBn>%)LV%xTDTPL<{>%_KmV%xTD>%`8>z1^?h?S5Ua`u#W8 zu2rjQSFJVY{Kou!V^m_#|STo%jN}J+@bNC3WPJ$l^8KVs1X` zB6x@(r~9p^q*z0`R;uG|W|@BL?Sq%(F21s~l@abfa((*j#(r`6IF-AWnae@TKm7eQ z!M|N8WBO#!T{N^VLd*qlt*9v!MA64{=9ifp_{gdM0B`7CsV)FgW- z?Xr~KZ_-7LnlueA124NV8IBzy1%;jo50r)jUKL&Lu8V8jgmxG_mn;a&x{{ZmeZLw_ zgnZR@w&+c^tU9P9#mJ3^Sb->OVi~OR>qqhwycJB%lB%S2{EaWP^Z6iqt0Fji2vWj1~HGbIHSv_xC66dS(53zc3 zS&wj^01sJX%AK-s*9pa=nq%Sq7;)@ivL5EKKCK}!xOsiRM{^<5X*2NPTH>+(x}(eF z)f4Fvi1*Jp0c55HNS)}m5l+J>b@KQIu0Z6Jpy~J@ zVkiyZ94bYLh}c#GLwT$i*G6A#bE9+nP-W;b*H9@JZ18h-<+J%;y9+npu%^<1+DSP# ziqg#1XV*aT+g>(*=f(9?(AUM2agN7(vadYqhN;82Fi; z_d#_BZ;JTyrNK^$>Ft64e-=aAF9=m9;C|ly-v7g}a2IDw>;GDuT-a`Kp!+5V z_?0?jSVD4trZ!6=Sto+X)_1HL5zYdxEU2Qeb>}_NeV%#4iX@~`XvY#wi-{t$V<;nd zQscr1ZoV^dJAfq<$2!KQsI+unfU|LBvpmqjh%}WX^vnd$#TlI}6JPfBIix><^wP~O z(UnK^#c1TRJp|C>M^gD?$$WoyPjWs04#|wE-LUs$8p2XeXux=`@!O#;w)riJbO1d( z=e61d7VPy{bd)1{4I6KlDSr3fFpXqZ+Vq}fd!&Nc$En5L5+bie$HVU2oXJ=xP2P0j zjR(u5bZUem8?3i_uy`eUHo7`Gnk1!)-($G*PaXD_RNdpuGS>29MH>J8cpj zz=y~0+g_7c%=9`Wx_9HHB7LeyBHG|i*ni^3`p*6B4Gch%gGBp5)CuM{{Kx%N^JM|? zgeA5dvIk#sfA$9X&p{~@O0OY&a9oHKi2tqJ7jwR)l(FsbSO?M_htLXoNH#UYG9BOYO7^GZV)vVNR0Pkx4Fg$6deBn4`BYbWd z^)Ri#qzSQfosS2Q&vQd-1CL!!pJ#V>=TGx(FAuNBm%edw8^?uW2xWg(Zbw5;aCD1e z5{noyk1QPfj(+P#4Vmjw!Oir2chCi*!fG(H1RhXjF~f}+bdyT&aR$ar3JC9);M#rB zNHd1sK?+vvpf;x`1oJN~8#eHlXBNcqBm(r(K-WUXvWBF|BPb|-bw5(@glHYplzS`k zrw75qY4Y!W`yF~Ndgx(^zphK{Vfy{ji0b|$1KgUl1TIA$?<5EW^>%kQe-d&OlSCmL zo?T1hS}t6xIydXEszZ_;cyV6j#>Y+PIS4t^rG|Z2-pL!p_BY~A6BRhtoaTU#qC;HG z=S-tEe8zaXV}<_{oEUm8f#3hCAYRXMc?38y$x>|(Q@6;myrlpF-PzI`k{Z&#r# zBrP%=q{TnD-pslbc_NL7<-(-g-xklo9v8!?;((uErqh9~R@0dY_w2iQoi$pmI1H{g%9>Zy?ni>v!7C4_Ii(HYv*uRVwy%0KWF{F>$Mc?{zRlWJZ z%xLjV9Kha;(Le28U(bgUFeg2rFLP!`uRy&UP9uu)-3q7hr1BXd>(8HFSbJeOS)vd5 z=a1g)(YYzGa06eEBTJ_<+X9g~xjeyLap(^s?@I3Ywkk$h(;yXKpPJEM#C4IWJVWXT zGeVb@T>3o`m!@rIPte1>AYwe{mQTyDln}NgM<^PT4S_iy^{CzaN~uF6rk#aw zN|!QJ|8}%ro-ZIuz^?oFtn~^_Iw08q&BG~E@`bCA$0>KV9_(6UMK+@P1npsEqy!;n z!<@;ws-`NGpAOU8HkO(v0h*yP^t8+_`uH+uhjnYV8CJ?RoW;M#DI%4S zZi&Ejq$sJdXI);S3Ol^Yg{C|MRJZu?d>JrC<}-VpgWbBAr)CU7jd91nP^Sg!jAA$yfm&2|J#5^{#ud2`fm`& zM=svrnJH4Ky0+FY(}+K$N+}YW|83xkG~$Rj!AtX8yE*XR2SFTW2R$}Z^>~Ap;zgcK z#iU9HL`t8^xYGSJ&fBAF{r@X2I-L4 z2mY;4y9{xh&)%;ZG*73|?4+BbtKn+67GjFwT63Y97!bO*L8n_c)n+qo<7AuZw2`%O z{6TIxebpI|&0c#CkpiPZWl-+_pJNwD`a9w$9TDZNgws#FNF7Oe1h>|@MLkZD&ubgY z3$hT}9xj?5Y#SLQB%Vc~S4j{v7~Hb{FXe#=7~k$Ph1vi|8OkLwC>$aL6e1Zy+h`Q6 zT`c3(f=5&$1`!|u#3cuh1t8A~{GEvPaHQaHV*Z2(GLQ+vhNded3<#Bz=oMU2U}={W ztgQK-=6<#wBC&HC`PXM20xi5xx*m}5A^jPkgNKYh`_;7T1BZEWoh}|xo)$6CU`$&Sxh6OYBu%)Gft4yVYO~>b#XEpQ|)I-5H_7w1nXueHzwNH zxQA;OJS8Z2aJlGEVF={yDZ-l;VpLfMMY9KVHB}jJ`Mv3_x4dgl$XbFKolBCQme4BG z?k%qGkP*40CjNB`lWIKg#O$7iQjQSa7;|qi{bhxThV@E|9anzau_br@l6e!Pm|$Olpg=1Ye3akNA$aTXGn`NUnRzG4UAEk!5=&ftCIf{p3z?#M}H zDPr74*m}Z0{}`ZSSJdCq^Ts|sep)cJwLQV6q#4yW?rQmLeA;$BAe+~{er4yo0Yh1Y zdC#vFtmwW};?cYF{aS0|?cgN1iFquEhw&R4M0Z=zZx{+hm;bwJdjrHLpm`XkJL%)} zlp7Uyz}ko;pU&~U*4tX377*y@qGZK#Lf~3&`!8qri3zpm=GXXD>3hfhd%*rT!=8Rc z(QLkNToZJe4e&Mow%>oOwre&#?fpb{kPhJh{d?wYCbiN|o83Qtxjx-p?N>Xrr9rQx zE4>fVayYc+6>f6fDa|g{s`{=G74;K~-48$iFoZw2_O_|@cRr>1pIleUEx{+?3Y~@0 zb<-l%!y#k>S3rRL>rK97(l+Neety_+ZFgZ~t?|kbwnbxCyuTsMvdJaT!@_ZYk6Xi1 zL|VlzMjzrp+-*#T@eO}y^}66yoR^@Qr+-r zWd-~!YAy%GpTK@LHj^glyB5?KjytRZWfd~H-&KRHNR=Dyxrw$*7c?p-r)yQWV^?gr zVi*bl1hYlCtp|w3$0c|#(USZ;CMTk5m_mBfDX}@gFm`U!SeqU&B+dcx$i1K0C?y(f z37e8W&n29;3N76MDBNcs0z~0Dd_CVrNt#s!4Xxi=Xe*vT@mi==m}0Eaw{)6D734*a z_FBHUC=WeE?-jx5hD-r((`U8R?g_T@r6pGA>g>k4FK#rHL;H7Ue~c*q+$#fX4VIB4 zVZxW0M^(t4_MXmZAmI7g{GHwkTI-9WX&Bb=|j znttOvYBdHU!EO(QhDZXC6~J>DFS;~Eh%8C)IJVc8^=|7XZ8le$m%Ko&{gaS&A+3RP z(QO!Xw=N-aC1}(nqD8;)*(;RiSRQxRK&=D1bv3!yUDD`seZb58H#(zYS%8M`=0;-5 z`nWSM-MYG_xjn%Ia$ft_a_yG?NUu=&tsKX0>&~8HCbdp&mt5nhO`MKR7jRuig|Ync zE>!oHsenk<$BLtV>6_VeV!nl()V6F1B6LiWrIZ?QRL`s6ew{sxcxFeOa|W-hq&49_EbAj_=PAe!HMb_J?Q6s@W{D%7=uI9{e9yRWgS8XDK=p*^&v> z#*E`hV;EuG7uRwKSr7O~wXegG|Fw^`c8FWQm7430gT_?q0H#QqxGPSlX-U3H7;c^GcYE{y z%1gmF_RGO6DtDz1VRQx;xmX7Gogw7GPIDhG?@llB_=J=UJb45k|u z8mLChO({z$ZUY^URgo~E0Y}oH1G9!D1Vkpp2c!{B(nJ<)fNUj08-TP+Yrw{c;vdXP zPD#th;$K8$CbL@=9o+{RLu&lPMGftqvZ}5~1*{g6rRM!|bChfm2+*aKsUsm*-glr4 zw8&pWBK{)=OeHM#cMjTFyAa9H`KWB^#!htD&=Y$9hnAXl$&Uhz-Mjex@KURY*>fW> z`UIt>T~_WDJaf0p$^Ts^kn<-AZ$2Tr`D}&>G;EjhUdx4zLPl^HdG+)$Cw)b@WsH7j zz|*~f{(A??@b{wAH;5);K>obSIfx=B?;MCV4>`w5l2ONz98j)mE$SB$c^A)L78jVO7fm#yXI#L3)QcN{CFj_@M<49DA z@d&M#p68ce5qHvno}xQQ(B%ZT=S?T^2p-?4Q0M_E(VK#cin1CC;yFSg%`waoPT)EU zKmxX*+PM-y-~aaSU}Qz&14fm+R(ep_^99t;J=a z+3u@yavV6$UY(2Y>&@xi@%|l1_V>NvRwE9irIZH@D1lO99}0WF!X9%91pAS{b*LtE zVW~Oc?JPV4H&oK@UlNY$P5p7^U!;JgJ6|J8_57? z75;(kW@hFKof#*q6|>0)xF!$p`eYKK`XAMa*C$PhC3A@m%NINDJ*UB*uU+okJqWLju4SCD>p_?IsX@m7q%_ zsZnfFFsc3|;!5F5ASn}T5+IEiy-2nC-7|3E>uKBP3M@gS9fAXf^q%5!7X@|X1QOVB|0C!NiZ8=bk-NIa(PH`qCi4QHmtM20w=uZ>k zym<6B61-gaey$PePm>TOb1tkqe-R9OgdvijCLod}nrrj^6%W71XHt^(EE)_ri$JD^ zg8moS{emPK`|K;=frT^eG9dcdnkqKeJlLpS=@ctP6txx)0AEs+UEBN0;M_C)uq5Ww~PWU{c`Olx|G$Weh6 z`f+9zML1Q<*wJ}BfO13|W^3i+`#kda*kA|d7CxC!qXJd~BI)4i9H=tH>g(+DF&*(} z`EE>uV!zR4$&7siO>jvXXM}MCC*yrJPyq z&maa1?zbsat}RLJL>KXNVf$}?h%UPWFj3N970hN;d04_9T7rnj&P-EY4x=4uo2)ux zbJQ=5#q9N-iD|EHzze+eqJ~^<6hlpR2x>}&#)D7BksjZgHKaj?!bNOnChp+t>zu2f z4P78M=GXJ(GQgJ;-+;NU=b(5Nu{$!*!ao8-br5p{S-}yOdA6!{9b8m5U!H!Is--w* z@rCz4>!s8%lVhYm`vUPFvgrS7PW&G`0afLR*bN4_kN-Fpz+a~RFUJCa@))fVHX3r# z_EkUn>_%ABy8!%o67ykCWGHIf87tl-4vPOoiG0c9j}M^NBQiL=Q6dui{!}UF7vXnB zF`XL3B;>OXQCKO7qzA1+!_#q>CpW2;22W!kZZ@vO2Ragg+K2Z!Szg)z-vV?b@S~gC zI5xD?jU>RM$YqvI*4xpU`m_jW+xTutWXc-Y)F5gBg~4f!GA6AmzsxenUJ7Q9S2ImI zY1p)xFey7u;Srq)yZJQ`Tso6fxVw}$UZZBDv#(n>HTlHu?j`2JS&rZ0OSZ5Pb?uz+ zaycbZxvE??Yl(vfVSbIo-?7D|m}+g4G1W@aAQVubct%!r#&8t|Z(}I|B2;c8oDk|- ze;i07b@-<|gx0rnMlAD*;et4-CPFZ=)U{wt+BaGrmp5T&gJ$YyGISl3jtT_HSRhc% zs37*qw!Xgu;v7bjmbnlK8vhfwA0qhSv z{F`JZ(tCt7e3ukEnV>G3^`DF9=G)P12L{XY_gGxt;a)|yj@J7UsYlFHTcSrgCaVE_~ELHt!;-Smk zagjo&KVX@p7pTbOs{G@5!6_FQfjmY6g`y0to{9LV)EhPqetEReu-s5@AGl# zBz0-s%fHh%r zuX@Gjjw2?SfuSoFJ(3G9@9@Ni&A9An;w-v;E(?a?KHp0t+?{y~uLq8oIGmURQyeq> zl8uqVyz>8e4#o;WVAR;u=hD>51R~_H;S(T9>=Uv_7MeS_nk2;@`czjC=OaCERZlMf zn3d)#eT}yv^;WW*6Sk$-TI#*BXZSXBMMQ9v-Mqu1DQ5qYCbH%4*97$2?c;rFP1&9g zPSI#{hO^Gs!Fe||m-`L`-cPp?o?lOU_jM$~c8g$`b*1FU*5{$Lc`qOv=TF&iM$zU@ zj+=Po=_a8l>oSpPyVjgSMYDNwkF^mmzsQ+sr!1nV3goIg;-K!3G{6`vlSPwU`y~r3 zb3wCNw)EbHVas;Gq8T)3hF`;Gi5)Au0ws5T$%vLbq)H_9s&xy5*^s5e!J4M zE6y$J*js`hL;Skpnh=gCM4T5v@J!OKXFPBvB=xHH(21m0Z{T3XF{}hyVuTqjj=ZN4<8+$@kVAj_}9ts(Kh@xYcE&zvIId^U%iAQZ~^)?C}S`po%-wcn89F_5M| zKi

0Z(eedZ%6YaKVyBPk3mL~DZwVhte~%Laiu z9EldvrC_=-nqvU)tV0*oq42~e6!10ID$=*q$FZUzW)KkuaPBNA-e*RpZ-lIt1Qqot zmE%I;2Iw0>YB4cbq`}Oz1JJUngrporvd|&1gtFM%3&}z9f7XWJkG6`F zdPc;twPrB57hAVtQM5~9&v0!e;d1BEQ?cGKA6gw)hM8U{u?XG_4S8;E@+S1?|JE6j zZej^$a=LS4aY4dhEDKr?`vJ;gH3?2+?CI`pZRzmq{NCnU{_=OfLLfkpip?J`iNnWh zJd!rW>IpaBXEOBTbi8NaD|SkC{EGHOkZ3}G2O_YW_KCjdVe;RfJpGx#HGY-e12B{s zYx$tH0|WP|Kf$(TQq>b9{=f*_el1a|uRm9KJ9s=GN>ya0r} z-QwN^K!=EIYBMi1sk7=61*HDpXpetUbLy@=V;*HaY4{KXBj7txrWP;d8C#ri4WzQb zoHjs1#McT0UU8)S_wX@es-&h1B&5ly1Ki9!53MF{k#O|!xo8i!mudpfNPXzwSl{lE zdl_OEM|fpQ0Gu_3HB?x1`un%BSuP^4XAY5nL<`;WP?e@OhzRb(4m~+Ccbd`S0jq{o zzt-GeDsn8a9@MbNGvt%M^|djkdfsVi2%1H>z$%#>NOHQOFqJu8sv;(n@R8#^#Bp|p zl2uBK(US&Gzce&Ra6KNneE|Ct7%DjJg!GHb+j0^1NzDLG=(H+gbKAd*vhGU3_4hmuRRY(9d#b@Kvae*qb)BlNW&50 zCX%O10Wk*p;i&M($nS?lDi>Dxa}S1x8JoN32%nLl1Nifr#_8Uuaf}u#$ok!!j0#|C z^{L5LNlMtY`29^Rss~1!KBAKINSjCBG*UvLkkwpZuH6h=O}=5{xm>ZkQ=$RYae zPT|(6E^ligfPx{TU?M$qNh}AJ3QEmgIB)n2!R4@;9wtVSgbP3(FX6H^sY!<+xUFmX z@rHzm#|7V&5!?g9&s;s2>ia%O--4Er_hhk_p27a*0xM|5=;b~5nsnsg>Y&eOfh#*u zLCNjb;SP+3Zke{+9yOr%@SE;^DJ9V=ObA-uuW6H0-$e^xpw82a^bH*wq}mYkWnJMU z)Dn+_mjnpdL?*+*GmpQLo*t~eJNn}Mk!NFlKaav~+)J0HD?CspiW}nwsNk$eSb58w z5dpuXRH@JtqHOg?M3mJ-bwo$fD~MS?c}dmw00b=QC6na?EVyb07M2cIlg4L2XfUM; z#$a?tNJkW;FqRR(<2DsqG@!YPMZ!;&Gk5;niWs}7{3~SH38@v7Y zkEWr=UFcKm(nLh5TgfLV59vcopB^Ma^RG!85-w&aIz}|N2!B*p=#3wkBK%HkZj<`r zohaTX1Ub}A$dI5Ola6i#Z=K54#L7wtbUUH4B5Vl;)do3}Qarg179*HhTRBFI@KhoW z4h+cd!rp+ThAOmnhgz7oE`vc43U5I+tfl0xLR zdq@@GxJ5MJ;7zF=uKU%%L;*M;eY)7?`|f%KAaF+-4XO@ZY4(lmqfmomUWnIKs*;&p z+x7Mz+TeWlnT^dOn6J=scrtbpbwPsjLjo-$_m0?J8u z&X|GG!L;ie1TE&AcF0>ME2nP(K4cKWL%!Vnyc)aze033fHV-f)a-q%3@h;h3K+w=h zJqn5mZ%EBtrw<3Gj^R3kpLY%%^CUEOmrbatBtog6@Pdd%~ zcINpA)&SN6r%O9&S;YQ!t_Y69&Q2_A2n1o=oBTnzUIeiq!NJ!b%AtA7p${bkXk3Se zu(X7_xH^PeLsfVKh%H*5qdLGWfL9p|w{+Em3;F#xcM9Ls+Iy&uYa^J--VL2DY87%P@M>7R+C)UkY03=b%WM+~W;~mU zVH2SaKYmKX&<6qRDLdPRg2nk_2HNODM+T#7DB$2mL+-z_5O!>agS*HI zeXk~-6im}wQL8L6^l+{MNo9WNTxhkJ#-~TIDFeb>DF%T~+!onjgfWku;TL>gYRxFy zU7b!fPPlDlAmjypgv!*cD$SO_A4UgshHb9@I)ZE%U0yzP)=6B%8hdkM7gArL@Q0z3 z^e)$!-{I+YExix@*r?5s#3GG8l!3s(b5N~5Js$@Gac!(Y1l}?>4TcLM^Y-@L(+lp3 z+e+4jM?TlES3>&F+D<9a-|udCJWkd&y9H^$8aWHaCzuP>*=xrV7XLcxA0$=Mir`!Q z#vE%|qGBsc@;82Bqim3+l@v0<h}^VZ$d-tX+7w0-E$xd0uDL)@K(EOupnQWSQgS=sjG2p{JR=|r-n5F zmuD+$PfeAJWzabYW+P`|h0WWnt`oINA1lWA2BnXZ;Eh^@P&@WM5zcSBA=WGRcC2Ys zCi24sp{Ta!pPlfeSz_@aAk!y!cQ~5BjaXtkzEcnBZN3rcB?oj4x9*>cci)f;4v2Jt zHzH96A4prCexO50Dd*gFT{P%OeqH~jQAcYnJ02D6#5+o&XnU}&&p|*e!6z+FtX88A zO|{{#vBlT+pMTNaLBgm|-anhZZXVAfZvOG0Gv_g-K7>#2iWo~@k2C6oXEuI@>~oEc zf1fEW4qtDVmslpsDV{P?HGwe7kHKOeorpnl;<~)(W$7D z_nb6jD-L=>O0JJ}h~euIihdS-h6?Pv%B-hQ2KVs)S%kjq^V2NYMuBUuxf?4*1;f^X zAAL1aNLL5x<7_CP#IN=06zG+Ujjvcs8BfXwmg{8};(A6_{MWl9-YK5^5yq{qvRh32 z?lBrxK`Rn_v)a)x_Cnq_Vr*aG3R~eu@@n^UnV9Z7@;jD&_whzfecx3$EQLb)4S7kMhcF53xp*-;Gn6cANmd9-U`u?^;IjdF2VvJ z0@Y>pURXyP0;7{=d1J+bpJhe{o_;;BFCpwm?wq+`P(Z4xOl@DgOjFWG!W1gh=b*cr zfzg(hffbK=2h^FcwK~FOA3@ZNN^aln|F?T5Li`WbXP;OwQ2?15TCW@Xj#YXcy z&S?M{B_%!$sk}1*fZJKZ^H8BzsVx#^QU%EcDVS*(pXS+orBJJ%fkhW1E!|1BRsN1nGvJZp`eT^FE*`YE4%%e?q;{=LaDP>Mu zcMSlS#Bwl{E!_7)VV0t9d|H!N(l8jo1CSY)n`aU$?{XBQ=~4iwP7Y_eD1 zcdG<%+nmIHvlmx z{`;$BIJ#&YZ;trsp~U%|_mMqldNzEkjqVWxYO5$n_e|^WTGpe&%>kf|6?8IQE@1Tg zb%D}`>>A7g-hwdrD4wJtK03m7|*8I2AtCnF@_w!S@d?Ao8hJdP;NmtsV&Ko#B2M?Pn2TYE3-bL zX@W6hV}0^AqECTzLlzV$o?&bnpGzC%bvrOe_6%E4CrAMH8B}!Gr>Q7FLIGW6F6^2`shkeAuIw%nuW!!mGy$~ z896TkjDmIQw+sZr*a89B7Jfn&+OrGkkqKtwgpp_cL)74$Gx~@P8)0$;R-D7o0MmG; z;E0L`JQIy44aJRth-sMjXC^eYj7%}<4AIw~`@a3~Pf{emRlR}n@^JeR)52qUjqkid zH?&>mJ0B777i+6Q{zRPm(^RtzpQk6)j`HxsNFHtWj@e=15gff@EO)*Zb}C2p8!i1C z;ra_{so^LDBGqFu#Jij>D@VfKT=afbpJ-wp`*Y2em}V)L)WHmPxjU<&UUifqN;WKK zI3-NzJ{T9R3cNS1ROR;}ZooDoyF*T~AE&uRnfJc@*lZy>N_k=mLyt8KeFEm14ld0E zybm#YZsV3SZ*}jjQG)Mkzw$V!ZrEr6I}zx7aC&MO8w@7|@Q#HgEELe>4jbu5t3U(1 zz>AuC)=H{22B~_4J*e_w!XQTMlZawsnzFbx=32HYIQbv7!_DnwcH2LbIUrd>tO7Rv z3^;^x2-LM~NZ*8T`3ih0)7SDkHgVG8MC^1tOGFOaVme;k7&Vg!HA4G95kmqMTC?W6 zu>EmMN$Pl_8=C^RA+^8dLJzyk+;w(lQ5SBLxqN~dDqJ!_IhmeIyis~RNJxlLDrnyy zaKd+9-&G6TFcIttuQ+KU`BWw|ew>(Rul@oeuMG|&qCf*y;Wg^0Tgybt(IKKJmZ`$& zX_H!ejS^4**{5t~XDi}i+YT%_=~z{&ilR5Hd@%IHeB!G07$a51oHq^)@T>pYv>8P} zWqTyW*gOG5C-j%)Ds)$e+R}2~9F20-%E?h>61%%B=p6V^a8^7T#L*5lT7n64n^d{6 zvW{2JUgA(DP4zH-)S_g6RJMatEQ($m2^6Fae54qDUNh-ND}|Z z&JZN+Cn=O~Ie)X!H@{o&W{w6Q)o9P*g7oE>k;eI#I(woHL|(F&Yv-1H z4FHU)FoOX7O@MtRlquJJk@d1|7Hhfa?53ig09*`Sf=}fUNo~>LqO8!2NXUn-8|=m# zFu5v!$$=_rjJX*ZYFiHKYlg|{HTnYXgKHlE@%y|6>t|-z^z6m+AvZGl(S3-6?IZ_? z?d}${O^4$GK}SRdrN041xB%u&VAJ1UReey+%~nj5U$Xb9U(b2<1Vc5;#2}u{ z0dO^K7ANd8zuvPiHPOV!+djEP)IUotbQOmdwf}IrHWHIKm*-0%F9B3uJ zLD3Cmdp|ACUSTA1<}T?d$*HuS!qNgs6R0nQ@bK;ued9Avs$KUe+?Jn9f*?~9(1vp1 zMFOxhfHm%Z4UOIHuFsP{x6Ql~w?5nmzEXrK^BmVuNDW}t=)RFj@{$oYoNDqYbZ?oJ zxIq!D(K#saB|^m~*Bzl-gzXko`jQ>{j#H(xt{R~GSC`w$M<(!9NzjP#e%Tm`6~eA0 z#FCE5O@Qahj>{_tnor#kesJd$crtT{%R_>hV+?WXnUM9>QVD_~CG$VpZV0G127mJY zg;B+vlN%I(QjGkYdW{)Zf`S-sVXjOLRHunS8Tlok`NKWfJ^UNti?2QeEOvd!ZJgv{ z^V7O`PoJrrr`EIMQ1dhdZ7`QO3u+*6cf!+@0@=qsld9ik8>us@t5zD+I~J%Q*W8e0 zZeKs=0C%ty(<3e#aYb}hlK`4Y;ur*|N1+t|Z6$Fop@xjBY+NNqPJgXJqm2yN_yj`m7e0!vN7_{4JQI~-<--`PELyqg5O?_w@wA#qeS3|0JTYP zq)z!R-@1MF|aLL#X_c8F5k;T#W{-QOKc9|w(SMu;5&QbKEz!i_UtOBV)9{CZ2V zyv{>Kfm4grZfx{_k?-J*01r+MI}-^d3Pn8dSR|nn@1&I$2YfMCHMHyEX82+iSqJ0E z;4xEXQ3=;u^f}(S192Rv7_hNySNr#3%Q5b!yfmE{J*kjnY002ItR8-EHg%|k6fJY1 z3@Jfqfu;3=a}KR==a#8XLzn;Qj$*PCbpS5@B7~Ce(q+#&{~#H|uqXpc|A@+L?z7bn zGxadSfzmtfCql$aaAsYc@4%#+a*!C2TpZ?_hf^L*u4(8}Rp@K}+NiCbkD%yrLRoYn zj(pLOf&vSj%?YHTI0u>8m*;3T_AqJqw?y#)p_YEC%!-GU2eF|T6&;!1K?J!Ps-TGh zeeZ_ya52~P_`o*7FhR%BQgv?CG;lkq07|bCg$jnw&RHthuntjXDqJNnD~@}yBq7Ec z*SXO@AZ)x2Qj{XfkjTq{96iE4+{@NskLI#8Bw7tSrd9L>IX#ugOin6^F$Ucxn$t`p z4|q@3f1Ctpf3`79L0yJsZV1L>$fDixR})`ooccH5xBIIY5S#XFFz%j_K)@-jPJt;D zkoA+jQTh1CVddD3<%HfJ-Uw5L-T@OKv^hVOp+v2bRiTby6gSV@S3Z zbr{dM_;0VxMbg!O7??qyAMtlI)~1?2Hnk;h*3#tg+O4Q5Hr0kn;jN|J@Xg+DX?N|} z7f5BnWu4B2s44)qIT~yRv47_{dFx03ABItnyJWkITQz09X+@dBnsAkFne{$Z{ zp{*MN4b4FFO2grA+wpjORJ<2#3?P}bZot-rpRp`-3*d9?l1pTu>=Pb2>+?Y}x8M-bzlO4h&R zrw2ZUm7ANZ*rX%MwSL9(n#>`eEO8ReLU{^}>g}26l^E6Iq37YuyYWYnK3xyvWo07T z1;@iu{-*q(bxi!woY%E)fvbF;0)~Z8pX34wA6?HaG=6bt+S|&$b!w$EKaBe8{!kz` zibg(-Lzn&q9l!r~Uo^1`j+g5Z5bkff=(&F7)3)@J`;Pdx{_?z^7=Qcf_}#B`Z?8lW zg|fLA{S4bFL*kk|wS*&kXvgivjtx;qC6|q-V02CW6Rt+lsKt z%>EruscE!ji8o)e`=h<@_Eg24GE}jOS3zvVmq;kEsfTpKn4s}b!YP%c_q&Du+m)a^x zhRiVbjqWM6O?dOd`u7r5q~H-&i&b<%1!p8J)Pxc$n{d*05m5pvctZ^~!t-T{7UETr z78_F>{cuYp`bO86O=)3xnNhl%vgaErN#~cxF3>#mlFCQK*Z2gsT6D*ziX-gP?k9!{{2bgnp^P|bpKf4bf7n6TR)8$jVLYjNm z6q}5UGF5wEsYiaF?tVY* z8T>I+(=o5!L*|W}Ry4t?lXQXRA!bIyHvn9P&xoRjIfQwWQBHxi>6SQ@&2|yYq=bFp zUYRo;ip!~>j;doQ0@W^h`W8a2u{HK<-jvR))C7cKC+eo6C^6^qrU*!}UTw2L+#8fV zZ$F@o_Q|d@Mfo$mI{O{aJmvZ$KUqdND~v$xs>Eqx8 zucGGg!(3T>_^@6k9D<1z`pIIM_L%}ynHK^LfoLK*Km!KG&1?{1`2^8Qc550>!153S z0s{eIGf@S@)CN&QGeuE7m2n#(I5OZE<0=T0{^+J%k$OiG1rI>B5NLWa;#LKhrRL#b z;N6=8FhhJ=aU>Ds?68iMq>^CpZ!DSy0kc4KkyKs(eMF(!Q|5XD-@3`MM}-@HU-1cO}Nn!_4H1PP}% zOa~F9pNm^nw@4NP1oj{xuV|v+SBNlJGb~O~9imXGCYw!)D3hx@(m<{4aeqB#xz?io zTq)QU{Dc}m+;f80=fOLw#k~3vyVj@)lT(B+cP>YLN7y^faCBZscvKuJb}arjS4hin2%8I-?}@6iWOYE|G{t4xuJgz!pog2l{`H>VjYZGi_$!NqFSrP!FG zYMa7Osf0l(#J0{zP42KI$fb!)jv3l0*mlY1O_BTW!;vtdn!a=rbu9$Kd`e-`(X&B5 z0l|EiUNir=F>9AFLCY$Y$S^<`1@tCyCGBBz&`YyB`cw%5jpJ?UaJ%qWxvk|}vFbGi7aASb}MU?~=BSdGY8$;F6RFvaETZZ5@-^-6qt(hgzWybs z87Dfnlynh6jXYVE!FnG(_97C|!M`bpED;SOmx7rXioKjeS`V7~_i^aS@L#Le-hFzLLjHxd|<{>D0{H>nv} zxD<(KAJwIs6>u3ldar+%UU&tq6nyp8kry=NgouL+EH@p`X&S@%_Hm4MJY!^tl2ojsb^Y^A;dE*>YjjP z>u%}{5;PEic7q%tidOJh5sVBXZZcjeXeIUap>GP8v5C|aP79fp$Kr(yhB7RA#8#_z z?{iwHT_&C7huEW{Q`=!z*|Wo@%b!2OlwF0`S49xPHbBdps?(tkDF(wLNPg}Rpb`2^&9MRleqgdkd3ljmCi zMRCBSaOZ|F6|6QnM;5MLUr3^`z%7bZ+p%qts#_kib!@;92LI-hB+>QMDsO{E7Q&nt zs?uKb(UO@oGmTu07~rRL1UhyzXGNrVf0{286@fqDGgF{bskDrU3OO}C#2qEb*!>4-a}7=;LtbC@+{@BvYT<_T4p!XA6uELr>V@?Y@F2(Gl%`n6}>f3 zJXWNf;9zmQlN^sN1F7>!%jzfTQwUBzI=T+GANY=65=UxyfY);DJJzi=R3|Q#3t<|A#?J{((8+iZ#yI{Cepl!6QyalaSefECmbguPMxiw6l&-N&1}n?mZVb2SAw8sp z1VF9SdZ)m%@527s;VyVV8YDx4A?&K z=yk&FK%9QPl+z%BC;7Vn>#X$`BP?G!-I60;Sox$b)^Ipx8z@b35}DwK#g)%ymc=%r zt=M&#*0lIV*wz0tVRSO>>PYEqs#=wfSkW?u%666aDhzwgNvrpBqeG2#b#QilJx~6u z)MxVvh&-i0==OBGpp+Lz8YzUt1>>){Y_yhjlHv|HwNu7y#7c)Lq)yp%26p(snM7ug zt)^f5kop!b$YhaFnoJeC66jx$Tkup@m48TNr@5cpX#bTOsboxn4Mj}nnOcGsMiNwZ zt+K;~`?D}RK&mVFB2{xNSdlUYf6Gr=x%u(r-sRxQf6F^vCa>MVM@Q-4W1bsln`a+A(5v0 zdIHB!6U2K)~ z$>t#^9pGb-{&vbQwm#|C(rBOa(nnT@bfjNbCRx*gD6@M0P~IH z2`J_37B<$!7N27?gTC<5KPM!(j!;N_SD7-Zz&E4=~vs{ z3}h|^2g(lvTYentDcIe&tP7SIQFbi$d0y*=JIf6tXIMu#m;HorO1nvM30CLcT)X%| zsce%#ZP8KWI)g)=%*EMKlv0&a0stPG%ND-2k+JY~Ix;16QN#fArsYKQs<{toX;Z|yDos)o7Q0FFzDDYsdI_WAd) zd$pb0T68u)>{|TMZBdHO-v5gnJ!va(qII)tagtPm%BWs`7oSnyPIyXm+xy_8*#JwM z{(Bpn7ENMnLCM(F4`A#>t+ejEgU@Z8`0i+nx~8s?if$M+6AB}!dUoToyN^EFc`wQ$ z^LBy)M%?m;l-m5d)AoE+7XvjandM83$w9-a$%Nd4F}RiuXw(u4sSG+bt@ zGMoj&5-V@@{{8sCJ_2oH@C1sJN}F{&W-vRDt0fZj8p{lHZ)*6atL-cNE)ha`9PR^O zr&Do3ev5xTm_bn&*))KBK5?5NPfVWo1m*gYgEtK!BIq}^(QwuL=Vf%X@-pwNVE{+f z02PAcjjLEzpqyEXtg?O86T@Dlz!)iWQ1$+FxNWv$V_PZ3t5D87mh48t+@( z$01>_*#QL@W*1TWi;WWl&3kOnNCqeseJj+}C17s3+28L}BAXbcZ&&e3KAn>=w9i3< zh8IeVEq48(Z~XS_j>kPs{BrqhXvw+fI3& z_=B0xk?KemYss|0pSb=@{d=x`)g?do5L2OkJ;!g;3;YmsoDJ5J@q>R;`(7x_NtM4b z<@));RlphCS^1u7m$;Ju*2;(}Q`dI?VNmfYk4g+h9a@86f>K2qWgAHkFX^yot)QeY zv^swklgi5ahX1X3t%&3oAA#zgzjD>mFTC{my0^#Y_lf`A7=^h(O!1d6lD&QPi1>=7 zrX8E!pb4yDK}p#&bG%QSYdawR7rO|UL#v`H^@^i1jA~a@m=ukss9V5camJqQ>KKsQ z5}s)d!{P}FKkW)AFFL`Z5e>g;*!H4eh0&%df-1Bnqmky-q1voFC|>9YM0oE2F+!ws z5ka+c#{xIEUOry)i#3BvUbjqZk6H^LovI8D#%zTe_#}a|%TAUd-Xw1$Y`og!v9#9I z4iz46m7TV20sS!usMLyDm(CtA?pcrAGP0tnLAk+3VV0}Zh9$&M6_cfcP&W%nkt*?a z>7L*glqsR>mnxlMVU#kO(%G@Su%g$;w9)%=zS)l+_f@$(hnkc*nt*16u7eUt?Lpo8 zkTUTTsCH}T&Q{w#d?L*!r_WLFbhhq3I+%q^iG7(_;l;kQaGiCjKzxj}QkG z1zOF*1DKMP#C||h81COLl_SgMWH&320cLKehoTt+!!~`V7Us>~cl%#H;}hb0IT|7B zE`r{5?IvIW_b-9g*v0OmS6w|cQ=PoURs2-*ha!M$2S?dEl#hEE=N|4}B7iKHu!6K5 zg#NrK#yUks?kFPNxn)b3@%Vfe7l|sXys}Z7uxS#nGt*ei{%`Go$G?{qqWC%+_*L+g ztCu$igBCFihq~)Nf6Q1}b>S4a#a4dce&*m%Q92IRdgyighU_$jb&5?8nq=$cjK>7i z3iD6Z&Shza@sy`=k!DFN`Z#mQB`+!OOyswGyxerN$RGXvIm=u65h=moPKH-)E1e zQUlUJ2#CbV?R3`LJ`BCZG&fih*|vw(tEws*+G~F?+(hT?>zQtBrm;$`I@T056H9)M z9EvpMkn%R6_Nc&ZEX#yL=`wg;SKfG+kk^;tV!nr1xv932QSl5T*8TkmxTr>S>i3>u z)T_0siW?od+D!IHCk@<`=0t*O(JFPt#P|LCe3&mz>ZxE{>#raj9YgFg8@mkEQi7{| zA1F!Ln)X%vQlVole6Z92fnf4{Now(Nr8&1$DQ@{lV3Re+NQsW{BP>oYufwF;W@lv; z^=E{?C6?x8O1I0Tt9repDM**evyLTuELD*?Zb#zQ6cT?N-|0ZdMYy08#v8pgq&_ma zZokmao6tgq5`L-BGp7ZMF^&G1dVq=Qp;!Yn)iDEYL=H7wEV=@;!WBBpH1ld(ML3AG z-i_e4snX619Yy(HYe2Ikh}Db$h0kXP`ikAo$ioS(&4u08vT4IP8ZDw0gCNx)dnTlO z;6G$|^=`|kCU}dEGKA@QhPml+HbHMUsC*Tro-_dic`pNO#%#2KGZt=XpnE$2&IH#n zK`)0ejRJS^2`F(=Qg?;V23>r<)9*T+pBvflxrT9IPB}hFL5jH1!9$ibVE7pW0lQn3 zbj7W+#XNw-`3Gbd%~gF366)7bmag%Qf|2OvRb}+OfvND)KuQ!tYc5N6oRGE~I3=wV zdHkfd9wzGpoDJ+*UEe)BO9!&w%)ABI>w^RGhl3o}<(r$~T&9(#eA{=0@?^JH{$FwMDD`c|LWi6t?49p^LS95!uWxB*>7uVs=isTVb}u_4r%Ev?p(^puNv0q%Nh9@73QJ$5BB26N zK{GiquS_8!Gbbk_fAar@*Iu#E623410FM9k4>QkiKF%oNA8C{>)W$R0Q+$|;$e z<`*(ZDatfWt*LA!6jI-=xM}G|N)x(6mZ?WncwQn4@dvBilTwMM=xB~!2K;Fc()c1C z?MMoWsZ}T5@qOyd`Pz%;YJL@Wxtd@7iVtqX5GUGYg|cZ4G_a{qns0;76w!n9KjxWo zSRR^b*1t+%g)`B_tV{survd_}mI?2ezR&BPZm37N-WGwFgV6nC8RCxt_I~QTbW=5D z8fBwo>8s3GtAXCIw&g|0zoV_tFXkQ)DTzU=yzCE-~=U}0O5n0N)?U)?R3$ep{T#eQxflfIb)@F z^H7;0ShihJvsfnh^VMhLFT_2Z?jSQT;CnrHP1i(;ch9!GvI;iKrCBh*8aAC@f5p&B z;Z1ylMw;_6q~x}|Yz@Jl^`}8LG<$du71lX6^B7vCJR#_qRUMfTyp}o>p>!QdmTLat zgIa(7f+b$^cBOdD1gb)vWO&*G{_)iZQ5Vbsi5Brb!m zNu9&~QN_vlyBbV>sG5T=@S#M`i zHVqpoj0{7Svm+eyU5lFtF>07QRbh|jlq;p)BYsqcLmVs+%VD984V_uy+ds4+p*AN> z!!j%&si*nYF|uQxJtfp4MASt6{QO=fgR!`mlZz(auP~^4DhI2uf_4C17j1Yg!+U^& zymI+tmyj1JBQ7Q*@t8!WNiC$n-a6dg+!YqwFCSkotQNS4q})yc0{RTN(||kqVe>Yc zN5+UK=d%6G#qRZc28S6~qFz)v%jrdhf&x75idiWN9ug)J|A$0&^fkuLb?vk&HK1>~ ztFZ#OmRe<=Fin_S9Mp3~kRV+}qS?k`XBbns{N!}d8VU2=JQC5QsJ@GlEU{@ganS`W z1HQliXC%a@WL*zIK z+>k_I!Ug9V1`j2Rv8{oIN-5+qWT|vyY=hJofFwo)FG0DoN1~s zd93!~KJ2eJM1}*Q6zijX$)@{hUT}I}%Yz!`0L9VS4Y?4jBVt&|c&h#r> zXj^b6#egqo$KNJ>;W|SE?2VPd3!(urQ$<(u$mSh$!L0$zq8fKb$Y*jHNzCOUA%Fgd zt8)kvCES*E+qP}nwr$(Cx!bnQ-L`Gpwr%&@_r5rTb0;;dK}GygYvuoCCaVi*78>=M z2^LusY-`%gSe9$H@uIza?b3M$iUfY3SD|<2Ng)?XVU+VCq>+WWst8vGj6uc_LNb1s zxi@q3rlVEe=bbB;{dv%WCwkSxjtd*gB+Tb{*l}PGB$&_Flj+#+njk;|5aDrVVnbOnm@M~LaE2}Q3hDv#Od z=NiHAhcGsQIUV^wjD3<~_j(&|qE%BxrM1eu@oc*iH&8A|>$;$o<;q4M0-1>tYDEV` z8XGm&T;och^IkX#ubVu8oKfvKCKCxBgZGfvO`_9X_LH3vpJ0(Z+B&S+o@j0mc%dEY zWw2V!O_i%`{$PppMaNqP`*gzKYwaF~t1Y(WA|2USQ=au{RiHC+Khi7-4_1{K5B)`% zN+=HAxx$!-Whb1#>k)Iu%dn&zU3>!f2H@%EI0~M>oAdWrr7J~6 zKCttwPN>^*a`P%{Y1;x{Zf*ELttfB<@r2gYyqTN;N~kErRLrz{c?MQ zdCI003XIhv^}RzX9~X5hryGp>O<0-xctZ=&yz=d!0RnK%_1lo1-dywugFDIS#pS)h*h=5tn|T$BqJ-Sck+d z79}!5Jbm6mPnVwB-<1PXI!|a`i48c>C>(zNp6?%YMG(YW!lk|bOVd8uLQ;YxT#^S& zNN0^O-V=JrkfvFn)=uQYf7&z`y!+5Ql=PpQEL=D$RBUV8z`tPbZZaFRcpY8p^gESs zz-3?N3w+-wx7EQsXSdtdgmVB4>Vv9{U|~Sr|Fvf}z7km1}V_8pSMch|7cx*R}|e^-N`O7H$HK zPO032!mpMuA$nwWR#+Q2nuRJGkVnU~JhM${lKEJTcUjF=Y*b8L_#?}y*s0LHm zEdoR~!jQ@DraEf$u=VtdK59g+)%Q}oN1b|%P(<4`Y8sbcQ%d0b2zGUP{WR#ms-uHs zl@h%S+AB-vk(8l5N6EDn5A#e1?9M2;Y16*~GI6&yw~80sbNK^9J18UmDF9 zclX35zWD0=Tk4gE0^wr2J|QvBTqsLXl*`d=r<$C)3TL&B! z1LrkOn=;m@+MmlKT}!ByIt;%l)ZjJCn}tK8p!VJbJRsRnPeJ>y8wIhj-u2J4H`D*T zvY|a#7k|dkJ!84Bfb6Udl0SWh5mbpEFo=_B@oXpKcm%fjBi%Z_5{w#ne=K%zSz2%J zdT_a$Z&m%mSh|PyZJMJ0esek(b~yqW4ee0kCn<(8SJUJ5K?SBeTCX{Qt84&dp>g2M zTgY$NQA2qwX3U~Lp|Q@`eX_(UJFIk-`3C2)$zNK@t(*=L8nA$pQ}COO&k*l>h~1V} zFYcd=2_FKqQ3>C@JJ^V^byi#n?{|mwLD1*A8q7BO)@9D7I%G-wzPrar6Bn}}^b;x2 zQ-@nDGi+Dn=f5IO#$Cur4;Ohw{$T1+hwn{UvzQQd+5qujw)q9E8c0b}j?Z zr5DraU0BNKJ$v`Om)Ebemd`ALIsHk7SQ;8G&w(bzV@;W@|EU9^TW zl~QEnM4}5yQKFsA7^{3;`MyGRyf=jT8Qb{;htuslaBj9GZ$HzQmD_ZoD%9DI{0n~R zBPYT32^LPy$vMpe8}>v9&ap)%QC8E(a8$qs>42(OhbS0ySf}V*JnSi*w?x64c@dzK z%?JG51?f$PL_#3l4#qIH<^9w+^#4D7p&qba%I^>W09hpezmoKSHzH#vQxj7=7fVAM zeP0-{PK*(9?5g+{b=L!brO%(u9iDiRzQ zuM_yb_z6p)Nh-R%koVNnIE6CrekHn~h!!D_GwZE*YSm?FNzBCV>ehwCU+ILK_OI&; z`6;P#onE~rxfjiUs@AM&nP9`HDs3HOs?(TO$W79uX#QlF)6iSfp^FIloNNO@K^IHU zFr-Q9O=j9v59E)VlWnLl*LIUUpo_~N>?#zNz|L2+stIXj`n8b9JE%La@DSO=|xOU z{l%wB{F~vXy4+4dNZ&c7YHMa6n;&o=HA#!h$s&r9v5{gl!9hOD0Yn3h%&S=K>fW$t;Y}B2uMD+>pl$ zaHczg4kJW@yRC6=CpAWrL7#o~;iv-qU27)Mt5ORg#zf2`=?*`&62AgJvi<|!M z?kx+8Wi!4}sVG@R9*lvj_-;V{8IkUT2R`*LYQ$&WTfkQpXHP_zULeAvMfc!x zfd?rGxsv*M1qGqb;j2K862nxq5H07$B`_VADhXK>UVPhI^qvVGl1 zPp#||q#v@Q;Jlx%U0tfCimcT1<;o^9M3e;@js|`Enwc1lkc>(2?D0N`Q)-#-vrdRvndK4rnK*1tmSxbk2K{JqvOMsD?U zOy>X!Ui-8mgDp{Sr5giH$&tb(XvetObD$6zj^~lB-(T!z{<^xqZjU!_M>K+m@!2|g zIl0}w{*FGLN!fZjjgF-6LDDoVkj@^sjgOU;kCT8-7w|Xp4gv?RDJ$(ik&jM?3`OuZJ9LJ24X?0HOd~) zw27MIHX(y!o5S5!%#)*AK1@q}&n`5^GO#jSu3gy)APt*}{tmV(`&(8Dn#NraVnJ1H zxjI?QT)hlWJ~siLbs=|0ou{?DX}IR{rFY2jJHos@W?R-&10b_S%b_1@j2nuSTCE#A zMuSa0W12q@$~>$AKA|j%wKYGx@2ozzI4ARW-Ao9s7Sq|r{B`WePvN$t!H3l@Jv_@O z>E}LdoJI=hMfFQEVe{Oew&}v?7#fYOjOh;He+J00V6{0xbAE79ykAI!-=`^plm=Ah zCuQN}tyTsQMvH<@FgpxUkAq+9R4M!vUYJU#1%&K?1TERxvKEt7zgT^=Bb8z~9FvLV zc?!~cerpZGW5ncB2hyln?3m9;ZL?kXhj=vyzp7F>dc^RePuqfW*MYQ{Fy#ZT#AG)+=cIWTs{N zl937|@cC895VyS#M8(Dzy;UG`Rn|htB3d+3YsqOCYj#x!x@JgWC4Jpbm}G zDC_G!XJ0gKXwTK5*0wUm}NZ7{!)4as5#d#K&S^!`}tzR9Cm)jpXYV!m4?;ZnXcRr`;_^tL6AOtoII3nzvoph7BHx zeiXNz{AS}U^M^PX$xr~GAkHX;R`Nj?RXpq{)F;y-)+$?`5Jns5yY*?@D=w`UbXF(Kj<681gbFGx( z2BV4GhT;{GFLmbLPMcVZKNcJ*P*V9BSaNJGAz?p&r<>I3*^<(TOPUNtbdeb~&do4) zrQlfV+soPVFMoWI?j?a~CNK}gX*rblVL?pTFtihRV1V8HY=Rh`a#a;u8*4ux@7FZ6 zD)=u)wL&QqxiT+*5c)iJH=*C3i_DNro{$^~eULYC6kxh-kX#Yt=udO1P>gTz zprxWsJ^Az7muRbY@rDzla`*Por`4Tx7;97dHJYWQ<-4%c7qJg?Jn%8P*YjYaU4}I0 zULuMwXMPMVeXfi}iKw?}Few9b?HUQL4g(=Vc7I?-_UvtZ0kGf4dec>O_m0z^x<{GT za<^6&Uz&ZRH(?0;SGBC6>tE&;yfqJf0K;nvsw0YNqG0umkI$1ypW$wStLi;O#gtIa zyi;o7KKCL$>C0@Sj(9MGum5v%TAIZ;Ix%-R2nT_iS1E1cXotQ%-S|Xl0OS%n+mwN4+6;Cn!uXMiOxZ>#!lG zF!u(5z;1#48u@t!JRIl3*bG)@yvWQ}`|DP%GkMZD3D-N7ruJfFHom))D4o`i0$LR* z^iE=2Ha)eN-|=;~g~iNRnLB#Ijv{)HPrlGWzqH|Mdvzl6JS(g{sbSBP=rBI0r%rvU zD9daHf`-qmmEZxa?`PW4a`Ieif>L-a>X9Au6*@NIx)y>Kda;?uyFg__e#4O~lIUus zQhX_Y-U$-)J$}1MkUK3(uhBK|&Rxewys*-QAGb498#A#n?35DKW6PIp-a=H{B?%?R z%d$?UtXvREl@>{Y5G&Q{#*e7_Y2u^Q6FgOqPU>^R@Ax*fW{qZ(#e3$MY%uB&AHV(5 zc~j(+G@+$7Bc_aMQRVryUK-l`dorj6++{YO#rVMDJTM2aIJz)Y6OkEkT(|F=I^Eb^* zLuQR;s%+6&9~Mw;hJ<%k7u*N^hlk(K{S^s!cgKg5Q-*IlPw?5e)P&enTr5ITSEVVu zQsN`4kEg)Sy_X_gXX*|hC*X0iz%irJ%3F?e3cO=KT2tjwb{8S_pY7=aVql=5DFK0L3k8_aG-}BSe+M=t)d;V7aulLiuJF>{1n*oZy8+4PF zJw}Xwq8`{&dosj0(g9MZtWqhb4=6dmu$Ph^!}s6?Jq5J`WiapyzWXo28jhc_^oZh0 zCX}>P-C26W08M@WI7{fwy9Vh~*hdsuxSbT#Gw$$E%yOb>_jcck#w5a#4Y38qX9lyJi*6$mKqD={dVpRRmLty|K zoz7MG2OPNijZwk!M=DnkQTY{pBUxWMjJa*e;QD;G@E3zc;x{d|);Sn8*^-2|eTK~L2{T#NrAkz2G zYyll^?OcwW=+TE;kiYtVCW z@^Q4f^4Ce>aF9i8f77QG`v+#P=xU}o884u;tD-m2j`90-la<~2rt~bSdd96Z33Xqr zzK71o#fnf~>(zpb5W0(hM-w|8DwYaP$KIAnM%Ai3R| z5hw9G@RiB=8Cu~cP!ml^42@uZw{Sv=N8MXr3yU6l#=7S_p_8(${lbB25b}wqXWWFj zlCjH8B?!wQS+Y#nzJ`Lbaa{BU#0w%?U67=(QE*f3+25z%#XQJx>lzp#LB-};bBGi- zIZU?r6YVO2wR@qk*u4q56b`@b6?oY5TNpwZChLI%_k0ywq~rlAG^$a~wmwnt!$+_X zbWGjC(q0?0Y{rF6$gaXpPNi{-M#`-LZ{5#KO`oUB7D} zZR9GjhNLi}H&SxLpdBP0kL>qKpIS85XFf5PF=GRyqxl)`gyu5VuLl9%7BIHMwgF(U^r*-EvNG-*o+i&D33s}0ALu7$q|=psWKRIv2R$qy(nYkv>1g^BoT znslx)E%5mG`P6Z(ApkFOMxeNi_A1Tz&TybrHOLfjrui^SdNY-}QXU20DMR**> zOTRw7nGcXfy>Ye&gnQ?U6myZ2#ztjwdX&}pdQ#Hze0=>{fWUJo5+gW+ovNBIXEiY_ z4uesiTx|=D%&m@^p-0J}x3wjwYB(saP^h@@gf<2PyYe~h$7Y&6Lxiic+lwNLHxU&* zp#p0(Q5|uAo%}Tq9q`RG2RYF!(R;0%e_0#R^o|g~{xW~ivx1->x@tZxcPf=gA%%S1768IcFG9m!Q$SO3NYWY@u0;#KF{+u)vdrAHw3=6qu2N=U+i-yd*25#P7PdBFf!ke$4fVnt7nhdJu zB_sMg5c$^{WqPW0)cZq1kjA^>dcK#D^!(Oo=@}2X1%$(`+M__K729{aH4N785421Y zyO4{uD?}ToYQhF$X9LgJR)SH2$i0fOA3y2~vnIXD?6lXEa`L1DH2D<5_nuE$x}nl= zB<)5&E5GY03@`)tfx-k#=sAyoIjG}$Gw%bGz z#_co%K*7sS))0**SzFY27EICm1*eo}DHM->17?`{_)yPQZs|Q9kbg%PAyCw4ZiCcC`ss@;Y50PA6 z6#`xO0CQ1?4*{qLNn}henxnC@v2~hvv%wKc5w6&>`PMP~Y7R8U6ae3B+=u{-hlB4VRG8a&9s%NyNWz!E&I%eL-8u z;UV<^l(|wr*Pb2TdR}Jc=+H{16)GWgc@hTZrDbJhyPdC+Y+AK7@HG<;R;lpkul)yp z=4EY~$&;#V6*s4cg-!lHIw{VtpW72ZT-3ESk?$$rja6=LrIQb2+S~MZj%Iv*sw$P; zSJOtS$&d3k^qm^?X7740g&2p2hwt6dn)K$42TpYuw3S=2NBp7w)h}n-KZ7&`3h+`u zpRK|7OnFC-s*IVcZbuQn5^;OJaGn^nY&j|JZbQ;vcrr5-nzZ7Nkv;e{kG(aIR>x`{ zH+M|q>1xBpfv-IL|#b0r zxdrj+PWjR@02S+}Ml1KdG_S6&LLaLiW~pJPTvV#W`rXU+36#28OE&R>_O$IzMgP4Qq)n`!d((xx7Nfu;X-b61=E6b?-w zWTb|x@PWL*075T{_`J_MAZX%IPBzoPKgopGGbH6f3lV~w5DbAG(t zb5cPye(&I)w0~Sr_&&LMo@DR*(&-sec`>GWisZk94||-K<-@tTNhfv4U3;A&$z$Sp zb8pJERQemrQSehEuyy&|^Uu_J!37@lJ}#s0$2o}v-hfeE^V%@Xly}g9K(7TH^0`ce zkG^OG(>FrgQy7e!`?{1T=*F#u=EE0dsz?`=!WnlOd^Kyo^S%1Md6=|FDCgnvaPu^L zei_wxsD3(#ZhqU)qe+{0^$@TN*KoVr_5PzexR_RSU)DMA1Z33-*fea)IH<++{zX6Ml@rHr8{nozL#>kpVb+UQJy<@<;g{3XEC8zv`kXSR9_ThpTt>#^(*uYsw1%oBy4!U!T+;QF(@3?^z|EU7UF|k)txJE)ORh#pElfOU$!ynH9omDhz{iiP&_uFl!CbK zQvz{~2LRmuQ*1=Zj1?y-6c~2zLpk@SYs5Y2hfWY(u>SGj>G=Lr9i5en&&qsZXmM7Q z18kHjEYlVCXhtA3?R7oNc!h8(>9mUM>X6WEveAYIE4X*oIXW*szK=#4r;i@PXMhS} zrSD2%c*lqzlSG*3mh!_3UUFlZ}Oke|Uv1)IN^h@9f$vpXj%kVR7m3 zl-B~H&&!Cm-JaZDub1bO4{In2Aa$rb$4Bj$=}FQ}NA(hW?0K0r)=?`y2!IeCH*m>? zY}z0Q@cifmF)?hiLB|%1hyX~u>qQxdD8jrj_Gq_AdhX@|ODTIV7-e@GH6&6|4ZN0o zmr3{P_ST&Ls1cOze)8Fe28xLfq&eYkH2QJefu(m{8F~HJ&>cI$Mn;G zO3g&qr*35g+!Vs+No)*!>gm2{k@9Uf4Ip5ihg$q;l9vUr<0UhGxC5{yY0AFuF+g4R zOd(uy=E2uYyVz^Nw5LlynyQYn;Okqpeb$|@6O(q#h-qVtyQePYbl-;2^qA$c^16>= zU}^9R?1Lo9A&;e%cV*Wtf^+H~nuD^69#BSh+S_2vgCj6?R`dL?SnH5!K3e>EP)>Qy z1PhwJoN@{N{BsEJkQLuskZ*Kq)g6XEUpinDp?@)+VW0X#abB+~5u*w|N)JB}q#5nW z!n!UX$t80qt=NTq;OJzL0vRRV1tSePDZ6?+782A0`WL7@-kE^{zt8Ku zZ)I7-8kFMZD>6u%GI49)4vWyP^oUkh0lnXJE&OnPrn5X@ekxYbNl}bX{KZ z<@z7XaQcTL_I`gXD7$m>X?QdrMqd0?k(KWdZ?gr~Lid#qwY04iYw7?n_hEi~$~3pZqen$|^>g*E|K1h194%as5x)Dkau&u!=ueW;djz&M-NW$jy=pSH&k1~| z8GUGBp{$HW1z9{09l+VtK&a6=%KTL5dRY(~2&=*D6r1D?h)vR(D)OO83G3e)uAXa$JM&WuK?> z*UtgCwPbEL2g*=i<9m+o>(y|mqLDwD7}St#k>N&8H#cuPMvuR{Tl%x8MXbT;7|)7r z-u@+6)_5BfjS&e?onFHxIvMte7C%lEUJHDWIeLsH;C{ie#G?cb|2d+_ISSOkLlR42 zBVu_7Cv4T;-V)BEycZdf2{s`f!sLl#kX_lz`<~dOLX0)(0&f%RZty767V{(yB4)6@ zk;Nq=B{RXyJe1otR8RIqAsg9>Qv~>yj{AE_3;?dFeo&>AFZ3{olu|GZ>^J;Rd-B

f}&7mo#Ji?Jo5m`N}5+&`E8ER0ljIngR!>1vX>}X4Ev329=pd$Qn#D^)* z7$Ow>xKunYC6TH*?twzgJ#?1DlWNGkeaHA#=)w|E1^#J}S1psssD|#L%+Dhdm}z)|0xYtZx4ct(y{O<8d8Z_qiocF)*f;In3`~b&piNNx zFpgoabby;zQ%*+!s}7Y(;2JhR;d!~;_rmY%_ZD_Rb9?IpD+T9l(2Nju6b)~QKIGme zXvlVJy$dqTh0)XYyvp|K{#AST!cEzn-_aBR#ca3``W}-2IFwcdIQ)M_Q@4)rJRL|zdWWY^AUT2-M#tM`n&XO%N%Q3|e=N?;iR%G&!aLer zTEVW}SoT&b0-jz&5YWAFKIHmN-1cMgYi93Rz1)pK0Y6BdjRTn-V>09cL?aBu^av^D zaMvpV;b%Gl2}+sDadWYhE%$9GL(^a44w>uM!AJ5RUoIUv=e!KnThZHD zjUR3GPImlv;dpHzMu)X zx5-9p(9j|?B|Fr1FF6JezEEQRBh)toT6dM15wwak2}k@qA;|Zh8qbM}eDO=q0!soU z!1>YO;&Re(`_{*nVsv2}jS*)Y+MW)~+*pbs-*J`Fb+M8>IWi4zLNL$1GG0M0lGi6g z9)anR4C?kQyp?y)5?Bl$sYM?o9A&oV{sgR?4~hv5^jQ3BSJqA|*nxT;qs0~m4=Y|J z^$elvhgK}X2m-$Sn@Cck+F!SZ1BDW(BnA;==@zyc_BCG@e5AU1cqcPmYkyND$}EojQ#~m@; zugCa)zc%8US!dI~|9Fc@&6a&3)Fl5ZXgA*-?u)ktwlp z#umhHuVy+j;)OOEB>nD>djUcwwgz3BWLQ(I{Md=Csq}34WrLaAW*?p;;kp?fr#rpW z?tIc;Ru{KJxd~=eB z2%=chfowE%F$=-;fWCelGlHgzOyZ#TE~2s`L{heEBu!U7*>knRw5oPzSg`%r0K2aEVq}asi`4FJzU%BNzy~<@0Y$C`0Bg)gj~#1 z1DGj0($sSY=0pZ-n{8otRW>zmoo=o>FFE3?RUQ}9EoX=bTiVaPZ{`15+N^*{{)&bitjhZ;GgEB_7Ne(roVNniO0a*qyGEZhrmSVQ;CQORRPa6D< za0MN-LMHWKeS$BB7EYh(LtFAyB}F^cz@c!X`qKN;vnFLz`4LpJf%HIkn)npuFny9y zI4}i$YcWrwOsL8*{xB4Fvx=fT+2IyE;*Ky8uoU1MLiO*}`u+GYX4lTG-0vr^QAjul zHltj8!aDUa%euAPhF#FjdSl)J##)oRBGP&cBZ8l4R*F}n*h(>}b|fj1xE_s}8?GKf zvYybwh#$%0P^Ircwb%?+ste zpNbA*9!|i_;abTo)kI`i+S239t)&9XmVcH0RZ%8}5AYTy8L|1uO6JX5QZ|0KI-0rg)Ox$La9pmwTSis!_Q#0IF1c-Qt zdOsF%NF1re`WD&pMwRbrBCsckX&h8OeS^OE<1#{H=~6Fwpy)kJ#bF_d2xT`E{z%ro zXnS;_m{U%a`tV@BSJVHFZo1|$fUp@pr@Us-m)ZALJXQIwOf1RkM*#)kHe`Y? zH*jQc53&#p6{EJ=D6Hgs#O=VSIBwNRexzQ8hU^1ZMIZhcs-P=G!U4~E9h{gP zgj7HD*X|U72+J=s{lv1Z4iw=v0vBhO2}mjwGBG{#neOd=Tx#5}t<3}5t9a~8;ig>N z;O%!<)U|Zo~c#KFsBZvHY@mSQfj7YrHh7&n^7r zJGsi`>~HzX#Q}$}!Vp(mb!r@0HVCDzPz*{hU!$V(Uo;K*z{f2!J*fybb3)}KrCa-a zGb@6VB3TwZwWYf%%C+F5BWLqOqSe_*I1Ecm6$TFnXb=t;)kK*cgh{!2qyoq|8YMN( z{-9$r&xC;k8*j#N@V4Ghs27uhmT01ngb(3K%58T^G0GE>32$t5FRU0T#sHUrj8@Td z1xAW}AUO}{vmfGm(U|rQF+$dLm{0IV%G;Q^QMT)x49|K8BC0Hg#X;tx!Zi(G>A8gS z(&qXBzMlz?64iYVp+UGA_V|4FBJaNk*3<{sm2kKYVSUmFW6S!@a4c(MziGigypw_% zkolI9Jv>&G1%f)`$%SKsGE8JX1UfXNmbvj(RYZ~s)|wTP?iFawzlaIk&w?OZ>Y_g@9Lhbrga zeqPn`$4VcwCbOL+5>K@T`4z)0#+U!V#_ zW(I$FUBF9(NE_?|LcSE`cTe`p)?a(A2!hiRXOtRv-Muxqmv+ED-$<1gZ!7j+HN(f( zsg_-bq5Sm;*LVKxMMnC_|zTunOgMvn++!hQ$ zB*SM6#Rc(a{^H5Ltg^Z&<)FQKMI@IC&J;62&)Xo&F0JPzCXbqgM;udN0dj?kR0LrR z>g5kmRZLK~t%NO?Px3EJ(O5*xAhN^(=`78ZlLQ`uXFFt^Pat~6P+wS<0e%n?sZIoH zEceuOx1D~BfnQnk)Mh4S*4%B{ySZf}?tBcfAttJ{Y1W;&L3d^jN&iR$eFsC9GQMof z7hD=N(F$5ny3WDU93(^bV?K+a&CT>{sejI@9Rv0OhUS$dOu##LkHVC)fHLWSAd_!GojyesvDbi#Dr@@+*yxp6D82HWILAex=J ztl5QXcctMN;5@pFYl2j`eaw?SNvbCzN`lmg)ZF>Hu<*;evm^3$p#ehX7m$!ZGs*u|( zHtDozQNt94lKr#KwWp$b1}JEg%YB(Np2N^7i#UxROHN7{q%A(I6G?^=TI;31y$My7 z4fx%MS3@awWGBV;l6}!RT5F40T{H>tOZHVJ%2~dep`&^|Ng9^oz|ncB4?xUDLIH@J z*+mxrK|OW#36M$|ovkS$F0}o|x_oqG%|pp1$sQgjvCQcZjH3vK-CgDxW%Uv z>R3T{jZtukbvf`}S-W2Eh3(A~zeGsW(H zHPn77#*758m@=%iBMwbsNh52Q`*vemBNyZu9DAS1BU00o0?xKK4F6jfZVzPKkcIN3;=6 z3UFQ!u3ndByW0bEi4me!bE8yd*BZYTEXIH&P*G}lsSm_akkva?x!Vf~EzNkw*hJzKC3 z(@^ojRQ9*H0&6A70L6L6EvaR0UHyy0{q~Sq{&IdSIjBQ4`g_YNuRe*$m!U#~QD*HL z%b-j+xYnOGp;M&8rgEW|lHKhRK>Jl^eROCZ50%|pA^S?uv0i+*za`q+7V~t2spi?% z*)FS^;+jnH{)}Kd6-#J;JNb^)InBbc03`EPe zNV3uQEuU~>0Y{#}hQMog-MCJ*7ag|wu=*sMr6Kx&U%OYcT$hxwdL3Q{pR>%YMbqn~ zyb*fEeb5{z)oS-uD(l^qh9u?nx-#%!=wi>--Xeui51*=kuyT+i{m&dsrct}hyd8aq zr{%@ayX~RT>YzRiBUpeSEI@?Z=SXz&f+L+Ra&tK5Q_UsZ=1EXERN_&U4kRKvgDHQe zm4PqTGuht{Rp%MyB8GjP!K|28H47c*$-YtzjEVK+Hdu1>8N>^`pgU) z1IT=O)uK*>j(3lkkLf4v>H_?WMDBBGf>?`S({`yEod-(f6=j!^a>EVY@<|?@9Cz%MR_NSIj=p$=G{D*d<3kZw^zO|X?WS%Ou+0jN(@RgX`HA6mATn5 z2>(ETMX=tRd;yun%hzMU6@W~adbildpG#S1d6ZZyCUdG+CyWA4xHo2as)XQ{p2h~S z;FHz$g28%4?1Og)Rq-&MvGJ-Vc6)fM>(EKwvZ?!ekV{hpe@Sz)#X{YoB1+mFT|1ir z^fJ@J6EGLHOCl2)0n_5vk%#zMR|qu(!}w>8MSa_~an|=6l_eI%ln8L8N_A6}^TsXR zK>J*DFCjRp0O@*L3gl?fhRLuz72LQb!_eucZ_V78U_y-o9ODd;>gkC;>og$AC}NpZq1dQN7Y?N^Iz_j<9Y*f{Y)5 zo9L(-W0P{O2#xGxL&Iv{tihgQN#297B$2x*;%ayKO&k&Cnfj60DhI}P`cs~HhHaU5 zR+u4mYcUR4-YzH`f`AUO-f7h7W%AwW6ya_@Cw-fNngK2Z>(}{Gh)u=MEe_Dj(ee3# zs3CtKro;|%Qi?88TAe8V#|XGj&L}!kXV?O7fwL7Q{w#t4kjWu+7<-JUTdO%m& zSk3b@!1q1Cve=}okwkRo{9i5Hu#__uH1%m_Aq|h=x;s_yN{ohYZ23Pd{O-`ump2-1 zbkluq1&1s77e`0I{oDfL>4Lie3?8XkvZbhwc5Juj#|ZhnOn4JJr&}8cQ~8g}O>lcX z5v>3|7lPj2p$M$@L7|(plJD5G>^2;9myj(LJZl&3R>ne4!cDvZBWc=lBzJ783J^u$ zIG3x-Jr8l;fM1$ol#Vr*Nm=!} zk#1qV{7}Ilu7}Ma;wM$l2#DxK+5^5}ZL-Tx(i3ObUkWR7-#;l5?ho0)=y4J@GXZOaE(nLZ3dEs)45KHw@v=^@MJt!uTANN*;!FylK><2;}570BMUi_ z25uHFhlgz+DOtX$y*Hd~5MJ|5I?0A$8*Lvx4SA|LZKoa=>CLxcge5J?jD(0-CG6en zJx<6*0v`7GT(uX=7WZ+}e$Edv&-JHnr`fmjL|S}T7x?PJFZ2%UIM|6#@@ekEc5Pr+fXD_xn)++kCyIhThg(8UTBy76L19JENcJUaL;?gHJJhC~3~hd$YvRX+5kp zQ28uQF0to&PlTU0%1YMIIc#-vF|Wfd0D^MalDX`re*zg5m7A1EB~>T{E{NDb}g zN&sZIP$lEpND@oM*s>4|AgdFzm8K0XL>4&(=~*0FkyT&vEU8$uK!K227@3EQt{T~r z+6JeKqkpfJEE`QDB-z%Ey6jXrNN8hMf%&qtUE1`ewhI7HVP~axlCrs2-4@sU(Em3@ z^bkeA+YSN%;0zA{fcGDY$ky0F-`UjJ+0x$bzXLZy)Tiw>IS_g-DZwU&!IWq>iU83q z7F}ASrtRk1G_J-CiW5Xdkh)1ymLMK(a1#qjq@oXlp9e8#cG>a8Ei=j3)mt6Hq#Wl5 zVCf^V^3sP$)>U-`nY#dXtdcqLnt_Ul?tkG>dFxsLLR^Zdi?Lf3=_EeNE_W787m?^gE1Pr*Evg{>WcbIwDCiYZk4f z$NZSSMqfQi<(>b;ZQr;d&B{1B^sc_$5c|*COTX=e1I! zUz;9n!&RS=d}tn}ABWW>h6Xznvnl|AAlk&Od5BfTWVLj|q|7Q!i~A z?{cJ;l$lT%SUIKR&i|S0(Y}7AwRY&$CzniIm01sZwu`~kq)lqt(O{P~opRO5-(b4l zRu@k3lzz5Nr=(cx0?oHr16J~D-j3pBvb&VvsC$Fl2Z+n|oa-Y_+6qUK=;F46ceQe+ zsQVLCNxbOUtfY~awsSS2evjlNG~H7&%?2E>a(7eIA0Jh}`=%C|9dys_!7%HX4a=9F zC+(}Y=j-mkao5WfV~Q0!6Djz-cj4@x##&CT*cDCBrD=|$`+QJg;HM(#?+xe<|0S_z zv75nyCCEzikcigA&UBTGxn=!4&K_heZP%Lw?QTkKu4P0k$MOykpz8ZQHhO+qP}nwr%^4ZQFZylilR) zKj?HiCnwca^+iiT&1g*dq2L_bV1T*!m=8UDQNy{dsqyvtZGN`}&<#Gw1$2xCx#bHUu)(CX@PW@<2@Ei)2 zV-Di`t_CA%x(Cw5h1yKlW}OyV3t}2E78-(-`u8;I&MEF92dOWKTZQ}Jw6US={P9Ld zx$Q96)H@Vpj;#;0X!VJu-Rzji{ysNlFdb%V#8CwzT(!*Ct`n~Qzct|;;)#nA0 z^NIThCJp~r`OJ>{hQ?7(ym-HcSbg5^Vw!OMKu%qf;0@q;VZmo5%yS+>Yh`$Zxc3T;7ZG7H`Cwu|H8T>d>{bU|E3*vJOBXB z{|>$X37s%9{NLdF-^A{Uj#nI(nB%V>-v~D7P}dzT4cGW_y_=PAs)oBMwVni|Tl`QF-76*uRnug;rDm!|doO`FZ`S~L~4BG#p}Mr&>1doeb?QowrBw9R zEY!HqLTwir1-p|foD-hKAe2YbB{jQGLmk`6D$AISNXI7f6PuR&3_x_v?pn?x;7aod-v=$lg*4e)tMEQo(@jU)|t+;U7LHi0>6*i+}E-ZIW63) zX7_pzH%;zc8?_W$pKaA+L1*`u;*7q7T~vNiy*jGLq<3%4`Cqz96}^YNJ~k{K?9Ajx z6$oyZLNq^@VVP8uwYt+y3W7SzvS5#;rvSnG#}k#7{Z=)|xusA{A0C(T#vndtJqI|; ziEA_d<;K>_$}Wt0)t3Ij=&?`BQi~!UPC{r^8wUe!*`k#kl_Rmotqr10vY23#yx~7vbC%goFx~>#EZ?LoYO7Vk|DCXNK0m0`S1cLJ1ja2MT zS6bF zXjV{1NcYPHey zL$yN!>0P-p?eR~h|9UsuK1~nqds?8F;CQoz8`?JsO6PVI`9RM5CS@vr(lmf zoa;n_f_`K7ohM(2dX-&Y;5m?!^cQ$oJ|-E)Q=Mo;Zfr`w{hRtS9nMtF_pG1SNiO{b|_&=ON~NSu~p+KlQBH^y8=P> z{43fqf2i>zoj2tf^e8SZ73aOGN>xG|ULu=PT&ipO;YnZ&c;=6)@8$tUz`ivo&M2Bj zrfl5Q`auUCRe*X~NoUb=DoK*>k<7T)QsDqz#R?o_Spc$Uz3Kd2{qIJtUAI)KO6IiB zvnn!ZO}C=4H;xM3e+8h{Y9m>1*mZmQr0AZXM9sex1Hwqi72}Fb4uW1Po%lC6a*K@0 z8xV5Ts86S{IU++#qECQx$p2#NH9@eB?xx|1UQaQNxD2n(#~ODcvqz`8&oGBt zow$Nmv9nTTE=3-)bU4ZN!2#Kr${^4_W)u-yc~n*d&`1UUA(sX*W%UvHpi;kqG%UXG zN+hzF{{CB#G=vrL_*Bf^gqwfxw#awz1Z8+p@58Fn;$K?+1CtXj(;A$gwiVg8>DB8qzZe7`~3g&vZr5mb5S*WTrESZo%~+2G`yOL^Mxl zO6>hkXa4j(gPUQl`({7{so7j<8Xo`h&f@^pSQd4GRlv`Pom08wZ=db1*s<7tq{4Tae2(Xx#s zwWm5jfQimqEj1(SyGdH^`~gjJ8VfF>0OlEMxU_B<0#XI0lsWBMiOw`X4Py>03-l4k z>JL%a1pFy3Axc&Y@lh6Nr4uZJ5xng%8H8Nk@trHeK@ecdD?Hb3_dqZCBu5wZL?W`R zASQUb&r)-p`)?RX(F^~Aa@EITn4SQG(q>%K5KZZ}%lk9mZ$bh~4Xac5;bBtJxy!Sw zf?mKQPF^?r@6uHAcl)wgzygogZD<&2dh-SC%>&-367Nb~-y(N-PP5sFlDb?fQ4^*h z?mSoCF1Z#2IgrVdyk0%3L?nCr8m1LtRlN4prDIih?Tli@0eiPSNd4nYXXVIQs!=CK zu-0Spd93Jz&a@@DlVe>KOV>nn9S3Mc;r;D?uII;{nZTzdK478lDjiBQFgx_z3~7Vc zU7~E#;%Dnhx9+Ob3QAwXA*SuUDe%v@euaU1!wgn7a2xsQq7+p~!KfK}c$xN_C>2IY zJ;t|f{AB8@IrSL=5bIcHL1rqkQtJldeI=0UzkSz z^84-?B?_x-iCn{ZylQ@E8=1v8$;AVK!l1kAA*Zp}AAXIDA#rpUx(6eZG1BkT zl)gzh`0c($=$dB2T=~nmfWGOe9gW|21K&K6Z8;Ue_W5y(;%Ri*-OlHF`s@ov>XTbS zpxsvrels~yhL4Oq-I7}p;#e|lOd)yOejPecsWPB#L0a8qv7>Swm{WcanMx)|r0h?#6Dr%X%0n<1Pr(53bvD04(}G+miVHV;dMwceSFvmn44*e3WJG7*gqF zU3~GVQ(;m{)X>N6`F7bfHunq3s8E$B1lUPk;C2qrA+o9$5W`=wYT&@gYd))xunxW! z$98cxx$sh$^FxucYu++h&o*URBUZm4&i3%RxR1mO^l`@a25cj=^Pq+x=HiUAqNv|e zqnp$xJ7Qzt^JIMRYVEfr?a;Xo%P4Hhz%K1=7;dBH#95vuYZz}; zukr;*5klmx)Nyt*-rE}PTc|J!pD@J{?9jWx zX51Ml>rqqE(O|!%*T0T%Wg0QcdQ$J>K|N^-iE(g`y~|SSO_qRZ`4vKIV6Z|-L!aTu zrNKN8NMEQ@qTQ!|V^7SG47VN6CZFz}nSw$23*ySplSPCufg^sMf&Lx$0@NywJyy_O z5(rD#(;9YOkPJ{k-%*;>aCnBIw1)y73%^!}jqB8NQI=_Tx=AV&C1P%v1)(A!eO=jZ z@AZ)Z4lGs~gPSf_5exfXOfu*U>k{Cps80!9@2UQteSvUZ3h0ed2->xcfyhx=f{aT^*T1ETXP}0Mt2i8DY^#vrTo9ArpHnZ9T%4?!dxO#nRie zaLSWmmLgo6y$6Y$mu_dwDe5ZqyK(_sn8c@ph(6`wHA23G(Y9vuy_Nj|T!%`N)N$Nt z%YPZk_$KL1{Naa~>&3y1DnhZxhNz92=6=0g#=WPQ5RfnBEB(&GLlY#^D-Y?q1~Q=a zu^#-r1At zr7*%{OA4D&Fg=OMPMqEc80HfxxooF=sA^J>x=H*UC>WLy9WeKaecHcGFulT1IFb2Y6jIqE3QGAv6W)Z zv3Z#7DF;Sufw}upOY_Nto&AF6`5~CbVZe?{`ao46grPrmMN?o$_SZVGMCQ7r_&UTP zOD8#-d)G|l`!em~szjUGvQ}$vJi1(-dI_VhxOVs5KcE*a8b??6kM;DYr8WFk^p+kV zfNa^!v@Oc|@U-m0%=Cpt^E+VkpKOfamTh6X8n`?`s99%|8ny+Ms__V0hzMkO@>rZk z&+%yoXH48*2W3jn_omP`5(Idf2f@IV_>%V6vW;hWWq6R4a^U2>+@>ivMSie=0CD0> z?s_i6IxZBI7@qVXZI?dar=`aguMwmpnj|O1;%VeYi9p3~`i~S;t>6_BDQVRQ% zz>RnCd?j1BNnM7EW8cMp!}V4O=W$NMb2G)M5e~X-I&Y-I@Sfa*pb2`ElHrF=Aqg-S z?NN?Wa4q-viY^RheVam=guYQa= zr5`Y7*bQDvzo3iSVN_nXDBW4Zmm~iuwb|)2>;!LoZ>@$`?4^#WosJA$LQdcVx#rw< zjp}Gc<_2iWdNjBe{Jz}I5#68^P#oJpU?o)HcYL}TUZC~y{G3k&oyPfnECf#`AHf8? z=__(Lb3pO}c&MEvGTwO`WB&CsbHa99anw z1bCgtS21VX6wcb|H@UYGkqMfB6sMgF37P1;7`6tDTX9DmPfUN96w95gdm8R=t@CC> z|9e~Ugj1XGy18F33Hc5i@3w0W#qMB7<<~+Yq0VqI!x$W$_C;goI0|rzLfn~C=O$j_ zv(ZT;>|6_EWNdQR58|;a%)lFb_zmx#uVjSG^X%Jfg^B9z?qF&S>2a=_1}JHNm2Zwp z$y_FuLSiALtR<{qtQM?!$rvhU`L_3-9DpiEb9sH=_9XVPmnC(EetkdgC-gifDp$dr z*F<8b;k74&VDHY_#OBVV%-$B^5|m+|<&VFl3_tL{w?Jh;TI{uZiJ3CAC$}!MbKFE6~jVY;M)K&wv;zN*QSCuy(adid* zcMCjpMv*vxX>z>FdIn7e(%Ohdh*o%bpewlxK%}?4QZ3pUr3v~jI?h=BXu(Go!12Ql zyfiM?TZL*aAwLw>+f+kMqqbalk{->MXlwm#nU*0^7TxN-l$TyYvdBjr*RjOOOq37+rCk#b2KFNsh&PcGj6fy4$Mu{J$BTUD#$F$>tSSDthpE-F}uCk)yUaaJS3oiGAxy`jZgOtf}G&%0%`cfzf3V z*-=b@pK)&N#1K{j7C#mdn+gkhiZX|A;D+@i_rQ@8sIC7EPkE}j>_DL{OpLfl>ZSwu z>Y4 zyxH9hq#7nQZTr!1z2dkf@GiEsbFOn^{wS7r3{!YaAOkk9uhxvlf3M=dG?EwFywIYF{FaHiI~Wc;N~t6m zYmv`FbXnp!qr%V4+7%7OsS=|hHH|5A3^KSI-+|+}CfY+%7_bT0*h&&%w2DPAKpDND za;5y$As5_qdX605!Z7qfZqL%Oj%ErPi0K$CbpN$yK0I}6PbL@VQ7BwkJ;o&$sB|t1xQH#CZ1=q) zYvt}xv{0wf26|PvFeGh^>b1EXQ3=7UPt#e_P5jL$vS!FWn|~ zd7HLkcbz&*1raO*XDGkGf6|VtMGM4oo;>$-94@x#$`sscP49bsYBYYHUO zF!P%_OCWTA)=%<636lQvTAuWAc41v4&mMD}rWd~W24P=wppi~tUaPRT*0rfbE8L1v ziEOy*c%Oi;Ye4^K-;Uk^@9sNc6*UOiw2f+{di+-(N)kznpplSNfI;YEM}X+O`qQ4d z1mG`VQypjlhBm1s{jkjrS4BypD|m8Q>7 zl#DK_t!fRI%oyapTzp>$h4*NMCea#5-Fi5|6ibPqg0ps~MT@V?GvfFB@xpz0fg5qE zQpL>^vG@+G!)Te4k9Ke{eIlkKSwA~&KC@|RjgW1&NW@a(MF#>AJ4&A-z1&fkrF@?{ zL5?(Tf;0xWUa{zex+CE9Px;TxP6op}q_1m6?3c&2quZVtZMC@!^;g-K3|=d)!&J^? zVxNga?+*Cz8?YxXH?znLT2m&cB#6}1Rx81&hv5lW0zs0PI4S-UkE|V}oe04IIgQ%K zuO~fVfZ^D?0SMESWBT=uoBS%4p}|=jQVx1i85`{_2G%P|)pQzGQD~DJKM@!jvh8N^ z zHzd^U>!x?vnVyq%bZc5lHONB)Yww>U;e&NW7ib+7MPwVcZ_c@KV!qZvQ)0#m=C>>7 zWIn?S(z=dEQ4tKFrjtE7!pJ3fc@g9Z;W8`rQ_DeJ?G^;>QNkLqzqN*?s9_yZK zODyOk`y8fLlVhE3``)}*?nOkdcDBu+Yah?}oF;!BL7b_ZZ*>zW!H$`xM39O){a$dE=XvjNJz;j^x*EzQ@_y^1-6PX5olV`(0r+Mv zUQoj~ot8Fvi8A-Xo^DZ5PGP%Yd-JqLv6oK$+71G|-uKLP$=?h@1HA_C zWffb@=RRUpSgETNc`pLS(oMR_I61-k6pFKbG74)P7!N?l@QY9#(ddW~nL--8HB)e2 zl$RZav1bx#Ye>M=oYBi0P~aW6DAcT_kXo-0AiwL!{qcK;-EU;3j)l3nb}!{ExBjA? zp?yNyI6zh&f>cmucR=>zt*xs}3V4@{rQnONw>Odpx=4uvu~%_dM8{VT8l(S$n~Zyv zc0V;9Sy(i@XL*m!fyE*s9`R2QPPR7oQbX+ljdOAlKEm&{bAp zu^mm?YJCXpCvTCFqU>_4JEY|9AQW|IEcdUy+A_W7go`FiK%aWk1hP7m6{b<#|hKx+C5rR3cAJ5c$^eoD7g~M2LOyWgz2!e(Ycl5SBAKqy+aV z)Iy5kBOX^LBvj)Dv9;+-Z+j4y)Wsh(3MH7hcO*=XN%QC3-s$eDm#o0KneEoz=av)T zcShB184I5Fq>x>yYCiHc_F@mE-Qk09tPWhRbRr%ua#trjj!Ie($%@3nTz4q8)g8#M zl1(4X^{Jln&7SRG?e#n$tN%c9b%%UnAmDZWARbeWY4m$6#L)%1Eu#RBA!S~mXJ(L_ z3HJ(4Cz6w#3W0CtmCU{OXr{Z5@oFI_TrB6&}p6@4(%{*>^ir zl<#ai2e{zdFA%7JeAA*o@`29FZzZ1Pj8MHxz9%lBmh7$+5i8h)-y7&wl*{{vikR1?dbYH6^S#NdUl8H|INa| z&qf2ivs`+?g)E}YEZyj`@yZg(7QsV>3P>Q=YHKVwMM5Qt%+PDAOjc|3*z2cSn(Aa%^uGV_0qKdjgt4qD3CVeh?2i!+9 zlGU@y!Zj{ky+KBTyyP`7+$Ly&Z+hFlc#5~nM3Tw1ke;GJK=ZBoC`vWDQBWQUOt&G& z#)d~j^I4*jdEAJ>P>4EyRmhHC6w@=;+dln87a3%Kn0(q-XQ?wDnRcSFnQY>KwbrdLsL=2Bw81zgfYTspvYpr7)BfS!q0^8vFY%W(rrg<( zVjZIDsBTM(^4^YdpHq|Gy(FK3262J8@!VYXhiIj=Lf-O(V8gcYYnjWJ?6?*gqSs9f zsdZW{qUkZR#4Ev9X0Z|90wg!9iJ9J5eqCH9sE~j zS42YbK1e)qX~ZxgRh#0a36N;OR_dWzLU^QA7(EZg?HUZ(^Dk{?vAA3$Cr*$ z6(S)bI^e6?Wwyv4&s6&1w5h2{3BY51cRKY5=8Y7ILke+EhD@SZIHS%yiRG?9z;VlR z#I^&mJdx!Fe3#at?Rm4U1f=&>@%*>~0adSELOR;oylNk~4Tam;E)1l*c0c1e=gj$S z{=#O6cU&?Umt@o<2pKfLu7=W?kUL*^%^rlzn#x%#iL`5PWn4Ax1wa^31FS7roSSx= zSKBK02F_ogktLU-PfKp=dTo=`9l!QP8Fuw*15ZR>6W;A*bZs}g5Mh4dfyUrB&UU0R zE%bo6!C-qdQ8*^T+zRb4w=OjwUxHI?hKG?pYD(@Nkac2x*oDbxdLrwS}Y#ECgh5d4FU9MFD z5*CZ^7R&UdK`;qGb=DET8DK^l5G{qR07Y^^ESYcL<$|oR3QXB`&{Fi}l^%a$XD|fd zhd+_f3?cVi@aRIcQNk{2%#DTQ_G4KBDh*^b;R0`&X1 zDj{W$g_Fn8*I_rY`vaM7mLKUkRy45kqx{y(h>?BBh@9ZD5(-mwg^B|BAXg2bu@0NB zzW`AK9mqB#0tW+j;Pa#gp{u%`Mi8;?t!C2zi60*T)X22hK&(I}XdVQOAd4`vtVmY-!BcCDt=;9*_>2TQrdBL>yr*t6282sYO>%a`|8R=!qr<6z$67)vK>g|SgZX?eq8VD1XM7SoIO(u1XaeMpvSSg> zwtjCOd=YDd#B7tc_GSOv=ddF^vHC{_q$20N4uKwDzrN3cir{)p>=oqp>g8ih&yA~A z%Dx4G5ccp6t+^W2czRs)Y58I#;p2zhgNP>l0tHKtn|C`z$?a15w9L-xd8Y!7qa8Wb zq9Eu5h|>E5yW3MH0q4<+^_*UBx*4KNMrvJEP+)rpP3nZYVcuUYyxPN_4mwWor%TF|4P0RXA_<8=N^YAlt4AVdBy`!u1MYXY`t(W_2ZA$vD zujAfE!9?Mx-M__l6cEFPcwi;f@!lRSiajJL%=;Yy=HuVZ9SwnS8kWPOD+9#cw3nsp z+(8b3awx2$g=!EaFRjpm^I!%$^A@=FC@$Clwy+k6Eh)QftC~I;55THGb&rW^L2-%X z998K{nT$F3(=o}#(49qPK(3db*h550ZaRlTCg#tw<-80r=l2&QyA^Ix8&u20aw+L% zal)Oki6+sV>4X(glQm&rii)BwiMX2L1c^kyY78y)jjO2cYRJLbCx#iRvsL};<@va#JEO;9=+LQU?U-2Bf%Jl> zn|=vAh0k0nypn+Z)^>y&!FnZkuhGYaNaC*G&pp_O{_8%n_KoY*`wC)a@LkyFj0G2i z?3}jC+@2wy{})k2k)gkC5LeFIi*_f5lC*6sG$#ZO;t)87S?b5yZv8b};4Cq)ARJeC zsRhlWY3tYO$nI0{zuP#%>>7S$I9p8s{F4{3VLwR0xTml@3X~MLD-GDFk0ku2|vO8aL zroXn2R*Tq`iz~%xvtw#;Zb_<>?xxbbfq!^#xxpq_XGf>gIi)Q^P~I_%>vmXMd{c_y z0C?SZ=6mBnTwVPO@P23P zCb(^H*WjMNzp%IKWtXo!CVP-e^z0TSeO)omFMlzr|1en00&PRH_@6rwy3b8QWW6Jd zRI;t#wIR{B4(A(#P4CPRi0))WY`QAj(eW+dJDiAN40Ks#KghUqUDL2h;0*PuB|AY1 z$V)Hpb?26n=}JENgLM#rXjOgYo4{Y7!=GqV&32rzK+DF#2FJ(AOfXba4{}=r z@sWj>Yn}67gBnm-*jJ^&(2aYTfnxsINB~%+=>CG+3e#|SeqpE3Z1bF1;b)h7e+Bl1 zVQ(0L%4{h*#yy%h%dEuc)zhAh(Qr*0EkC>6_fssMnY#PMw1SbtOl`}ntV`*}Goyre zMTg~%(9_9Q%$1WGplbI;C;8Yf2L^fvU1ae3hJSg<7U*%4>uV(_b^*Owc=MwMd6vw4 zBqFc!k~kFvPht55{?GlEt@$D2&p&)5BPRd=@_*Pc^^ELnO)bp+Ran+&UH)^NqyF5M z;WP7%Y7onA_UOaoN6irD+R|Gfty`D{8y8M}|MDl|ktlA!X4Sd5H_4=ZRq0KrYlB&c1==%&1ZKdHBb zgXGm;CSFH=QD_(v`!#FfYp-N;X&#l)V zF*tBzB#?_us3N9BEmV%T4dce8t|y+OHDz)8D&JNT$|Q{#qoD+@)!j?`uFcPJK|PTo z95ZxnFW4%0PS+-)@6t7%E|?>iCCo!AcyePgd?83Y{gy0f&SY2B)GV+P3jrt|Lslph z#ba7JpBIKVGIDH4w3tF`4y~ySrXgwpdEp=1<|E|Ra8SJ|4Zd36SG!1Si%ReDDO&5I zFqpkZrhq0?s!%Y%WMntiL>OrM`U}+4OE(rn4>jzaUx-VD{`4C04KC{=k08-$8=Jxe zNRJZ^Rf?~3AaiL59d9hLza6{2Jbv9AIAZ#?cSHGgr^^k=HC^tdgJv~u<>cg+dSI7i zX(taPM9Eb?VjEJ7q4sudZt&!Lxy|1O;q7#LAYtQpu(QNa+|>r|VmJ2XRNOXSv(cc9 z{60}tP)Tl-ZNwxqArDPp7o^tBN%wE=+tQnwQPmbax#ob#{W|gG_;j}ih}-t}wv@H< zT}ylms!oo*ZZe|6>2l|>)00%`#nT2vO*T9&HrdFWFB` z$@C)AwBiQ7clG@So$Zz=B!cRxHyUc_x+)C~7{WY^98^ugV^v(9@tviQB3{zK%=;5Y zTUfFLyESdjt*I?-SUm&g!pRFv6w6V|U>EigO5zvKArF>{B%skgGZsHD!qlal;Hz(r z&gvX!1?HuN7gLC3iv^Z_ZBkh&)IlFl0s?j!SpCZr^7UlQp6-WjR-NCbG_-x@QLYns z{)SuMMv)^iu1aN&eS&~a1R}y#rY54GI3A^X{ooKPP?c-W29T#o;KGkmsqCz0O}`-? zjgSbwJ;#5*(;RW;j4kWVqZ>**8eSp%V>vh2brzh(NLSCo);gILQnG(iAd&>X_2}O| zW+V1EB{K;(IJ&--|bA6B_ zoh)of>=ZBTdzFVtp`?Uf=_lSi?A#+>++se5muxYKu^FAJ91pgNe{Gyrfd`L$!ObDQ zuj^utQ0QxhgeOayDOFo*IzR5I7Qp^(KydFRICnx#fBnc&lPcboO=&A=lt5GXDxZq* zOYkFYm_*~jj87G0j(_7?@u!v-h6lCi9~a5Yr4A!%t=to69VTrpHa?c-Xs9i;U=u^O z91$MILhgcoj`fYdM;ytd!nS$v4Eg%wpFh&KfE1V+wa?`mgNfQbKAC(?6u_DK5nxs? z2p+evA-*OKKQS5JiLhbs?f2n>Mqvz~5A>_RK5VJU4J#W&lwznhaBio3!@_Jq@`AKm zUb$8jz2rU36eR%uBP0lzQsRv>GAFHds8_C4L@OijleMP1`{1z@xq;bS1+Mh#vSEE_ zl<1BRm861BGY_76DE32bbEOXPGyNnD=l)g_gx1rdh*W~AA0M1=9N2gMvZPbzlCp)1wkS)kb#d%6h%W#BNKsl*+2Gy%zkE zz05^8EMb*FUevJ^x2qaig+Jqp45HO`4#Po1~{?JO#xSdmfxD$aY9_qK0IM+n^+0 ziApa%=9tgf3_e9qm*|q%^VbzTRcB}R1YeLa{)$2g=(GzP;)2DL_H$InQ%Ay zgmJw9X!doS+A#s{5MA-QGIpMCj-hIWXFf5|vo|S8AtpaW{&K@+ZxircLjKXO@f0d^ z#=v_8rNyF=#cG*srO-%a4w=6z4B;Md%)EH(Atov-Yg4aOZU6e}U3`C+%(vsvp_rj$1`8o* z;#n-(_yEU=Rr!mUFy4RPY0gYh7BQGpD zzv+YVP1853t8XZfa2CU{rPsJUteqo^SfU19b7pj{QtAZB?wBPLJr1u%8?Rf zCraxbrLsxa%@Z!7Erq&P?La%^m9;2YFwye!8RAdnx5NQF^aYzjD#^$FxMn?IHE?ps z6^cNN7ZtZ3Q#|pU2^};in6Qo~3k}+dh`&y(5tdU?`VFn6oREa|waj~#eNX80YR=Ga zA};w21zb>iahq#;F{gc3+p;6KS3Nh@y8t-vSO78jaS|5ErYLkz~T|K7NhNUh!v zmZal8FyTIbnam|%`aT^kh85_xcKti^Rj}@~0l|W--pTGjNinD3E5;#mDzC^7{)+kU zq!fbl{kTV?c}DA7PaoG+Zx5y7vjSRtWqsD{qr;p#;v|6p%F+h0HZcaD=7j-QZ^1bSt9?YV`_oby1ij6<-CM z2MhipY@Oo^8YDqU!izMcm@#fDB2nGimp2&K+383$XL`q|rly#SH(@f(R1!t+S@^Gl zi*D~E1bK8yBu-l4mU|h8K+*^TE)TA~s)zqUR=hk7jYo2oQW7SCFnCb(H^nn;DB~jN zhcA<7VUBVPna#p7$y**%ZRby1o3{h1u!;=u+9C&J&?24{-mYxmH`6AEA}Z1)kV>aX5bj9V0Fw8ML&z11?<^5BYN)Dp5p4v?u#LSp-C8Jg?nqDu18D zi&qr9B{o<1FY#?v;grwiOH?0;BUQp)5HKY z7PrV>=3iORwA|U7R7P0E@;7KqxC{!>{_LkfHR-f%!g7jxtJ3NBgVq=+SEn^tOA*`%4Iqz*`3=eBAHBtBFK1&p zs)j0bzkXb6@*oi+b-?TJiz0hG?>Fm*iD_r3qY*&D`R7oX+X#b=U)WrW6Y2IEq1ce9O>$ zN)n^Uh&y8(_{;%DoD+o{IzVGv*QMuzg5)eI=N9Fp0s4HSraamWI6tq?wt>lAGWl49 ziX}T)=4C`gLg%nv?3-Yy9M3Xfi7A>vZ%T{1{87MdA=@S8%_8`|71k-3zfOL{@!$#* zKg-auMeMWtG$+{bp?fm%Ci#|6+Hhn3AZ)0Zm=~9jWzUW`hY1`%^xfJgL#W;`F6H;* z2FR8y=+E_EZolMDM+4yceY;jWML4s1{eCgW=XGTaV;g;OA%bd&HI7-ZL9nQaB<-zP zCgT#k2W4$#EcRIf&8p>yDwt#Dl2|zQd?3z1{M%)SGiY$mF}=_-#$ef19hg!BYU?nd zLS(FJQi~IpQxUW;F9yL(F0gLSIBkdHgF2u+Bn^J1`*1Ftb&{OR-Di#_R)__c0@u@j zdnzauW<-}to4l&)fhLeEFZhgAChKT=W zYBJrnN;lmm7EvmJMJ_ka9}{mVz3AVvDE$@3vUEB+vNN@PyRUP8AIG3M{Oi{};9lxx z1QEG|9Y0%KxxX-{C1Z~3GqzI|JBrh6C>Y`h=b`R8e?bO9MyOd}A8oV@K!jtAqMTEn z9Y#>g#B&hy)d8pziPZr{V~GOW9KY`A$085s?7X@1JQR?r{MZx_s_K%}SDg1pyz)_y z&eDVs^Ng)3tkq$B$(RFs0!`7{#(^1Y&|L>%(C=^Wq0~mAlGpI9V!?1s9>oztXe?dY zo3x-h8@vv~8(87V7`;oM-IU%ntaiI4q@8CXC_(Mo0znV%%~}#?Nj||dxiL>4?t4

H`({2H zZZG5`Bj=Y=WoY!Ow|9K}YELBE&F)_jCHXkZ>mob5+z1$5*F~Rn#Mkhi(gLHWUACt& z)FCHdA>pwbp3n8kJ6*TG`%tWSt08?6Wsp{uzVb*n(Vr%m<~ECy@;5C{yckzcJ=LvB z-Tts%QK5dXX@7*SvQla$-uHX9buza<%f5hvyG_e~37?_m89w;_Q#be;ND_Mn0|2o3 z2My_Fs1WpLB4n@lQHnMgDK;02|rm&(uAMs}>oUB^bq_! zqKQ+2a7^++yGX8~->b7%qE9jTWp1g^r%}2lF>77esrM<@a++`7u2)YA%&7 z7ABCzr7psl5;eh)*uVgLKDa`m?o}}o>xLm@M|r`ee>`dmuh$Mk%A4Ri5}8ju**=X_ zTX-05SoRrFK)qiG*MRtje3It6=CEECaK6>&in|W0Ig%vri;@#*7E~^M=vt| zO__xGq-YCW#2pIQG;4Id!BJUF7=jS4?ad>}ceYIey;@Qa<5~3-LJp{P(Tq0W z*sAC5U1HVE*H9D{X>D+G=)5k2luYnzabTXT@wwbwntM1-!nt~_t~a}O^Ew* zE#gbpV^TZAkx4&!;1D6`KPs#Egs7r05Ep>4Uomv-o%pozc*>xyrLgY+ewyeoOt9<` zjasAv{$#1j=IUbz{|(fY897*aU!`vYVC6w zZYekXZvbLUv~WuafF7ylw;SrHX6Fe@Wldu1~Gr` zxMyjsjUNy^0)qT+Sp2nNYPM4WH4^B(5^sA6`=zo~z~-bGOf??@)-FzvZrC=}{Zl@7 zAsYr%?dHL4?GPi5Ue1JMV>(%dto+l;YX-v*PYCB>olh;@> zPo9ZJG6TidgB2|T3b|w7U`d-g#o2_48TmaJONNOdvHGxT!Nr+2Elqg)YU_ZLY;7?1 zZjeglEVFG|wt@B|n&hQ;7vc#e!;W<<2hdUh`^u>de)kv^?edbBEkLaNR{G4w*3@xi zSMgK}67*YgO6m);hXIf3+6a>Ga0+g-n)b7KgvQJ8 zN!%N8d#b*c=N?EPaPh*~Yx_f@`451gl(OCdjEB_nvPffMUxb(5)l$gG%-d49W4Mzt z=`21`X2ck+r+nkdl(xudS!&aGz`8s>Sr}<;K3ad3w^CLn0pWyBT^02T{V>`$Z?}9~ z3=1Xgv*pR|)m(FmM)KK0X^_%&qVMUroKUmwoV23Yg`$1dsrmKuE7~TgnNRGzc zy-yrC%amoZisnhE} zC}p4TMF*w8`u7tYHnbXq9Hc!fkI8)p> zB;eoG+}?F`n2M5iHjmg7ge#P^Y8%nNk_wb>*Vt=*F7Nt(c=!daHMg_2U$vrkS5Itk zD=NhkEbxd|t!v}e^uH>Dij9hQGv3qcKdaqO)*K{6y%aQcDzQEhpi9!^czRi3Q8t#g z2}T7#X#xns$?6x9i6|s)vXD%0Dx?s~Nf48OX;}bDh7f#<<&bFswW*u!eTj%t)LuV| z^kIGcpG!C1&6lApCt>XQ9TI`RS4=0DaIJcH1MrII-L=AZgW~{5eQgjut4zwiGoUrq z20j38s^}Ck8@qMmUoaFfF*rZ=B$do=?4B{B;>N1brtt|x&Q>i9sXl!a+Gum|>O@ju zKS73=ei9P#ul;Kn*?Sj=*!klnuo|~AIwYalyrD|EkYr@I)K6we8>3vq zB0oW{gv@SNi+_`$SZ-yn8y?9v;jb=*(eS zHfor_mxC3KNKqVwsG(8W?tlAUy$qZKry}RPC-Y3>y$lJ}POA~Ii19}u(Q24$-D(As zatvVsZeym*B=#DHt(-5cyOLcZRJskgMk+8D;(RyNCJf~URKkJLR4?TIojJ=;6MU=g*mMwhE zDQ<=T2%ws7asc*OJT<@`b9Cvz`R?B|5#Ud`n}B#*E~Kgi7Z!!eU$`IjdtZo%S%Cn8`GgTJ~p>Z-GA$vjmg!Z3TM>_K1+SVm3uW z!LF&c{MONHlG!hp^BGyzuGjF?gY68(!Hoerr;#9Zc1C#Nt{UijTlGE-3oGP-5XOXR z+2HC;E;5zabL_*if{`c6k;sNVLEJ3F&ayfh7ja|=kvAp33ZAt!!iW1daT}IlUkTyN zf|S+IbI$HI1!oN?tO8&WZ!!UunJf5DKifA*}C4IZ2!B` zzomAOhj6vS+oB;4qaE!{`7gX){qR>ejJNEEIXWco^&eAt9J(7W+f9m9S&u{5zi@qm zJ<-@PS~9A(2~&{nL5AZl!xqhYNpz&lCTy5b)s|83a63+m`E+W^?=oio)Gd!3(D^bS zPG2J@Yyu@y%nn6oq8%e8R}7QJcz$vX(HLiT&IN%!=zw8Ij{8(fNxQpPn~&vr@!n?9 zrvr{2Y;WN6DWcxT;0UNbZ?Ci656YRCzeJH~e|txW;ZY18E&adEFlQ?D)tOOJ47yW; z&~bJ1k}DtWDQhef6th~s+n%(5aiumrq)REG<$G3;C&~T7*)d8jHREOwi9Y|2;%Jzu zqA&IjJB&{R008&@I|pZD`~P|hTK}!k9d@Ak&zCo3pi?c*a4+dZ3T+XC$HwvH2b#3yoHhw?8pY3t-88TpSz*~AnV7~Lt=8K96i>W0y zaUk)_Fq7e!dmjhoELDVX5=uwqjD7jboNEG^Cq~?O4ngOU1zj)a=m`8RF$m8&gjsvg z6g%)oOZ3gli!gYHhSx-BWC&feyfv#Yk%07-^b`@w3pbT<9+KD0LLYKfFcarZnC_b7ay7`EhA|G5FrsK*_}L|MB6=-wAF(pY3oMetOCTnma?f9!mDTWI`aqW8t`( zz4Yt5=L<+VxyT#0*&i4>@mVDZ7=erjn{#QpPivk$U27D^Y9=Y_Y;-%4HLE^3jQ=69 zo3ltEd#DaP{XV5_{{e4UhX$z*Pyj5icE~yT3ayMlowH&7tqq4I*5O{L{;;M<$G)V@ z@v~FGmY9x65T6f({PBKmWe{oWQIfEt$sKb$7B`Y6=}j`{mN%1w|! zm^Ji3$n#+Y5fNkc?eg5A_s-j*jBuY(3dB>OfT!O4?rX0bAp738YQot0h{j8#V>se6 zZq(pn8hhi`=W`$29m${n(k`tCU=HOj4hlbeo`q*=_~G#3+VnjE2#)bO5>fzeCYBj; zg=vbG7IwduT)7rbf*@>07ESO)Y#hq){M_$_*C6j7D;lN+1!+N1^Warw6O{RPxBPt) z>D8~*GXz6p)=L8F{R1RVo)}?XV^9ACY(hbU(2mEySLfN4(3y-$qDBp- zvZW3e;}Jp%c{wopy++<7pfr#aO0zPc_WAG6Sc3zMCAMa}3XgV?9r=&HmxR{-N1)z{ zzCdS>G6RJ!6xI+YMxAEXAaue*8Ck%J6L;o|)C3k>oK@=!Fi4SL7bsm}2sdK>A`cH6 znrqqK*UcqjhhtNw-%TU>l6NrQBom;h?i_P8Rm1F{g-_CO^mC_eMTlHhR17P3Dci5{0- z1Thlso%}>*7eBb5_y6FJY9=%zyicju3FW3`(nDtS7fEqUj(=btHhlLXdq@^ z%@WOr_0jC6Vw&nKWjr)J0z0)%lKqU;gab;fFocvrg(7{#X6xOsX-~uwHEn`(-7afBw-mpGcij&56S_SSJ!&eVq3=7qU7Gj$z z05@@jAwF%a0WcMf2{@%(3a$#lxmq^3Q&qKX$oHk7GSIX=a1dX=;xy1bLEGMoc};vY z^8$YSrX~B5>t8=`9~G3><@xD7p~_|NBFB`oMp{MXIq55}6WNr_w0+e(+II(4xxJGD zT}h!r$g69<)n~AywM1n{-Exz!CX#5rLLJQ3usVO5YU3@IYCNr9GKNR`LM5`1Z?UYy z>(%2ATy=oMInNVD?tyTD=1QPFFMM`kR_hYJ(zb)xxj2Ln&AT=mudlsj{abW3k(vBO z@JH*bnPJ=n@KK_xT@BeLO*nmLTh3Pa8m|MV(;`}%dxElx`OXN~s{`L7tzg|$oJgJY zDw1qrd60S_5?9u;f#z zVr5HXXA>J%So9bj;2iF5!j`|}@E^BKJk7W5LMR{BDwm3pc}>OB$ZL#_HpiTH#?2XtQMetGp z|3quc3Ex7;NF=sfyMi62=zZcf1zt)GTL2Za^_iNQSTu-fo^W7=E4%PLvAX(?ZzZYT zf>}T+yFNo$m1K~Q4KgSVeQj{vj1thfrNHa1dqa3EO@vy6f!Mge(ffTeLOruZ`II@3 z49lG0R$*6kq1EO{7fo=WumTFRR_q}|2%msx3Qr1}-r~b&%(KLT4Wn>p#^c6%JzXZb z&Ab6=K=L!FGZ0ck2sQ?UWY;hnh}embiNeMZEVA3sk^cvh1B z=h#7eZN&#LAWD9|YohsoXGRCEnhxF%xvVRF`R(8 zm(by|x7FY*NN&_{GKyTK9r8D*LGqWNKNvtm;g`(eVhMyrfX=>T^t0;{h=Q_wVNL_t zlwUUYXBmFvcnrCG3dzQNz}K)M@dJmoQnm=dIW(cSRr!J|H{XHy$OPp#R~3MWoB(E` zzZ<>!2UQ~m`(h5PTh)w^MAb$V6-J&cW!spd0)(#(s&xUoRhg|ovlqvT=spz%Q0pbo z$$1Z&?lvp9pv7#|@l@QE?BFX&3@^bU`KfL9Y9vws@0FL@bklkTS3|6USFuqS_j)!O zYl4(JaqsGxHfu1^D+fiAUFTVNZwQcRw5I}SnZGbWV069?fle#;v;`pa4%BSwE^md| z>$!fx|M#rchW-|q1qT2i+!p`<^S>*uf2IXXV^e(-Q)7E4Ll=9e|6~|GSiFQKtH=`?QEwsXQO|3>-J>)Se$r}pe*fCUYyY@7j;?fAR%KT8a4ay}cv~arY}E@hTdE~qhA_?3enr;e`)l`m z?&0Cq@q2pw{V+fL7@WK;Tqx``2fx=JsSGc-o12$|KZbcHw~Ny&%i`SgyL0g1S@j+r zUHrP258uDV&(G!XxR-;2u&$;ly)ULKP_7f07~2>v;avGJqX6gA27 z^>F_kD*o~gA093txuT@$sLp?_U(#I|wsp7QE514CHuCmuYs zcz>~AFy(i4%Q4apT` z$2vOnwx5O}IV%2v*qsT}^zPt$E!~>sN0XbcbA2eWeRWzE2v-QqNA4h-LNv?lFLF4AYuHdl%I*J*DQ}Q~5K$adppS8|6f*1VA-ah>9IIH{v;! zTU4MgG?B?^&(g~8xC;6jnC_mAtfIPpDX@i#r*ED&TK(B;)2-MzaSMq`-;mE>E1yS# zrbcbfwseIR#rk;qd656)&j%;YRcYnFO3`P}${Go>>GQO)cVEGPek?469-zW%s{Nz4 zsntYjar+#CBMXx(Bx_Tx$m+{WdmBypdMtzRBI}kfKh5Y)6Gejo6jh_exk9i?-yg0~ zB87>S)kwoM1h5?j))d~8Y1_KdlSN?p_a!MS--?}?(f-5%fZ!C z#Lf1{Blg@w*^mjPyT)V@UHT6Zg7XA`i3Euy4w^!>GgCGnlR33xQe;e_{GS1%(?@mP zug@Y2EkkEl&cxxhUe5|-4OG0jN3MQrh1=!iT|8a$ME#Q`-qpGV0Vxjer35)CGC<^v zgjByrAyTmVP%ta-+8a~eRC4EZlPVVGcVsPrL%+XReMbn5p{Jv$d@hMMuVBRZi(H=lg1JhhBOl+%J@FJdqoU4xECMr)yE~r zVvgpVn=@pjx-?|GXdqO%=G5Ft9jcZXwn-HGRuI?`(0krjq!wGotw*(TQ3(%p0{Jc{`&UlKPK@+;=EORn2{Lt+vaocikXZR$AV^;F zI6El;(O{(|gWpz_y%J~=j7kvD^>@`!x8W*IGH{lD1T6__7qkfdSxA*-FeU~Np*VG> z7=P?43EFk+*OI_u;KFZ(4rogABzpL#OL?`=o`*5i;vIgP{v@PVA2{u_)`xb@G@SV* zx-RframheF@6el2>@nwm0U<75i&FZ5D&0?QIxOi8X!;p~3*K|i(2q%BU;T*+8)18- zmSMaa?!rjSC>!$jIcW(^p%K^=o93dm`@2}Ixl^Cl+Vz{Rp4y;FjljMIjyrTO4IyZs zkce=N62OszLB-^>V7gjrD$Ivk7|#@?e{)hGZKNfMTrfmLmRNEjX4+KTIJAMHU^Mlj zxL2lW*EMSeoZ6-B)yC%9>0$_=J0lDxg3$c82+KHkiPD%opV`nV4Cqf_qS_wK2Da?u z-?|G(k+btm$6&kBKow$wj=klsu;x%y^Q$U5VW=C&^z=eMjued@X&8ny?0M`15)IKBxz>zUOgP--J7o?t(7ba|@`54q z%S!KkVhFZNI44k<-YKM8Wd7;A5x*Rko1(l!7iya02`;Pp_uOKOG_vQg71SNi3gYv_v z%IU$p2T-*~(YM8}!MN!yyl2h266o|2noBg|nbJIGe@Bc2ZdWDNQ)gC&=_?c9`SNiY z*q@9R#i{7dH88n0Zys_fiOF9=ZS1I#A2A)A17@t?Q)t&;0WaLXfGRwZg1AkLE0g(1 zP7E!$TX_sblAW`)%2c?xjHLKwtiJ4mlv$RZi%}?To)hIFTM(y(L#9KDa+BWP3Gycm z_f|<~X!<0GUL+>2X3owf@6N0cYcjm9uKrC;_mk!11=!iOJ5TGN(eN(jD9 z$Lu#a%7$-ag<`K}7X?x!mqGBDm&0kDdMyM8V{Rse8p)H>j7lxTKB>$&)|yT=-PX0y z;k1U!dI+ttwN1g#PG)Ju^GDAZch#AYS9vpR$DU^o;f~oY!%ULoX!UHOtQT<3T8dZ& zuH2jZQRCrNVKe*0aP52SSsmZVfKMGUYPh<=IP5gXaQ4>1kNSif$F7BAvDrTFH>(Fm= zchyuprZWN7C1wtUu!CPhGE)LCSF%!<58LT(4WwMz0AnTVY>uo*s8`tYYh*c%9+~Yd zE}{xC^C%Q4Gmm7aCB@u7NCUN3NP8#;@67TufN*7A>#&AfV&54-K?}9SN@pVGRjqDl zcndFa5&kl9kJiHIZM0M$d-cAIRDk&n+}J@)dgQN}wi>L!zzSAU@KfPln7(i=TIH)r zoJKRXWBG=wo)3G#ctW!t%QVIjI8U~@9l)=~Tqboos-{msO3$0ZS&-{(LSB7;NIz0p zfmrrG(fjZ8c|A|F@!cKnoz(Zw+`HbR``rGTsw2mQ*h+nvf_=VSs^zAwR_`Z0aMk&c zQo|I^=9s!eidl9B$aAN~4S|{LDK{H>l5c?Biq0N()k_^w#Y_9>AV|%#^b=P2+}{pA zj4nPbehNGLa|`rNTAbCe;Sg`4x^ z;o=a*?SF3--_1`hOSqlEMysFCIFKBI-D{Et5tRZcZ+@@!l56(ucF^5b=BSG!1#|6X zaTVjp(I54|RcAiUWwFduFiTyP)OXTGu~ii-1#A5pP>wK({ZZ61=O>XbvLk8CX49Ds zqzY}3v@)+{v*!~_C24G)>!~R44^371ceDsJFr=$|{Cv)q1VPaqL>n6{G!6CBlB?1Zfew#JFKg1RL(BU zp@l{qE?4(~hrXG;g|rv)K(>m!gnM5+H9KX7|Kc8>C0_QtJq~2qL)|4I#Vf!udP{B$ zO7MrOalf_RH1yWkQX-L77O#~CE=>e8@K=M!<2!O>p_dj?WQp{-q6;XQfsTv1hmmkF zrkvVNQtD!LSy4u*Eb~mD#HEm<;@@pdJ5Cl4LNmSyAfH?%se|xm3eDzxp=&^|-~8J^ zK?5i)=nvV_5ffadlBbz>i}8 z*eKJd`DhBLbsj8A2j`yDxumEh=N{%nC}72BPoOLL^`Sg1DFbep$r5Tk;&x%hfccAE zciY3VvuM=CGNe(yLcDix6B_r{Ad(A`I6X9eE}+QH4EBJ^S8*@UQKvjp&k>9TA4J`k zlv#gjtt@JDbF) zk!}|$UFqR!D)kXjuPu+dVo`*_a!y8=%8SE3lEWHVO_3sYu?lav6;J7E<3K6&eW^37 z?B)vDTh#7TsvHJp0fJ)5Yf>PeK>pKlHEPg37k(&%}?XoDK99WV| z!|?^WrZ8XvFty1s7aEur1vS zZE0aG4N%rst~Va*pYQF(8<+?(JRYEfqe}NXviB@RM3WicAjliBmn~)ofX3vcHh9P9 z-;M?j?eH>%xm6dIv8_3c*IafBz(#zuIiv{dx~l9fo4?{hUwLdJ0J;G@n1#rJOi+V< zxPs71`+&FFRG$Md0wL1Qg})ac1ADu<4;%2#;(wfUEnJ&6W^J^PMyvC#+YK6>Zc3=r zreLn%t+724k-*zbds;xwhWGf9BL&S>HwpVYC^>Rq$Kt91bjUUZvj2q7#l$YY*T+d4 z2O1SB64ZqUIJN|R3(ekcB@fbHHIyl9)~irVx67V2sHWRQvu zdvF3$SZ^V8$9;q|WFrdEoK;r`>GynDv}6u`nZgn1cqJOLNk=PHHMRzf>QmBjO{sg0 zE%d$5M_QX3t%A%&4KPjwYYrjKv1kxb^)E~T>VX_!{KS-z0Buop z7Y7=aBzPf7g7HsyvH(^_s@|#MO=}@+62@KX= zP7yt`WPx57R#-(va{D;oa%TqO{X$x;1qmusuDGc`6rcrwMAmyT&`r1 zK!e-WxN+(ih$CsowT~DPopT6>sD3&5lAUE)HEko)9uY>RV~djB$4+RRw7k)~kz{FZ z%Dowhe%)yz2KibWDr9!z-s25PP?0B=?>}ORpP5W$WWba~Z!#w>{ntu(B5) z;27)BBKpNXnFO!m#tX~9FeQMqqMXD&!(-I%?Ckyw=qM`?m!k{|tZF=sr3I5L^QwVFUm}Z6;;X*&!}DuKL<1mb8!s0Jz4f*=ZU|ZNjsK6kiB#mTi%;%rb zbkpbCMXCz@d`3a3;T>fRw_=P?;_I8MF!>MQSg}r=L>q9yT%8zG!%i{e=k#edzDvG7=k*~r+;EW(# za@ZZ9(%70)rl9vgVOmR^N005XL=R{-tx@+%-PtKGP4<<`F)OwnpwpB~2C-~C zUYzL7y%^V-%6+@NhYZBi^pOGP^_t^15qUWdoc&;QFz2wRzOoB?0wYWlioIuqcvO|C zQXXl%!A#tMaMs_c`86;vU8xCG;SG1hg-AF%rgNsJD+g zJjf)Z8WPe@d#<8-F0JDlq5DyUau*aHe+9 zX}2vz?h^A0`lZ{`=y$if+dLD(cmF0Tv*@n#?HBVK3SU)iRtsr*>rmcdG<;b?2ZdOk zB%?JO^z2?CDoQX1Zs4hliRznqOfaTkKaH&nk=ws(%TMPm4@Z&ei4EG=CZ0X6BJb^Q zkN@4~u_&RjUWlOgq;vmGjBgacd$1;}`s;R?mz4qVERpj{CuC*KHBU$DxoL+hi3sAg z^{f@YA2OHZ<4BU9bSO{fZ2NQ=+eZ5m7G#Y})?klQ)csfYi315jj^)rkLGag0(3@ohglMT_?|2!izt$JJ@Yu;Gua+n#_uT zki(ZglU2h(wWnOXR3CS@55#PQxH-z#t5%gv4<=B#E{;`cE~F$-BLmDG9QxpVw3vdU zm34-cJ!U(I$eIc++)pGW98DEsXQ{p>GMR4y^XPv5{N3z;H~WC|AF-UlYN1DAT7=e9 zc0R^n1GtVr;o^&e4`cXF9V(Ol!9SOP*&mBW-$x1tl>^3iu z(rfRKsa)7Antw*;{Q;$*GXya1jWmw>t8W>|W5>Gw&zw&#HoA?iR;9!AEx|+5)17hKTYtBreE!^==yb(Ac_Z)LVHUv|RVjl!|$vv#ylR|P-t z1NAp;qcNJM*44tab9OFm9HF7ZY8yB`Z8)cw&>HyN)^&%OQliRetMIsgb0!VYIAP#51tvJ3hU@fZO zw5q>A`{D^5IQD#Wx88CcuE*!n>22AIl?PCFCbj1eK7Qo_!MBk6mz-vi^wxP2)t=ay z)Lb)Ye$l$8(XBHWhRUlsjbO*Ebq7Y2b&WVV;i>@sqqbD^gb41~u-f*iEMhoEZ#!@H zAM|UBeA4|-w_m#Pb8OsZ2FClnpP;kmM-ToeIemyVKb-d;<^$JPgEv=0x6)5BE2(M$ z^SVWzo)@LpZD>jOgdZSiHZlA5JulUW-?0hbu;iY?a9Yq0dm!(>IJf$QnmyBM?N&6O-dJzML|=Yd zhbJ67TFEWwF2Qa3bAAmYaRx#khTsx?f+JrCpTo={H$ZOG@OQsZ!y{v3k@Q9d656O~ zSjcS56dsOn)J6Ks+LxVZkfx~;=ODfAT8~PnZDg}|mU?>&5{-Gh2fwmlzyHEFNkaMd zYyvi4mI@Eg*?aCc$?nI8Dc@@F_4J0kYmWSzHf;>E_ZE~M^8dHgD7WFpXz&UE5GM-) zfcj5rG`29bGdFeCceb?qr!_J%G0?eqxLo^g+2KfL?egXmm5*Vdj#d#xlS^)!-ZfCV zvAdHh5|rA}vG!!+(Yd%DvAP*sn|t2gud6w%Ky6)o>)M0v%iSL% z*Lg9e)t)|Q?!9joiQspm2j$d|U#~-JM z*QckMP1;Nt=JRA>YP)_jI$Zg+lPi}zt%y?{Z}pMdIv$@tzWhUSF}iq771wY+9*{+ z9PXS$#hO#K*UARf{%f#*CT^hwK|3@wBl1yt`J-Wi`KK*pNlS-qY)Qn$0irh$2sBDY9n&{0O5u z2GG|k&UEgPE?2w}FQBf68*iN0x9wXJC%#PmLMHBoXsBM}-#%;%wxT;j1Ve$HApqDg zZ1-eDSe^(6FiW2ra37wVIr_yhS5td1q(6In`3VKdUKEAp88>n3~Ja1=lsaeW#mpVa$gMIB6iKU7axiqFxy=K6tn6!eQ>nXd@nMk>Pzn z!zlC2a|;)etu4PSvxGZmG#N2(N_NKl5rPATSkCb<*+apRF38H7p!w1=gj(4g7elw! zG;=NJ!6qk>7w=aL8V_-wNR&EI_q~9??`-2Vxx9Brt@_ubn!98D&s`wE-mUdu-$zh@ zcXK0ltu0#~l8t!o+*{q=js9rv+=tXt2Wy}AnmqX3#k^%#(`wgkyf(l5^5T@1|2k-6 zAv#j6NdK4=mKq<+lE@?P0j$B!3Oi`g1a%=w~s4*{P)_I}2F zl41rT0#n)Ui+c;tIMwry*kY0BJCu5Ob0GED*_V5P1F>D{jJb1A@Vs(k8w9h8n9995PNP0 zU~0{}>}DfrjjJ2H0qg+xY!<8Yi`B#Lel_T8kE2}Cj#>7aw7lA%Z)F)Uk){L`UXQC^ zAgGLsdMS^2@hmocesTjCr(=Hkxo_W)4x6}<(^K5)2ZsKrC=;N}c!Xy?j!3ELutz6ouN>m0!W2WnVr0p0i2wj}X`Aw3n)B8E8U%0frqVVKlf#}0}dxW;W=JWGM^+mp^0Edr4 z*t}XAlWT1i{*I61$HD3KydT_$7Z3LXfshpZNzT4?@V5@W|4Y*uCs^{j^%W^vb;^-P zav{JvFo8U<#s_Haw%+>v{e8}<2*3~l`2ePcm5t~*Ue7v|cuo{BbH8~l-ueInUBT}a ztmcxuW^g7LAPGh~h{m*VnO_P2wUr+zeFR6J8y3*%$Db!he>yB)4I%?G2jfkXGfSuf z7^^V=+u`@lUMykTd{W7ECT zU{x!^;3ZSwT(YL% zEvyp^-P>f&QoE9(+#W3RuDqR1*4*J>&6I5Q4|BK%ZLnoDQI9O@KJ--44r>QI8E{+(uSl!2GM^(4;(gP!TE`gC<%avd$dm^(yrt$8UU|-=Ka7 zTsu2t5`zoiLg5WU3nZf-){Y#~y&G}nRM#V4<+8&C$rGPYb2WDkMOiQu z%oXX0_S3A?WA-^fgW!;-)IK1u1(S -wV=@U_j4T;5KFQH$ AadN-|o?vDSanygiy+Ed!QWDoj;p|4)T z%xwAd+k<5mpI;2H_m@U*pD;uGTUa{VIHquyffJ;OfN%f?lJ`dNSlHe$L2Nn8_q;-j z05w1=%{eAxa0Y@$6Fn7ZL&|J~-951d(LKA?A!#sp!0tceF@i8(^kW`1nW@6I+fL@A(W-5LknY z%yIWU3w#GR4YFRLb*wGUjRLnaA5oRC@TZr>#0O-U% zT6R>b4>UvmBvyYgimZ2OTL?_Oj3gCct8Q!Av7gQG&5J2g29ufe{!|Bi7@4wyx2lt%!l6e=v zC-bq4HFwklv_SV3^Ir3?J4FVogl`bkK(@ebxmwI5DgI3KUb~1010VrU#;{Y9`ssq{1gBAGlY1JdP?FL6QE{MBDPpX7?_m=u0@mUqRq?3SxJKnq#ONZk_6HKJ zE#IJjzaqVC6pY`sMb3Q}J zU3SL&!2jL)jq#Hp(CD3uNG>prKNbKOu4FCWf#w1ht0JkTWHPrE zZ>}vQ5eOfuYL?T$5Xu$%YmVtdJ=QBMB5z!Gqtc>8XlYemi zsB67ch+FTo5E>?4qFR(Wl@akI&L7wTOhiVgM#YcCFkDnc_?lXyNyT6d17?QRh>g0v z6qA{_lMMPGs0!w=vX4?d_r<`-I3{QoiGf!v^Np?tR3$t0;gyIT{ zic*VyeGbYGmrxckxfe(LJO4)Sr*o2qp@ zti?nyrN%q3oFcr2Po#Ivb7q3hz+ISSv8?oZh&UArS8=4*6%h*a1EDE!A>b~c8H$s` z&&&0BOK};mm#4#5kl9j%FmP@}ZVkA*D>ynv^dDh#7I4M}$EU+lX$xe@bn%v008JG; zccEnyiWezj6H)v79NT)_!ByO>gY5y!jlg@sBi&@e5cn{(bKDrUSHmjqiX4gVj6fjh zBnK2wFQB`$mcz7n#e(C1F`L}96!-v_v~Ve`UT^=9+QiwEceDB2c$~s_R|%9n^`?_hr;2sYKLX5azLxLP2t=Pcie%uIXQnGc7(>kXu=Z1-xU< z*Fou&M;Z#L3w0qEjl)N;hcr3)0d1%sh00Pw;9}MV-`o@G>E7RxfMViSoqDG&n~yyO z@#ZMUQm*n!rGAEtmpOkt$cUGxSfNR=FtfCOt9mbZ)~<@O!e}0<3B7gV&MK(2^Cy?L` zvg%h$mt86`5oiDOHfuA4YLS`g>n(@_HP_NkSllyjZEjfE+#u!5{yzXsK(fDYKCq>>j7!e*?vv;2H^(|>woe8aIs!%@WAcS1Z&ir}(1=H0Ej;nz zV8O8zHkGK8-UMpM1Ki#fgaMWfE{4#}!aaS~Z>=_XTrx>3#GYM48TWyPPzL}<3I6}c zj=V!+&-m@Y?_M04>0$pwmls~2P#3iY!yAza$d;$%;@L@vCJ_*>lMj8PqQJKbOB(oZ zL9!BjAmrR$xich5*(@pyp!a|u47*o(gEH13Py~pQvozj;w%-EOa1ejcoZaEuvo|L% zoijW;j~>^hIq}`mAHL;h%mi1 zCL8TW8=9eQ_?xsj7C?xRp%?^RtU%|OAYOyZg+Q1WKIJ>|?{2<aereN&~nbKhXe&&e8rt+Ynz>)2_b!J`MjN<6Xey7CXLmR0d=CWv0L&%+)qSTx`y zb%{~MCGgHTRQ;kH5C4~ZQNTBWCiHQJAc}Ta(CdQ!gn=d42jwVbmUUwg}`vVz( zV>ZoX&X*AqO<-N8n5fhViKmH73#3A_oML7HAjEOXfX3$2>>}s}huUXmgdPEziLQYa zrlPYF=Lf!R!e$1RSvq6xMN73^epG-Em=u)a3XgdXuL0gMi0AKR!`Rmf#4k1`F|DI% zCP1|0K|y?wY7O6Z3cQq|nQE?hbht9c5;A31Y@8DUz^Y=a7u z!(8e~Plp((PU%4l|UhbW7H(VM}D1!uo+vJtVs-HNls&G!iuX?jv-LF3={HHGvj1T zqTI(ICMe6sBahweHql0iP|npOs=qsD`qvS^*YVx?`}!q+v+jPsx+)O4vrD>|?PpMu zZk3TGUT|We}+Mu-gU@4Xn$3xJ=Nsljj!vMR20i zRW%-%S8RpH0}SxKc}$oGXUzUJSl+>ZNCQCr);$~*=s>PJX!&JqoeqCiabp537=egZ z{#>uXJ_ERMZ_rT-{sDC-9jV#9YQjgQnyGPm#OZgk(F9UpSHXR<#}qJVHjeG5LZ%O) z3<475ZJ=O|oktXE18YxgrT)^~7U5nWmULGSrc7Tw5pE1m!)}ed1aymtHPMgQ>O@ax z=Lz&nFf8qEZYjf3m&3uJlx7mp!9@Jr>dMam<_1Wt*X=#>+fyGl_XraaK#o%(!?MTn{^GKMVbfUP5(ji-rJWX* zO+~}AQe7N;RWQJ>#M4As%oR6an8hWyI@`+;a?tPQm=goAsPf|Y+Ya;vM~81u5s!a* z@al}DxBd3H@>$Ecr~~G|^*S;~8e#hvFvO%T*BmS~PZWtHj$UdsiS7h?V-F(cz4!*9 zMpEiJwQ*4h2(y|okzhKiyqUsMyoMyQ1Kz?cg~@_f@^Hj4RTwwJ{+9hA;TXABnP3VWT16Om$%cDJ4o zG1`5)+i17xLA^YF1BwBk9PCl7L+3A!507xiqH9zj!FqlUDiBs{?ML8PpdT66IPKwM<5nBlELh1zxK3^NLu7D*srlqCrF0z<*G%~Nk3B( zNzx16GU*SR7K5}7k8GH{)^~Sy`*C-BquIXL-G$8EZ#R3-w)@SU?q>hl#zz0y?z2HH zS}$CJ_JWOG8gIVk7ctoCZ+4$nIUpLLrj9Dy>wAU~lIF^YvP-ttVUU?I+uAh+vg*BzUk&OE#@- zvt?~t{9aUu_y9UOa6l@w^I&xM1CH_SWVixR^G>`Iq^Sz~ZYj4ZnnCxHkTF6pz&<=> zdB2uk8Nz07iTB`9{_>|c3ieqCM*mKaUY@);BJ}{E2v!BTC+*mc5|&cOXoXb6{(y@eHOQAQq<7!2x+Mym+QO%nYQXj!1=I zZ`3C0GPQi&k)}olZa6=(o)A0$g)U=Ce0zx&Z1{4zqa9g3W8y}IaJu8Tlkl@tX(B@H z6v-C!-%!Eyb_2w(kY)sTQ1}zZr7%Z?gd+>h1V>4KfCRK41|T;EzN>dSl)$Fb(SihU zHmq&ih-uwrNup9$Oo8+h(0YgzlLxbU{wrdX*tA$v?oRn8e#{NgiJTfzXqt9ky#d*5 zx7urB+6NK2|Hx!a;T5uBTfYQ0CLnk}G$2< z!9+n@I&gvmVnu6k0j+S8?oa%lzSOqep~VTf_zTb2C=wHr)K%!ZCw6q|!W zDole|KTFJG=z*FuS_mubrN4?(BNUnETmU zgAIq~FG`nVaCU&CS?P_G=&aq)9TmN@h+4xk(Ub%q+I}!d5TnN~XNnYpUaI_v4u!NV zzv{9@Cqa^Pw%tlAH!iw$_{6hx2SSSQI0V311Cd=ve$8Q`Yk*HfP+3aDSyJ~@Qo^PYSrJpQ@1`d)F6 z=Gm+D>pW%Xv0pxfhc=e(zX%c)Hspk9z{CZNByK`q`aok=8!D=ef}R#fLppKrVXy}Y zPZJ?Tn>5_-PU*|{E{l^bn3TE7kPzndl*a&vq?2<&0D5Fv#jLvL0iYybGBX-$dW2t8 z&jPkCW+X8h>_n_e60MWsRkN`Miy2FC4mKN8PNuv((!j&FEgg|smyv-)NxSFbaZt|A z&H04)FAP>dI5itk?e@)^vhJ(^uR%zW{E@2fZA{K;=*L=M<0>mA+5#}VsGTZF3XA>( zeMVgdTm9@Fkcj9!l0)vT<D&k~xtN(Vq0weP__f&n~v{pR@O)miPm!=_c6Q03+-m5;3%U!R;kcuFnN`dc)T z?w6Xcbqljz^8>Q%jAU-kZf9^Wyk!XA6`iyr)PJEv&#y?HvA+gaVNi8)^XdhB)uSqU zX%78G$POiKG^u}i03an)3ZQ&dCxf=bNC8|TaVF+WPxF$V^zgKrkfHMTe^2Ts1!2E)&A!4|{Hwm;oX>X*G>Tb^$4%kM{EJ#)v*DAhyv z{6%P1*(Sco@)U(DyoM5$aQGf_G^z9=zQ`UPL^7*^iDH(>x2P(wWGk8kMXDcMO=D(+ zXNgFiHS_0i(Wk%J{?5F*4kUY*_9&{{XV}dL6WV4EDOttYMNkQwvRZM{Qfda9LAUf2 z#T}yhU3c?Y>+vc$cJjgR!gi{EV(Oq>ReP|D=zk_LlQ4rsFSrXGuX%tJ-OVN#)m)hI zVqX04DuzKCWKd$t$RJ+0Yy^^v(VS&x-hp*nGzPV5Nh4}guW_Rq)W9M2osz(L~?b@63^>#rmn#>h=4iBEaL{Y_Jys^I7{}A}aKqk5p`;28SBf;fwI6s&? z2JysBQc^j!B$P~P^XmXJav|jp-c{&-q8q-bl699>^B1yac_aIu_&>f5z#Tep_J&dW z8narES#VbBK!}5_V~u=BI>$`l%50WVwIWcTq4*1U$IXq6f|CuZb1!Od__l3Gf`ye_ zF%gdEjSu}y@!?g_6z9)-smaR+EI4Nmha-`srEl>6`EMq#2ICj7AHk&_7JRcpa#XVA z`(?&;UZ6ZZU_Z{{Ml3wPpHd*m7RC^Sl-JNW5h#>eE-FEVRt3X1Y04ordszV*(V3ay zKU2PpStM^ETg@X3@o4pH0nV3O-#;yE+2l_{05(F#iQ-PufIMCSkUkJ(KZ0GN9Kt04 zM7Fc6{c7~*`w?(R>5g5ymt01+W}A2VTIZ$G^Z*8Iq9vS_NythGtyFM`f(nj>ynxlb zcyLjAgDR~QN1SJdSI)~`-Vp^Ul+7tS5qw8dAe~7mQR9=# z^_KJG{^d}%u)Amdav|zo2!I?6JCYS7fYb#LN|AXszygBr`JZjJp>|?Ew8JTiG%h2x zYOq21(N!G104mB4lpG7CfVE|YkHKIrJ5i8J` z*(kl3MeBwgSe)LeUXgYu((YAocOcdgwOPmkyL=!i|77if336$dun-x%e9ukODQ{!J zmMS*3Xw!_BW*^mcsZM1qxAi2bA7^auPT{&cspB*5Bis2SAjE}WSTqijo9J;!y|8c- z!Gp(0I4AwZ;!On%fZxiEtGI6ghEMK9pHI$LiG{3rMg2uFtA78LRhwnOD^Nc?-qD@o^Y47>pi&0TBoPEjF2lC$}X}m(JE>9`Jj(f$XPuk|~*c|x(b1G=h z9S{-K_JEw$-%}F>keLp>WfW~kXp5}OLy4f1@9l834o;9B3s_r^rTt2*Yj?5I0mrBC3h*Bkuizx?1fxd$=a#^}jh>{+L?;n8Y3-E3DTaU>* zGllXH+i1*i217~VPuxm5iuXM%^N75(YHavaDzb<7-b0ab#am8jH~E-5{g|yJ@>Z-} z_}3N5l0Zs}U={DpXPetwebh8o<-F~vyMhrZ+Agc}%^VtXR6@B!<-1Xrs>yzwnP<8c zCuzHw>(tapXoOrqP{U$zb30Rso%X+nKw8weGlHXxuJh|NK~Tf$5;%B7h!>V(?{@x6 z6Vt;Uq(~=BY%`bZk-};CQjNq#0Wb#Hr+G^&O@)%#3YMhivkKl?p&F;Ggk_g2`AwV8 zQL1eY1sqcIjgSWKIUGu9K<;4dbt<`K=JTY+;IdGrIg<=4(Q2lZ;1?pRck?YV3xX5D zlUiw3iaA5O75K{#pDiWu+A^-XHLBnPzFH}3a60^jDU@UC5V_%F$~YXn4GvnuoJE09 z=Jh*X!d^&}E>^$?MoI0%y_k1ke3bc@{W@Z@I{gLH>$DBChy)T&d#9R^@DbGahE+`)kc6A=TPF|!W3vUoz>nozZ*Ny&brz!lOKd0$h8m0)}3 zRTM1|S;cFhjUF9gX!kg=4&y0jMfwmdd`E2*lCzrHPrzzSDA8@qh|$BuMW~S?B3!EC z(ji5LGGt)z83fB4?3!Zh79mXc*u4qpOPx=)#wixb)WX@Md_LA(5%Cx5gXwzHrhYeD zekbb!$%IF2tvFtwhAfvR*BnSr=y5L>iBPDvg3gRfU*_MibKj@h>ZMpN{@Ono>-uwN zeEi&tv2!7yD9F;F>P2LRVT2Aurw~%9sJY^)MEgngIod2UHfB5u&h&>XWS{mZ52l|w zpOUH+041{(9Tdi3VOpdD`!YCwD+4KfRKS409_>A9{S0rl`V@>^JkUve^sTJ2sBI%~ zam3&$!RaIGrh~h)r7$heGe0H%g%`DVunj5b`}|(j5an*fyvd0Z2nKeT3kepq7+AqO zvqXn7vEjxULM0WA&>9=2H*kpzGq~eC;E9sN>HT1piwpM^6fG{#Wu>l#qZ4Z|*f5lR zPX!>{Q*=O9U#M49$okwu&flQhl&fNOFZDZ)d$WoNxYeoD5zBr{?m9o|uesKzl#JCC z;c|^M6lp!760cgwH73>6ELv#)V=muR{p7tgG)CoGgL)79*5CP|3Ko9Ik7>ce%K(}D zK}=S&#nAI?-6q_ew;Gy>!71?~q86yWxpRB!<~XOmgL%-8 zZk9#6k_;22pkZK@Mn@Pqqc3L~9=U# zbhIh1Ys=zst-{L0Cs^YP`Jx={-wUzROyo+lBjnFar<6;U^irZB(^8F$uEoEMTX2R% zqycGzs_5Hv5O{$5aa3StAK3w_E(P0?HXsBAztbC`4OzEV)N;RUEBdAs@-AGBJ~rfJ z5rjVM-l2E)r-mQ1zZXJJ+jWi%y%5$A zV+@@xhKb#+$!vkl&uU-|U_NCv8Q9c5L$sHmqQ^;w-E~>fM_FyroFpadq}U{!z>O-V znGK@B!JE~+o!pOlx$=en-LMH}bXC7{@dB4xR^Sjh37&mh>fIO4s=DfXarHCB97K`! zC7^i;YF$b-E~VO*5{-yCwThaMAj{?7K&i1Iuf$S~^hBU->JX`^VWAzc5{@n3%+Y-< zX3&b6vT{ajIp(Vx!xe<4vV$^D=bsw99HYPjt3^dp-nhRQ*g@D7x2UEGsJYS=p zV_Ts?=d1^iz@xkdz*gjQg%CiSt@2a5Hs%PHm#Fe+iC|*Z;i(80ss{r~T1x{M)YVdI zU{Ngwb$rX}_mgpV5%Gioh7D~?!O_3t$y3U4PJrU@p zu@h1GM0*qZ9r_-|qQ$qAmlkKI)iOD=h%=0~N|u@r)qgEM)|*rPrK9F7i;~&8gI@Kl z_b@yB^FqB<{Auj-+P6O#^j+peQs9z7MMyX^xk`cIJ7olZSKb#}n}hAA{b#Kf(mkUT z<3=F$ap4G7(&NH5oPwXqeD5ITF;#LRttd2V*M$9=j${+&|6m*ju{ zSrz@tRWOq9dE+;c;v3JULVwoC&IG5iB9Ojehm$9iskp5(6&ufe_)zE$YW)pHa$IDl z#5qI#h%n}1(nCTUs$Zs>;5+^-1-v9FD$d}Bqw=`YtENTlP}03gNnMakC>)Lx;Zj;z zikr82ro$e74?`coi2rP}a;3)cRI9VU_# z(&$=}Lc1$)hE)_)@J@lRr<>ME!^S@8_Ox#WC45lP8Bg03(Ye+}rRqg5G^bpX%6x{b ztVA6}PSn8&kXqtMepsVm1AcbYYxzslWn?ISa&~PjAmQ6U(qjxKJl4eK{v0Q%*1_y0(6SY$BU~ zO|3`s>z-9~Uvf!jEwe-UR+sBHO+#6&3pxG#d3cBT>*$B%5`pe02dmFV6=$ckoT1dm z(2Y?51N3h)0$MqDT$0_}K4YyCC#>m>V6ES^mWt8aDcs5zP&f%G-@U;dUA)4@ej8hM zQN?bgL~~Z4mClFer<>y9D7x0Sq~27B(R3lC!AAoZ1bw-1FyN%Ixw^GEW#>iBhB%X8%leOGRNwNsD!+HO;ab!do{5Ks&-{s>jJl0=_Z#e#a=R4OIncQMInZ z&F6H)>+)+cnxuoIJ~hk%VJ%j^L!F8gS59H|R831a9fmkqA&-WNeyT5Z+PER>iz2Mu6&sM>j!+1C<1-8|}? zSC9>1ylAPHYI%&RJ&9^Xe${ctG8HGUlD3lFFWLvf(P{lG@}Y3xU!I-3I@P>&!2fzQ z+yC_0Gu5j_AooU&Kdl|GnAaqiZ4``a9#DWo^F$W97u6mkdZ%Kimy(>2dLjKIKuhhR z@&G@`GHC$zAV;a|H^@HYS$^R*AINfO1CU~bi{J}&XK8nK*2cimT8C-Q&mM(AEX$sa z8>BS(R9g_-0y07C6usSRS`T@`8%~$ocH}n^h<0`wJ1A+m^Q5t}<5o0W5^%M1l+xA) zDhjh9EJKK@1W8%^)_Q7cD@Yp9zo;S04EiC50jN`5Z$yPt{aUeZhPKN&=e`yjsa;r* zbEE4!v`XpU_kQiWJyo-2Psh%Nx9^>ZCB;b2HB2yw_qR4SEnqHqRf}I#*8Xz_gCQ7` zRTLq!P)gGfU}z?}5Ti-BnI6JNY0LJMdnHr(B!*^qN)=C;beLkhzo1JGT~adFFNZH- zSF@k}pDg;!d*cc!C8JM76*?>#G98f5vraZB_fKEi&N*tz4rMGW0x8v|No~k29`ZKI z0`oboe{7qZ0(Yjs@d74ycfkU5$9?e z^5HltpR#js8h)*_Fq0HGo&_ouh4@ZLftd>!Y2i(v4{b)fnK|jL<-kcZp;=1{P#i~Q zd+XG`!p2MhlIrJq&{6Xa-_<^mbO?vPo1(7t;0_)kOadRxXBW+vceJ7{{D9M?wLyw-gG3Q472xPuey}3^x_M)428}I3kZX^%g zqeo0jU<$aAy5#C8TFETFhF;ntkB&^?2ppQ^anD??D@!|{C2OO|VD(Vz<9QFjp?>rf zrRem?^m-(I%+@kVbuZ4U>Wg<)>-X4dK!bhn(sMS1m!zn0K+0JURcA0C--mm;eon|! zhZpLiG+H^8$zj5;L-7{m+Q{F5s-0JO56Y+L^)AlE0SXQ@?-7zWnLSSe0); zE!MsT`Re{Rp?0(UZJYwJC`Fo36iHOgbJ!Hijf@b|ld=6~j}26npT@u2CwIA3)Gv3i zma@3Dx1N&8t&MV}Z1cG{Gg~Ru)`CP>!GtWyK+Rq$8B1Z3L|qSz-gohkrd3(ep=FXC zdC?Z`Bo$9BlxK(!2ao}+k9b7oa+B#;46|?er;7C1iVk|vMcE!btWP43r>bYVkiD$& zQi%n2*J>}R1|HNF#`SuSId9A970utFkpTY@xT$Lv9l`WM}+Wb)Q8tF*E zBwF;Ro};wMM-xZ|alm6}HkIy^?_4YEGk1rDDEh^BlvC7kZ@nY$LO27kU zp|yOi*jOwtmOpx+TSKm;{v=@xz%8xMSxaCngccYq zV3e1mS&OxYHjMAUXk4IM%=i=AJBrjTvMcwbRIFQ(K5{W+hP8X%3A`PdDgdIG4IM^< z&Xtol3uAu|Fq)fr$|1^Zd;x68VpRxO-ey^cIs>K`aw=C23^MwX&?RL7^e={Ru~B7MelcDCb2VKm^`7!I zu>A(*E3Hn}g`p)z06(zHzqHi9Wkp6MO|0(?ZhTo#1`b@iu;~0eCv(cPH{*hvdIS!| z!T+QR7l&^c(C)<~OB$G$E~c)vu9u~Kl7^pYT=ET9LyKk9dLMFJ5i-a|r{5h$&$hRp z31FKx#LZ=q#HLQ?9!(Q=@_-{$-phk(X=lR(OM_#~cb?LD$tkklcBw*p(e_bs2@5Kt zZrz7l4wbC0Q+?VF;V#~RK6)fE!|*4jR6K4HH=Wg?Hz5FK$R34KlbbLULoYa}=FH+MmeW|C?Gm+ z2T+7w6@= z$0#gVmG>&|{1B$=7v}20%$0I__T3Az$knD1%f&s`OVF0JxE6RRB z;$xl{Xn+?u+uYejMf@d(9e)7HHI$ZY$|B0nk-y~lK2@E?kS)CT}?^C zn$nWlZ&?EG6t-S8pNkT|#JpL(??rQ~d>w;3_}(UchautxWN9>t$8fl)FR^@6OS?4Z zY6;Q|AD;GnH#R|860*J-yl%MKcfXDovG7@@Im~1 z`(aAqFcnv~wcdxP(6nG_irwR~qMn$?ie)!%3IlObnA%qk&NuQSIem$nRB`<0AG7RB;FxIkI zD#XLxOTB+VjGnI3;4|lc3~hgA#pI=wOAByN_`(|hKVhAsmg#j~slrnc_Pmc-CtQvJ z&Pgf;N_&LD&qD=iiE}^dsNldq#PK+yETv|k%Xk#4svi)-s9eB!Nac%pAdBy{betg> zlU^S;t*emd&`UN4QK!Aszc!MS`Wcz-4?4LxJ9`N!cnZsKy_V6qdS%WvFNQK8d)aMyho&1*3@byw1o8c(%@1s=krWjA3X zDc36R45o;pwiEbkwsQ>xtXTK3WcfJ+0)9~#(OXuaS6*iJ!}#}h+E2D5l0C~<;B@f| z?b#y2GJ-*w6y(T00qKE2A*xC7h9Jk`_bL_~XsDpD;6qbT-m;`DA7F~wt6MVS3ZGEE z<-5K1duHh-NX3FUT1COq(eN(Y8*s%UO7u0X7w%3Uzzr?I5qZ5HKVA-d!&0}Hh0z+a zds$ADvQKj{w)(DGtNzXXu}5P#PRtxYA|Ai`Ck{T;#6~IwfEy zeN1STfjHFCj4~WCoA-E?^!m%3FUJ-H3Vz#(st^gqc|ZAtrMpB%5s@XEqa}Da>@g+m zrN~|;PA=mN((Y)j|n{KU94SoF!eKjneM zOoLcxV`EV-3TSk~3|ue2GHET?NN{=9P(BxkR5GG$*%~<5HXGP!ug5PF9WyAizXt^< z6DfTogewN-hNQ1i4r)nJ6O)w4ngx@1vR+Q$@nulwbGZbU&1ip5js}$^yTDRB(awJG z&QCOlNPW3MwAh#Rm2WVAsUWzVB$$m}(ky325`i%fD~lw?+`T_gWc0Sm8gdkF=A81G zGm7#-DbU4q*c3hJbm^60=k-}~9k=NJ0#da@cI>k1667;$S}rD7&QG8Q>F5zc)31>w zkahNo{#}qGK|j#>jE-1NW7Wnvy`go z7#z~?j=QRs-2lojj`G2ve55ODSwRiSIqR0Wc!4(njiXAU-iWBE6Bl%%t}APjvE71V zh1QMY_Rw+XPjyU1Mwgl7#%Y$duEPN9CUQU2^21d;G6 zRe+weaIRUd<|XFTK7?XV32*#mb-=;i%PWKPYvoGUB@4|L8MG#&gFJe5@U5P3lPmY5 zfY=nZL|dSgT+Wls6@WpTkCOwmog68HNoYRO6(Xq?XWjye=z_=N3hrk0-k^)5Fw#4T zfQOaBsS0GA6TFe~D!2g7`<7V|?Gt?CBt4Jnw+U66rdI$23F-|GVMu2F<#pWb?pb1Y z8uGbdQgj5YD;ywkk=%e3 z3!peloo3Zpxexyf>S75cs zaWNjU$XogCv(Kv#&Dj&R;-Z9QH$aqDG}HNesB*qE2@wby$GjVNl!Do{2G@HK+8Q3d zDeBaM{0Mf9H|l_^hnx;kJqsi}pv1P=63dv!&9#Qsx^{&J-=4iWdFfpHnRoBj2;M32 zvQZt4mi-PN5Ss+?0eFqGiviAvU(b5CrT`D{-i_KS^yA(;rm{cL096>c`rI_Sy&{ei z+5s*thZmDhsNk;;RaBy~P8qpdbOflYKSexMGR<5!O_5C!Pk{S`Q@#g0QEze8R6|%6 za1&m%9BA>7_GmPG!76mKJZOo^7o|@ShNhb3x;emQbg%9>Kc0c@2G%8Z({@i)L*&QQ zH}45aH_%}Y;^uAKBOVM4O3raY2OdaE$mlnPO(3mY&3k?mAAHoCFh{xQHDrgGdk_aN zUYz`N^t^L&di?z{4Di9Xp!$0^>z|8!b{>WDame0d;}p zGWk4Bg=#8W6i?uQoefomB!Q2~h6xyUkrW^+l2Wi3wOv}Tz-q<~5?JP0yxcqYbemO4 zwyateN_rvX#O%5{%fE9~AxXyax|i?Xd8GFKEVHS{l^}ziDsi*$zLgP|?}{>KSPT?Z zu@S7~>`%%?`IsM}H*CPO`a{aj(g#d<}- z=Pc-|T7cHZLr~`Hwlu(zddmhtA2ZvhtJ~V&6&M9Amku&l<=%JBnCM9xdMfX0zn32# zmKUe|9wkiw{Uw~qN-Fh zTNQ~`V5>91OGTPJ#)4U;`9+uboCj7b*5&0*O+4-Cla=^!PC*pR;!tWVzEsSZI07)+ zK)fWYXf-RX0~GJl_P}aJC-8Sx$1pX>tOl_LsrgK8Sw!5DQ3C{pv^N%8(A4S+}7WlwCmGrajeo;8~FfkRW*$q$NSzsK0z+({i zlZ{|1Rh6`6IU-kfL6?7Wg)4?q>wGo5qGqVlKeCu^%UL){8LM^Gopy-+mL6c+J3z`k z=M)jZ0a%Jn#$gS5MvAEOLL8Ml>^IQ%L(7kQfJr17koyMKIN+x6bt27wvP4V#ai(2U;lDUOTpUYx~bi#^-Sj6#j&#jesc449m zHtpmIbkUPZa#6|`mnF0aDn6Z*6+Um6PgfBhLnovtaRjO|j7hQLk96;R5&nV}<%$3o zXdZPWF1-bz4pko2ncB?}MXUx)v3={Q+Vd}_rP&Hx;mzb5^2!%wQx9Sg9#Iz)pGKw! z@HOde(fEZ>$faDA2WRngYGt4bAAL7+;CM+=Q9L#GMM7nCb!0wi!OZ5U0v;%j&LE8P-55Dl91vcL#BN_w?Y4T+)P>`F}%;vp==OudNr+A zGgrZtyH$X<2ipEoSliSZA$7*y`1-Wq0g!efcrSBcr`*Rf|GE40)zZdyBojY$%3K5> zkfS75k_KZY_74#rf&-B21JK5TYmhw^cxfzs3lBPCRfTK>TuOER=OZT1Vg)H;whC0d zo-Bx!=M}nHl%ZNu{xKA$L(V4lR5~H+k(#~S{#bD_IX$MmmYim&d^I`iRnB^qRp~}1ax18S})8~uII{D5?DgPOy1piEy4g=Wd-F|<4=>n z8B1NX_+nhePQ?q!O%tLwu8&3GX^fSPJpW{>EXJPXUVe~n_4S-TWbWrmev!TSV$j~$ zTI3g*AKdnjWPf+ZKT=$YIfpH~wqbi&aGviZY5W$;;;?y)WHg50oYgPQbiONV#&`uq ziWKIeUUY8|*MPc7egG(Yqh_9ZMNWD-FGW=fKIp0#a8hhA)>ExP!cf!>PJ#dj;q{E_ z#P*;Lw3Y%3y0KDqsdgX*!=%^ZlAgK+zT?kk*%M~9DiyT%-73W&*tn5v??D_38xh6L z+_?_ppvcEEQww|yCzo6klSFxl<}RUMsr79<{;HP~a2n zc(zwQUzY>D_YpfF>hH!c*5m95`Ts16wb0QXmuR-8o$d=RmZ+SKa&k`7JQvxD!F3P7 z?ON{Bfkp(}*B$XsSThG;|+XplX^7HB{s2Z3j> zfB2XG*pK1o@eA0G;4inTtnKt^5YnCNvF2U^IHyi6Sy@$CxqO*=<#wPp0KPKy`EkLn zsdq;*rzyk)%Rc0qZgLP&SIg&NaXq{Bd~`rtL*{4O-6;;PfbNk-lBeBQx=2q~rq~<~ z;Zr2oE&A|ie1_|Uu*D3TzRk3*x51C z!NP9ittokHwys51$Zw<;ByP3FHxxAMUhTv=MHAP=;)R4vX-ZL;h@-NmBfd8N6!((kU3 zP1UEUn?tE9{V1N=a;^4+OUYJKWl&Pb&BL4Zwo)c78iV?pm1o3_)AQ*GOi#y1%A)a! zksn7#pNGUF?D1_*boO9-aR7E){OO4QJ)?jBNdJ7Z_^vN&Ryse6M|CU(dn)pX8R~;7 zc{XcmR%iaOix+*>Z2Aud|LfsGt&jZ7I~h^?QCQmuBI@u22cA~ME(+w9j6nC3v*@)h z|MJvRL7K{OnY?VI5%K-lxtfZR%>&cj(!#qgOmG~V=5U>=;X#1JvXg?rVqp; zF5A`1HbP6TcR`r!<^R#IQv@~}7?i{-8iG?)D-z^Wp~cFFbiOMeAa@S0Y62kzi} zOTCI*_&cw7#QfO+({R$ln*gneK(tBCpbzuYVFF8%_#Pyj1Va;@z;hf5X(!?x z9kD*Ar=!pL-It)HDoe)&ueLhgUJCgNa3qeKN`=7A$!*5&n-kZ)=_IWyH$P#0_iAMI z-K!GS+z8Tf_T0kLxpBKJrl>yr=AlvPNA}roa8LxARF<78%NuNwdaN}TUJgZ=tUUj6 z#?tdeud?=%uGSY{($!8zDMnuU=Mgg`#bn7gMqHyv&J~>>&&3$Y#DX`HnwdRHVgz@V z5iD0QnCwtyIGC|ulOHnljM@|7PrgGI^s|p85#7j%9FR{_KZp*ricbEtN`e6Akw=7=QEXWNsk@6I#9`-5KP zP?U;h=_=g1;5NxPo|qZCR>BrH5f%V_3PomBTfvpnea%s;r@ShyxI5cmh)R%hD%5o8 zuKqXkEl3d;PLRKw2Rx z2C~5r4a@k2h1ff?3x+?u1yOr}_ep;eYhlc^kYPGT?`CpRAd=_N69J<(P1R5Y?vBnr zxZ=v!Yw;)&m3M-7%9 zrZ%>IBU56l=wU+ca4d*xAa(YPe%pXlgEQShYSqq-`%}Q@!Gri1&>@IcOqiU*7kRse z9?Wbu308@(2wM;VJg^5DhaQq2N5`760m?>+lR|6Sgb#z$K{oLK3VoETs`e+FAvW=03FYM!Ab??l=O<8pgtTI z=~GA!tcyB?)qpGqh9gO=%tG>XguJJ;J*b~1`$W=?#$I62!!kUY;kqe!lNrk7x9)4_ zUADeP@J5r}u^#IAUblG!U-77^wp zd7K9)HWzU{1U+BM{8SbLItYY<=!UBR4j$PyPJFTj&>EtsD7wW=Cm+B%fRf|8FHJO6 za0<~#83*xHBm@w$gQ#`&{;u8Tt%Dqf;0YKXt>%lp{jP5{PfYQBGS&48k|Hx|P-f{-k>w7l z)@QubuE$Cg-_t)whg)D+pTT0?xDe3@t{zLkJ917(nu5&~P%1+x?4rx*sqo#o_# zppY0<#&Ymi39)IjiI0tTv$~2XEWyf9`&h3opOKV9ppbCXqn(izRSD$AV0Z+zo`CGl z8bt!q7a&wJpuYL!b2}*a#<&_2+K7|VYwuJnU%$J${&2j$vNB%3zxH7Fy$S6kop;pi z<@M@&lG^Xwxx4fb8A$Ns4rd^dIAICw*5gz^&Jrv~zX{SgWkR4hKi5CV63=g6gv0*w z9bc!QUntiI@zLTDw%*0d!7r1M6w#IJ-(mK6aZsZA-4pcPmdf^{2Mqn3J{7=c{g@Pr znjo=ARhA;yAUY5ii!sb05@#&efiiaR#&W4|uL0s%%y+Avr6M}8UT^&=Ty+tIm_tWJ zv>xJc0*@iNuwDaZz`B%5wfTPu>=niQ1n46VcU|pzm02)hwTR4$8KZ`75JNv5WDw5v?dAkv3s$<4DFtH@gI@?`^JLCuaxlDx{to!v-R`kY;GXDl_H)^)Xc=>~9{ ziy`x6ra4He*mDICR;$u01sFb{X7o^`~Q_W9z{6Z4c^p0fY?jSCNs^V>V0y4^`5DbyaT{ zKDs5+k^pG7xZGhs5^q9A+=yCfvE*{TkQI>u?yWC9fDCZ|E`D4^1W4@MUJtUtCq*UI zy-T)L`;QLgES+dN=ninafQiTVV+>n0A(@0ClEX^7kse&2HA_#F5jxgCLFgr)a3Y+f zTUT5@mF_T5+YH{+Sp~(QVg+z3>rhyE60+>h;?@});Z;K$sh+6wJ6-VMlb|=^w0UFbRQoyVUC4k z^4`0@w04*1i|mAVu93h^0qE?LNXm z^o(-$25q;b{%bC-04qunb&MKHow1}=YT^jD*6Y2SUCYg^HMHi%C%Om_bBupthP?EFX&uIQ9zMADer09v;p+Pbqlc;1A^SvB z4cSB5Ox8)Qa0~z5C9~k*HJosY6l5;^ElvC^CeODVgaH#@jhB&NEuet+ z1Wnu_1izgfa-fzSXt5;GqHa#vDT@6#>ozTC0+z}bgz2YBQX zhwO(p&z~76%2J9-CSOKWG6#1PK7Va*i_$F&v%K74xM5kzc;~vYoOC`Gg-ME>RHQt) z+qrLWdWEkbV_PW|Z}FN4wDyqnCTkBIiTF%r&Y-N#J7vGhJE6A3Uutu9$);!ap-4zU zK%#nn6fdb$UxR@oD`lU&{BiTOzNab1m{UoCH=%O*Xol?i;?c`{v2^r#O;Av6KyfZW zq{98BEYBBFN}~+D)Ptq2U?`PhT#`5#2tucaFcz=a-AfCf#tyrmtKN#PU<2_eMk%WddbHt-`DuEK}BVDN%0GKb<`g^4&O zPe*rX^k`=d#>8}~6RDhb;}!1mWQ)ruI2}cNOKK!cKa{U{%%8ft<)xoq)ff<+0Gq*d zH`)AW1_f4M}P?s(H2pEmmP|(rH ztz9FYNYVy1zT5o%<*Q9oOd#bm)}WLQi_~sO$uVSXQQV5En8&J8BExc8{@?JWFwHCG zG)h8yb78ptT;6i83F?hH=(-|_bSfz7&I~gR;VDi{1LT>#Lwp~76D3VlT~&pBj`L7t zdkAY#gX?-^b9lcUi2l8t3IrMBhlAiWYkR6E?>lBjR=>Pop|N39be&q(Y z9-jz?C+71Q?$A&-J9C-c&N2%G zG%r?UnEqKYV)Rmz3z2Lsl$E6KYaS|z8e0)KE6VCPfmbq3Xe$i031#{BFx-ermL+js z`AzDJQ38QepLY3{#_oRvflZu0@g41v0!pm?<~T+1+^|kh_LVAYD}%orp9cfa>3G?jTV-D-Wf|-#X_{{Nd|f{OmC-E- zEZ`Wo4I~Yqz(`+@-h$5Ar$X*R91AM7+zG_d^P_5nlkJE`|2gR~&J905TVzXufVqoR ziwJ=!WqiUyhOZ$J)^ZUPwlOA4sCb#8vUtU|=O*u^#y(Tviwphm@;c6!~Wn+Lrj9J>q~3UEUvGuEv>DmtM+=9RdE~HU-;@PS}=|Rf}rr#R|@kL5TRbJsEKHdUpBO?pifYi>?O@2o6tva0)6L3RxZ>ou@VOVX{)yRAkF;j-z z2(n7ZPJXn5)Lds91ubGpCjuxLvVIa#!~vN0MB;@{Lkh<6Rye`FYa9T+09-hgm_2I9T?0uV0u&GoBGfxL{Dj%>uQx-W(?d$iJzGk~lG)}43Pd|{q zZuGSK;D3xlLVoTr*Pf0M6H8`~9U=t8fBTcck_xjB3l=rU#dma34e#(2#ndFe8q~HOut$=Z3Ms})+mXA={pBS zmX7)bdFbH-lS~6zfMAnoeydriCWez7kWd;O$ib`fp%%yAwnq1xj*#<+L70aCZxk64 zd2TX^1qPXFin!JZZd}q@fs~czjYy^fY)TA=!)UZO%`3{(B{jf0Enml~tAg`vUnDaH zXF*(;wKEa0i&=wF>kvzZMlZ-WFt$g@g=8s(v?rC+=0;>EUGQM3@~1GN?8$vV1&uCM zW3AhUe#~7JAUy4zxsle=ty`bQ2Q%g{C#avYtgzJ>QXG9L;nT%lodM;L`aCq4++i+i zEeR4^oL!)?-J5SH&*=^fBka-Oo}-lA=9aJo@Bz!+Br%HV?_S#F3VF95-AjfLwt=`` zlUI+H@jM_nh{g}yppQ}t_-fdj8Is5Vy9^sK?m6^sas*LjTD-hE1BdDZg10?E1k?2A z$q60DIXlySjh+-xfSPkz+|8|?Z5z=$lX?@1X!!steR6zq&be2_!`G&LdezOm|#{svXD-#J16^#wV@dUR022vI~m7m z4vmSk(I9H6Q@RTl3ADZB!YBk*;@x}5#9LqMK|D{u!7w4MdX4}m4I zJG%X1{OitNpc;h&z`0dK8oR0@jZvXVqN?V2dP@0Y85j*ZEEbWGgokxmT$j1^=;&fg z>HG|aL^4$WXA8>|PoI&s^PCD1e{niP)>X4c*Hv!b-rPFRvt9zyV>OOz=3Jvc)jLNi z{;v!+X|QT2_&5Sh7G)b*91Qgw;Zsv%rC6F?)Qtp1bdS5aLio5|bU?+QMg%MNXt20_ z5%;KgHpP8u?cU`(K0f*sbP4NfJY1U)vM<C`=}NUV_;5e; z?FCZ#kc*=*Q&o0o+-TV(4Qb2w3W0Czg?6F9(s^Y!fd`#&NbUNnq5oan*7PFD`lD{D z<&_9_IN?9qVG9nd1H=1O7>g3&5~`YxnbvY94tS-zz&vmiDE6Eo=?+)upSwc}kgByA zPoSr0Mxl-w65t{E{UZceHJ5RVfgxhDZ-~}Gpwazhwm8#ILds>HP?q-cOmg|{gr7#M zO)=;>kp{3G1Ot$Y~d*}bGHLei#1g0nlE6d{~0kQd%yxT@kp<6_nq+nN zE=&@GTlelD@PtEpX0A}bnNsj5gFw?(VSkiDSbC0Mt3Xy$fX^Y6)#tn}b`%0nDz(g0 zRYiz!;u2AyTAMpXQ$4K=|Ht~pAB-6**Hj;>KyoU8z@=%erP^N<;a-_Rhp(9ZS0wEM zJj4mK(N@OR-bOGKPOu8N5FX0>Bl*|L_!C^-VLPp=;k(F`cEmF2m{hTZIg+mU@0NJe z&6;XB4)icZ_+{duApo|GoiNwI%vABu9y9fxrW@yj`0na`jG2%IFj=29OebHk@z%?Q*@lmikqJLdkc1NWGGMfM@Q z?ehV`W0|Mk1LP%WpWRJuat0>dt-U!XCZq8A+RJ?yR(7U{39#g1`Mdqm;p6~@3h^Tq zEe~Jw&S+5C6+ukNatX`*Jp_A`qbYejKFN`vu*W}c)l)TOE*?UNAZ2sCa-YCnLoeO2 zqqJ!hme)?gj|6Lc5lXm593{WUui!KUT3|_@9D}gldBjtdOUfS{yXVJSx`;3z5t|Mp zTkMc2SLnWTye}puW+lV33=#p2=U1Hu~4 zfr0TTRs#&Qb3DbP#UI}+x@$$&qfl41L&|NlyY%aL^5G*6J>t>t`6KNdRih8IcT#7& zN0q8-_SD$A8ajFZcj|kIE|yKvG6?Xw#Jd+Dj7wu|JuTTvn;OvUQ?GnN_IW2V=@mg9043Tcl|1~U3BkLjHl3Uod%sDcJ+r^M%9OW9I(zEN zZ!0XolTJTRK^=z7UZy!fih_^w_7y$Mtp?Q)vicv1@ow1TEarwufH3K zsg>TSy)&KjrG!@NYd*Js8?I*IhI9c;?_Mn3`s5!_DC=6TjroIy_cVZoQQi%D886pO zxy?>RlD6{O0TCB&T}(CD=8YEkB#IAil(}d?vOuh@bRy1E`&9OqY_R{bn@jDyvTiwo zW~GF@Bw#HquKX1|0^}BYQlX&ojd6LbPcT#zjO3WXy4pEy+EaWcSDL)c$OJWk`4kyn_1E3w zgY!dBdQRahh|jG1y?*(bs&)Lv{e5wM_?}FSPsTTk>PDJ#@XC(0#o45dp!EN!V*`Q`5#f=cU0Z=&pvf^z+L^)2`oCcPY~2xR7h56e3^cq|khZlRe4^RS2OU`LgX_%yo3wv~C@d{IEqWU;o7N zfLy^iK0=dKJ2gKY48{MH!l%)?CN0BX?o3unNMHGnnv7?3kz(>!Uhe7)Rv}lxVnW2k z2Tr-nef?z{27#^Oozb(#hTas;Ab2DbYoa;w(SbCWS$RcME2^xhIz&08bybz*K7uZZ z3RZMm{Jg5yBKUuuqS@6;$x8k5qS`>rZ(qE8^>p*q=2P(9JR+m?i&AO=`RL^1cig|}HQqVx z-pJPg+6*97g`~U~M)T&`|3gO&PO*(I*+U6%%YiEFSY&@B_n`UKeK)-_D(h@>6|@C7 zIEBUU-adP_`DO<&Mo)I0Y`uDdc>LG@{_W#en~TnlNP%lMIF93Y14^$9kvi1D&vz^D!e*EF_&a0QtAHM*oa*+|Ua`@E;_xE+D6PmI+3$5*h#O?FfjJZ3( zGB^ysRpV*c>cVba9PL^xpb%;}0xUP(WkPivW+&{^wNza5FDsuCms_X%+J^vc$Dcr} z>M>k$lhE;r@>A>s`&um0Q&Z6!j-Qk*RRijPxbS=e^h>M_`B+pH+D;`ZQ(#q0p}~Gz zW%Y*i#JV5zA1SX*Unz#sCK0g7fZ>|B2MIB4h2Z8U>ul$Xx`?G4JPK!B5 zqyFZW4vVh@_-XQg?XxK2yKmE8Iei6}3HMfR!w*>vgT=9_yc1$WlSyi_$^OFIBTc+b z83U5(rmS{aD@91siN6T+6W1>B2{%w_a$CRkF`Q)d;^NxNxYVi<)1i;X%PF6tfBZA; zlYT2My0o&It{2!n&XG?Ha1Ocig@4n_{o?-Dch~|1%SD<|56lIFgH(49 z&i5En0mJ7{X+7Z0E}&EJgX$F=GOTR=XbxA6#7Scw?+iId#8=CD8!0`+BX;R|#Cv#l zn52*Dm>5JKIOiTjZJCwK^7nw(c)yk$w&7=EiSGiB@VH%)(*%(L; z6MLVfj6OrL?RSY_#>b)%k#;XF=Vn7+GS*adAk?Qr{h3C<`%ki6FiZlub)eht%z*E| zDZLK;+if}haVgqR(l1tg`CLOfJzY%&aO2SdC8Z+x7n+R7$}%hAWGaT?@f`q{ZS;|7 zptR`r@|yGY_x<$q&wlK#CYeSr2&`s(mzAwdCuyaH(c(nQOLl$MUwMgQ-cyb;&K0-i z37{w8NuAuhLZ_|hyX{apbzRwHI{v_H;#w=XPkPCn(qHSHvSrhwQ&#ldcBs74Dc4%< zuHw!Q+mP$bU)_a+B{qH6*pEk+buW4c>a=(4x}o_^WwyBAS~cApEw@cs*lqdCPR|Bd zu^wif&GrJni+Y(8_bbn|?v6%W>$kkf&)GNOt`_+`mkt*xr!rq17uhdWsS#@r@5w!1 z-fp{(Je*E-d+{wa%d+@NyrmfwIMXwK!=mjl{OLBev;LNRz?=~ zv+ja*D)2i`0V&*L^ir@@jxPzkOTm4ai|WBizrKyy$Vxqhk_t0u>2so2Lbxk=kf zG=0{Oi(c-l`woKO=IXkT4gR4jLQGxvtB_9JW`EyIu>lv8S>1x!QJDY9_TZLK%qxX! z72z_IC;3d((RJ<1+9NUO+2$%9_+7j4`qL2VNPY0zan@bwVg}WOTtkyRB(cRWJaI=j z0B{ixTEP}uj_=;h+UcvW$CMun{Gq{^u|(J&MV zxx-^QF(`cmzNj8CgIj|&pSjVJ3v|WH{L|ps@v)>IXmpZ0sPkW92NAP|+lcR5MfCQ> zErst;O+~EjbME>7`k#R*EiC(^kiqql9)|DRZF*8<$C|yV5yTBMsiiSGaUektoxGiV zhl8X2CGXXdacFc&WgU&y`7!xg)o@aGM=$tV7wyk=<|J;aNL}8DFE)H$r zIJ3DPk?YamTVFP-8v&)7V+o|*=|vbtbW|54q5 z=)DJhX;BQceF=TpnuSq{^GzZvEO+`Dh?zpP$o`zR<^_Xk$J+BM zy?C*d2vv5V!9RpwlP?$&r0vMiwj~)4-6ij*}30iBgws+-m1#Vb_Z=5b^>X%Z1 zmLa!u`Rc+|aTz-Wph2VVM}Tl}oW9n4X=}5B6P+tGOaUifY`eCUl|5e;VX}I+=Z%)W z49g(9yO-aG+q+)cQL4C;Ge%Ol*OL1=B|xXdGMHb?RO6;M$7Me9e)Uq%V~+-_LMn=) zc;&W;$@kw^?rUFisgIXS`XKnZ7DY3@-LTrSHCX7FSP%`hHTpnkj@ zhnMkBoDfznN4IuZFLHSUdx>%0-1Wk=i2vO=IS2RAGD!0*r^d^Fy-!UoKz=hR>+F~k z-TU_9&EJ1mksFCMvJ;pTRSgwLa8m4SzzK-4Ki!qO-NHl*+s8>((KfwmJ^!zS%C4!? z3>D~+fRoWWdr#Xc3tb{8PDlH2K^dJ=%JsTNP54AMh6?QpJ&Ela;7|L=NiV?DL9+5$ z#7NqvsFhHsCyL*-{cT+}95IB#WfzYiQ&h`&UZrZp(!cp?Jd| z4L-mDo>m?mhnY}(4RS2S-uc~vIWm{LI`IGfzmg$O0xXIZzIBDA3LO$~XgpFyJar4t!C=Azg%W~hPv#L$Jt9G3XH zA@Ks;74%ZzUPYl>ena~Y4_wu>-S@$E9iUFA!BMLF^GsJdw+=ir3=+L7d#tQ;fB8e= zJFf|wwY`ogyJ7A6hu^DvDlHmnLr$|0!XEY2Cf;(*>0bNW?5)WVP9mort+8o;lMuJqX%iWjgbG1Ql6l7MM>5B9f#X?Bb;lXh=D;kuMzHs0y`e1r~I9!ue zS6y$s%6yMB&s9z+4?G|l7bIb>6c(i^IFTXv=cp< zCyk)aLr0vbVh4*a4sZb^@O*VlL+QiOr)&U2?0vEKzUSQ6Mn7{SuR^G9sF9=R#W1IxSAQCW4A5lR#=Tz7bzj{DkDZiM42&5O2|pVsP-{WfCGrK%_K?er17(TvY86HrNsYP z=Qzuj^qCB*vStIZXF93rcuW@_FV%??aS|J1ldCr`)n$YQ#x#J1fRkOautAgVqVg%Y zBtzUtiId>A{H&oWED+L_F#Of;<+d9bXs$QsWDN7V(lRG?tU~Lg`q{fYC6r!{%bVc&98t^1U%Yf(g z9*nv9I0jg}s$yVg0ti}ALFs{;W8y2geut$<+<{goZ^+3kjo3?S(sm?ZOdxEA5`-A! zoJY;fZA}bARWmfbRq6ywZbl|GLN#61y4+iHKe>5q3AMfw?tV#n&LAj=ZU(P4so2Cw z_AcfF;x{KcslNFMda9OLh+KvVe?wSp_*Y8Xsol6q!i2w2cQRgvs?lMryaFq=i#OC|-g&=!n)Om~a)LQ#q~$1f#jL@sh7E^ho^!)Zxj&>OHJhFU znBY8#>lLS?AH-i>ecFbIsFqJ)BXXb9o^?1liG1ze3MU9pa)&9cg*?$@o>NzlUAM2J!#5mw$!Zi%s4Y_LacA{}3BW4bjTk$&ms`Vzb4OFSwT` z7xWgel|!MlAbDKS9LQA=T%;bk$hP2=Q_#D3<&VY08?XSv3gP7#8W7Y7pCr1;Cf;)5 z7C6%zurg{!!eYfq;o>(3%4HkkawfIn&MbEvzF6{A06yvGc%LCO^bLp$rnL_5T`!!* zz7;m=O80$UDw5^%zKTvH9a?cauT-4*t=f%!QyCzef63zQD*q>N+8IR?egx(eC&x0@ znf{^da)wDq`sFedkp8N${3R!8z0?3V%e>UUc1f`}oj;Pq*wL`}qvw8?z zZmn1YL*3|>25a^UngEj6*V1C^h zq)6VA!Eg^4XNZTg7pIO}=;BVR+-fz2%*qr=v7pL;E+7SqS^VjY_vApQeBnboy&Sqw zwT{CV|7aWYW>{ z=cp&9`V)_|186aUXvC{4h1sn42cEoTm-Ku9<{M7O7*|1OWy1eOCn-@k$Aad5@5e2Cep+ zG+P;Vtn9gTvdYF=;6~3jdIghCS7P#OGmiz3S=pP}8^kPQFw;g0Bw_SX&fNe7AUs3+`7||J3^a-=u&@x%VVTevkYy*_iC%S*kwwKu@`ggaKMO)Mul9(6!txFWu zpClk*1ZEkBlL=w%w#>!~Dm5kLb|hoA0Q?6z%m#_icDl2Fad_{)D&>suwr1UnpOT%# zKfwN!9Q_jTu!7=!ngO!mG|)E9!5mBE<`4W0@GF@-C5>oWDZ*nNywH z7P&4lbda3_cJKl0Vzn!t+xAqMxJQnE+)7s=y#*WGSoHqQr@_JB4{oLbf4$5dIHCK0W><7s6$H zgtX$kqGA0hFL_d7T2;^|glkq)U82!?OadNiHZYe(Ga6jNdQX%T=@oSgj-(WJ4wrx> z*whvQiK+ymVbE;rP)f%FtEfh+NC(_IcsKW62Q!pL-nm1PSyufz3bm2B+0hrryisGT zO;4b?T{pbbh9t@S4HZ00l4q@l(70V zfuoL3;Zga|-w|^$z8_8!`MCH5;Xxlx$?u{5nFI}mLdEwAD;P`U6}Y1{7W%?dk%-4m zN_`JRajy}50TbSzs5nwaN1um~iOKKG%^A1FkICyXP6^q$J*oNcjEy?-xvF)L_KHN^F6JQW_dK2 zycy<4gPO!i|Ewn67GgomVi$q=LT16`D;;c}~CR~Je?ZB0eKe7h&2&wf&}UcRApSh1^vai;OCx9tqY{jA^y z?Ij$9a1&Nnw7t{&7(fEPnFy*Du1Fq%UYn)L)NA)DpU(2J8W0@g07<_$3&k4}*)G-irb zDgq7&aBslvIzzx^HCb%X?UKhs9W5+a%jmZH9#&g!jB{={fEZGPftHim(7HO7ILs{1jkP}7O&aQ;i% zus>6h%ml_s=-mtDN7pGxJTpo?@XmkBAz^7DL}DGdVUys2cgHvhxp=RZE%)S+z0dE8zQk#%hW>6YZz^#yE}BF+Lp8xhaTa0e+QoNbN);m_!o z@KN|~&41^4O}@NiU*27K=S++A<$Bp-qG#HA;l~!|XM1}AAFWYO^fxqtpXeK!pqTYp zQ;~m``+!3GOLIRw)Qnb>qa*B)>Ev54mUxz^<-&L>K?ppFe%|oyJDOFpDc5~aH-qh@ zE7rRinRZQy@5EM${Nm&%U<3Cq7qn|-hLkM5oh>Cc2Cc9cnyg!77UDblnr3BNCtVo_ z#ID&>{T*-KC8@)-VP{LP+x7GGWZaiSipC%lhPtJEKus4EaV?QsD;7<8dA*&*eu44|<&t%zS4)tL@AD^=-nFn@3ji#yD%%wp^nc6}M<~p1h zw5~{!492r;M7pFY6(@z{<2;Pt-Mk5?g|&_$haHxO!rN@!%C~|bE!LJ_#M&{3-%mct zc-@h)(i|=YQ>;GDg(G*!mNV5hW@JEe&SuMITd+AwB>MRyGQ%`ut2kB~oA zA`9sUG6a=}1+6WriwmX$lg?0M>%E$a!F}{uv$gfrK=O7%zx@S1vfXiwiQz$flG^o+ zOvu930@EQbRN~V0sYakbSYO}Wzq`M(a(Dc2G~Qpc_;d1za~ z-&?wS2SMo~n(rbsa)n~dVM&s}ZoAPQ??VA(pn1wM`s;1U9Z_YoU>L=FnK)!;6IV~HE# zL9g(j<;&2L-n-H)>VdlZ?a^Ue8(QlmA6n~D8 z=*ey}*ib$lDJzEp8pU%dq&*UW`+EjUN#E$zrugDJxC#W`=6;6UfmFn=_}d>Ny^$-AQF;8(6^c;cIN}1GPwA1si72r=*T?v>nR*Zsd(&W z)_|pdm(P`Yi0eMJipPhe2{Ix?6;R;k3B-l?xZyC7=8sRE{DN|+^!*E(CHH&+z?jKy z?G4@UPU-Ngz1#-_OQQX<2mEe-bT~QqoOPtk`5?bMC-RZu-!A?mch2K#vWq}|*%5DB z$TXM4RNtm(4vvm{Iy7Y9ILFV6?Y~eB>U4!%rD*gsN-` zdn@id-UFeLNpiCJ)iDkQ+NN4}ZCZ$=5rBGBwYqx8 zmU(r_@5fLYn7TGBTE*octuR12wzDnp9WC9B3%5uv6+3z3A~DDjUvFFl=Eej1eB;7c zlUSFii5HQnH!jGV2imy<=m%s<0i0peK_!*A`m2x(ARmz)q>?!SN9u7NT>SKJ_Npij z!c@0gs3@i%@KLB>B}fX63IsG~zo%*Wt+w)Bw7PFr4S!Dd=af|BZh-^U5=>~0Rf@5$U7M4U;#|_7} zeAd{zeU0MKFgy}^hI29kQr@)4$9bEwM(QrrzYD#3vl!o6V#gVY6`6bIB}@^4QsK&e zoLLJ?A zDV)xHKx_ktfWo$5MS+Y#1}U6c;@~fLdQ#3Pw%-8GGi2*ij7ouTsu-UbL-q_8Am%|8 zY&ayC#Y(Gh&GOyT8OTkMghxxhj{1vgagS8GtCbP=-7hcJPss}sYL^aEcKiz2;cV~N!u+_jbv1k&{H9)bI z88GUDel#!H+;z~!95)*_%noujmUhE^r(@ESiW>7+%hmatOh7fdju&eqiHw95gm7eq zD^Az0+Qj42ZV$c=TKU%MYo$gNfrd7!goT{Y#LIxXO~zB<_dVVTjh3;vnBg=}e-J+S z21qCaJQ%MUb@qSKQCYlwmF`+5+e}){nC-*hnhpfVXTQStNtgH@E{}R~6rlIka!y8* zLMb#h$L1yz`e3);v=8Aa)1>0Ba1HCBGKap_8N*LHP#wSLG-Y*mN6>>bXSjHfCQOm` zusor&5jb#y_%Zc3XBhVinIU{F^#)|rNoxrdLOSBcUQ^NX4}Fz(&15VgRzJ)mKUZn5 z73wuQ^KesCrex7&U&QT=N^V@xzsXk4f!_JP)UHL!Zd}Mu)Rb)k)%~npT`0bB5q{0P z=d>NIYC@5Xi|}i;8dHXJRwRYAD)}R+EdEwIMhI_ff~D`(XcuHsR}bx^WyTsk)-4@?vyT*^@4&cKbxcMK))q_w?Z9!uZ&{_7!FF4z zt-TGWUdZGV!gSw&f37YI3eVJEoqOy}h)Lt!b5mM|9%D{_rnR>~diCj}+iZqh5v?iN z0O@aT_x9fHMCo&Tyf=X{HNC9t06HDuF_d9v;SYlaA!?ilohDwQnvHvZLAGl_kjM2X zY<8EVQzWws6agy>;SA$_H_L0gzi?4>;gVefOlO;PeT8*U+=ENFh#eut)&Ll_i>p6>JdLcRSDnYQo^FZSnvenbq=v`S?%4s1oLAK5x~r6TmuYEp zYb;e&=~BUs#fw0bOYD;R4IX9)yVv%)#BRofuAU68#gVmPM2^bjR6I9vD534804J9mSH&T>pttY2?}t4D;NeuKMSN6+3`M zrsiYyrNeO>29zM(2&Ef&(O^tCcdI+cRA96 zif9#==<~x90*4~3Ec$2k%Q5+3QfuYcbeFA}0+bkg_2sO=$Gj0>{768c)6p2?#~=PCTZ@2nC!_i&7jCvLojpdaQ}f8r^GeU`J3U);KE!=z0{2=Io~O1zFBbfl3y(yaCIMoKq`2d$ zJyH3ZMJeo1?CcueX%!0=zFw*H6oHjlt@H>BV6<1MmEVPGZ``7lTNUhi{K`kZFDAYF zioQVH7Z>#@;#qdP#eGG(Dx~_4V}eb|Ud)o+$sv>){=e*f*;-szmgc>lA}Z4Lfl?v_ zLY8d4RCQs2Z7MZ2pme#FZWKUZMG6rl0hZOIZuj4$MN54oTA)nvwBm~kbT8u_L5^k$fp02B&&>io@g{G! z=_#_8|7mmvH&Z)Eb~F|ZGUYD>i#Uu^!JeO`$|Z~D-@qlXH@i8_~MhTC!5S*;l1}gBG@nDy2B-UaW;-Q z6n{`JfWam07@{eFI+RX2beZtEmO8egxui?8?&Ew*c~5VF=FmtzN;V{N zjVzZkl~qN4DRj+CBW{TByQ6mr(TDul1SNj^QMA0wXTv^|vD(=xO)$DVzMLVGUs$sg zrUHzcNPJJW@k?J}VR&&qS%9FeuW|TFU*#Zpe=OEL-z^_5{jfg;GcP<8a{-#5+M#}5 zzec>?*YyQ+DR6qK7y!#uQ->$!SyxVVJlI08_1BfOBXD1(?OB!4C zImARoRB?J+$I18#P~ysT`ZOK*c+h+-i{q)tmR75&r4dM7ghf%cG)sI;XE%hX3cB}% z@Abdk+TPjSd%phN<{rWS(pQ^*+I-62Tp{8{XWEHFuJ6|dov@{K#v1?E35V%Cka+nu zWWo}O-luejeEMOsQqW}^(|@lx3j59%0)gq=$&JjX#|0p3+;M(#H2fIDFEj><^3@q_ zmgxm^JK%El1UN3d*ziENd;DXZfGxRy=!LKg=}|HB74>gg!^b+ zAn3DksHKnA*knE%HF+6U6Pe2naY`hT*jT`P`ZN%T5fQ>95A*qF>dot3@VS?CTUX&L*z}d!wepUyjkgeqYZMZsv^gzrPLUH;{DP=^!PcI=+vwcx5UW#27}a7+`p*_Z<+GlDVM`)JC#4 zsuq$paWY{y-$&b9yIYUeAJcq$yX)UP-t0YlzPY`=yY=j;%dolr_=oi$cL;6?G$%Nx zZ#GeSQ{juk=p2esx(-Y#O||-XGLhVR{qqSzVD==a0I>sLDJZ`8;+&qHCE7}wBNOfu z(99yY0+m4LlFK;2l19l}G5Qy~&tL5JcD{f1L&y*+eL2r9>*wal<$jE-xR{40LqJyn z5?5#DmOfi_m%TZ3kSt+T&_%Hr6C*9vVMq*14G`m{&__DgXWM`L_VKeHbb34Iqf;bJ zBg3!f8PDsyWHbqyNt7aPkPxKEFE$UmIE8oL7L}Sfq7Nc>0I&NOC zEbF-9ZW-g^@A8c6Zzdy~Nw?j?$Nk&Rv!@O;RyqFi4Z|y&AB+I=Rb}mp_ivHEb2GvB z`d1Wiiz_0cl)snf_S>gdwyfZav1egfNL8g{>wM@My4-CC&Yen~_5Gnhm}P8$*m5iSl94}NMAwdMi&T@HBn zbBQ>K@J~fe{9O^Mg=o0u8pT4<=XA3a;Lv>q`*pFNg+}+a!j}o=Qbdo~CD#p(7hH4> z1!aewGp9(#+E6gOYeT+itLOZ4qpJSP*4mQGo`e8ufX;$8k|p`i0@y3_4I8|5&a6TP z+U+mn(K|Z1Q$-yL?3{q~2tKPeAlqNkh$}X${^ECcwPClX9M}@WJir3=F`NO+6KTwJJ{3Xhm^4D+UX~eF- zx;7?U8{X1WbSSRE**7R|L@p#Z#~i;NCZPg={TZHLE}AEjb$W}n-jB~-^q#!f+3n#I zoT*B}QjeVmdb`hh-))MPVC(6|=D+`wYwXGT(~b4rXWKt|(VE~{o>57tgLZZ|pYNbX zk7HRdt61!|+L3ba>>wJ;;v`^`C>9F^B8t0&Ql+Ng)W&kPNm1iub|mPV4ypoZDTyGTDHJq+ZKeiet%bsMv5rn)XL_LOy27>SZirs}Har^t z=c=O21Y{cBdkwfW`iHBULez-YE)N6*NZYyRs(k3r@^Kt8Efp3{(`T?2xrD15jADCvR}V;gIL z-NEMJEWdm^c(NM=CcY6G@Ikzmmhw9=x{m9$-xsltzhub(PQfDrVhE6BQjaw>9(mm# z(FnVq;djJu+8grz-nMOf$rs2DV*mU4qg{h_GwTIno(%s&`<&T}{EJx~R2l44U{YnPIoLVKcb~ zm#Y8~7ykV8!{GIB&eL$sCF}ncM`6vtMRgS9!D!$408CA@LmplpGf zx!GWUNY|1o&fNzE8??j<$?Tw5E>91KX@B&d-}2f-U9wioe(7G&X)`JN?ya;fjYq!j8XDTt!*e#Gnsa zuqmI!d`|>89C|EoV=O1Q!D;V5|HG%$h=d0kKDcSmhaOBOXK>A6fq{onunf5M3i@>a3~?+$w3h|`TbJ!l}U3isNR3?NeKue#pN;S+W#ZDcy4pk~Gb zA=Ga=l+wh#0nvm!4seYz6c1H>1TK$q&4g|Rz?A}wnfCdK`n&s6L|5exugA_sg~3i} zc8HDln@+jR6$bTYZHay}IzJe+$zzC>7Ou^CTBvLYFY9+<3y4=0A`NJ7dfzDFY;|Y5 zv)m%M6`z=LTu-0@AalZp&(7z9>^_d@y6!Ee6>mV&oZ@0L%?%={Tcg}{%7j{ygokGk zR06Q%Ycg4BSW!Tc12BXiyGA%zQ_8(G4Tnro*3gh9cJv-Tir1dlnqLVwgd|M@)G1*0r8rlpf{^@Tp;y%5zUUqm%URMrqK0R9u zjkzD#6QJCLl<;1R>d+vrJy}D(SkbM)1S;4C^@*YUxxGS+3z4D%-gE z;-%=70F51_=$$Bw&tjuY3iF&>Oe-@w8~rA&f~iP|Vuy5+hIg|H=f zAut5_k=!(g`-HXo9CPS+hcW`yE-CaPeT& z%XT^^N6YsQkM{SwoRfy0%&}QrCUkJ!XiS>?#P|cFJGOLQ($Fw-InmKEME>#R=NK-P=Hji$=*n}(Nse-v_9+mNCzZ)hU0jbY&fE3 zof)4T9AD73@p_7|r&~6fs~|@~gcfgZ!^Wr7H>{o#V~D8{x=@!hlqQ8S>+l7~02|3y z@KkY({!!1pY_O3WA9?+xX&$razFN z4P~0m8x)O(#yoIkNt))r($0gvv_m8xlcAyv2&&Oi6!=Suf__P5=-@lsFKbKB>A(ZQ zYjRA$vnY@htx;Q}$L9+kFD`;ysjv@ENJGgtBAH|o+nTTrX$-9fn5pke*EOEQAU|8c zc@1&aQV@4#CcC-aReMKzCh}clh*tcx8_eWT{RG76j!DclAIx> zkusqeY;ME`2_x+F!EKm)!e?!|ClVz@Kg4HV49(6M$L+dTrVyr_57p+$)@t<(a)xx`>*Mcytdyv5 zX$0!J5qfVVmea_{0rz)l$!#~8dVGobSw}`k_iZBqe%E_0uSFbF?^E^#V`xbi5#||5 z-iG)^RfG;Ks^dpnu3>|gT?HC%6x6J8eW`^EXbG*9H`&XzFO6ULmqnbzg1#Vm)OO9wUuRZ$^9W!z_%B!YU4 zF@J)4>|4<+QicrO+v)9Sat9`pjnCUrU-+SL>x;jFsUHql`qDRE5Y^Xy<|n;WNV1yK z7xjnTUyEjG!VM|W^_rZ#<1_p5-^;cey7p=oprT_0w&a?(g<@?g;u0pvX=}BpW}nyJ z9+QhEkD+XJf--^%x+^CqtsrUh^oL`-s~F40h^+$lt%s+=trbe9Y22b3FM!FZOQJeC zwLykWb@FFf$n%YTjRU}In$xBn>`}elT$atrDJ@o-+rK=*C?E3v1VWFpvu<$WWGI`u z%s%*Jy-s4#Hv*VZavn=@E7I>1$6Wi3x`+VIK@9U%P81||gVA3Dx)u7XbW}ndz~@TC z<NBPq)*BCr|ewurc3` zMK%tY9pu?f2QUW&@9_4`F#`3u3mSp?b6E6m_NrOkIABk}v6zr1!D*Jj&JNrP@r}(*04iDaHIFs9yBhoZ{?bS9~Wa{eWMV z+S`bu>aTP}(b{ZhnHVQmp|_}%P+BIZFEy$T1f~z%QhMOhOw*cS-ocb(v-(!jT-6rl zD;c{@&DJ*dBD6hqn*^8ASH>vWG7m-N=XvlXQY&ZD+zgY863u0dhT`~u>PmPa{r zzOV3f<9JGTih-xq=q#I!(B$LLN_R`=T~uhixR*jGEIr%)1;t%COt6RIjR3zAJ6$qG z+5sj(l<7)4XS<|GUtN-TG2E}^wJC9hyo-5Nd2%-+y!A4}CefJ;%Z2^O)qeVLdd|St z{7KJ)8?u}xU$n|m8~O(r^h+W(iAvmSkypwWJ-s({@n^U;3Q+4hG(fF)zZ9ZOq+MV3 z2qQ0h+Fhmu`rErub$jV~n%);HYCwI?uD}Q6Nme_~w*a_iM$m0$0fFrbGq6U`_A9)E z6#du%sZC}(YwoTZImrYLH}v> zsIy6PQqw}57u1@(eBJPxtf9IiP)ujAn9hJP6||-(=B%hPa25-35h&{&bR&^ep9Bb1 zrF#miq2uQaCRSq&)^2o0mjOou&87iQ##PXUXZn(^`(j6jDzV{pp+2(6#^Wi2%$``Y zZ1oaWO6@V$Gb@41RxIQ^A{8G(0yN}cwP?x;mMVLJnJ#Ju=g7ho8v!6Ut!8s1A46i> z#c#e=bE&slwxr!+Tq05eBh|3B^#P7y@O|}dAx{;HaiOz+L4<{q->MW<`fE?=Nx*B2 z&F^k><1a!dVTS9Gq8^N!eQ${HCyJ4E?@Yx5smqQe#hNI!M8vH;=w6^?F$;da1-iop&`jLy@e#eo=Dyl0EeOBT-4Vaq)k6bpSi+Affk4S&= zu~xvLh;nr&sR#KylF2if+X>lSiL-us3j4g#Ucflcgqmr6V-a%3lcaIo?Mcxphsw!> zbDpY#OgPH~W*luS1kO3+SP1Mk+8|`5l1;abXym@GQ|3rE_4EuU5IpGQ;%82fkoWX7JrNoIYt(xhuLG9Z(?cX-!@XU;C5DncsP?T3`OQ zke92v@jugpI*+4zA!uX{H+7+mAxu}7^%y-c@*GZG)*Z3~V?>Zt8lf)e+GtBYz#b{0 zI4OW$K`fkG*L7hAkg4rJ6@wrJC_=;xgd$E&k4iz9J zNsbeeoxsE!o>VlhRv}Mc%@`~hAUNgQ8RY{i9wPY-*_imZfZ~Y|fOrs^HNuuIYjaa1 z&>8^fELQZm-_|gOBQm8YM~X91*n{QcK{PquFss38#h5RnEEP>Om)l=2Ym<8<$;Xz^ z*2z(>fAk{++opI;%NFY}#+}ZoX)-g6 znRfHtk~4t~&YMkqV1?n!m^>#3{6xB9qnWD%h z3;U&^pio!ICSS4Fo^LoZ-p6Y_K~lLBSz`dY&@ChBZhXbUa_IHwe#j#hJx>e;Cj5js z|1~kgZ^plJKK$PyXC)W#`X?C%XH3U!MUk;tuiS88f4Obui&$i!KS&de_LC}s1E(6$ z`J7M%`nOy@W#Vi7&@>*8ejXmcJ}m~i2*)d5IYY06f_%dB4Z|^(@hNbNjGgEzC)?_Z zXGJ%>Ha<9>yrY!fcIS@|-M1A-#d$Y1v|eBnu(fqlYdg$9F#kLJ1x!t}J;6{N;gZ@% zmY?V%WUYl}+M$dS2XEdD&jf`O2V7F`T-){1dA>zkczA*;97&BMzk+J2foXw;+x3ug zB&2Yv@fqGC8Ke!-E?on|(ZLzX&&%mJ?fve+F>{>N;-sz8N0Lv%crhdU0XfD9JYav- zv9JfX{n6PwxZzajnC4HDq?JstBvD&*H7M_r)^5-^)O8}BWt2nbq2>g$RF*jtD7h@| zvonfcH&S}|p4(VVN0`qc)aW#NJ8~e#u+BSdLDq%al%C$G<j_ z)dUOWN#zQ}ZnF9noH1u|$ehV3v*MUpPyiaW)JMtL;O+s(&iFT|i&;zN?g7E z>6rSmTBboNvc0|Z?GmV`OB5s^vYzv&O=LQ4oQf`X(^TBv#8mXpY$}nCs9`F$x0k-% zmZ_LdyJXENJzfEH+Y+AmvCis9UAJESmkZpK zV7pVf>=`I7o^#;{B5EoFw%%VZojG(x5NM(cjLrW})^pQwa!W_~h6mLsvy99qlK1e^mJKaNVI^A@MRBdfuPHr)!;IH&oygW>3b{vtykW|`F;P2#-csWh2Z zib03d;z+Ghl>BI9*+%8j5g<<$n>cHJX+_(Qgv5r{GX-MGs%R8s#I0PrkJ86+M$*jY zLzas^saELvjZrl(9(VNa0UF&Zf<@hd9^5f!Roj=W}Md3d@9lQKtcEVL{fBs#pu<`9R z`yPE}zR*3a3v5st5Tk|BCAwi@ERiK`+0`QVO8`EdkVcuSMJ1;99 zY0~<0NbPx+{;zMENvmMwU9@BzQs!`}_rVQKZt2LP5Qcs}ubTZ^e8&3tg2isMu za!6)ccQsP?wanGX!qCney-`8ND8_Hp%XW(3*k4{AetpmhfuW%%8^dvLCB$$Dp3Gbb z3}Tx%AqDJ;F*!Sc$sFl*hwsco>t^3sYprbP>2&+`PD6tPR@2sKHUwF4poaDZXZNVh{CVZu+J2dV=i zC|zW%Hwrj{9wIXU6UJzoKJqHKl)#we|cp@rIT+(O+%WiU%CmMZ7XV%lqmyhtJu^jH|s zVW2AKOI{5AU_f9(rgaFoy+vZWgM>pq&!uZhEl_sgq{!^B46AfW$t7=~n%h}|1k7H1 z+@R!5=pR3q0%+fm#7P5H+{V5gU5hhFi!HbuoazBWIc?Y?Y^G#@AnovRu|x{dzn0OV z=+O~j!_pbH3r5L(R%TF&}5Pk~EIZt?6(S_A&l@M=lsP`tZN;|{e!xSHveo5Z{EN$N{j2Wcro$*4_Zduw;=(fZ>xh-uXupt!Q-7R%AV{;&x& z1fRBwsz2ks{Oae_j+rq@s3fJy)MEBi1D94OTWhp&buYsmB4L}FcBN;0_+XXs;VXl# z*nX0p5VJzT3lX#9Ih@%$EmNy2@JfLN6Q~$RMO6!y$J7kFQuHZFSEubgC~wgja`eim zQ$3kZA^SR<|F{`g7?0oY(f;;YHRG<^8c5^c4G+f>q$oK${HIG~#s|qHyK72i*(EHc zcJ0?PNXXTrF@sj%9{w_c-lF(26{OBTUTiqQBvGIigv2YxKiQEnV~^OOEuT>IsH-2Wu_itBj$Q zPVp>c=1q;P?$P*OYG;YE!M|KS2#2aEzDE38Gc=piy~TCvL<#pVOqS^=n2(t04xWWR zK8l3S{BO{3-xS=_7UR+$?~<%VDdwdV|KjiLdhvp4jlaGw--F-vBt2ueK|l{tTR87 zDnmW5ojNevl##@`JOowEHQ9#CrWPg)Z!uS1dbiiv;_?z_NGV!tGNZB@w~JxxDK(90 zn19NHirG;7>FmR~lyN=Mfg!i!jMY%m!GBI{1FGXTEqw^-f(F3{9ic_WR84T;@<%7T zU1Wh%3B>f+VubT%>tHr7HLrHz(3b+0IB+MIpCs_{9o;HPmB zMW#MP4?8f8?S$Q;(oi?L%VLokIh9inQ7v4#y)5%kz+pklUO~j z-7;}KR){7=A4gL@h8YWR-Z9 zM`c3QVp}RvEpX{jy5kqjhD7HQ_1K4r&CIGqz*h%nlc@?vlrzWsJ(yL&+TF|Q3t)hVwI2{Qit0XuabnRB*7^;fnvUrpA@sF&#)&YCkAm! zQyk<|@e?;cFXjlMm4Y5NF;<7gBi^C9cAzo_A``6*)*9?HlFfI(DHOix?eJHYm+%1K zxMvv?LZRRr3m~YZlzAOpmQ?bxUHG3bhfDu!ed&KJFMYkV_tUK}Qq*FAdi^Q@|KOf} zK7Xx)pm7jCvGU{_(Ul*M-;R@KDIm}7Ilb~CaIfwyF9YvA$(rl2;H9BNR9|3fCOM@z zMKZnJ-2o%gxkql^1$!7JD5@v~;oFEh3ZdI4-wO@9f{M`VBtk_Jhnqh(-Za0N_6{A@QNo$S=lI!e< z8bkW+Xb*-bR2R|w?44GPS@vNmF5E2+%b>+*Q6Jb|T?PU8uyBmi?cUF5{E{|LBX$6wj|5W$}jSE!y>^bmT*FIPuxa?=9a#LFBYKpma`g;0*~>w)@^6aPxGh zB?&h}0W*S23q7&e3z#LZWgFzJXn*p@q!c(Lr=dZ&G1I|#gqXOq(Q8s_AuFi4=tJBY zYbhGe2Xjn5(2k!5&jmOoXwEbd3**C)EyvU804Da0;n~@cF{_<{l76%xj0s{-_ajUX+eMO-|?*f(*FqL>qSKPPyH-7o4cw+KnY$IUk zrfxgNX1w7PQ$>4*jSedu;g2yNlevF>v-P;Ks zRDK@Sg7I?9^~S8lt19pc^bQ>-dXy|J16Wt?%#oQYO+iHZ?lKw=@-yI$qmEpek49)# z=&>I&bsT4(IeEXR?$$TiO}86mB`{`YvPM0%kdjH3ovuwk4Fz11NEIk=D4H}3tM>K? zL4-*SV# z!#oEhGF^~W4{4~+q>=KrNDP=PX~iMwe4hUEKcc`ud7By-UXs#-BOB+eM!wprg-~4T zeR^tQd?{ls<7b#7$f)w|@rYG(dM=;9Zti;hjWx6qw zKrAgcq*wTkJA^PU7jOaI^BT_-#x(egt!S*uE*k z5x&P2Mm!DWIMgwS10*zYi*7Uv@&|usMz-MpUbvY)t_*CX1f@HYiAv&Za!czNXsBI0 z#4c&D9}0wYb6upX^A4BCXoDQZHQKcxo_1dC80{0k7Is?s%#%5q6~uY$GR@GBmkbGr z>U;#Xww;wPSDsnHd1!p1BOxAzdr%d4&da1q+B8-~=DFya?EL|$Ed03f!SyiTg!f11 z??wPByL9bCG{g3}bgg0&@a?AL@C|fZSvf)Z+Ui|6^Xf{yD!WmfuJ+ZW!Lz5m;|hCh zMPGA3?Vff>U>FUnuFjlY;l@Tt)wS8&3(WUwTp#5;X7}9wd~dc7;`54V$+pn(!Hyh+ zt2!?~UN)EcnbJ-|sn8r-R;)lZ)Py7dyM0OOZURkwb%hnM^W5Nv);%{TXq{rM}UC8kcY5 z%3D`1z&gL&E$F0$qxI@?(ZQF?UxllU4~E3BbY^IyDelk*wwLE&8fLE{k4d0fNHI@K(X zd@uV^7(#RrePa_b6a;Q%Qv-oC-;s;ZA@gj-V^0X@Hh`{p>j=34unNl>g20W?=PQpu z@{NVf*KH{C+WqFR{mp0dD-PYw{R-y4?j}I`YsP7`SpSfOyKs)#y)Vtd;2a5)6Y9V3 zbVhyY_Dx2vPsM{7dJN~WZJEO8yF5q6X|l9fbG=8l&6Y$l_CX2*U%2#t&-WB{W#nQX z);1>Y`cTDYX60ght^fxxyOk`Sgk$C$MDJn=Ml4yw(o}cc`xy`P6EbTRsIbP38v7bw z3on*5*}{tzVabZyT)3Kr7<;A+P6bL7V6VE+qvXUv2C zN>d2GP^EhKLLFF>tQ_;I)Lp6z?luoFX+7ZSxTvjgVGsl=BOZ@GwW{2VtI?3_5oe}C ziY`WxoC~$lGF==)O|Nyw(9q}%R!t5d_BCT`Fgyh(r?IDy>7!LEH#sE`iGxo1xPZuX zIli@GOb=m8z?Np`I)g$^@ax5rK-2=#d4#YO zE4FF7bMfhvOTo-(dhr&a@$b6@{)G>e-vQ*7%CzQmpj#sX8#2u%G@?<+{mJJ5)sq{B zJE#nc`#r&Ts3E>a0>b!4!G?P^Fwh z$j6sSr~$Q$wEIBT6JqKs zKWSY4z2U*i;r-RY;K9LgfArvii`xHY%tx5I!fjCRfG&!9C*Y=zju40+4|e)`G&-N& zk$?6mMt-VE2FkzaP`@7D8y&9R9}K=m-2ZTR&kdC_2~=BknFT7pQZj-2w;%Lw-M{@6 zQVA&7T#_!}$)o2S$uG-DYwB_UaN%%A-Ny8> z{ePJfqug5Fr366VH-rNlUYx&nIZXBrM+Xy}Ynp&d8NjUNp01q(GbDkE(l3)r+0Y?Y+&XyAm}m#U-nv z>(6GWL;-$>_!V!gm9HLjCt{J1NUlii6!zwbe?(}UZaMjRcz6i)sEHKA{`2~+ zi14n+*un^62*6+4y{PM;;>+BzJLw3kx1PW}O}qLuELatk0(1(y{+X@Q+|V_TP4% zJ#~>JIBU<}1Gkbwo~DMj4TH3brDR_^;(w1Y9JVs!8p6tvj7h+SZdU#6^&doE8SP(? ze`9nsM1(GnKK{}4YsW+`;p7zE+d5LFlz$cUO<8~9`>}b5G(-?(^-wqNj!y_(i+nJ? zzJL2Z4#w9HZr@$)dNP8PiId6007f>fFm||{KeEpY)0bMBj)!ttA{)=kLi0btCjZOi z4K`&!X$G6(yMrQaQ*N_`%j{%+0PnFAP7j%7qVtBz+e9V`n(>E-lKLnYjUZo+q6gpR*t6!;Tyv3aAjc0c$3>Iqfb5eQ_tM`4xgcAR0RSZz^5`3kcEPqCitxk0cSgiTL$mgFiL?yo zcRbs@1h)ImBl>Ree)txmO_x%gvUVLF96~_&s+hH_OCKg_*UHzNvuk)d{uAyozHWcH zd*ha{fKdZCZW-rxct!QTM^CI-k$s?C&=0N-GwM6wV?k-4p+~7Ova&3iImIO!Ti?QV zp|I~E2ds%9k5B2P*?)1jUbj2K|-6-N7<(L7* z!0DVpr4RlWIuLr1Zrob3(_0y=e9hl6saEMQ@kbryqvOfNA?1<#3k<$8{%7luZhI(4 zs7!}Y!XOhH+FgRrKk1VW^9e#S=Z-NL_+i4upY%Gs5Mj00dy{?=gn3>vf#V{U7g{(Z z=a+EBSJ8%fg|lqki8$ucVtYZ&=9a#;Lkbbf{sQ;9gUdwpzk$mv^S`3(B7Z< ztS(XeFoHu2fL9K8!95HPj>nRDv`@V+$;YMf;Q*JGD@p9Enkwk@_Pe#Y!@tDsN@?7F zAb?UB;Ymm1pD&!5j}8I_zLAk4;4Ux&rda-3oW8bPE+v?KJsKltg@_aWtwoc6BR)l= zF#iU5BLqUxCn^3n6vN4P|JlFoCo0x^MJiZa_dw=8Ie&#lp87N!`pXnwzFNUgTzvc% ziq!#rBE{b1>^)VXuq?h!p=O~3gW*@I8t<8?ne*Y#rTX}qD)#%Y5co#wk|L4WVfZQA5%0eZ)VI0>KT;St;FE z+ICN^N%?iAW@!_%fJSY)MW-)SIl?Mj4$E8OZ~39n4zyMN6`*ftPw*QxBR5E~+7St| zuBK=aM{?g#Mdx53%0hC}mX=WVuEV^epY&~<=K<80&(@4? zg@^sipP7jQZG?w9Bo>P7zy7Tx-Ut>4ah*u1qetquPnP7?cnC3jRQmn(1Uvz9N%0>K z`_U8z)VS=sjAO~_Ba`fF|Hs4rof%4#ALv6MQIC$szs4)IlfDUIy?*;k6u*So3N=2z z5Bv1zza|S5(Jp(+=xyelkwQ;Ag5$Hv2`AeB?Gv;7q(AHvRU=Lr)`z+@a^Zf2GD69|d5mpvLaZb>Ck#Uy zdxgVJjlUwz5+`4wvfHq@Kw|QRq49wFR^&>@_)=FWhA{v^6Oc^M6T_kC2y6$n;DuG* z;@~VXB9S}^2nV@{roCUrL$E*GJJ0@=3ZhCF+y)o0JHg$6M|45i?8yLgvUCPN84*Yp zUqED{1m)ngmI!59#}~NLocY_{JMw@emyzihgQ5x>$Sr|Zu`Z7R|3VS+ zr^Ed*ve&ck@{$Tqk|G&I8BA6F}?dIt-1c|yRW224F zzrWZI`p6mejF^CW%cza5s@J@_77+x5FBPVG=jpTE&7HwHd&108ypv|}D{@F#76+8h zn#EFQM#uQ&m#kuh@Ia1w>4|x<{kXS}&7!9UKSLb_njvD0RnC`}OJt}Ri17?Io`~n_ zeSmin6%pI*7sUMZ)*fcXnJMDJypMFj`bo_a7bScv;lELj7&BUCo0T2m*7Ib1)cSO;Q9`@=mInx~;rwDcQ0EYMzv4UnpjNX~ zokMUgTC{GH9ox2TJ3F>*{jqJ^wrv|bwr$(C?s<*-s%~R8R%fo>tZ$*lnD4)YVjxvJ zgTWik{Q65;HUGHDH$DP|<2{JIC$MplY=rpJ-0m4>CxOmCFg^eW14(dSR>)_& zd1aH(hNdlmXJwSlahGQ-zvCbQx%@9`rF-ymragHasy?#Zvd$h>0M?yJT9rrukCY(Nt z9mA<#?0Kz}M)L8sKC-j;Opyh(El1(*+=b_Vs7K&-gqT+t%IngpSJ^&<)OT%{s$~RT<8b97iZ&ly11m zebAo-ps98KQZNs@2(QejD?rIX(f4*E4Vg?x8xk+i+Vq1CL+O(_6UcC&StALDPgeP zbg@&YT9|StAPvn5Umr2$uIe#8j+ts>-H{6Dc8t`G+-3X$qx^{aICD9Ap2!0l7Bdx> zIROaQ92>%13H&g&q($alq<{^dTjf9XD5R}~lg{qD_I7`Z|ET9rN0Dz$Emf39B%KU9 zn-2fe3l5HOm$A0g&?SCDm~H)SfAq#hGYAF>CGF;K|(4T8DhG*a5y&Ux}nUU3i8VC-^m3fMfcQ?H(pnrsUUtX7FXLTf7e ziOqESr5#sKPV)5jI#@lfDzmXh+34dHbpY;idwUP%h+Sb6Yup*=GlaTJz>O-8dFE`8 zs6Sw%+`;LPtra<&?!0??C;Vb}Z*&An?yc&2Nl!WXZJAZ# z>7k7HYzt}3(}`~(XJI29C6IZOB!!ox&hSXc^kuOB;n?BifRJ2S*xcB?R#DW^DAUbF z{M>XFBk14p5;D2z8L`O8BU_O9khoOOx3M7Hq1I-eiue2o$fx~yMB>m|%6{Yk7MFkI z*o!ocxy3Fh#4jf=tGmRc{^sJ~xoKF@+n{sjWRP`#)%+l)r-&^)f3$t{{QSo&Z~`xZ zcFr4e-Ty=AY|C&(2q|PCAP+4-XNk7}CFpFzJQP*qryFeTeIQhzW z)hmXese0ttB6RO(aXh+)Y(Cy!;7@$jR-5&JU4rZ^N;;ESa9)wwV4mF&IqYjrwdAQ~ z%awK}rOD#=q0KJE%7!F?je=YTWa`-IRw~XOU((TY#P_KPNVpOGg_EA@AcX@B0~ z!!IWYQluS(`f$C4VUO4NZp^|-ITjicc-&Q5(i2jxxpT2CFyPEFL?BDywkc%j)#2lQ z`>@}UO?Jh)CXUpOd4+j*-+{_99bEmlP}`AvEd*P%OOLI*|03S+CH?7gljbOE<#1V} zxyF)RAFYpw#}Hf{wEB?wmLDI=byR1YKIwDaPnU4nue8cGYs+gpHOE||{D(lc_7LsZ zAp5J0+$iKCPu{n<$D-?ZLM6Iv3Yh;M^)%PyY?wjILGH@q1J`RzB-%bf%`1vp*)Cqh4rkE`p@IBM>HjtE`T?Mi41MZ|E_(= z=241JZa6z{$%kv+)Vb9h8d%@%-Ve`6l+_;7u?bR8X&Amd~?0B>thl6<$MK z*AfEu6Z>FpNw)FR*_EBZ#%`edHAB9lStN?$&szXYaX%40k)X|0sWDVdy(oKnRD!<*L=$P0ygO^@5_`OTu4DNrKyeL%XC z+^*N_jnD3(DKQlB0{>+_RKG@8H*4Z^pSXMTYnNHJZPcrzY5z^5=oyWDbf*0?g<=c& zqhW%~rD+tqq43Zr3cdBR*KH(gF7B(SIMTr`GriX@jqF?wMD7?LYiKUZiWmLU5boL+ z*936YM53Ap_~rtfPI&yu@ndX7UjWrLmUk;;0O5=iRSxD;9%~LbLt-x$|YT~!A1Od>gxw*V=iDa%- zBk1f8GH-eCs&WM5tn>X3f$^z7M&${w@43->=;&w$CN3EmZoS#ECl}vE9_VBi{&2t@ zK0=x!A|aav!yN+&Lisl5b=Tr}ff6e)OlPbvqU*GKUs!&6i_lJ=G<5(mC(G&dF`i(g zVN5*K-ukc6uinvKe+cs#qm$9xKac4jq~7Q|l6;SZ@Lz3tf6U0p`Wp0oJ;5Y#^oauO zlu)R11w=D5&g9&zMDdSy9?&9HYV`0Et7jD-+-8-$QkNEGVeh5=_A@B=+m}j+Y~=>2 zi8vNP9uR_g@3Nn9^1;gH6AOAIPM8*p2V?UtxH^1_2{tgVfO#fbTaf=GZ!T=aK%NLT z|D5pXdpF?|Lb`BXv?@XSIgGaq*pe1n**($tR>-P56=4Xy)iKQE6cd4g=(JXTR1}O1 zH*Eteq-GKxRR9F`LNpz%0T2+H|Fl{R7&~JRlayD zH^TTzOL-AmJldM+y6N3b_>RWgxm00UF65!`OvdT9%sk6!>KmmZUa(dI1B%570qW9+ z9&K}(`t&`5C_>2G>!-THDw}~161z@oob6TpJTJe|z!*5aSOW3d7=k^83tjgxCrp)e z+Q7q!{e^|EAD=kAHC_9SJr?Ci;fvXrjjHaPlpOVz?(YCa_}6t&4Epzo@omiK9<}aQ=;Dhpp&|~~8`0&hpvr-Y_>>a(l4FKF+-DY7YU-|!_4ZcTj?T6)8-dQiId z^4NlE`+S1c8E?CI_Jffq{kK0C2mN0`?`^@m<(m!YXCC4~iEAaGcWV2w_5u@Pk3Eov ziRV4Wno~TYDX!|#AWiveB7!Yjy4sq0T}4cuYm$&c(o=i={-g9Sl_4W$CUK-E8s>Fy zCA55&OVrD>-P5owsG#_mB#e}BgR7O=-6BfVSf!aZ9inN)uPhDANHSsuss?;vspBoW z#mq21ugxYGtY;Ua2BgY!Nq@dsaT}BcE4QJ`&n)K9jg;-_i0n{Uj4eLx--eI(K7^dt zA^B#1CPN0o6~H_BKw};xd@du>@wvc$*oEYLZb;<7F1$G>ZQv3(gTW%t$F#9SgM%nZ zi9AQ&<_K=2)&;8}&|~YMeZq9_=IJcdDR^xd;TZ&$$R=wkfenIT@t6U{yPi1=Q2T~e zUWC)yD*WL~e~=23`pDKo@eh9JrA2Jz3fVC<*}dLVLzA5cWP!QhVU~q}jhFOc$ZAwL zxjmv2p@M@B4PmC$SR-p=P%Qs@SuDEdE(5#hx(f|(M&l>FQ&;hJAfx-&-Z*j4jtUK@ zHOwz2rK5-_4y`4q3{0S^B{>j1<5it17j?iGhK}pnEz|^|=^MR^rKgb~CCwD2ih3Mu zKaw8}M6v(sL+Df%MhgK7)Qb7sGRQBa0Z(rtK(>Y-m*s9g;H|KteFDw^p&yG@ z(oYd5aLwxrjj~eLM&P1nZO6CWK)3YWRG>-DwAO1{$YhDd0^}20K}0QjN)hLKQi|c@ z_4EWIG86z+-$nl4*+p|4vXQlx>sc?+Eb%CH3uR$DaQm4f zJjY!r7)Y|P3RQ7E0+w~p*}n-_j2_fetGrY(+aXWt4=lm7W}^N>_=*T7 zHV>*~85>b(=+)8*9W(n!mGodVIf?9Jm{qr}vB!cL5L(QN=1}r$12>$+Lf;S+apW~O zpQEWN%moz_`=Vt)i+8s|EOq^*++>9*<6(Y1X*AZAau;Pl%#j=9&GLD zmVDd-rSiT=lEe~k$i{yqt;>AQU}RG`o0()<22d++4E<+clDwJatNehq)|8r%>*eKN zbX!$622GP%nRi{%gH;_Xnl39+r8jD%Csdv=xf_}E@i@p-%h%Q{>1AM7hd-m$2g$3m zsJOMn!~B1Gk77WzTYWE>pr#j|bo6Ia zhm+*L^IH4qE)SO1rO3k{fW4J7_6&v!nVcyBoWy|A)XU2AQ)#Og&eOdqLfRMWA5G#M zc_-}P)2g46plyRD-KcZhOMXo+MLGfSlYR3oMkmgbEy3C^9hmIC@Qc%GU;gq+Ix6`n z2ISsSO}|>E#J#Eju7cN4(jOkXU~TDSqRt{Y!4|3!#G}SblvQ-BUovf*fKXO1n`mw7 z?8WxaVYyAlUasHA+uuW;vU$i#313t<>T1Tr1}UTD@B^8}7!3(YC{oc5V6T|X_meGA z*A`sUl6L#Lc-J;BmMsZtVhzV!07%K?-dMZ52Ocw6mwYo*PIJF`Qe_7d^+u!IpF|hC zXvpikQV`RaSc1?Wd%}hrJtp*AS++2(P(KjpSwUr`pO02W>(~7tx#dZSPL#e>tl0*$hjS>rl$pFp3w*o3&zdlfK3pCgXnx{{+{_^_e$#XH z3(|u_qW8A8SzNQ@rMWdu?*5_68#V7sb!ta}0>!y^6uN5a0ID%}0a$Y{x}-E{u6XR2 z!hvFAqRI;{zbz)La$Sgt!)+YfD@L+MWHZ@W!d#rmFc>C?5{UY`t{YBp6hc%+#^L@*QSg2NHyaZG4&ksk$ATNV5$c)m!6cm9PPQkd&n1{Ml^ueOcGM{#eFbRRw+pE;e2scqQSDmnwU+X zzGXn_oAaXa0r}AyZAZ^M2iV_5zFx_*G~B#262mQu9&wbt_n)@k_cDPW?P)TgBE2m``rbz(V&3%z>+|$hog_pRVJoCu{?WmHu$r4xdJnDWK=5dz&mqC zsUbHqmR3G^(!hJvP;PZ@7|7iw{wJ1(K^VfOQ|K-jNPGXT?GgWgGKF~GQJ>8ij_W{f zFjAe=8XKd{AF7%f%tOw_=pmiOzQX-Lg8ZRJ>I)H9r|F=5P-bM_zV;f=@fm{py7{GH z5oHgHhi~_Oja%otwO>Q`93MTly^aC9!Nw(!NZX30Ckq0h| zV5d5j!Nw&iaV}AXyzCz`T;Ii=q-@j$vg+ z@*gc*z+)fVoG;r-*0`u`^0^Hd?L><4CBW7SZ^ArsLYpp*{SLDT(i3JyzUKLhT^(EsARyiAc!i;(dnrfx*j*kGNvMy0^GritrE`{ zwKq=?5&3X!CH3-Q;NGIsj-2#*v7QxPbIH5wc@M~Fi;^(pxj`>j%ySiLq*#MH{CR)v z{RZfj+Ac-iyEF)L{oTxZ-q0V!?2~_tl*CMwX16qirjY{&CF#uVd7jt{gdH7Ce*@L7#RbGi&1>0q&5XQjTg7D^vv?;`VV}1F_ zIUJW+V$jebUAV~Jj{FAL!_dQt30n~O>GxY+;U&URk3}Aok}|A@xS&$lp1PI>H7lN>q@0G~ z=yNr)`EQ4uWy`JKyC0Vreh=H9+|C4KE;#X0qvzlzYsgRfEf11`DhG8DFtDOgy+$pl z^VuT8YeJIFXs^s361W#wE@gp zSUOY)qBp2&%mfU2>5Sn8f)oBqMu6kG3Ur=8zLer~ya0h|Y;N@3>Mu>;N#L%Y(<`BY1j}`q7q2X#x|Yl7u5Q5cee{Uj?DPJ<>33 zMVUuJ6Z(XHl5YEz`+4ZXTJ8Qm*5&9RGY*xBuh9?AcnFe*sgsHNdt2N=vwvoON@ECg zF-j5i>zvlFoX-WeEFu{K%7o!779E(SkJHH8s+lE$uPUz6u%v71YG`X}>H0M+9U3-o z>9~}sTQVF0%WeaF^GcQ;u7)-2LBCiqqZ!cscVpkhISJ4{&!PqvM_XoBUI;)iDNR14 zSqt;(rZK@&>pl*a{h6I3bn=)pvZE|}j`)A`Iv)2VxcuG4w`wgJ&yacW6}`&0(BTE7 z=H}aP4E!J3?-KOk-jBM{rW(1Ig=YRvbhzK;?NQLOVM}he)@>$q!i7I5q|YrnxBPc^ zD6kpIS1A*5

@PKIo4B*8TN9+qdFVuDs{2nT;v3>eO4GWo-uD{={BIvang1!-~p zuw6^MxPDTC#jOQ}Cq*c7Y1veXHJZWjAA-wZUW61Vf^?d!o+O-cW@!fkf-%Y_lX98&aUNSxvhO3gJjW3^dHremy=CBhuZ1+5TqLzhkdJ?~e zwX{q~!5ZAg4!l=U%3AprT7~5nz5EPL@ei&+7?zw+)WFBjkzj+mlT=Y>Q-$p7_x<8* z-t+~8>}uQkTl+emP^0fkp4iOv#;9y<_IGk;KMU*_5UUH=M=rMH{0$E|GFw%kr@D#uH_A;uWqMwsc7P3H^L zfI?--`k_;LNfjL=B+(8YtQQS<<>X@%42->uw%)<6v&*)p`NqTm$J7~B^XW4hHBEnw zB4@njmW=n64jgwV89Nzq9e81i0#`o@^M#?I$N(Y8sZ|w+H3@fpnlvhp=%g3Mmauk$ z4R;@#be0!Tf;$Bzp{EYjF5d~sKqEV&f>t<=cwV<{Zs5BWIZCEnC@K){JG(L+Qa{q` zmSf%m`p(#xm2|d9}edZq! z8fi`q78sf;PJjm_q&!l2pfKVD&~k#N#2b1gGgI7K5=>AKtf?JWb2ss?O}SOy;#Z|~DH^JK8a1F{uKPMtMh5r%AI>SAMq=Lw{Y)K)@i zGh-1cn7TByN=}EL$~&|(|vefw)$kzj*!bB*@wg%V)E!nmoief7Q%*POXCw*&)Dy9 zm25)Mr_-_m6+FL~16q#GmODomJ(>jq!fQC(#n%PJtq2lE1Z-{6UooRECDqU(+ ztet~dcLkr*QSp@=o*ByYYgA#uFac`*vUkKNUvM#ao0q74DoFQHi4kDGfay zj%YYz^El#LR!vN2+?0mGHj*KMBKpYYSs3DCHJuuQ3{2#3dYL9Y0JeGL}%VcY{Cj6;0*-B}& z$g{3BNZG!e#IYu`U5h%*l4noD*m!&ZfPY8vP#yp&1QIH$x4^%s2|2}Apu1tXj-w!L zbOfsPbmFhDvm*k^R6mpN3Lvaqa)b0mkFX4H{|*s$(zWB!U-Zl@WFOavH)0|98k9rv z3d6Gf5pWfHF$?-dGBERoTlRC7H>^>vroi?2OEQl7?Asn#2FSZKs~Q4AWmFofRPqFb z{b2{3-I1B|gFuP4`9s}e(ewCFY5R$Q$bRqRj`c{tC#+dKgl*VO$TRIPrTxi!Q1)JW zE>RGD;_KZdyaP#~q^j`e|K@{*u^pj9qiY8l>{-kDfKiYJ0YwD@0)hl$50}@uKWzP? z`)@__Um^Zi#&(VX27P@CTMK7>eR_KjWwkrmDLGZjaS2t483rj;iE&yfS^9B?N)-te z$a328u~}tG37Hu=8TsQsK>xS(A7|B^Sx_J#Ya}2b^#5&bXm6npur)NY2G{^>o&VdQ zMqSbAfF05IRNeeccq*Hz=U)oeMgc}9v+S7cz*B)BUoBk_y_h2zmqq4&ubX;e5FGgK zbe|05+J>FL_3O1(y$yXad7`TlRjWwvNpxLAm*=$6b<(E}OMlT6#Ut^odD$YCUv6m9 z`dixO__hf-vyjC(io13KlE)>YyUQVE5pRUaZxk}G228_@c`^&ykkKYy z;*Hp)4je>~hCLZG_D}{TavS_A?1D^pIq>oPF8Q6=oALM=ZMB^;MIztgWQldKQ^m?= zI3P`W8B+a5YUm;`%TmxOVkuO~r(b2-9ncs}_LMk|eXrA~GIkB-#5T~O!maclmwFlU zRB1R%LchKrdu~D`WWlJ)ob+%S3?=vPGY=0YZ(LHCKxrQNzO^?8)afe`Y~iBQ=?G!G zysaEzLtO?I{S~$sp79LZH=HXKJ@;xY8R1h#KhL(E?QxL>ijG&S;l%RDsp&J&Nh6+fH z2ukWaO&Q4{ltk(Kkb&oOHl{G*q=fe9zoSF^3c3=tw1od;T#1zqh@2o!(=>>rJwXM# zGxqG!cD;g2f?;Hp6z__~V38gqtf;x%weV*a0r7KYnGxAiEzT)AnH`bOLu~ z&zBSN#PR1iBYowyBAGXoAy<@8J6`8i?Bt_OBQq;kl$t<=tavT=T|&JegNS1KRIyc= zax_<$u$TGATa!UBRjIWWll16v&<7N=;4gnbTSNUDSaixapFVZVSs^t0hL>lALZdYH3>e3^;e^I=2(Re~9aQ;rDLr_J*Nq-NVdx=DfuB z0SEmGa9F-sD6appDeh+2 znoUqzTP0#H)D^~;tlQwI#4pes^jz~8u69+${6UhwHCeFRs zq0za@0w1Ds_caE293-Vd8VS7k5o(XX#Pk3KJM{J42MzAD2-15rhen(p!pSvzlilUq z?;(17zP|q$^5;%V=56il^cQc7ATt8Zq=Urg?!$t;C^7OKMi0w}{(yH=PD4N8A2MD+ zW9DM23zj4VZ>M#To(q7svD|E5M$!8Dv%^b}K6hSx8~5yzjx+(LI$FM8 z`V-0VNMvE2nE_U_JR^CLl~E&hY~4d1hO577rLK!?fUypJ=Gj*dU_F9+v~0(j?n)WsI#dH>J|=+!y#Ww4z!Z#hF3V4P1a979Fc&aF z4>k4r?V7IEangD?D;Cs3v59uw9)a(ionq%^gh`qHqR?+8HB$5dZ%m1G!W#4QK8xNZ z!m)B_h3D5olt+CJo-BC$+N9pRVve+*Oe#(xy$bn!+NUBxq z;c*A9lMY8ebS)27%ua)HiDA*Rjs*|5u8+=6w%yipUEb`<zVf^ zvoCt^{2X8&X4=Jysw;Y=@?M`Rpi{G4q(K1_oIFOv0T^wC$r_##k6s^zX&8G4sM$?! zyT3o9<72Au&i8mi7=WAjWivO6sT?RCqeCnQO6J+qTVny7#rTlcdlToL4lM?On&Nht zlW5GJ@USFw6J_(gXoPc)TxRXSaT9PBkVaKt4jEH_SL|z|G00lXfcOqxs>Vu<8osYXq2xoQ)L zcnY2k<1{kIafOuIPkKEg7JV3_=W(N>q}2o-qW!f!d|qM7>+mfnW(2Djn{EuFF9Z@ z@)O`pLVr}=W80g6u)#@tSJw6$cl=Z##>TK5sh%hrfMNRD4-;&3q;+G5eiY9$Bhwy- zNU^RnpdNYF*M&0Uon7358YMtl2Ldusd0ZxTMPxFY{Q^7RJxTT|+`FpNQ@Kw$a814H zfn2G7T4K>25i~}M5?N0dD6~c;t*Jf**D-OcybQ*d7MAVy_nNMU#K~?AZaxR(ESMx| zDuxueTXTvVED*L0ALD^V-{E+S8NgkxO_m#Dg$FEwp#kz%I3i?`Ktjd!S^nY7D!TWY zb~z+*a;s=6q0B!L&dUWShUbEa#4`yciPv*Z1ZF1Z+Qt8nGfPU%0xuwc-~kdSkR?_5 zQz0sDCO8vC9>lXQj`J63d6a_oF%e|nw7lO+;&Y7>c^! z-#~rtH{Gcf2qE%==WD_3{>U{JNs%7w)Z2f_cUSTt7!cSiKp&mWqsAA|$k|T{Bn~>|R z(V=3CldRHY#6FN?kFp*{G$$RgmK4yuradnozwfRIT(l)UTXxJdWIazGk0;}mopo+< zRkQ`;%1ARf5;!UfxmLSx73GT@56CwEohuxJ-8zx;LvSd=RGA0-i_uwzI zjPAXLJhQ=#&Nx9(F|@3ct6*F1fSS@cZ>GMhOF#QV%3hOe>aRLTgm66DS}CbNfLvg% zwK&4%sHdNdE8+R@GRy^#5I?#4q!uHrZTYNTZ6pMmB=vIElbEC1x&5|DS*$;|fhJ>1 z0~Er_^;vF{T)CJ_fpcuK<~HCqp;}osmnda8dM$jb8cX?E{ScViG2FON4>&Tn0yM%T z@Imi@TtO{1WAP?N4)U$_u&G3y>Ap{q2H!~OKIF~n(93xMx;6l=OQoOGs>8g^XiC&ye;W8n9eQju9zf^Jdq#bTpdq;=<|+ zSHW&-#-cYAf4>;qg0rz@p_6p3_EnfC+d5-=fz!R=Eo}4G+pm}2lAh*VQ}XZ z7Gf^BafjWKJ3qEn0kGN`mk%d%D6F~odpMGZocIJfHGwt zmL4^QML-399mHvcKC>6<;*;UV|DU#WKihU_WHxhoG9l$7Rwj-I{~P81=Ep1>xs6(Q zAfQerARv_g&5y>`7W(FfwkFm9$Nz$3i1t$stkK)NNJ?gj$gUKI$aIfaLs zc?w!hZJJ16KxPJAqgI*Czu~U9V_mha*gn}7KnBN!@=EQ5Y}JLV;$DoUdj9%Rho$qk z;;Qnq5XZVr0mhWYqWQCL6ch$4d&jcqX7s^n`q_^V-)s_`X|cXBn1^?VpccpC{Fx0% z<7C3XY0|Q7qZ|!eMzM!j+$+D?#cH*M>I2jG!x5je**rqyn8jUM!&`vfY?Mh&9yjfd zHFT-6Lrs!s(a-(pr8;$m9ZpO2WQ(Pi2H=Y_Hwh{!M?l2Q6Gyl5%sA1`xtXVJajwyz zW6hEEDM3e_yDof~pz0wh{6FBaimlj@Ns+Z~3S0G@brdVj^(=y{=Gv`?hnF3OW~AB{udkXl!%`Hj4Z~VB#)ZqWDInp91S`jSW4XEb_tZj@wkfuL{<^y+sjxub z$#HHzyfk=Kf4!rut@Ar2Z`euAnx7y}tSCf}x;ta_16}z-i{Yd@w1M+NLpHZjqN%io zL1F;Mfmtd0=cl-U^Q*IrgGcZwX!AFnmCgqC@STL*3;Ym<kivyDx1 zuL2T|aqM;IwyJpJ@7G^tyKpj#LI(y?1VoU(Gc;CEaSFZ>!V6&of2bUYK1>=#x#<_H zmoLd($eE)RK5@bOX*6Vwpj)=mFfLRz&DWD|Aed*L z`FNRZPNSGZ@-+}m8S&F=r7XvQ^DG#(#a9LRB*s!Hcm05{U`}Hgqcoe!(%ngIeI-~O z9l93Bro9mSs6df>ny?dWYfvIjA&EWBLdsAIVz2#B=Q^45j>M*YZmEXs$~o| z0A`AfgnZ3E;qylhlSbSW6{nBBJ+tnY5h|jZGEbeBZNw^FQd(om$&f-g-LdX%qEc<= zOHPbQ>$pG43;B_wi=`7y?6Es&wIqvS7Ko~Mqwp^~BvLzol#AiN)NGPfw0j@j`peJ> z_|jL3Li$ism~xF-REeE7l(wT@v4}Xs20}QPZ(fOPTL1oa%de=`o?liqE~h0hSmkFP zaOASVPH+%#aX3 z&}S>z5Ccv;FSvAN@$HX=SA6@F+%7Y*CJteyL!Q+QXPRUN()5O;NNebw!I+bF%Ydq2 z)najakdr2;A52t;E*JL%fiMa}01WvD6ckcn&&=$DKI<*9`y{XV=`EsXmRPYb8q&hh z41pw=z{48`gQY*i`GgQI;WHa2Fwi)?JP0N?_yXG1NO6mh?g6rt?QqPa!M;rc0vd?Q z(4ByWap_;+CPE)DH0}`zTz;wm66ikAQ(&Z!||zPg&G-r{w{}2-r~DqL=uIR(@vZ#|kZWA?4PHZp1P;B?Oj~P{f4gf?ewCB5fH8%m1O&jygN!aST zw!vzXwH%Yoo|0$Ac9ypRlRd{ych5l?d)I8y{na>}2lKxL6I_4iSVvas1ZB&0l#xw1 z7^&$aKQY(c)jtAJ_Ru8&{QKec&h?&p%-CPr%+_ARh{ zO9Nc77nb(Jo(748#+RSPw!4uaQLb0Rc65%AGMMTqkvL;BZU_aO>py+8)#3dnYP2Yh zV_%C10JWj1HS~EnPY|W~1Or{}>yted?(DxEHFFm8=PBi-3l^;+p#}yoCGSf}$7t71 zOZ4a*xl=i`$&&e`+Tw5P9Zg9i;Hda3v)e^b#j1N0OdV;@GKlHAzK=(y3@o%oX)d~n z7GpFs1fB2o-K>X=Tc}azT?e{IBQMnwPR?8O%fb|cv2Ma;N7+T8d&j0L4yl4>RmzXK zqcb0wEweDFFy&-x%Q1ss#ENj}5l?}V9|I^n9W=+_m*OZ}TyiPrrZHP%+F%J61~()i z1DHL)+tzMq6?H1sNkqK_B;MYqdkR@de2QWE;=OJLeJ3i$b+!18))&MOmuk$lZX{(& z`5cXoDhzk+`+>lBS=sQ{5&`?hM`D8+=RqGP)-hU{F8D|DbNhf_xv7}I09peMSqked%L9q+c0O7>F-=RFBbBVdgN-qCx=c&Ob2Q>2!1|HJW7<3n&*oq@~gUVr>n8L zfYmUHD3Hb2h!IG)Wmi~k&&3MDAiej>!}v+Y)>J5${z(4o8%OTC)M#z~JeHRYFhNOX z$|4nKr!D4zR4$>MCCEU?F{L^PClJOjBkDAnOFT8fH2`aXUX0?pRReOMb&}bwVY;Y6 zv48Uibk5y!wE4F5M4NI(cQff(&r)?aQ`6JUJP96KZB1Jh%+$`(%Hawq&vs-O>Mljz zqT8L=TV}FZLp?g8a``k&9`kn3r+}GT^)h=h!eFN;Nr3pzzd_ONWb99dXfZHzImTNUslb$vPjz>h-MW);j(oBV$T~ zgT}4F%y9P{380F7xiEVrk%?h4ZYH=S`Sa)F<m=6Quky8;QwB^*{ zK$P)?3dCj;G&LdM3DdT?k5S5!MyvaV!;PJD3gl^Z!sdNB4hAd2ko5&p^3oE?a)j+$ z1$^2k3L;!IC`aJ`?wT3dkpXGXIU=!^T>JMfVF1J4rFk-^8^0q1fmK1r#3lYgClV%?ulHV{4oFK0?b9S;*WtU)z{ukoO+1YG z|EOdlGhX6y=RvtM!w2#we8SQ^A>DmK+<-cKJ$DBjaO2M~n_VW&-+>d6+^pCoKK{0w z1?~o68Lg}bVfzT3341?3r+kb=jNR}iL2YKAyxE8M+7afK=$DI68+9W#x$LU#Tbo^& zUD#m)_Q>&Fr5rEdl}}R>5uPnO(cyBMUwjZp)75@aye%v-}P{#oRWi>HpSMG_2uuiY3iy6gM6{%1-^Vc_z$`YG; zfMv^)GfuN)7mgS1tEJQiFk3W^M!oobvf4R4G!E%tth0EWc_D93*N}R>Q~5(Z5sl_e zdTbHVco{g`7ijP`c^Er(CIWCvX@&OBK_%=%TbJbNSv zpI;PaW8p~7yLm*|z&{e${rE9vH)y}_oc3VO&aNQn$|9WP?LRMdAc}<2wcUz>hIqX@ zH|R}UczqFV7o7%bb2C7}6ef*q)F4gQWn7R{cmfCQ4|U)s%3ibvStTqoMjQnZ2ZeqI zhe`1o8m}(~^78q%^OUDk(V;3bYXu`}v#NF%X|bs<%@^4Y`Qv)&iru3%->2x8+PjGR zZ8Ij171{lod3|(t?z|kWdYyvAfE{kAX{3Ukqcap;`w1#8sb);hsUsr!btdj9_=S{U zoX`9E(zUp1#CYwNDvwh+6bI}eeI_@}(Mdi$F{GHLz z*64LuwnQ%)zeCA5oLilu;BC@tY2QQ?O(3CZUN+Kr;^a?7h_9n6$x7SC9x-~0`c;cU zM_aTd53yG&k{s{yrCGc5M9TIjsKS5*{a;8%r9;7XhA{_cx!2|>qD9-1XxX-H+uUW_wr#s=mu=g&ZQHhOy?t&+ zcii(L=HL2UBZe|Z<`eHqAC7NWs;Uw3hP>JE65iITD>UWjumz)dEnZ>wUlXVH+o?eM zcfQRX8H`DizC2%++PMyMBTOV+8$)uR2Z(-*UmS$1zV-r>BjKA~0S2fCgM#;e^ra19 zD&*8Jsbz}(BeBe_eFjLgZ^~*vEEJ=X+!plbSFn8${CbhmoQXiAz`>|IyXIAIJcbPC zyfA62BJahzBKuO7PPWPzMdselJc9LdKct#7FE`;~{B-ha$*gpI{iEvg{!? z!ZxFN;cqvSKRiH#0JpO?sa+!Ffa)^j178Qiu}93spZNbh-U=!zN4|gq0EE;3FYwvO z&gMVZh||B>_WD1wEgQnmjb6Wno^{_MJi}hT;36DgM~!&LwGb9Ygz&nyi2`b|acKN+ z4|k7vk*@Z*Yb#wfKM9+Q8_8{ZXV}8{W7oJ;j?zPdT`LPM$b0fXg=X@^S8TE|)gJ(bo#8Z3`wBRHVjg&fuIHaE= zA_*=LkaXP#`Kj^g&;_7ha*q3`q`(A2p>dNLJc@RAH&Qdk$vw>JXp60dU>oKfbdvo` zR;kq!GXhtOu{UF;aHk$pK@Dqf!AEek1r=c-IJUkrw3+kLgk$IClu2z1&SJ;{Wy=Lg z?e7MvK!ipkh%LG6UJ5 zz?vslHPF-Ww7|}ywZOkbxru50AT*jLf_T=)Yu1c}cYE@qt!@I`$Ks zMTx*fOjr$RA=z>7$QIv3deE%+z8B5iA-%z)NIV#%N*A@?ocBa{DID?OyCPtde?k7l z@b9PkQzOaSsjgdF-{Jlgg1RG+Cr}S_%13DHw44A=k0^T%<-msZopNll(oXO+w>Xav z0O5cp(112A8Fy6$)iI;vY19*%OzhBi&u?;O22r>g&+oqfN(@KKpIaq7syOR!Nmv-SNV(E%+*9v|r-01iVKHMW)X}UV z*WSHvAW*UV!_%OR;^ZtwMLjXoL*WD43>6V@Y=zbtFO(w1PK9O(_~tR6{iH-c&=53F z)LS*TtZLk2Ui!KOYEE|vyn-ogUw*xeW^5AJJp`o=yxoM<7t6*tYM7Qux?t0Xqyto? zGafJLCF=Wu34UR|qTmkO7!MFppB`T}Va|jbQQvfyNo2^^E)9>w16D<_fWKUzCjXdV z(X0;K*2t4>Y~&q7;5%|Uje&pT(%P4E_R@Y&wk8Gr#*YA#4FD){ZX{!~cNA~Vvi4_S z|1Wpv#ZYN8sMzmh-f9_}k)mj7QP3S{5U)HJ6Rmo7WwKi*T?Oj;+3G6%5zz`Wfk~vj z3`*U-!raqDxP$+0w#_#+;(T5LBV)euZ1y}cRi!pSpDR-644rIf9W-->Gn4UQ-jlcZ z!uDFa`$GwvCt8Iw`g_${!{D!86hqjcBaK-{=eulX-dez|+CY|3!;|{M74%=b2!8mJ zw{!|;m^85lbN&?KzfJGn`|k`EMrLLO3vMaPjrJBs>9Y~iodj=&)?7g8M(0iSxwqbu z9fUuU5H89!c#~z_jQ27M7yHuu$fYp@kK&5F0A}dE{0Z1l+Ys(Q)wx$}c=5o(bq%b@ zYmjkr{x0Mto|3i@1ME7e?gZBh#ozwgQkw(Q8@9aIwTJ7?H%SPm_3owa#3fTRwbMPy z%%V4Ny!qU6#SA`wSA^_H_X~HNOW?pqV5wMi0NrkNkfyI)Cpom1050m*)%GexMV7PG z+bEWo$DPTgbM@!_#}ce-WU*Yra4gKS71uBo8sz4@D;wSB36^1KwOJfo7ahX`h4X)P|$T;?+HqNfG9XePX6H zb7oZ_gl{pEkj`X*na}aD1|WI_3eyL|r&NTMcsm-?%^VE>39PfjPW~ybeUUmANN-*c zS!>Bwl>%dG?_M0R=Ztb))G?uLm6bb6&~3Dv0Vp)Whmu{?NwX+ z#g|WMty21;WK%ObcQ6>X)Ty*`1;_Qbb+ueO3{@VTwq1o43VWM>dP#gpI0b~;`k5&G zC&yR%y>Po@S)GOU++e9b1XY@LAFDewFrcU0U_|iCB@V)}c2r(gN$fe8_7LcyNw|yG z(d+x-q?O&TtE1}?Vbf9*sztDr&yFI^+n!&Bcue}+5wxio-B@ondVQtTkuV$PU}7^& zc)1<7Vp_NJ%%T>}M}8a!!hrWdEBPbBla8oJC0(LTtxP1PbJ>1!(@~w=A2G!@0e!1b z+=+M1Ud^KOSv6RW)wJRxst$z=Qg;a5*)7o%%^!e0#`y^ za-5eIkFtwazarC#;=T^+!_Of_HW}A@f8^k6XLF5kYS#&$??`~)RAitg_S~JA??qtl z({nWY&j=UN6H=_}GjFUbg8wJg+&;_64P1W-#z(!zwJ=K?4(3tyNUS)E zlOi|9(~a6@&71lj>*NGrlCy8l5I4J>rL3Ig&{k_lZj3I6pBx3-V_jmnTbNnMczYX8u2HS-Jjzk7t4y3C_pKmY*Ve@wD}oACcbP&2Z#HMKDN*CDtl z_SS6N(r>;dEN z9xf6WQW#!Js&D-cF{QEZ<5+174T6V7279`md-OjUAhfH>$XTXDv8cZb9{1-Q7;ACOh9mEM zo58GWV#r{mg^%_^@UzvQ@nXoALPOoOOhgSRycYu@;*p@A* z!IC+T{-qW|+t)K|)W8`vOV~ZROyaM-k$6!GFoF*kPJaE&oRl(w%BMQ?Uh?{-(|JX4 z*Da;)8EY68nphlt{|5Hp-5PRn1a&Fw>M}oL>+B}NZrR0SyEkSDq!C-l81kRkFW(G+ z!Mp}JK)8aUzjNz7-Yc?po)3{qeL8<KgHQ?X2GXu7J{nl3D0VeLazJ?Q&K$xre#FtxTwZjo<~+9t*LCdcTs!v6vaf#!sco=#{*9v&Vy1{ASk--}GPfT*;jLXm7Gj!m%|Hxby z`yj=d9z%~#jQ{jH7|Brt6h=LA_`R(14@S)&JMX*YEckNPuQtUzEn|fAk^CIBeLpDF zxRRAT5-N1*#!{14p_XCGUAbQPl}uBX(C)&J!@j4sVnRz5V4rWP*i9@!WxDxha+?|7 z?HSDzY#jQ~?lP;xUD9u{o893y?cbQ{Adrno@7D(yC?f4xJCudiEiSzVb^VcD^5Yo=V4Xh^8!piwjQ z*|0&PIVnjqDd|lysX{lL+VD}*GX5u`a=%peR7qvWtX%nkPpOk+7GI=cdxB&(B6+MI zm^vJMo6p#j_Ne5GSBZ#Hv7Q1tnUWToHp#aL6;}sJo_^&|ZL$2g;>ae|;HP70E4NL$ z2<}2tqD_lQvBX%!KTm7LWwqq0+NHt^XRb&d1<56_fu51BoMS{*xeqhk0Qe&qH_24& zU6eMVT4dS;78OGV?3htQt#OkVTv>v~DB@t2KsE+B$TblhHpOydQk5aFxL`Fy^%MJ> z0uua$%znQ_REZc75|Qf`>%l!wHZW9kRC*j)I`YMI4s<#1$qn5o-}kS4FR~6jKkwI@ zlWl(XZKagEG?W-~?{D7&^B-FaK4nEuVLh0BB6&OG?=;HfcJ_$gOVGZfAT;-!V_|Cos1YxYWt%=3-+npCMoF^xov4qHl0dN~o< z3H1)M>21`LI63KR+#XcE;uXmQv+%okXA?J7+uJ9eFuIrpFZ>BYv4t#KPh)>eW&yl$ zus_}wdaFp)^@*iCuWYimkMNr0T-*7KKtHQ5^j8s6zRMULccH`}FNw+VOgphpX^cW# zR7lZHl5>{RkqJ>tzRKu#exj7CfY8&k^lRhzrwkJ(Y5VO8`Kp;Sh`Uch84^R0849T4 zH^M`Mc<9K0DPB!na%qdq$aaC@(yq4FaBGWVkekPtJ`2p2A|s50s$1FHyHYuM&pTJOOTupMHfa`m&8hA{~VfkOEAM!k{&l-+@ zMndmR55_O_^ZbQZ7O*4b>VxQ1&y*DCLa6PmH@0sOFLYG?n!HjzeK)!rp3lk!2<7Ca zJvMIIq*G&BegjR$rGkpdJ_gaU2}<`$9^f#hKo2C62Uhb_(z6J9P8jhn=isVM6W85hihg%~;e zFs9+vNLF6yihfq(FP;^89MCD6paZ5DppvtsDAFwmh>70~Bi8?P0#X-bu=82j>E ztdGu{@0e0Z{tL>U0DSOAAxTLH)caPkPhX~POC{n`G#V!U9<0>ENCoI_*(LV>eB#BQ z^0hIdOSB9iIKa<+H)p^F%e1R(OuaC8V;1%Z_kyBm^KkU|^oFi;Oz(xSCcD4hzQ<|B z)m5qoa_&uZ;kW8s=tlYdOB70mPEkh*4M+muV$iTf*LpV(e8pBYW_NGvW_NY-BK-ojbK3+CS3|`7D$7!LhECtrokzTzrJZs7)-e-o&~+^|J@tE2%A;A~m`CtNgtlehO5(8!xcqcS7+6Fx z@1}`+XgzxP(YuFn`1FD*dl<>m6(Wi~G{7Cr>9z@DI-Y1y=rD<6) zc?p}JG6vURiJZK7(Oh4FhPyR;bv8=k0+|w&LPVGPx())*9U>UT5bN z1{PeRVHb)B<&r9$K%@De3}SJjU|PEhC6iyl&6Q(Dk;O5m zEJ25@A#H_OeLmKY=Oi(w-+9LN!Q`h7nyS)^0uG_Ks}@_bhAFsF{#*MZy!|mP)L2iC z7JZa@VrLFMFQ{3WVN=MjvYhL_f_$_AEEl@!|T#}mdw&)hlU0QMX}9h#uo%_Nqj-T6Cg21@zZVt+Do)vEx6 z_HDkrzSlG{)@&!P21+H3e3tcv#@lIm5w0l~?*_(hgbbPsIvSZ^Tj*N%J~BpjgT^=d zV#Z!&{#*9=Cmj6~{$5z8hJE_xN2|lP5md6$7aV<_ps0Z4w(eVh%1oAJ*ZZ{iVhr_aY5o*~lsyHqW&`+sJ zu}fW<#x6}gi}oa%(7Q$fu*PSW7k{FN$g$5ha!(A90}u<5`j!7mx>yA=qcB(y9MS<^ z7(Mm_D8vCX4TF)}f~0PpW533{?ixYex9{0^(5-Mx$ug^?D#dT5V&(x3amcSY665_N zB3G2JqaBG0a6AUfi_E1$2xx-S|7yDbM6`ZAU@& zX~Vv&wed)+^T2-8^24C?&`%onsr@uz`gVD-V89uETO`bx27jDCHfM{hB||5kC?cg? zy&epKM|nH=!f8aQVwzCD^wCk@=n2zOSaa%u_QbyJvg^L_+O?AzBG?vM>`vs+T3Ii> ziS=J8E5UssiVSzSI&@y;Kp4SX|J4s17j$M;2Q^ayN8XP)^L+Mk4CD8D>OjXnz$i`M zaiFujPT5;YvDzaBn*;X%BG%{2rv8RCTX&Ngd=e}EtkqI(fz|H5cx9pvB>nrh<*Gss z9pfqd2|>_;+6n@C$iUVAb+$9SHIgqDfH)oX7rT*u-i{RR#|7&2?&cCNM$S1@M(Lp1 zef#j6IrT<-bL)@=7rEr3)Vp+*0{Fby19wJrJ9Cv6qha!%%5z)mCL$!C8+frQPGnuq zE?t6`igI+X?O2x;vdWn{km4CSWOm|lIxA(#GagycdfT?ChnmMZ7*Gv*?3k^XKnE!SajsHR%G*^ z!PkS{e|M5_GEWGp|5`{It^YM8I+-}S{!a^et!?9U$d>S*J@Vnw%9v8i3F)WfWme71 zy*ba$TFUGKNoIy*y?J;;n@~KI#QOZq^!JNT2Ot6fq~z0D`3Ag0#R%g(0M>T2FEG}< zWAbrvs&Q+pgJ&oC?^Hy$M-FYbW8;9DoG))|K-5|SPTfypu$GXu<< z@D~sDqAYA4){lLKwV|MC>NSi>g69eKiw>C4V!PGiQ9rM z{F+G-zhpDFdzy*JADfhIvgN+guhnGh?RBeD_5WmTwg4L=H)*DURtv#fe~1 zwlvdoo|++IIx3VxmM)2cbwHm;U)n;P2Srw{JCNrJ6V5aTM^?0GFs+L=7Lcme(|z#2 zVJ0ZT9y!OTQvvJBRi#Q%I}2kpHqN{3C8z6$&N-Y6{Gywf8CmY=i`K>SxVcd^p@+aH zPtMt=(r$qJypIc4sq)U*f#*J%cZS9IBZHTF&MaKK_+W#PhmB742w1gKgD7P|gOk=| z=;DS(ey;-qgMr(7$pC>+jU2HhCUrAL7YDYGCj@DlVHl=Q9&uhEDk(cO^@uy%!{MPuAgd6EzIP%d=o!2ze3Mlv9y zB@}=L2U~hZz}Qt+qia1L>s}(3jWcFepsVxKT(VUv4AE8dF2qTUi1vNC?WK%$<{%-; zdW1Uyra8~vb5KKNvrb2O{Hwc%(F9ZU%VB)=AwTZ2kG3 z{|+=Ec}VEuwZeOeE6OQuJ?ede=jfeS#mzmkD+u;BpkyJYu`Zj%{vfuga6}tOr_Z65 zjY6f7r1MEdP*~$gri-B-=tC`TGBHMzJfGM5%fgPlST>bSgV!|1}CgaNR?F~@Q7RM zzL0fyax}#y1}Uv(9EM8>8}*(iVFgz-n-}EKPD>g^N!rip1YvH%Ur>fHoxfu_g-lL& zC_4-LArZS{jl8i`AY-uJUGs#zno+LH#)Bf#nuN%RgU)@uA(*<_kp& zl2!9l6W;#1rw;bB>v-8#fFVjcD~X15D??nsBSGHU@yqgXhKEV;u!e(Q`O_JUa+~1| z5*nEcXwZceIZ?Nu<$=f+G_9@}?0(YUK4+S;gC}M&bPYs!iS2YA-h+_Q`fGgy^IRUn zC%#T}k2nV2ub&^UA6wjg-;pQGgREp|_n>ePxI>N02|m46o6)ajz|4fB1GOf3>-0mg zHA-!KAtokMQYOLK>_4G#&+rz|3WOqVl2K*wy{>_n`#X=h~;p5LL`#z zR}a{cFPau_ZMBn6l?gq{CCQ9B?4aQ8V*@1>Vk4s(H+t&4wSBnrj`1*zaKth?ktwpK z2=^gK?}2W_7hBzpt&Ncj1>9#hoT{l2^ZTX5kP}@UEgjtJJWRX|OSJve z@5$h~&qZkilh1777iSwxuuKUpC&uzIg#K_r$tEvl86%9`5+x+l(+!3}!0V%=#k$m= z5YpX+CY6 zgH+zSizxZeWVta?RsbBZA^}LG9rv=1JKV+Q`j4Rk*%%?;_DB2-G})=qj*{Lia(J53 zX&Js4i;h;J7gRfG#$NTD@v+SbOc%f3V{WY`PKFJ1V)}PpR4St3nErdwP%qxkZP>m$ zlJpE+Xd!&!-a);$e`zj}+aJi%U9iK%`Qzg|V@I|QZ-NDQ;s;ehrkAKpUY(<{ZKqCg zRv7zErLIVF9rU${IXWxr>#ya>t$bknp9benVHxlu)S||njvk&KjF!or8=1uuH5bE6 zC<|v2u5I6yMEchYcOI}f`w`#9V=n$ll92tqw(Zywqf>Is1jQ$@=|PL%`+0auKTwI^T{zB81^dQKR|?VtDAn(7P+&?@RI~>>w^%ed0)t{E4m@ktQ@*26 zI-X$5BmfO!fT={-V+q-KZb8pzi%Lf++C!|T7vrcT0_->uk&mt+&M~SWzP`IR^znV9 zLiD?w^E=x0w$ZNQah04HCwl{p6W+{41Vfh-Y68L)k`&wa5CF|WBG^Ab<&9&VcX@}H z=Q(S-!I@h;v48fXV00vPkQ^kt3Ro=4g5`Xn#hfAojqk9 zI%`X8J=ahL+4_G7g0IHnAks^wydF!{g0T&evMiE!4fmZ*VFU7ngVG*JAxVKURKO-x z+Kvk%$A1?q)ok&rq0e0-g@tG=fi39R&DMx0H>a5T1kblE4IoTEqGttuUumZ;OQ-eRZ|8f zezJ{PynwXK6{TFmede#_^Q2~A!hiy&R5zXo!4yapojVi{)=^pAuv$ay{m^9IhbndI zuBvK;FXaoD6SlS+nnI3uV7R31n$}x0fuIjKc2VhXd|j&;0gg=IP56zIN>P1*Q$QA)B^?uJ-sg zpneF4yu0o%hhn&-K$tluV>pv%C29yf!2Bpu+EVXaR(iTO>410!V0c5pJU42|gOH!Y z6c748FVbuq4!qT^?cOYw5o38?@UMXMI`++!_(O8gTGs+DsCxzz1K(e!AbGpH(L>#!y<1l%eyGEjW(r zPCfnHG#%-?>i5cq_tkafiaxiC_pvXpOi8o`OJAM08$51I>c%hW_HGvwozP@k+45{u8 zl-2Kyv%f&X4QCO}nto^lAEm9W5#o#zW8YyGBjg6~tcYXQuvyFLoPolL|Mr%&wt-vs zW{cHIogenrolNse#RAT@(kn*)@BNz2OOZBZ8==!~Z33feJ>i?H_(`3Hr)X zkt#my>tK+3lY5pH1~Y}*yu6i5*I!D8wqY1<>1^8sAL}5gh!6QSV;qd2G3MctnUoT+ zh0I~p6=JZx>k~gnt0$F&b;0a^PGsg-Vy(dq8HwXbr7Y8*!MyvrADWUAwK25AvoFbk z;gD3{CAaRF0>gi2oII)o<}5z8T`wOUA+P-v+Xx%!F20a$pfXoQqa)aMG2VbrCJ7sJ&y`&+L8Cb09RRgHYj?D z90*XDV~vb%WLdV^yV*ZUG#28)Uh@vun%SdiS^lu@+zZ!igg~`ugQ4m;+dQo(o<>o7 zwm$MNzgZb=@m)C{6}-M}krio~DOE8U2x7FYpg8;>Q#VpWX)0$L{eJ$$zz_AyoooMT zH)WG38eHD4Mi1+)p;!lc{-b2#@g?Wb|4#O2OI89NNmCJ-)Rx5IIk@%jpNit`Vz|y} z_^H>PahA-r>&Oap z0knLErQ8V4;hl@d3KqW7?5HX0SZ+8sg(RF`Nv5ymqanI&AhWeldz!&ELbyRPL&u>a zEuyGo6=nWnu499e~`t=Q$l6bp6%d>jeDQw{~*$5c)2q zdw`UVnD;R6=oX3*Zd@YVPQSq+{#m1F?LY>?#f7v&qXTxuM^v|@mA0|hQI7N?gp~@n zmb-hk{PlB6zecr-V2(CA9L{^s^mp58L zQL0vnm@lx!xGpsfNJ%d(yijW0&Af#2Dq%Sl3pyIr>#CR&V(@LQ^7NE7YHl~*v7H1f z3P77+P^T0DF|N)KkuqGz2ZDgeca?a&=`b9Mv-x9hgNg}hVU@Gs$#zSrxt={P!bBQW zpq)THK;s5-^oocr)6mz)u z-au|vNr}KP$-!BVX&zX3%cvMnsp1dh%jkz`7R|7Ug~vuVA;s`3zfi&X2+SIdhfK@5%6$6W(xZ8^}ZES zyP;SxD47zkq4QLuy8^tH|CtHcdY3nfZUmGI%g31WB@~VKGwm?27R@j>^7L|GG~4o|G+f!nxpBZq`G5{Ksl$6{p}_4 z85O9S5qO{ft4Ss%KpWqNq&e~olDzLjty+%P0&haR=RHzx=E9yO7FJpak^!QTP$WU; zXvfkXOSH$kVSTadqeV+T`fqnEfz2>Z8!^xqFcljUs(01fiU->4{n5XI+bA$Ycs)4jvqxlS_!_nrB%l@Z#<#+9K#Lo|$* zvRad?a=o0kl+WjE#iG+zTiZ3N>Ay%+3efXWvIHX z7gs-`R5YBa0@bf1(Hd26^vJMC&F^;U+`4;FNlx;{MvRsCgee8`dp16y0Q7 z*zVXN#lFCm7v$KcUKcR4IUh#b=nf{Rc2eW*Ez#S&d!nVvHC^EYj*=hnUkq;o8p~x%CV}32Ub< zC>EH2vC^3XPTN2B&V9Z0skf^^#djr`3I`RAn!#r z62wTidm&Gcu7CF1^wzO^%a{pz%70izD)7*opofA4fTwA;zNE2CYCs9Lj$vscz6aEs?1Bm!en%w5Mi+7`;V zGs3jITS#-)7x9WX4jrQ4y`Fj!V6}P2$%>6tJ+9#Y@@^c$Nj$dy z9*EFRAf4Bu(;H)ybqu(&4HCFIu#)Zj{FPDh%3U?JGRh7bN$rrUsvhe5I<(lnJ#k7N zS?C$~$oK&q%WsMo+K1fLPlY8DRmtL%b%4c|&E&vDvk0=DkP>p29=!hk|0z-73m_U& zf&lJ3DJVM-wv(Cuc{G|8P9ir)@Vy5qcge!JY^esgNB!k>dzL7ePQX zip0GsVS?1-rNUWok1NYr-r+yq#iF8ioajhdenIETj*@a3iGPI%R``mh}i!Wm3 z!|`f_wN@(7Xsrl%OrB^YYTTNGyD8vd!)nl9UB*cn&a$CAk|J&euhW25SPldO|Ij`m zv*APtdO0;IcAc6u7$l?;Xv(wRo@=Vu!H=9l-MYY1U${b>Y(M_#2Ot0R+A%BgtPf5= z2fB^WHHGv@Uiy@Mg2JlbE>Ko{1|r1eUeJ&AwQmlz%MNG=q#hb%gO!`oah30so}1kg za4>A1dOmDL=d_c^g8?xn64}IYio($APpQO#1)Yr+Pe4$7`pgc>+F#~> zY81(7wVr+SPA4(DYe&SEj)HG3>pNgDkj1Ie_S5k~x0~C?MrrRBA=VR#%poJK6(A}S zHx}e?9+x;j<^ufEPr64eNgq@d8n6}>V448lW*J4mo+rO24rWzv*`v^9Y$MI;%@sHn z^l8bPt4>_20+`Iy4(lk`XbKtkyt(n1<5dgm!3|=|W2nU{w7rWDb8bEy z&SrybI26%Y4rz&ski9ynR#3h}5@g!|GGF};0M6HppapwwBtF+`1Hw87&X6J#psx!eO;@vXdPdLICGWE#jZE{nowI?#3= z6v9XO1|MF;r>zjus^T3!gZj6vnoRpxpJ-ro!xuu+7-tW=E|7#3e9T#k7GAkKBh6hf zA+f9^ERc(~Ya7AF?8~ToJ*>H5NxrC4r&^Hy2iY4>4vq0)5SdTh<(ui)-ldygwQqzk zs2-Mm)Odw#K@oy3TLlhaY7!CXytz?;~wz zJh2fM+NdYR!!XVRa~mFY!g1$fjlH8d%g*`tP?uI5v52;akFvNUK|2wT;ZG+sFt<8! zz7-S0(vc0KgUE%4Mc08ZpQnVI&Gy8+c<$ge?No~bh^?U8T9ke}G-La?DP-4@4%mK! z=+&IEaf>`o(8jCrLtIK)SPtcmRm=OP}K$ya+< z{cAJm)Df3Z8lPB7Q937M9$`hCmgb@rF2W_?%4<_NZ-=2f%Z;Gg`+WbMfm$Q5#6l`J57I(=C1W|jH`|D zm|_=N_j4Ws^Ss6ldjd+RIa*se`S7gatK0z^F% zMRRvrNVx%p78z-^{Fn9@v6+=yMSyrh^;8yPboxn~e_kr8+E%FBN9sWdkT@v%FT0eEKvPKiyh_X2+$ZPB_C0_|G%2tzP2D-FNoH zc0Gp>bIh>Wo*KBS>gd7iM(0lC?j-FGAt^>Hdv=R8@J&>2^@0mu9b^O|4? za?sGEi7gFH>@d;|&KVGt=j?P#O<(`Rq+$IS^*T$S`9Y{loR5=sFA?63X_ zqfa+r7JOa0n)s|%rv(`16A2e3ZqlagM8@_Q#ZZAVs_{XSu~3Z2W?to4kTtcW10Dja zSq_yEEfAs%ks`D1UpGOtiqfbqyvtVkd^j-njj%ssc|tY%U1R7|ywd|)X3b5(d#i?= zNH`0E@Fs+r@kl;glH^{TR{Vq$nES0#nn|`3Vrf&9{7ALFrd8w~q~gWl)`|!o+75zF zs-pclnxc%WBhy3MfmYQy8cf2VqzfAk_DrVXrluHAThb2fkG=2KLE#G#59^>pqrHkLl%tPE|&P>icMuIL@|zYjrl@6<%5`L0Yp!v9uACzz71fgQ7is3gRC0>gWlLU0Ssd4lgCGz^-La?Etn&| z$27B>Tql_#0P-O^$iN#wwi9Is@HJqs88t^~6ZU4hSkRC=mm%#UoNYqd!L(W7zOINW zFLG}^!mbYQt3TbMpg@6b`h$jvBg(+GQ>oy6fLr)9`G7!&SABp2@wTiHb%aVrPq!|j z%qp3E;5=&xdJ_yAl%lD9mVHuWtI=MEjQ!X;8lQ27`59A(1O+p%n9J>Dmso?hLdJDt zH!^-!9DVxtAf~o?VPN54)jDi0d8||(>3RYrQ&xv1H^MgI!fRF6PH8F+FPrT z!peLepywdG`wrz+9zwOY;$f0chMkj?kJQPIaAl>5ML-sh=AWHc*E*d1l{L1JsS@ON zoJI1EP!Yrqm{)~oTTnSX0?Ur#ynb#J1b4y1swyA7ab-Q2t8eWx4yv z4l4mnem2E=fmih1Ut=|fscU9UUdjlDz6`;+Z^5B&zp!-zD%}BzO#LwnU^oRQWJK|) zJtZFXZL$^0uXMKHWFojDows2Zj}|$~wm5sq6rW5@DwB)W9enfZmhH~MY9j4-Luj^()5u3fpgO^@MfA_9Sq5^>;^=9`!ft#f-`0jp zyssI-E9JYOBzN?;^opm^T<%h2U@TBC%%I^Qxy!{pt`0+6Bv5JNTpUpLLrEe!8j?*d zZo+{oUfWvKNMHAKjcnS9g1RxAURF;cfcKu5a!D*qs+_a4=B^I6dj7d_tv>cLr?_bE{MO;r^0+LB(lR zG<9L6%fRSlqn{*#)Y22C2pP_s6pIX&H^ahq1kfkZ7_Ds>mWhE^WPkDm7i@?x?wQ` zuzSsE2mIP^nyJj6D|)HqK9%d?%5sB=Hn0J|$RrvWkn*Bx>gie>7%La;J-V@bAUnxn zPRrAV+NYN$xRWJO$y|}dNY?*m$b|D5&9OkR9j%B#OJ0CL3}~7SS$I8yAmg|NUhZP! z=G2xd0G;{m>B_Q^;9J_D)?CVXm;8SD=s#7f*O%t$#4xvnvXIGj@TI&-1M5k%ro&IH z`c|yhxk4N&bOA%xzadqxzli3{;j-5)FSR~%@wvl5dK#2y7}#lFU!~4C`}IyDycpdE z+xy^XR$wdeg?bxY$BUW+kZp{dDXGQod{N%y{h?|`uB>KqH|68(<_l9tk(O_Kub3Qy0(}D7hL2_`@noCuqcmA()sT#`r#_AXi(BV zktUK#t#h@=eQtzJK1JdN(y@b`uXYt&3GxJ-vt-X=J6!1}mrFfYp08urrIjYC@@55r z<}Ye6w6)hrs`J{X{c84J*G+Xk6h3ZF9tY}9g!1YRs2IM#X7E5FYY!nX^N0iWlnLrB zHH#pJ?FW3%$+DF)p<137lM8=AplffVpouR$u%Wy2a^K&YaAoh@}UQV0ef9C{C z0`0Y;e@hn%c>n;||CUDQZf|{sY_~VvG4%3n@qPsZ*O2Z-VJEu)<@dhuNm)u>GFdc`w& z5%gTP-6 z>Em|zlTM#~2o*Y%PuAD9?5-~QD>q1!J&a1^z<4~l%iAg?I_f;Vl}rY1x;(9tvpo_& z4iER2mQ2koh96MCcW`xN(V`;Nl#7>(g98Ft2jzll)k1K{-E2Z8WtTzxP|YphU;#H9 zkHTBDaDLK70Z8`nrc5KHav@r^97Zjr$P?^N8Lnou1cKqq|5U?Z-13|sg{)Ux?b6YH zI@d)z?XQ~V;eKK|T>3DJxnpG_happk$Ye+;32lQ5QgK*u2OFAy8E_x+vr-oJ7-~Ri z8epaf4xK=QJb6Sgx{(Wru%ozTf-&|~pn)3nU>**dWjt+k#1+fy9^}d>DD&bai2hl+ z!fFQt%f&Y>0f^I*P1W*yCj0I0(?#j|Wh8rT3BS(Qmtgvn>h^_pbR!YLJ>Ap=$ggva zl>V$*Okj|O+1}G~Cx75{;S4N+7l6Q0Qiw`kE|Q{BpH@`t(l|U-K%h6t^}BN7X3`~( z3Cmf{gej`;pz(qM#69_w8kyb*seq~DVwQk46dRzZeki^}PBBUJp|5s~yF8E6Aqc~V ze=NxQ=mn4u#M}J^1%n%Pw&+muy+T|jk(BN9|Hsui1ql*tTf1%Bwr$(CZQHhO+tap< zY1_6v?b~sGM4aq|T@Y|q)9uN3 z&wlcb`3HB-{UZQ4fzG$+{xe4ss+iBOajPE4It01-uKnGusvhSt)U1wU(ErC`EYioC zkQaHEj3mZ9;)YbbgDLf;;ENeF}-0yd`>eE(B_ikSsVFTT9;T$AbI*N*h!2RE#yn#I5!n6T z>@Z1sW{6w^tL==4+p4RqdI#hL&xg@qMeCu_D-~S`UYzVkSIq=Kho}4JsH=*ZLNuYJ z7xC4Us&Ql%kYPdV|2a)fH3OgPG+-h@StmCU-M9OzC<9k01T_jec8S`jiU8qP;+ysq6 zKiLck(}<)B^#~@$#<@aZV=jjo_QnJuyMAMMShd1LcMVLY7%E^B+UPH7dANw1RZ|RT zT=dWj;f%X*Vz^l=3ygLfW%C^w3KD0AqVhxohlG)Eiknf>l}KS-@bspLIv3ucvxQ3v z}e)g+6Ek!%P6$}a5$(Yn){MEj#86BDvowiaq*iq$nmlZPCz z$*v+?txNkK@K%kJhuV$maXUhzRvbF0%+a?1kC|1HjDz(wIJn;E^)iR-NNAqxl4vTCHBc&&7!=;pC=5_>Scenq9jPw8D0^F0D zw>^F4A`Q256N3)32tov7CXHCHo5)Left04f?Y7>hp_)yZrWpl0K0uzwj6<(TGIlV& z8ogR&B_%JU#1>GX@wEZZ0*oHpmN;t*y0}>qC6+uGm?|kkHuwOcgf1rhwx_WwG!nBQO5fUCUdUS)BY%;zN zkuys={F_BWW9TO)gJ;jY2Fd_D0zQ>YD*c@=dEwB?fu>)&wg-+PM+k)m7>3O@aP4mM zVYcBHmYM7`Mhrp)+FT*$)h{P`=;Xs75EBWGkrg5QN5*daR_8#MW3AMs(HG?G}wQK3|j|aHY3IV3u1G3&0`2QXiYEX;Pg!t2_o!Z6Cj2ii5g47-&)1J;$s<0Cf~`k;i~Ce z-WY6cj5CN^D|CQTPN+{Kr*+v*^wDxa5Av3PezYY?fw4WkO46d*KJIzrvt92O^{xD+ zoh_0}?Wl|st+FhG^{89a$}oyNq&fuBPqM8w3Gh%GfiSbO1Uah>T8bW1h`tZ6^F{-be^HPBS&ax|nK(?4#X)KP!#HERLSt!e0e>E|CK>!K+V1v1 zTNAD-=b??7=m+{phzcRta?Q`;>-{rP1+|<6Jg|sVfqV8zC<+V2AdJ(FLfY!PM#E;h z0lxbdOL2S572=sWP2nX1imK(OE@k!ljyLIE^>4O8Yqa95@So)cRuHwf-*9MWH}MEa z@N=?9FNHi?*ms4$_lFp&VV&gqIPkY}8ZoDYi-6Ws(n3`_gCJ5EdXh(LrgMuFLSin0 z0Z9kwAW{|hU(UsGMQBmS6ydOz5+#V=OBiqvnfidpgpF!N-`AKw^+SH3u#3>MuC}mP z>ku^-`{T~|L0~ruqJbhph*fV4(_iu`x+e*#R3!r@U8Nn!j!n?0?($?wKsZ`g!~{TpVzB(emRSJS^_qe^y?H*mVeIOMa^#kg zcDN0?Z(EI>Y}dQcp0*x47pY#}_b;jMoBrSEHIWmf1UZbSByZAYWV8|>o31r=RB8_k z9l{;9BJh#5a`JYI6`}>miQ*&(E5k_uq`MIdtqxrSKq+tAxoSn_)(SKiA-54`nJUgk zEsDt?WN*uefU}7w6mO0N`#+yZDkW5TN-%GpqoI&hwgr1qkYQqp1}#+Npim2r(hk=x_m+Lfx1^JEaBCz>x=e+mKw$9AMihlIcNb!aP`(IVh~+>G-tPBg zvZA>&iBKAY3&Q4m$b(dom~RAXA;azEkEDe%@QW9f|LZZA!aIE*3bVmc#)4W z8F+;IWCRS$ba36#FY}`pH!Urp2gE9`L#_c7(jb$q>hEG9tZ8^i%`L0o;YVFmQe6 zhN02`3b%pY#xL+SUnj}%DlOOp@7Es5c`2aa6vcn6SH{Yk0SQ*%`xxYFbva`$S}O^i zy;^5xbH%$|^Ey|qd4%Z`H7?hNePrKFwN$@lhF|rSg&9eF0;@yYah?slb zk}*1uOCZX?bF*N^U4!>`b_u$fZNh|JCFn`V`EX}n2__S*ZI_+=g96%j1HRKwnD?n6 zdsgg}5_7O;Eb>LCWJ}Au4S%&1e4D}jLa@M(1p9$Nf)6YrxbhY3wL$PXK{mJIlsB?K zPI2b$`+)|`HEla9&9SqLG;W!- z^F9$u6x%BbPjOftO{}sIA1%B~a+AFMqe=x7m$YhJZke;HHXC_*Yb}K$^`MOp4up`6 z)U8U-Mh-FDrro8TP`O>4sWO+~{!y}PwU%+{qEk=zwxbg^;Z@(Q`c#n_;A^-vulS9j zQIJbi2*+olN4G1^W>cy9hz_)UcuTu+;ZACAc6$D*A2&3|kedeH-JXLasUECbHh0;< zWuyIvZfTSH8iybwPaHwT$|%vv*UrHQz;>0~qD9Wd88iKu;MNhW~g+yQK z;(n=;%v&O0RVF@6?qO$KP*AcAw;~tRc^Sb)Pu6Q^(jFk}6%B(}>jFZEdL_$5oKuiZ z3A4KxZ%bVxT(*_@8&r%&;k<6?ooYyKCXB^hQ%cYbcC|LyJ|k)r69hZ5Wuu?b@D;VL zBJhW9jrvK4TF2l}G&gc9uyRg0pint^t8pS!kdIZu@H@qgNCn;zec=NuRk9hholSM$ zr`XS8a}Msp*mD6btW!5bke0$fOOxP=Qry+gQxU&B#TnvRaR45Fhoje-nX&O}9PUW* z6BA_9V%6PEkkuu2p}va%Ie&TL4c z0XSu1vN7gmN7f=S`Oaq4>{onx*d_S|)Ui}~w0$fN{S@3CxdPy_092(_9twD^65QS; z9Io)89FFd4;*t`l)U$X=sveAk{HnuAi;}>*$*&8xeA^SuGdc?vzo;$Je;-fhurV&= z|MV}o6|zhLtp35rdb^7>kxV!6rmkr?I2Qaz41 zx|bToC!9cCfxjNRk4x5@mIEgcG=Mx_1nzs6=)J%Qsvy`zZierprL8?a2qt1Q3H_lZ z+d<;EB^rcFcV>r7;0={*`p3Ow*y*9{w(?Rvmx`A24SdNBz<0apr-BxCnNx#Q&+#XSxzaMOO6WN1Ca>GSlz#OW<8<;7N!+SZg_3qGuu}vB9A<*) zf;0b6yTFs9V^@sU(XML*pgL%p`9(Wr?#5*fUuxP=fIhfwn46Zg@$uPNnS&F;rR5KE z_fRJLMs<1XCKhEv#wRADWRk$mR=78-7dt2ZIQ64cJ|1K69<*4_1)7)f;Z+&%%+;JM%HtpvqK|aYJHq(~Sd@_BjH7jgV zi#(ngE0Nxk<(#O)xMRU3#JTB#L*2xve9huXDy}Z@AaDP^noh?`0$U3kcZpbbZu5uKLPMvU%L~UmB?eX@kLb!J4c&wBfwYVcsn$G8^=vT%Jy*Hty!EEz}>7!lv55d>gfq@dc+;%y1GX9 zZ&2~5>}=PKd&$)Lv12*@e0)?_OQ%m_`u|*I3c(>c&geV(!HGNmgT;ziFN&EFMzQ`E zesCALi-f{7BQMVDV8jX^o=tY86>e1OiWN+(1zxu>`1tGwVScWY2o8}yLt z7$l1eQUh4JNSaFn#Fb(r}?MAv~z03dtKmqX)cDpXIp@V=smv?}6wJK$8;OD=R z7sqNRV_JXPxI(|MB&6Tf#NOE2)QR?&tm$CyY)a>8YhxQX4Hv|K5OT{SdZ@c+k>$Z4 z009{#cnh1hy!1qG8@P%tHHUXqXlZ_$jSFpNf_2o|?qV4xUPH0jZp+F()RwpW;59#^Fx4=3&q zRZI?1!-ytCGWfaD(TS-AnkRmc=g0($C;WJM#~GbJNQ9Eh;gs}H_HkYxaIt5oLemOz zs)_BQz?!Q=553WyMt>rWD(YAqNObc#$}MgB>}e0h{NrB#xpifcMSf{36@8@O%QNCO zP!Tu_J>EXRroBkBTb?bPxlkqZ@Kr}a4L!yNUDgoU-Si6npDlje@146u2mpZQ-zd)C zK+ga7w)FqMNxny8+YY-6v3IxJfSsXR=omJs3rtlTAOCH*!WcU(SO;VvXy;cTT3re! zxvmz>BXsMvtA0iNg#CRLPlAu$QvvwLO>ze2^fsGtm!T(fmcL%?YciQdHrd(ks=Nu? zO|4pXM8x>nt=7bNUB}W4ALK){Qj~TLy)@lyT6(~u1@T?+j3<*#Lk()EPM-ZGuxEC+ zVVBvs1GR-AOx0+Hy@gdKTAy7hI%?ZqGr{AbAZA2@bfsmZj$r-BPCQblHnp`zEjrW& z?Or+apIIwerge)^YGaY7$+edrsvm{qPQA2`P`-Gjs_@o2@oLhpvHn&0`P=h#%DveX zipo-8aA5CNW2z31#udg(bvf?9zR_6NmQO_Wmbodh-!<2&bzWPne4n)uSTVm2U+O? zQ8+EMht)`}?Xv0ONg#V^1>N!i&rEDX-kNRmop-$6nTKsnjqH&kUxbrpiUX|-ji)Wt znox?`xorcQ7inx_73897uRpmTs-*&tM%RsFlhkr38nh)s zd!l6;!zZHje8O0DP#Wgw^p1g`AG5LeUCA3)l#?^HA->8nxgktOIX`Tb;;ej`iuJHs z^=jN5iDUvSghV>S2##bu)vBnZrTQ?>gcyDfLkS>HkPf!Vsbr-Z1UzIJ;V!|*AZOS% zbUmsJTT%|ePn=+SH=#UbZG?S-9${RcpY)bOb|&)6HY~?mf~L$LIseWawAJ$bTfScZ zbU%a2W$Z*R=XO>7I{P24E>7NTv}QjycQ5vyoEfu`^2_{k?o0o6^JsAIK{IsqK7qB< z^`~F8GjR@=*DYwkf|AYHU#A`fG^jSHqlG9{Yr%JFb}i+Dt`b;@gkUP*aK)~w!nyE? z9LPoqU`|Uep&cH`T-WI1?!IsLb1iQgvY<36D0liydPNFG0<@yQITmZDo6^8|nd9gk_-S)*vwpY=z`)70 z{y*WLZ*Q3wqdP0PbHdo4PF~)jAzq0QFaNS#)>dk(838SbP=I|^hW_PLL8R&}sT@Ml zcY794c+%&J;+Vf~jW^O>DOO=WNAozXHdjjKkZH$Jf+`e}ohy#YS{_Tqz;GLiWA3|q zxSI6X19a^}`;-D$9fZls7d_=kLaBo&l+JtoU6&?2PcEJoY5!8Jip6Xa<3Ww@-a+{6 z6~aZLUnnwZqF#b)qCOQt0?z4s@6%^al%C zGi{+&bE~+$*k|_`M!j-zb8|#A%6Gbv9MtNrQaDi^vVIX|OZ5`c1{QSNjD#OyQ!o=- zdSu)~_tbwTnaAHK+ZTYZmOX1?^BAM@4TI%08qki-D&)g+x4rD=^XOh86*_XPjwKVvGOF zrQT<_D%)dv26`8g9K6=XP`+2K#$_0x@o^PvY%wEWl8&jMgtbnP+K^8}gVpw69=9*f z&od}l7xnAgnhFKF{&(3QagHBbyO+oRys_q{V{7C8xycZc8`ve2)19z|O|0-~_3(qc zv0Dhg$qTiq2!n}C^GrDNMymEVy?!!rPBP|M&d4HY@PAhtX6cUK28N-NNXs9^Gg|lf zL_{cX3C4aG@~nb8)pO6hSD>#x9-f!?+rl+-U0?d`_ zT)`~qQ=9K9o;7!Eo~m-+|6zhGc;|sZ>dRw9VuE7g7v&UIO?z(b5g44+5jvR_N zJ8(l*kGk8BxslqS@8))Fn9c~?Ppy~aI;r)qvZH$kW9QWLEhD0GLp{`&EXEFQ?Sfu6 z+@;H6wtU8Yn=j)W7A#_pA9dko_oS-9|;jOBya zv4p!Ec%&E(-?_6S7?ylL|Eqp+;NlZd`USwM2>;*u#necjk>%GL;$~^;PUqs`a;>B9 zyvc^-zgpgZk4KYlshMOu^1_na8N2yTCd(b^?aYh^AC5$r$s1)NNfkL(eCyW*od6<` zP~>yYyQK=BCUFxKJpv#7l@7A+G!YlCZaDCnjOY_u%`w4|O^qJR0Qb+FEt7U8Sp9L{ zg6)}nYKk&DW~`~HY2g08VU(MLi)-Pn?=1{BKW-b7JE$)mhHf=_Y0C2@(Oj?pG<%ss zN)7l$JplyFJDcV+0`!wOKFMYJFok~{SRb-Vqg(&8cXp<(2A<~&G204LxLIYuN z+o5-0GyIwf4uk5*k(pmHoDUpKfZz!^hLJ7b8|r zmnJB4_Sv+-ISRFre`ut$yMH9851m=3Hh_NJ2U#po{cbii9bFy~9}dHhNzbM+WBFBA zg9Z<@q-Q{JI`ciVZ}Ri0!GvGvMf?Wbu={~X=ZbpHVL-3RUGlH){Z>4H5JVs~nABpv zsNvrpo@aE1epGF-NPC82zk@;TFikxViEYtTbU!eAOY%KO1K$`VhuM5`{OiN$Q|JDN z@=@phsnwdb62v}#WV$HtjN+-f!*XzRv-HL1p`lcaF-M&HL+vH<>Lh?!4v^3oxCur-ZkFZlwJ}jI_#JVO`I`I~o!T5uuc~Wfr*)@k4qMy(2^2@fJ z?P>m79k#%(KkM=AtM+!gUwb{=HQ>r>S8xJ~`c1}ecl$T*U)zCGaJ^lg?(UCx{@dN& z!1@Hw1H1lT4(=ac*VEa-UM#+`yMw6b$RpbJMMN^i3FbB&n1vynTVeom=Ts7Gj}g>K zntOM=-(~6J<#!_sAO;!i|40OO9!wpD`rMlkOm@Pn#|}XJ+~%w?-UHBM=u%Yej+4fb zbX*Kik$i3~`>Lw_!|#kIl3-(LFF*k#0Y&IFgLVwGy&L*YI#jGeC4v!Ce!5ZRLudoA zU*StZ_LabKpq;~7QQAU?)Pk?5*~Bu=*!#?wL&IT`@K18ShT_>i$)=KQ&dzWyVQc>3 zu&VkT@Pr8tB$-=tILw9NGU20HomA8_f_i>s#m1&%LAARp$PR#i`o5+(odUA#QD944 z!1D7c382Nw%^;cI|2;y%1jhn)0 z$h=3lG5f4?YXq*|VSw2I0};~J+s2&FEv$1%#v1|R$S%6l^7~?}9O;O)MEM=J%`V?w z4@7*C2m)yZk~9)mE!IS3;L=486n_EBY>nSUbmC;2fh# zCgbR|$AhGSrUK2<|eLCR@GdPV25>@mceR*5M#RXP)X#O`(Dg!=#Llz z<`o3rgxwQ)ny3$B6M%A^qW61#No=r9#mE5CG$o0T@gctl&=@hX-CK&RxklA@k)RN3 zl@uFW;jh~r6zDQx*z$Kcu&7cU|9%5#4LZc(qI9O+y+M?Rtfa_e*L+F7(Kxk9^B&%k zWTVw2$v5ryH{`3C-Co{q&2W zm1`l?arNPn>c}WBs^G{b6+K0a3X3o)O0P-svDg6^r%v8VQY9s4T z-_-u&>c7X#XCp=jf!AEcy8sTtj;HnKEQ9HeVDy4u9OVlT%N6*}PZeCjx*!yalx=$f zIy_#`_(2kE@<#qrSN5E#hjg-jV_fYX$kLPQ?s(I}wy9F$e!z6rm5r)r~lNh3s zTKm|#C?!u|61afz95A6uL2~`~mFfV+LsGXh!PO3x$(Lrq&_oMssOE;J(y|troA&UP z((seu#?m&Z!f8H}_uh#j*{(U->q`Eh+Tad%^r7ncX+dyBl?;Nn3a(&2X(kUF@%yBj zeoW*gD%BSg;8sB$;I%Sb^1%MJm$MaasG6wpUHC!HV0T<{~$ej$Aun%C{Ibq4IOkyD%Ay?pqy=Ly%)z#B~Bln^%4W# z0W({s>#!hFN9}~HqUNK}#aibTt`RrREeno0-CX5!QcY&x?kvY)(`-FTuR?RxBmhW^q2pxG!Uqc25!mK2?OX$ ze*Waa{c- zGV8ej{wVC+b^Piw`>!6un$PZ|;G){4br(J?0h;i`MU4542KV8VV1s)sNIy3$Qxi6; zE~Vz(I?>#wMN%mRG}xNOwG|82?rayEGs)X6abEdHSS8O8!ad5K5mvv9x4SHGw%N?Z zxg1Umb?}B=1nXKqbvrf(KR8Lv9lZ#9M(j?@8;-03t42{ z5GWW!Rf$*&5aZivcDWIP}$U^)Iz6%RS>2x4||K2axws}9eDUdS)BCbX6e%YjOI>Ye+aB}$L6Y(;G72p z`VC^`A+$(ZakJpgJ0}XOW5-2M2%K##YTd;cWs*?trFUeLkh9V)+w=*6o=#7DTSx)U zJeWgM<9sX?DLSj~jVj)2bDGH(lY&=viCTo{T{E3XVBLslL4jcpj%Ept7Ym4y*){9X z)y#yIA%YF%h)bhK2`%S#)=0w|`~tFh%9*X!a`R-jCo~$UT1~4#6W6_&h!wsl9=WqL z%*?{N_jMvMC0176byQPAI^kC4Q-jJB1F9(;)nui5+SL%1373TID)XO@6I+^kk5t$< zC2LTpy_BYmG|63|$u4p{H5UitMS`^|ksU#4ss7E(Dc6LNh-JP;r|b9mK)S^<)vU9d zjz2E|T|r0J(^C%jc4K@j1l9CVRKJ#nb%>7+nc^h-;JFl>7KS07qjRU+lhPD(9t zfw)E^5bWMNQ32Q~GwMp+EHy|pYHnjtu2vn`hjCkn68X(q6HPyHI8vNyJ+-y9t1v8F zG1QSWy7s5r%suD9qny!g4U_}l56|q4Ok7Ip#8Lw`X0MuG`Repr^%u7_J$-d1T~6!l z&8OyURmvALZ_y2WDLd`~C;_Y7OYt?NU1C!0A6T8csWMbnyavadm)MR<3?ttHktl}N zxqSK_a#d{cSjCj{x-SF?T*2G{2jbOgzNVPF#(OdZ!WkFj{#=Ntck}+B*{M10q*a7` z9TJVFb1_8|(&61-wh>lG?dc*BkF6PiR{BsD7?Cj@f!G83t< zL&$9wn(s2Wh}fT{qpMtm#_E-G54o%5K&*3@-k4ibO|>*>NT0EX_TP!wywJP`+ zs!0H?aBIfQux%5e2PP#o3BBEl5^3U|un~LTRks!QYOqwRWQPAn^#?4#OTy&I&l7xp zwQzGQTo!jDD=4r$L{^Jtpj|KSsu8#*{RI~zM$I=Ij) zi3*CyiqhGdv}k)fZ;rP7;O85KXGW67N=&$2m2@s!nF%brm;SEWE~g>!!+b(mJ<=`fk?ki@B?+(G7i2<*{ip zI=5*u@477QUoPsVqura%sD_u*<&}ym14m`6S#Fhd*&(8Pv|k<&Ii7#GY_JI<2wdu) zqj=xNYogMlIVQ^<^61MJoi)vFRcAMMPAqIvY&h_wrM9(1ZCrjk^e;aTLk3DP?4XT7CkRxA>tpdASgNkHWT&A5{3A|cag(z@+@^vq)W_u+$R{?@B;k}AG!fAo7-gTp?Nqd(A!dusv ztuo^Q8)U{h4tx;Z%{&nvixMja72qB-+IXb5#R6tc)FiQ7!^QPj%hqSKfML=!A@}Typ^#+s~5| z6o3%k8nBTfnqxjaiAoCc45m&_Ivq4KU?1*m|ObL1thZm@H?GZ)pw>zTt0`I`=Omri4YUc<}YP2J0V}JJQ3Mi<< z!~Gq@>B()n9uVqAuiNiysO$+#_IPjR0?1sSJPz{8L#Ll#hIhFL5iR<)8Jj(KTvS(fxr;5t64cuRV1fk& z?<7RJfK&^@Xoje8gyOmfs0k8KSqS5!HKi-DhrZiy4KQJUE{nQDGe@(jzTC4_EsajV zUipu{xC^~;*&}43Na1TISZ3P`G-+Ag0>sreA%(-oh0?n=IcYdusY6(J-v>qlyScf` zMSDOnm&Fy@7hV-O_oELa7bbjT_vTuvau1v+f91;nU48GIr~vi|Bq~N1`#n`r9B{e7 zyZyeXvTiLwtADgpdD;i6LM+uN1mTX)&T&vctoMs3fPg(rc4lU<8xk5(8}JDA2}J__ z27HO(claW}C_D2|U{Ua6b~iZ}q0w?k`zJuqm_Vl}$P|_9)FqKr%ND~@ft1@DU{D7- zK}w7gZ*s-<9{E*pmWl)ZRmRANHIwbm>k7&W+}g1vT05jyA5)KE-XyUeUVa(&<$d=8 z13?7>g=t0;tKw`P-v@1%gYRb482LD*6diy#6D5?y-Wx42$pVIlfF*=@ZWuWes;m4c zmnX%ipGsr*atvgh4&;ln{S1k)7(J+U(VN@$I z!X$p-o$}1A~1z#RdA}x7Oa>wcBtt%q^{zMRq=l+0OR>2iiLuh#AX?9$~OFnl4^LT2zE`@ffi(cMs;0%$c zHI(UoAcr^eN;4{uE`@nrBy>b%h%$EAsPfHde=m3ujUMfy z5jti+qzw2E6CpPf` z3V@Q({Cr{t>d{&05CWLIxWBwlX;)VYWcv_?8ZeuAgvsOZg`Cc!U=OZcCVv4BY!EBm z?=qZj+ghJ#=Y7aI06llWe$#)FE3DMajq3XEq_=LoG>MP{0SNYSNf_497x42ftdc6& zfHi!xM6!)pkBH8dGRH#E> z?G6=8pKyK`J03k_{27k$iyEf0AadVBc;A^K{H-IOYzU07p92X?(xFB8vNeK}M0FDI zCyPWv!c;)46|w;SoUs-R1pFz<_^hUbekRf*>ra(y_u*i7nW1>BV65kYuN9{+LEZP) zEk#;e#(Ywx1q7n2Hpe+O%?1&V`PZZ?HqTC3C)p)Lv*I~hFCWF+HIPtl2TIHUVhIN@ zkBfwIq6QC7V;%Zwok0mnhk>&0Je^N^8M?Ov8ab({dr}D&!(W>^^xeT~pJnP>-v(aO zZo_n3HX%d60KAs`*G`@jiN5L2y?)x~^7x<2yNZ(y+qUCiLsQPJn6fvN6UT**Ao~i^8Mn;Z++S{j+HdiPX)#8Ln%v`)+IYGTxm3At|V3bly zDoI}}%!^hap&(TM0EtGVA2cIevd9S6+>;2E3~4DG@t>a2n^pmUAc2rlVwXwCK35J_c` zMqr}>B)G?u87O6>8WrFVYEp-(B|_YBl+yO$P?mWW=Mqfz-lnvw-C z8QXwFaP#rAN}4VeqJHxR8KwRaC7u%c1v7r@;e{q51{FS=J#1A#N=pxU=SV4(xZZS}=V-;PijoT!AJLX0Ka;7BnL;O9CJ|rp-%Vqtfcbb3xzST90k-N42Zb?%-+j z613VZBsHU@eQ%Rz8_@MuM^%DdluQ^vOX60XBB8{9W@U<=zrUX}*w+dYd_ zciY4@00)TB{{rs)8sNX{-{qugD(s^cBYI5H_#hT5^cM`JTWh6BXrLQ3%~9RFk2OqH z(3vU2V<5KE2mw1!19N$^ku^AFV!e;B`g`bwPrO`U2%CqV&CiQMykH#bL*3iWI$X!c zHMv9)qI4M|@!a#TUl8Fj9mHY1ml6-Yw>F~n+CrI5)ipuYL0B58I-iz!_z+hWG#Tp%_Z(f48;a1641Ui+BbgfBO<b+!0LM2>- z>bV5BA{Q}JQedss|IHSiOPZBRL1HFtWZqyi+wMyGx-a|eX>eKK+O$8@%5&GY8VCrs zy|Qcg*Go~t9Jm`vyax?X_2cfRPHisgwL1S^EFH?!i#8JK9$aed`y^%v(K_rd&O#EGp zgFRf4c^O(VHfjp#kWogH?(QOM-}MiD99#7*R8y5w@cRQ$O2=-ORrWN(jk1f#V2sAv znaaiqf`>n;(U^LORKX|&0#xNRb^nLu7-~o2wjDe|!6F3)om{R2JUaxK6;lGE<$*FF zUE<&%^GXb11LiQY&pu#@m^H^%=|;r%x8xzujun-zfRA9RnU zYX*)dzjD+tP7wLWCONeb;?nd9c&B!#{T{HQe@|}GU_S0H_hJ2)-Qf(-5SVc~C%0># zJGCmeGq{A~^^f^n#cacyHQ5#k_gw6t;D7>cC|NNpu}$b&u&wj0kBvEbhsRzDWoIFC zTbt8c_@1!Kk+ZW<&yy?I4Aa8{*pHlIHh_>pne0lL_DrpRFyAtQ9$=nmN{H<;#vm$u zL*88&wr1A|sN{YJa8brx{1VDfP(AEU@BxGYk6HUKd^O~yhVbKm3BGs#Q8H^tig@}8 zdAo*zTc5mLK-AR+*q)D}83cuV>sE6mQ2)b<=xkmKnbZqA!St$G1zv65(1{rEv z3D*F2Ypy8V-P07@{BdJbfA)zc{K$aO(DZmcGKH=)+sB4N+|t^81yV*hst>U*gHScU<7rmF_4k6n=_7MRoFf79L6eUE#Q`_?bI}5PZ*#~}8 z*@V`P_w|a#CcwDS3_qm6;F(d*Z2%Uep$`-5G{=sZh-f4LhDBmY!Qk24*VWf|>)uKG zf~0kX;78mgaJn~NfKC?i^v)S6m$Y{>#5dB2N3Hm3M(cg)-Rs)%Y9Clni_<6-`xaH) zZVQSfMrfH4A5)P;1YTZRMjN;Gmg9fX9lxuL$ zJ?c|4dm(Z2Ho%m-6k`;Or8-N=#)>>PpOCBdO+VpaHKiIGK10sXJxSl@Nje(c*okQw zJv>)Auon_w7AAzbWj9aY!wdXZu1n`C3gsE46+9*d9W0L~0*H=h1<>&91c$j05rOF!+69G3<5&KUd&h}oO6{}% z!__&)Xcn#Owp`t1+qP}nwr$(CZ5!S7m2KO$ZR7OL%}w?>E1B!x%KL9+W{zi$hmaLk zO4dU@zLNg6C@d|B)g*I2C2d{j{q&tSRMeq6%?h$0GVVsiCnu-@mCD6%@=DIQO}QNo zIxkp`Z)neW#C1DIltrQQM!lTU>oU@tOO3w9?KiZASLShb79a@tKsdFuP5)14v|z`q zX_E7&xNKN07W)p0x#9+MV{ay?@VH)7Y1!k><-r7j8b5w%&eFluqp&bY(`Fsd;K^i@ z<1a~%ownNBFNx-%Folqn59yS-UTuTOkj*wXW%ijO?^RM^Ka;v(~eKxqfR zP$^_za#V>Gmp?5xO>Z)U-_9d<`YXVH2^OA*5(yJdOw~X(<~?s;c}B1aj`mVZEG6jW zz|l|&aC5k*u}FzUs4=Tq{-XpTl|R1S#qq+xHj~$bkMwqiO?7BZX=HqYwj>zxk7 zqUR;RgUuJS#}O`m$u0kz7G#Z{A#tLf;e_=3YUolv2X^X8m?_h|wLG5;c7T_4XMlD* zbFSTo8KeBCWAKrPw~_>uF!1!)&b6RxmBZe;;Lcag8=N*>b9%ZfzJ2z3;BH5g>)2jS zzK}UE;8bd#vQzn0H|0oJJ!pB9DP$4cN+(BWoRkcX{6)yFtYqL`5dFZ-m5bjZZ;OHE z7F{F|{jNWEW+Eb7FhJat8(I)*9z*}2L7Cg7-=!)^uOkW4n2sv~8WFsek`RQ3ExFFLP^QjIZP7^wOD!aHd1{TB8cLCXwN& zN-0N4+9HtN*O9A1!>q1cP3qbRjI2m42yMXI&B^2QUW6FZ`eEgJV~DjJt`e|1`mW(; z@dcie5DPdxIt%^jv#(IRy8o;sc)K1tn;UyMd6f$=PW=J?->l-mCv!ys8VHEtU$OGP zyg$%?vkDbaWfe&|@qe)5Kh4SqY)IdA`hp~)){v1~cFx|Apc|s+9BTz_vRLF4aN)tS z(v4GMXsU`%`vYIE-ewY#j}vX zWifvua68<{teXz%hsqSn8muf6D>jkwNBRi&v^UXe`>orUTkFj1+twNJ zt}x>N93Vl0ey3P{9`O^l`l&L)Ea?qjM^0&>Iu&+{d@=v>M8B-{Ju0@*&_{fazQGkS z-!DkQZC!|(5UNKUeqqCgp(~eOpbl^nFYvAN1tLpQlSdT^+Mfmzq?; z!-_pVqqX8T)GXA}OiHB+8Q)AbG`=5-GmG-Tx4eG4qEMIMc$OowHW|4~%pN05bFWwmWjG}mh56`nEeZxtgK%+Q*X6)OMg~y7rXxHq79khEmP9@S3I&*MCkU%$1ZT8kRLIG223! zuH!O_p{K2_rz;Dl&Mr(}pPU%+f$+J5!|(mOfetYM=!-qRU+0JAqvMH!WuEHtgzINc z6&jS44QziG>qacRJ5)ldWXfr8Z}lcRbE`{TG^)5DJ5UuV_z<3gtvUusM?4vKHF8x~ zU&kBs5%3k;Xuz@Nlk=dem0m>=HJad__}~0$ke3=7$f?beRS_`;jgynrK>0H5UEy4j z6kzipwHk%`S3&Xq7%PW=Z9pZd(&dHH*KGJRA=)575i(sIPgW|2HtlLtOPxQ0siA7t zITZsuilvK{vD20x$ijFAxxgv%IwZdZxhA7{VD5?Ep>Ye0oR1QhZ0V(kwukJVs`jDw zODg)*U22q6{-kD5V$@)F!Q#2C6`W6RD9F>u5eI_qepwrDJnLBKRh=a{#cxA_Id&1WZC@LU)XHI_^ySiyh}Vn#k#nBbLG*9Yxw1 z+QJ-TUr+o{U)9Yu)7o$!Kd#5OgIXSetiI@mhEgGb*bS4%Q0aK8FfFe>iJ`)!%TaS$ z?@eI;{VEf+4NIh4#z_lM>#1zjKLac@3)7q_KMG7hYW^~)t932gut(J;SWUcZ1IYvF z8E|l3qg1Zq>*Yo154Y$D%7Djswx8|qnP@|nJ)##@(Ekcoj;jo|xd!Fb1kPj;n3c{v z$N-op|EmM5ia#tUrF`tk{VWgHZrIYT!#O>0@AuEN9jsxXSI~7a%E_V(+)^U(;)84^ z7E)ue@z;_-%D`&K0U*Winsdh>aD1S}YeTU-FKVFizccUy(!cqc&+GNa;pT+XyBc%&LP% z45Q8kD(w-tM(LB#NU;I;sz=%d-L#~%-Uhc{Jrgo_x`qB3$rnRMXD@#6x7EYz^VYP8 z0;@BBF6oLJ>rTl$gItVll5v6kaGc%u{lj<5M<(KLC$YNk6?CQCs3oH45{BU}o#jBl zLL3MD&=kMS6D&{oZdGvKT+WsOLN0o;ud+8D<}_~`uhV{t|J)VX9j551_r z30M*`dU{o3s6ZhO%k-d}_E0;MB5vT(#sx)2vWsGi_SP`83=X`I!(k_Nfxrvq3{+Ri zNU8{wF&S5Sk*zd&+;Ji-XPQI_4>FUKg}ov`x?scVFQgbiGXGjsu3kn;;qR#C z1yeR^aAAgx1A}q?P`TaP?K4yAjsDHcx{AIv$yuv1jQU%TuYa;8%Q#?AwtV4h^oX!% z7+Fq^6VeVw2gasGt#G;m#WU)NEcA3U!j0LkS8aQRN_cQ?5y&=1=&m3hOcwHU9a+IS zdU8K6N11Lb$4dvui8cPUmWi}oL#>_MFY%Wf3*@OzujnjgffJ>U+^HHG_Al4l;m<_u zu~6$;oo|ovtA%V80h`vYwrN$UG_k|1?KQctLEKAfF8F_?Y!jbm#OjpqW7fKU6=wVW z@)oJaLbnKa=fE?|L2U&EBk+Uhv5+t8vUnme_Wg;9xGmlfC_(MDdd{CU&S`^8Iunpf z6czo5%>KNlwU*54H!cV5lYgtn{{VZ(n)-;GuTz!Z0ZneL$=cWB$6I~549Yt*=&VO| zh&Zen7pmk_ZtfciZ!U<_R6|$^82^?s7PZ zhT=zMwTcLw5UH#2^6oA*KU{f+|K4>uo7&Dx{=~L-zbuZXV5E%zja4T?rH_3bzk^`^ zi}+JX&n+!6U-u{vZv&T*{<8wJhXQ6NKI>hy{|0&133BFm=^9r~Q#qg-$`*Oy_w^G> zAtSm_N$P>*9))rs-jN6h5v-(}WSAuuXYTgy70tw9JjJJ>ggoypAiQUxWD(i548Uyg zv_i1mSwhOZczH!1^sNRACzWF8K@PZV*lwVw3iVlWS z1}4GLl43GbU=F%0#JZ7|Kxr}#+@Zv>tqUyr+5@siyg7~B1!>xXSI{XQ%tg4v*U0sh z=N&QYVB$1ju0nNG+Lz)XC5t+vKnhO4KgPqtVMj^5+NCzMWs@j6NyEO||Kch?K9F#U zc;5Hyl~IA&N6nKrlQI;7$W273JLR@@lf-`)ic#~}_7CU;k$5f7G2GvGbsA^?Lc8Ou z8=UGKnjy;Jn(7ud25+Rx>65KKlQ3D3|K3Irba6o|M{Tid9J=$*li#&SF9u(HKo$~KKp_8l{d>^=t^eMG>+4(ES-R-!|Fdg?{-3+syVI|FIM_fyu4O<#1pnu= z|0Is||6k-t|3Ajw|JXUNbZzZ7h8zBMTMJg;|Lcnz-#F{5q}O4IU07_5L>`=`q}voI z5M4JTPNb3~ePaCFnPCRdZywQ6N~PJ5O9T$HwQ+ZFbKh`tUp{$h(*rJjkRWHw07M*E zCu21*di?&_IOc05tLt3d5>WiYV~_9vVxvB6qwWR{47mmuY9LF}EV}tl>@O<*6b4 z;S0}9gUtIY(F`#lC&e^?V$O*s9N3)DtiOD%g$G`G{i~)wi`0N657w29t*tLQ4nh`# zEeKm=xuaHCW8AJy9*DapRf6U)IH6NCB9h3RCPNkwaBUp8Q2yMz=CIU z1qSa&B+#*mm%Y|XR5QSSwukoOqaP6R{BVdYad-v%i;_cnev~L3U=qY8niO|X&=~xVAcHnPS*h?{OvX9Znw@V&NNs3;)KqB>fjS8;#xM@(#MB0`lWeb z%_b=Zvk;!YbWTC$02mKlm~rn4=nx~tQ$uHPZd;Rs(ufhh$TMNi0_}LO5ondV>u_7Y zOP4Wg)zwezqJVTSRWIr8ohFi8F)+Q0<-4Qg2_HEVX3*XV7iH7j-R8X-Ar47iy@8Xj${91=41+1Aa7T49`8Jbc!{QSLN?;~XiN|4 z;p$uG3=2tS8i1l_;BRr?F#+MfIk;p+-!D5G2bVAhJqLH4F-K(4U7HKHkyjrAsCmy2 zePfVoFxCWIM<>Tf@y`go82f}E8aBI>y5yQ^ zBR25smZZ{=P4K)JBWPYEIY`H4xW0(5V1Fi)8tIn>-Y?AyDf`6MN_qfKfNHD{-kqF3 zo|242-PopQhfX&f`NA3$Pq0(?H%~-Ku^P8Cen(sBumTXD(89tGQl{?^-PWdK{9|d9 zKF^8UsSZ4G(L|g>-V|c4aFxs3gbJ`p^5qAT(nf}XhS5U`6m4uHN*oHY&@{Cm&>-ooAj0lhmJ(1< z%S0g))v(P>z2%^p-fSp|r*cPT%^3tcTWL3HOR^GF=Px>TO>5kPV44tuSWURiatwZIWMu!}^z1h@loXQMDhy`QAnCa zx3gx*UH{RA`ImQ_a!R88ql?4^OyZIDbr{!;xKz@}K@0%GPY9#poF3GOEx_k^Pl_Xd zi0C|aU{VzEXbFq^+ibjQ(HseUwK64CNuN?|-Y90~PKmw_lSU?7G$mE}C||5dB(9gO zX!N@r8;eRxi!SQ2RX43c0|GeGhNmh-jdt|G3+@3a!NN&u>?T1rj@Bp&jU;OK7YGmu zwR_ME`tN?BK|3dWM6Fb|gxLz%G8t3j#Yi7d;{hEJa#)ox&b=f_B@gxnXJ-dsqt-Hi zw;(p zZg#qapjpkAdg~dnDnzDijk{yqb>{8ZRiU`bY^cC*EZhfnK9vj51Gflb`9FT`PNM|r z6u=Zl@W)!z0JJbY`6kFXdOm+K} zHB|~w8zYG?N z$Q{ii;vBQ%4P7nR4B+i(nfykipK>7v9^;!!BN_8{N zqkHm$F-FlGje0&XXl0J>ZqmtEEz-P(URD40-@(BpUG-h4U@%L9q+8|G9er=n-??(4 zN-x!jlI5WqcmP*)rI)0xc_Cvt)KHH;!3(AxN2ukAB?BQKLCLC!<5fk40}LEJpu4l0 z&P-+@T?oECXRLe3&8o#Z?Qf3Fkv5@80pl#v6KXU$vq`NQswwS)avH&Vr3eEA>vkPI zl}@ebeITw$Qt`mzkG1`96C7a13jGvelmHLy*P{$DqA#hsg_$z9%8}5T zavhuWBYRTWB`@o@QVW=s@ss`(#c8+zU4>OHiykIr9AJ zYe^8V)W{~32jN?Zh7lSVVh(%8bwjPhQqufcXFZGwVePGPz`B#hkSnucp+zizYjErq zf_6tX%F7o`R($u#jk>g)9@*qBM@ZvcScmu`Bf|92W&c+hhogNOraf<)LaTvU5E`d~ zYUTlX}Af^oOjSTysUfC4r-QsOLjjX!+A7? zT0LXz{CjFY4&C%_y(ZmXaD<6n(F!_F>C?(IHNEd3kr9yZY42ZD;8N zZH+7rvW}}G%zwi$qC3;JsBq6?0Hq-WXT)l~^@NP!=xI|X*T~D^8#z%eeflD~5^xLU z(h+{NR;so$J}w>NDIs&n?_qTfGw<| z>cCM-CW2i7YeL}po!U53l0KzuTaUd~jLGi4UlMjSL$;YQY#M;pjR2566BwtSXSh-d~n;c9q;WS!c&`iL9C@yYn|C z$EM*JVtE|d8nMMthUM;7b-E(^r6_clwRCDiT{?eVMxB#}c0|B}yeKcZi*X2gXgr2=WcN>Nxbr;z1D&BTXh_?W)mSTO3iY8W209DOQ+HYrPmh<19b zK{TCy(W_FaTguceZe4!;4SxXga6$VRX}vJN|vZ{#Vsa$ky6>hjN^w> zVn&u%jHV0O4!tbDY?Mg57aK8Kr}s0_QhMN)W$^y4jEyo-Q>0qCfm@dj(FRC=rFYYMLsLw;vkpHrF&A_6%sy$>Qmy89zb}PH1PhIM*U+#zb_;>8=U#-uXzS3&q^0}&D3>OL;F)|QYGKBORJ!g zPtm8lAbJ{Ay>MXJv_1R91jw#?T%m_BtrIX0b@|$~uWglBUu3UYGnY7_dWGjk*{h|C z+`76?h$v7VbswH8L*(}feJ@?+lv(!o^_+}NAzrboG!+AO7r=7Wj|_fvnv<^Yi~+c> zyYZIUoQA5+qKYlg#1ksIt%5~%9aXzo=&~%*t-KF@Ae~k8Jf<7xocGQBD=}_Sldt3= zRr01k&#yd~pz>e>x{-l6{FhtLf1Up8GQq`5$EEk^` zyF$sTPg?zK5F)}e0n4zb*u$2AB$9SpN!FcNQbMPP39bU=ADFO}i4MEr2N)8t>wKO$ zBk*FMy}z-d(@9n)vyN|%r}Sj;&(6pGwtm!UI>A??JRF~s=qbom#hlJkDu-DglqA-Z z4khhOeAl|jXU93dS9$^siM#3HvK z(e3zENmbAeA8Lw zuZSe3%*9ZR8SQ(q2FV}lQEYV~qpo#0mTG{-W!H3@?5~B1&h-0<;@+QLKn8^KMywg# zda~m1vv`Vc8sF9XX~JALchuhH^O(4rtPFtzAS({dk+qubN0-nM40Ju-@7VLchZn35 zEQ{Lx_6|Cy9vVyN4FIxIANmN7c(Hy^#(M* z%C#K2f6KVtJY^4jng8P71mF0X27C{{=yUBrp9il*UMA0SO?IG`%_tF({Bh~Q663?X z5BYQ`t$DqRd*YEzFzJhDM+OamSIUWtdOLH;q!^aP(1N5dLwOU}C#H>#UOm;gqqpD2 zzXxXxB02z|OzV(fRZJn$A7y*Ap6nYIx)d;=&5?nBfO!TnF4K?z?Fhk@RAO>%7{`cNu>x{QXWOw>i80cGeqW@BYDh-HuR$ z4B}eB{R3JSUnTfHe3OP_kp>*Jd7bp5OX7+GdK|vmi7zaytMvB8(!{wBb1|NR_j->y zJ##hpjVq^+mAzdQ6NhhAW$i z%U$e+Pk5cX%kBGBHG1D&M{qj5erL5UWy|UX_|AnCt1Zg#OrGc}3X*9IiiFxh($OC` zq9Jps=fouV629bMUa1`~jDv0HT*Y!sxv+`1nxrgC9P!r0eb9Yxw~W++#`hdLp+gWE zq)X0xcO^%fmW$xswpJPB`;n7HL{{8@KBY8G^v1?$8u@QxYssd7J$qwQ0y-ry3beaN zL;<07aaThV_v|oy$^6ma^_{r*ohlW1d;T3}42)JdkrmwmO^+;@Bql<<{!Bd7+61jM zfGJAwJhMdbw1waIKda|6nMZTa%}8l{Zxub$x=;zWDn1S#F2DEF`5OHN$<@d}L2Ns( z?zESD*nCLe-wN4k%xjA)x;<$HH0lT>PuT7@Uer**MYO+{$MrU@`+rz(vbv0GJC+Q~ zKRXUj-@!YGduWz#L(Q;N9?JgSt%>KRapwCALuIS#N`M$=S6m7qz4L{fTHIc53~OmX z45MZ8j$o#^@O)RaqFZuGwG*07-T==&rL`dT@K@UoXGglgu zzv6oZyT67$&&nn-J1_81Qe;nrCAOIxP4@Q_DQ9=1*&)r4TZ5&jV6H4HfeZBg1|o#{*g*-*7Iz3$2#zXJdUy1GOXLW# z2y(PS<&tvOwCN$Zk8MVWv;A}K{l7=R0m&&}Py+$YOalS^V|f2H;{VV3FK*}Uj3aLM zqn~eNrAtR=>?As-M!F}Ou1xY)GkD)d!&%8(s|D{Kewg$8&*vtB7r(>H5A-F#Z|0T; zzj4BqHj$NkCgxr|X8{CT;N7(y%s+ino?PXjCs(!Bs)JIW@2}mNJ3~GJ3BR4UG-1Sr zUw`-I!}y~RUrp+-Klie4(oJ!`R;86vtjfzx%ePz&AcA$6p*- zd>3lztly7ZcrmdhOgx?2{Ha01KUZYMQ7?Wg(m_nSH27|>Ltj!5fRp8{j4WC*=&A}- zSAMU_iB&v@*}{~9mnGIop;Cuy`1HEn?IRz0c)Kj&)1{~4b`JS>HQzs~9BnUn!Rm>% z=K?+);G_i`-T}l=Cl|Hn*`}d3Aj(>6eqi+%2dVo59_sVW{pr%Di_2w{&j<{kRy~?G zFO0aq>l5sIXV1Q#Lvu!e@&S|GU1Rqkc+2SLW$iwE69*CQ>x0&HlwFSj@B3paznhRi z1W4c>Od;y+PQMDV!h(&CFC{rEqE;hUb)gS5?7_yd9c6T_&myAu%|>;Z*{ksg=CRcN zisbD$Ks@*h;-~z+*olcSWU$!pZ_T8=#9x2(RG-^d*?Vt5q631&duddq;Ls+`y=zr}Gp;MFJFTD0_U z@c<_TBE=;GCi;;Hj7d9%5g3YR*RpIbx8aP@Tr1XyHEFd?sh1%vv=GC`wwfoBzbmvEQwZE3$}{r4n<9yi#RhAOzw%h(E<^#bf;sLcco z!bMTz=&&^H5bDijsOBAu=vP(-_N&^jLvx|HRb<&@96}s;3l%j%?JFT>*=V(8#ak}{ z4@MG{?A&<3V@9v~6RbEm(od_iizVni^m&^;I`w`1ed*(xcOr(fmwyj`}oIR_=CTT2guEM;!Hiv;)2YoSWOK=si_=y8800eV4)rBF@k-=UBFMfEStEp zujWdQuBA zzD+W$pyDn^cLMu-2hlDFflA9-F)tKveEhokZmv+fthAPs^VUADU24k4a;ZIAx7VDh zt-`Z+3O!f%;C44)=Li58`+bu&=I)f-?558WS(~MP8UjCxIH&nN^xYh zpQFiQ2xF}zT)3Cc59~v7X0&kQ56%%|U)0ic;buhw&}fbxrtM&NGJk7eIHESfQ-fhmI-4e=m^}y2&M9`v8Dkmt*i(=DT-#H?7U&=FlU{eB$gn`W{2!9*1RBL z5K?$^pdZU@=#Oz5&#G959Orh~G$UXNx;0=4NmD`13_|gY{B2R5;R~a_#%SBw`RV?; z>VvF4k*S(ylFVT`PQ6YTyh;q>>0Ry?3Ox}mU0!ZXGf+hKRjN!6-0JUBPjQK2cI4!% z;JI=p^La)-K|*^zP?4J7CoaUAI{3ZaaTk(Z>u#2BQSN|0Je~RunrDJ-qNQGLGtyE` zi-v-gmC6&powe)8skP)!E)FDl_sd1UfN9e3=AM6tftTqSg-gj)< zs8v(veCJaCe8j1%@nkD%Td88o>@UR`B;|u8dz1{7C9ZX^Potx5u!SvwANr(V<}EIP zVHuYX9aA~>`Cx{z=NM@g6QGo|$_$);RAuEP!&si7thV^NPGyx+@Y3<+sWPYeR7E=* zl@u-}Aq*S!@X zYG2xJG>}F{7>F^eX2eK9JI{EhLZL&amhf~2z5J-=8=EqYMb{aCh(?}_^k8FpoQ{6< zh?7?N<^?XheQteB+};S`^rIkI*k{4VOEZp`sZ_<7o|y(faz;)IP(;IqX)f=+2$l)D z8sJM~rC~Irje+5<7aa1{wnHbgJ(Fy;Lf)KTK6V7`4_ z$cn*Pk0ZksYv!*h=wi4~VBL9&E#@-{Fi#1O@tl(*SYk6nZ9W`%7^~?JM_EJ0oo-15 z@O#k=4x>^5TT9_EoLrjx*mz)_S%Mjb(dVxG>lFE)VPV-wYvMJ$~RJ z-1`;#NzgBnM!edLwtzua4%R>Rv*c9aAF%pP^q?eqP1lK;bP0ghy-JAG&} zF%^7}CU0tr72~C`r!+2Cmaj74W4X?AAN+d}AhyM_#XShQg3g`qdC;h961QJmEHT-q z1xL`*)Wq)Y!j#{mzuyQ)LF;Lv@K4heuoAF}!D9&*`n|3`uhH~m(4!J$oH_R?99!7sgd;@EM57r!yB!v;`ew8Y(Ttrg2&4bg z5~weru?bLUdoxM4!fphY8fc>V1-cget9qDNSfc%V7k>Yog#eu;r|O&VDe3%0f(FCr z3~(!BrQ0<~sL6?kMDP3V_BSkdjLS0d@xIX&0>X0%6ok_y5PlXy>csp|pdl7?og3=KH1GI!w>>(jKrGw3P(%7SHEZ{5c^ zcuA?KEPsrp1v|^N_;M3Mn@vt$k9bQ|deB^MwMmQ(gt>;@G)5G&q#(_Y#=H>1h%myk zgR9s=m0N(57Y`kyAyXzObfin$_jSPTUjBIz)?Uss&J*@t1WlR+(M~f3s1AgPs|iOF zFMjz-q>P6URG9eD?`9M|5mB>41E{{i3=TkgJC2ld25MK{(M9*o@wbq8Ti3DV!%O4z{?&kF8; zSx()_Nq*+whpX{T4Y(2hdmXuM&V;AcOnsWAj03D@+l3PWZ6QIW&=_kFTku==Rh_a< z3+mvRMmR(0Kt%{Vyw}&)NT2>phQp$?_98#E(-TIrU-_$l$ae-gR49>Acf*I+7=8Qd zG=j#BD3fS@(KJB8-iqWYDaA?|eUNO^j)29)f4d#;BrBIwJR>~li!vGchh3)#+y$f;N2~x;8!-xT(3F! z$vq7Ay&>uO{Zr@UJGRotqWlDZ3tsHD#m)HUx^vHgBki`;XS>6+nJS|)bcKa#G#1O5 zN3V%C5|5zfv7h3|EBfJcn!&Ij)6}~4SM&EQ><}(2%TJky=pES_q%Swm$Cq^iWbFbT zc19rMdsg{98h%div`sP1wkmWyC}#o;5?5I zqr-0zRvb0YM0_;MuS`L~Q+phhRcQbZ2CofrndwLJ(2;xwN(Ds1#n38?c9HYq)%SbO z@Kf@>F#p_ZN1W7BF=c=>9Xw?H#hI7ZUXlDI>ep)z^Xs(XtX)NS#G|0*c`73R z2BJpDWn?mrB3XI%t*=ln ze-P3FacnonAH*U;H~Us+3}jZH#IeQB>J|_VI%N+iTdwYz;C=Z)zfs<(X~xx!zUK(h zq`=l-MDKow(^!=D!R~=}f^ENwJrRLsU$pvG_V%^|sH0L@${UQ%M;KzA;MW2nIqFqH z3#XG}R`+-mSe)T(WMs~EfnHFD4xWK|l62t4)Z7YMSG-^n`Llk`$cTtF1oPe2(6tZU z4v~nDSKmBM1ozpgiabkBWLUP3<));C(i9cnLvXIwjdc8_Wra)XM4OXwzT}p8^nn<= zc=Mk;5&Dnr&Vm=Qvh}uoNUwv?3hIwvKNmHxA;AHMwVaL-S4+TJlhYbe(lJ6x`*5bH zlR)T7*%cH4|A>N`EsjtlK4kh?fr`}-Qxle^>Vm|2M~#W5l|QRrl?Xlne6RFMz9zcN zjL;R%hGmi1QFF{AhI4Z`sYJNGfFy-M?6-k1+^lR_Y6^C|7%p^Y^&A4EPeq!R&^ zrzyALF5LN%&O0cPrpifjl1kf*Mn91#9Gfw(9wdQd%B4Y|Yw8%^9=HN=d8Pp>b!p$} zce&CWevu5gw9ClpeqE;%?=pPu6W zf=js)O+Aiz8hR&XaJ6<`u30Z?t`Kr-my$1gOdDf-BDi-_*71|tzP$Z44BKB($!oBO zO_$VdzZt+yg2!C3lB7NFa#b(uQ!-+o^hNDODx%X&Nw&;USu(z|Jt*1= zG^3SevmLsb(>GFCCoyt!w+U*YsZNXnwu_n3Of^Xv9!%lNnp~tCf|GHAIR{eiL~w9D zLsyS65VIk@Yl9>AhU|l5Hqm?MfO-=mA?d6 zX4DqORy-`#cYQbU< z&MAC-IYJNtF%yCG@=LYeP*FmhSS}Y_adxd?`$Xfq^^y$pW5_j%4pq?>lym(!wuNwR zp+@tcW-13{o|MS6*)c@GSUGq7oK`jqnB}}M!)cEQxieV z7AKmF>DjVlm)tod*7kM1VhY*0MwbyV+hKIi*dkq~b0X(9;m{LGZ0k)g zqS<<^b5>_0aR4WZ?v875 zlaS|7ot0h8`uLoiT`XFUS_2TH3E0+2lt+HxTGCVKETr`=lEG58+m2eMyGhcPB9^k* zwuzm2>ARvTD$>#^h;1lFqVz+>Ik65f%d{s0um^11AO_yW(MOfCU+pD1n2$J(Se_Nf z=q-t}mfRNBN<0SnkHz#ILNX_@zzUMDU_;(;OvS*mv%#6?!(W8?kja|obX8Jn97@}W zobJ!XkqpB-X9gaXT<^BZ+Xjwtx*R@r7`NOm(DINh;H`oJgSX94ER1mx(8_mmz z{?i7E*mTkT9-Fww?a3REr0G2JjY!f>6zl*C$}3QX#OuFIU{Z}2vN~RD6R2wZx>!r{ zLkW&F9Fze?hMDeCL1)8ABE@390Avisypa08-ND}9ynK~0pRn5#q!Kbqse>W8p-|uj zEU~@o8>V8430L@);z-L36OpDA6#{P3ri>^I9Si@?f|V$8mJ$3(5`e@X#g8~1ByPM5 zo5bX5cN>)S(*iR$ioLnma8L+s){rscD~h_H`gk`K%c-%s5*EP}lPUAb!hJFuGe$C3 zW#mIwKkhs|-foQpObB?5fw3DUH0drXTZRn|kpN`O+=`s_de@ua)6N)29-PU~i}jOoz^o_)dQ^MP6g>$)6f)HmZ9>^z;gGcC z4bWdjoXGEDP8u=&Y&JZ%M#QSUhn@29i6$-wf&=N?ONc8Z{&=POfTj9=wwGA6%ERb! zSEhIX+g&X{GL!7NtC$a&Dx%#hb+8VfIY7jd(8?hc_RC7ck}X33npwYWi@#X7d&_OY zt(X^5Fnq#A9~j$7s34T%rN$m&n+|MvJTP z^ZleKq*-t@s`Md@ufFDhF4vV?IlgxAUYITB&>ub}SC@ptXl7*a$TqjUQ=IKEe4<*` zB<9k_XzWStMnL6Wqg~2AMp=HB((WXtUW@QKm|3 zM5j5dE5#&fnW$zGlcGfTpB&E*zQsSB8H_vwj9PO9M5P8^rzs)34qD8X-OuW+3C$=*tg|!p>@!PRrm{N4;Hgybbow4`uQRw({QVf&obQ{nnfkZNnIuZ`gWPQh>=1GQRY2^ictrd zUYZ^d4?sRj0wl(Gq5?D}f^#yPYJDjC2-dW(TFCY{VjSR=U z0Mr@xBtvrblb|H+HYWW7k{uCsL+MO#;8Z;J9G&Tm{2E|h7Eh_r}x-06gn8# zhSSsUc``6IJIam5SPaDMns>{D&T!Sp)p#RxdQDZYZDn_iyd}$=_a!H?c%l*)D?iQ& z9CL{x#wf*T_f#u{;2ZTIw`~Tf1#l;6!>bg^q*_$%#hNGZ9&kuT;IPjq;3Vx)c3hsCe+ygaf-VE!>5RU@{l68N*~1su3gGCNQayot^A>Qula3 zz3rU$^N7z0SH*poYMfOP&(+Y$E1!x77QlLaLZ&l2-5Z{w`4W|7r(XX<_DoFkb;sB! znw6Mn;cvBY@ad6EZRSJEwr+iTQ2OX^LE5K}CRm1H9_oxlOOXyCFf|kg@0Gz7HVsI3 zGU>tY`-=DU`ra+P)oCX#NqU`@;vX@OFbXxvSvU=J$9o z_VueEX!tzeqdW~MHYyKZ7WMYt{S)q@V-faLe_783%{P(gLoqcZtfcq>(aeAe>To&@ z3G01Jp^DObAcAeVxWAmhE?}I*xU&~eJh6yjrl|hBp#WKvA>e`o5t0iTp1LP4JBcjj z-0cY7m?W>eA2uG%MwIBdJ;7GzB>&9LuaQWy25hVE$Kjh7X4GW)Y@FfA zm+5^cr!Qo*2ktABSZ3a=NrXR6XE9~Up}>{|E;K7hRHFOP!@Yy$VH&87O`j~PbAe)% zNK!HSI8=b%E*7pLwjt#DK(e2+;`%{?Znwk#b=`dRavvvkUr>&F@|2yi5lAadS{u=LMRPH!z#CT>HS|Q8NGUhAguDOraq>OT3xYIoM8yDXh9b$q3~j5-^X)5P z9;D8=G#6ykQ$dPZI>~a+lFhDHV>1x}fMq7xsejONLn4j==SC~3E2=JrMCC>fUqkWU znYM~D5Ui~usg%f;6Jc^~%hFmq7-hYb!ZfvE40uwa~S=532b7XsI0bS@PN zN4~FNTmBozdxoweW3_Fjh4F#wWf`f4l*`On$3yVslt5^ElUENE-rOk5>3` zD0Syw2{kr6sqLTmPe@BNQ)h5k$qlJ!&(R5+K6f3x^53cRDq~&4eT*1C$w-vLT@nkqTKI7`@^u>9zDrct z{94ks6?d?$`II|Eo}+aXKRCMZ!lgxB5oM;-8+}ykXWa{y%0hyVEuJHym_qX~%$63r zh*Xx_A9VvIm`~F{x>x!PMXJFAhVIe39V2@0(M|1CowR&adv~z`XxeKcSHaM=KAT1k zC}K4U$F40eY4O&+7j%L}tiwHr7L)w2x^z)i(tX@2uyoNRn20ZdBTO+?IrLfL^7$Tm(Pi;3pdiV%5?x4xijhYY7sLvrwW6T z3TspK?BTaAg}q5Bh?npdIKk{m)gjN#11-VaI~uhf+65+%b-ybl0c7x2=1#LL1ay2n z7+hV0ngUwyXj%(%g^4>{$`7tYOX+_h{DF@Yb{PcwP`Dt))X>`3BG+JgT5JMoNQ(w;8h0slobMOBLuQ=dGx`96_O1qjLK_v~%W(X>2txoVWIIxGjqmXwT9UZPFHwf_iK*ZJ2 z-3xAuqcQR>510Dx6&_3&5&GhcN?TI#64}sdwxmh}z}EX6YxXd#(tkR9i5%IV3o%*R zAfr2&injz_!&li@_lt#+W0nM1fu_Je(^1o!U~aNL{yBKSW=IQ}1o_12%gRZ&CRi+G z-IH$-nt{8?QBql#K9AmoB(QN{%x0`yb^Qt0l+FfOTlIOR3nVdXjK0+nAntjlXkV0X0-X>KBmNpAhWY$a^_Qh=B=`u!cqIr zZdM%Coju(t3?klf>A1P#+pLP4jaDpx2IK@nyqhC&nYfX%BaT~G<79riv00nA1R5gu z21a}DuC>A{y%M`bw_POr>Nm^r^xq!+HxfWkZJate2hfBqsMQ$^v6En>W`Z`!8V|cz zJALR{S&NP_;>Ovpg<&F*Lf*^<)v0w(v6mG*HDzT^!cM*X2a?VmzDZd77-^Qh3fK-8 zA!=N;bO(0~`dlnpb;&2YAUmPcSl^lLaG+^Gj76%6-95A-XQj;9ai}_vF%;fiAI?Chx4oHQWqu%@XNE+34fK@$+?hh`8eZOLt^V5x3 zj;q)<&VhfY^k`(IY7~CT_U-AZi=NL?%2FX=;D`Fd1EJz}Xpt`Bw$8lUrE#pHJ7H>& z<*7`O;$;|Le@4#j5S#j3F28X`ZrQ;5itSp}IS)cgKX!uSF#S8Hg1!s@0(-)-<3z z&kOEFJ@K%Tj>?29nvPfOT`icgbYP0N`FcFeI(Mi+T2N1))w&R!YJO z7R~|^76Mvi%eLKSUI!aro&yl%hVwwu3q*NJWkZAaxB?IVPLg%#BN8B>YY_`L)Oh&d zU#ecOFKpAGFEHRY8u-O&IIwk{o8ig}0BOd&#%XJ_P)u5ze4rx~RpVf4>tT&(o~j|x z2E}}V)X!K;+8+912BE(I8v;lzGeD*APts~$GAQZ?+V)ZHa*s~Eqa^G{r^C;|EZgjz zRE>_O!0qhZ1v*G=y=49$$?o;|Kh&c=2C~0TK$fVwO~JuLX!nlzdo(RJUqqRWhL2#2 z{tzLuSn&>8ODQE(O+uf9nWF12Xfi|d6MQT&AIr>z8{sT5)7XbBuu>Xqi!9bA5l3Br zt_<;Y`c@2Kgx_oj+nX)b^e7E>53d=8AoeC|dk#f2{|dI=ZGG1gqxYWpiZ4K+wDP}H zEgsz;xr^ftBt9P24r2oSfnYNDt2KJCNU?$d2Q^W3Pl#9ecTbQ`Rt)0ei{ldsBL!qi zC9oWK#F0~=Mk=P;+QjG(5*i*7cA}?&{c9Ku(qf49gb{ozYu&`5N z3TA5ogTNwB4^JqfxSn1$Xd+WhBEh>9nR#R+$LU`5O6VAP^vZml*0p-DFd$$GXUEOi z0f>Yi(4P_@*zmeo)US*LuNg;wwvFsuD>CJ78m^k%L)@JhH26x~n82rua%m=uFTeDh zQ(Ic{;atJEX4D4Y&a(2*5MlqfI4k(Gqseuj<6$<%=lLc=VDsfjq!77(EpWBQIn~y6 z0L{WX3fpykDBunG6EjAi^k!P7_Z3{%z-2eJNSy1zoOKoh?X(cN`hIYLlCJwC-DUQ; z>D@fy9#OQNJt!Z`X&CWR*@HLOnp9TBjZNz{9oLIk1Wb_#XS`wg7+Tw0xGn&@xJfF$ z+E!)H5W`Iq#{F*O6-@<2@RaOYp08M-{qNn82J}B>X5#IHCa#mY6ay((xG&%6+rvUj zIvbjE$`AXJe(hnwTrp?!$yI61sUT|>R}!ij?2db7#aaGboWmsQK<56i0yK1yQF1N zvP+?rdcb&2tCC=6bn}iO_TzRGwKF@PRIx@xr{INzLe))3JW?%#rR^C?r5lMj?1g+7 zA%##zWSJ2bFMd(;9@Q1E_J>~bt zAGydAGt@y`_bQW;39744rPOXHn)tN^nn!2<{u?10zBQt)?b*)HdwNb7d!2ttPcY1~ z-2n(tJD4MfLp+IY#nClpH+vK2ZUgfL#lpn=>T)|RYf}fxNWnT1L&<$A{1$0FqkCc| z`CEhU;riGtB!`q^Rs-Wq*T{8z4~!WYMSG%4gcjdEEwaeO7IbhLl+^F*yDoOFz;og2 z^-IC@x(coE`#W`AEq@MT%LRH7Ihf=6kMaR|i~aS5xPuwmcG+fWEVKU_m>sn*GL;*S--VSSseUv#6POk zViVM6sdl0sd=3F6qP1hPkW^8EXK2^Q?UQUWHM?CzFEvg0@a}szZcGXzbUykJ^9Hm()>H?^#s?dO|rp{5|!YQIx^m8v&+;N%|U6%zX`ke#gXsB1H*RWr%svXz@g#5dAiL zVMoF=G zd7q@2T(LH?LolHxi|*DC2nt`h@v6)t%#0@NUvFGeAm_u76Md1=wgbkBL{#>VNOaGf z86HW-0DAH|w&xpcm|saUV7#@w*;%?~4d&ew5C<>pytTAEx>>t@c=WY3GrNES&R`DN z0gm%vfJ&M0GiTThl-R84O-&|mz?gN`G>h_zLCo6+c=d$B!w~+I#E)PO?!eBdBcuxp zb45PJBl<{s|DilT+bK+uM=BXB37L3l-``TH`k~+%5bd8cYojU3R%wzQ2q{TMm|Ay= zos+ImAkjHx9{lGghVW=ycdAKD3tLW@-Ex48Y*c|M!)EK0D0=an88e(2XApn1W|-p6 zk~|)NKajw=kXAZ_!tumA6l;Cr4|zN>(V93Q<`(a4}-uxjKxSbk5-wGHpe2 zeU+OU-cR?vtd*LugvKIa(7Bf-jfjL6W4HH}wOIy5XDVCt{MEQ>UWD|k8ltG5lPXM0 zI9}j}zJayErNR7jMVzqBMSu|h_tro5I!1lxE?4s0K*+=$`f(6*xW(II@J>K;D$yJK zv+7h~z44EJPlU`}c759ifk#vlnT_m$+&w3Na38-J=Rl-sa4qF{1Yi~5Qx5fQ@G}A6 z3A+jfoNksAf990QIs5Y6>jgdT3(XEGLSx$&t7|yQw!@EKWa4AfOUm+ z8(~`D9+r@F|D^d=jv!g(P=Z!D?aG|fL(i7nb>fF~jE|K-hQSd(bkm-~4M5GVCD;}j zL5lU}`V&RLf>C{RR#Bb)kAjpeczO)rOy=}1qq$^||peb>z=-U&TVmbRhtLs*YVj0^S7r{qz_jWUqvMndbtRhQ@D{ z{P^zwh%#`_X{{n4_!annK3lMc=vY_RcU%VfOmmwj(`a2L#ff}6=>p^fod(H|iSe)u zII1oEgMu852+F_dnrz+;f5ln%Ed*@x!CN=3=j}Y&&}6Bkd>W(KwV_$eHOaWc1-v0Z z%1Cn-4XsR3^14-7K-y^)Jh3oii$;Jl#6o~o0G6Zo?F8F^*0A%cw%A!{Igigmrs3*4 zlDxGI6i2u7TiOCVha-J7GxKW?GT=4&Gp4-`ps|OkKcGXl2B&ixfu#F@aUq zz0>doVqbqHtn!Q4dPi)}r6xD-fpc!{N|{%bjAoED28g2mLF}<8kW{6UyO3m?!=9K` zpi;SWW^1I<_VdhI%g<*AUb;NH^Kx&tWsOC<8|TLRw%e}=hlkVt(tt;2KVHhEN)p)S z1h{C zvrCMaYwWuXUk3bg3o6#b&;Xneo_%H`M{9=izn}^LiU9XUy((8xLi2-0GSVY`OJxT@ zH^bZyVB!}-HKH|R8?ywEcaU_2^WqBk9)Bx2z9XL5TXI66(O3?t4DGylFHV4K(rQ;) z76jYae>-F|^+1w8AA*)eCV{|7eXRX$Ust5WT#H;_B)>9c=e`|gvJ5zUTsbid3^yB4 z)PUS7q(khBP@}wuu|pt04p^QYsTN57Wi9jn;>w1Kx*yAd+{+O*K^Hno7V0WcZbq>^ zNFKWTlsPw^PaFQM`8po3G}t$|(sHZo`8v-;l^e9+VLSE63GryDs5M$zz|E8{UyOR8 zUmjzoL)ydNIFYr>NcELAKYqW}*P#`HP+3-1{y1VK zdeG8sx;}Ss1AcclBDTd#$L+LuZ#`hVI6j?2{28;`T%{h(d048qgD5KaD6Nb_K?#<^ z)84?ENS81A@%a)x1pU@4$+6)Ad(8_EEn~WehAbKn?eOB0xZ!yD|Ke_I$h38B9 z0sf!aN0rzi7wK=@C;46fZwSiH*4el;MkezrL~sfN(H@NY6WBrUmbDb%0Jl zW~}WPQMUCnXIJ0Z#w-OSo;gwxZgSIwsgt^*DC$2mh1VCvS%v4PPiMa_gCjPaQKK97 zv56MBKGI>3Y_!S;7u0c!5($U=iL$nZauyDM-rD&}9hA376(ZW(gpAfD#+In*3f*1V zFpD$ZI3{gv{jE+o=SCGA*hsoN&&?g%@Nmk44EZ>v%wAtsn#G$WxHtD!*(y)mGvP!n z7%4m@a?ReR`G3q5q~*$k|HOeSDLs&8m^^sLn={{aw_nX9ej0I#zYiJ5A2|phAB}q4 z$9ZIu`CM1m{beA|B!yyZn0ZT6;-OQNmtET#O(KZjGBo$Wy_$tX1h@?gMe4~aW>+%V z_Vq)GOY+5|dLZVMtvz@v4=({*okHH zKjQy!&B|ARQwWR&FM6YINb$sRp0i6d%$3WY^#B6hyn|SHLg_J@qC$&kv|8yY#eA_L zwG*vyMLHcuF5p?cO7C=1c6lzE?V1Rac(z#FP>RRW;llxv&Oa;oQQ#Ku%&Ew{Qv$la z6gg{I(+OmuSh&9wJp*BUqVMd!#?a;F;SIl@y2jXfT zrRcoE^Ouyn4;B_Mercvu*&oQOOU88EhmnsJ{JKsuCf2;z@QxO9;(;gWFo#aBg$Vm= zSy!AWP&dyat1JV`w7nhY>g6}LDA5M80+`PyHnn9djf`EKT_8a{Ot%Tek>yK1C*C{#5sZanTYCw6KF=C)I z559y_hhiR@_f7Z9X+|QN&!7EMw{xPudM{uGMbKOy$ zBp81QSRkDDX+jGaRf|B7E^e=9G$xXadpr)y3Mr2&l;LnmRBva70_|i25X<$t5TvA*6meoHTgp$=~Ttd|A6BH6k%t#Qgo%3(5# zV}%Wo!&d!|sxlyB1xEOKH)yvWUdDX=%M z#jZ)d(xlc~8%bbc@)7vKgnX zy3~Uj+7)Zd=&7z6RT4K?B_<`r9}m{tF=TFX+lqB`Vc$O~IwI$DjjG7Fot!3}juT2m zZ`YHhu8_aBkwADsIBu`W7{!kWQo`GZSalk>m5*{O@7T3%MiP$k{0QwR|M(p%2Hy^K}_M8y=ha2@7Sm#STLn^t26YW!s)`ZQ2yF$lhA{}hqQhN z47EOqk&F z`ZE!dW!R$+SXE}HRl@i5Z7dmUnX_F|N0P}G$K%9fPq=GtiTtFKz2SJIn0JG;3oRJ<$q>mgQL8Q8n{fUWT2`*5pY%gpAoGYLQ9 znKOr&wz!^}yweY3TCI;({cX3R;j?c%#A_FIG$=qVwgf6PGUS&@a{6Dul_3qvZ(ut% z*In_WHLDAZaMf%t7dTWJ9x=x_P?SA-LuxEGL%pS5zcKf4nTgraz0>L8s}g_d8(58LX+z*b#IceZiDh*lC?o3bGy+ zT|m1;1cwep$>9l8!7zsfUTBj3HKV1_xT`N-rgnaV-@M%#Wwa%uF}GpiGexD ze^FJcvQMra@vrL>_?=TqwuZ4}6Q64{57B9^UgLfD_uPP@lzuG*98fX91p45}#+-0x z5a&U=S6;~`*(-l+CRPViGM=)!6DKE-2q>dw>77PyDaxLB&6;m~yMoh9)PRD%E3odUtlY=kHAeN~3uRTvU} zl>c^Dck=5Nt7V7hND!-);rUIpWl*_{ERqL1Ui|kEpJU)?*mTJg!65LHrJ5Ktu+A-| zw^ZhB3;8A^`q{G=@-3i@tDIP>|GfVQ(Y^Os$6on4LHkJn`IIh?xz)yAh?tqt6~NgK zuIHe(+cO9ii*~7|V&#;&=;$+gvA6&G*8B@&+=_ecnZVQ)Tcw;NAJbH2)*$!WKUIOB zz;-D|E>kZ<7J-{zZAM=$sd=iFN)x=PFIB3BCpUl;k-mYa+)_9Y$DN@JlBS>D86|bf ztg}u?VSDPNx90zXNlSsXw|BNi(KB&^8cPpYJ!3(sht+mBmYR$(w~s$n6ebYX+46r0 zzxR88c;Wjz())he{r=Jr^XNN1udl|jnglgTZupDRsuDzCNE9v|*5V4jk6Ugi%$j$) za#zKkbXF0=QPiU@Y8-A<2Lsr_8Y4UnYKI15y$qZ><3b)rbRt=>-Qk>GU6Rpk7Yxvv zeX0^iWiHZSs=fdbUuL{mUEAV94i<3GVx*4~3fzsB+x17K>^N3aCttSk zD5t;5Bh%Y?eNoF_Y+3fOumousd8TxDWCS~O#o%$M39p#eHKdsU;|kEE#=Sv^F_Rzn zhw9$5;CY>L!A>9oOKu%$78c&v#H2FYQ|3DMX737uO8uaZ(e!X7M-fTg;2c_mD5FH(JIOsVxUb@kS>T)Q*~V^3GOJzT5%9xfhta87W3iHNs%n0bZD+q=UFI`eI0?y zIu`h?D4p98R{U?s-aW@DcnWU2^}xVY^mUv&1N`l+q$!?6o*Xjhq*>be`;o7;)W9F= zvzSUuK}h8-JfQ^}D;AO5B4OsuJ(b%ttMq`)9atnppE2WR2e3?(r@-wgiv+CKt;k*t ze2sWbbC{(eH#l3%4JfME%f8yvgjt)a{mg%p{3$=;K2*<*A1JoLM-5zcN2(JI#v#yS zESM4Wu7P7zX!ATCs56}Z8#U_G+})UwEkuX%l5nYEN>iiU#XKeSezImy*g#y})2A^Q zF$_}SM))>iP`gd#IQb6+)+}n}wP+fpnr-s#%9>8H7gWgRfq;!H4^bxk>!&4jdl@qw z`%h1F00jZ7+fY*rCza|67o4^jyXie9B1mjsIe0tJcEuP-^X*W8*2I@j$@{2;ZxG>l z&|O;JV<5FrtA*KC?$Bu1_56o1jGC~ZsXgQoE*Qt$<P}cY+z|3LFTz&31%b4UE_~3VZ7{vg23}%;Dt3m{KXE& zVe(>#6;pdO)V38!&`~1Wch(Wy#kvA@6-7(Noh45P7bZRd9kGTatq#c`1h6FFazIe3 z4@h1r{U>_yN5VHV&_f{I!c++ss(+~v#ExuNrl8BT?ZVomTh;!pAil**>@T*m2LT^N znjG3Pl5JvdlBh3(nC_bPZqF{Y7UthOCQ2S|i{wvh?FE(ZQ0u;5EZ(5^{&w%%p;^{L zGQB-qMFxz4=i0~ObOg9u<`3#mp87GsNb8#*0+1ADlQ6yzawG3zi(H+}uTG z7b})Vfgknp!O!~-gU+)&qbDwToDeA9bcUrj4~yVmVRS>vl}b6m7Ja}@)<;;JY6 z-ZH-1NZ)M=-k@hw@beg{A6Q7NPp`NfI!!*R#hCHPr|q|#+f(7o*mqWkBCWGJhhk#p z+N^AhkIQGHNjQJ=jAL+Dgg-OfRCE*zizuH0RTdGXsI5o{-+L*pZ4J5$F;_J$f)V5V z1}2n+N$#y$?fHVoC}?O`O0QcrJ8>&a4uH!8_rGUaJ=&C?D|XBj>S`ACh>NRq5@Ay1 zpHX!|-=7a<^z0kMLiEl@R)>{(km4Rf>F9Z!R68>tUvV0-CrBM}09RBI`*tnOTk2V4 z23yM_XoKA+>M(%EbI|*H-37^-v@p#y?|)e34Xko%auy~yT_(C^!8Cc=m2Zs7AXdfo z`iN<#TSPqam>TITJ#tkwmx$KSJG>(2Oh4oB+AM@U`~UVIaPrV{Zl??uJ@xO}?|j!7a7B%#t(NTfv86EhBx$tk|M+1J`&K zb*-)du?tgiq@?fH>&%z7L|4~AXU1$E(Lz5~LLStw8X~SnLND%U-$|pQON^-A>4PZv zXr=$f=qSTYd`WURkC|gxbhxs*L0yt)tz^qI?lN_nu#%dySk97KREI)pm@s}fDP*{C zF2Dy`7i`k%ewLdMTC5~eWQ4#xr}3;C&S0D_bYJA8LRzgpV6U2?gennk68?^Hhw`Ye z?VCql)3V!RwQs%Baxt=|-va*{1v0B6->9Q=;@0e75Awg*=@39l>bg?oy?*x<;9SDU zq%JwhvxXunHk~G-&Xf9Xxjw;v5OT`jBAH7v*TWVqhPcs%^N0_;=*lF^=@H#?``rr0 za(YMu&~tmx2F!AWdO~vDbZOI*Hl{4oQZc0jR<-xB5J&-vV|+s-0kh;tf}@Q+@XoV9 zeYNw$1(~AdVOf)GMHrIjo8D7wrx&1UqH}z%EWK-DxzcOg-!ziiE)0!vR(TJosKAL$E^k1 zwG74kPAe3@;c;R4e0$AU?aF!E`C`j3Ed(Bw=jMD(zv@k<5RBn$~?1EtY?=)$$w(=dg7g~2Ng%321FzKGv za)-37Iw)l&9%_<8kxEjtU@CHVBZ;{!FPN2)?OCCzf>_j`Qjn8nxB_g_y!yO}lSi@Q z;-Uno(UKr7)D2MQ&W5oj_-)0G!^XPz17ngJU9QHj$&fv7N$dVsL}t&YBRt}Wrgt*Y zLBjB8VL94+X5nZ%a}C;`^ef&EssZQL{MXZJX|&}n^1GxE6&bwd<#v=Bp*Q2}J?>=6 z@(}Y=^V%wP#KFvf>0!P{dv4L2>Zwo={@ba*xHdMSsv`4Bu;c5g{`f&D#W!Lo*+$Ng zB`_NfBY%c_f}BOOGhjFBW!jv7OZ0#2gWEyuZonN1G&=;e{XumR+AO%p!l4&-!1o1N z+W9x%_}QJ5E*&-*FpK(niY`KpR9{pU~c z|0D@ovH8KLny}C~pFPflzFHv*r%9ve%*)7Bo;FexL3`dMi4Kt-CCV~5>1(W!k#fFh z8)}#382Waaa7gjtzD9X0zMgg@lF7(XelC-%H5y500m;NN`vB>VC73$0=L|(){sj`t zd|3|gr+I1=SS3=!Dzve*CRli#a^IR3D9u39iu=2I&~V|qWPYK?otU7@3s4&9im!iK#Zt{+JO@P zLekK2I)|kid*_Ktg9FfLq}n%S4V`kW6Xjn%u3k}I4bm><9qJCWUEW{BKrM}s2NRhg zXoP~bW*2FSvZYMo&@rWz&rp5H*BIMM^+~FFLmGY zjj%UvVND(MerBp&tYJWxO)TdPhA+DcroL>SZ8Gv(mGw`0#I|*Vg7#U{^4jGq z(^fr>eEvSK1#5NqXY`M7b|Q$24-LwuJ5B0h$XIJCL8PBO#OR^xHw*XBebHd>J)Y~Y zlhzgIT6#8Cu^ZuFx?gVsRRP<3)FlC0z0&P7@0+GRHoN53b%#RsS<3ghUCFdLO^6Vm zgrSk-1^lLz_0;wN>ii*}hd$hdR{x%qDIX;zv z2oS+NblIh;Z6DS!$Sx!vk`-9J=^w6AuV;ep7HZIo!puJ9pyVH?B(fe0Ab*n1fS8;7 zjp!_IjU(CA(tEqhJKOD&qd*m$E9hu}PHv&}q!!@nj(G zzz0YEo|jTa(o4ysRk+-&MC^1+d$dBIAk-)Ir_=(PCzGaV;=l=VLhU=*#pI>>0T$Vl zVftd?no^;2t4&%kE^=~=*QbG?z9pQIo{Iv(7y*vi{L;S0ADAK8WFPk22riKPVsNgC zV7w^`=3XHLU5h&KJ*vI@2*{kdW=&tA&5ACFD}Sa&7~(=P>Q0nE{$g+6Q#r3#;mNll z(IU%L#c%}SNa779H;<8O`5~vkZQxSGh?~H^CZ@ak$vrUcgW>Iz2dQRYIa~PMn|PO))_Ns4777LWAb{hJ{#sr0Qhx(fY2H~e4?@JRh}cC;Gj^(sCMAScD&$qI0O&;T;L%53N;?Kk*dUq9IeAH080m z$Lp59@!~T|Cr=2i7u1*E{{+*1F|_}-`1%hjkFlN6{{Yhdx4Bo2^D{{RJOF@-;D4fT z|M9^8-So^&tnE!4|HIKs`xhP?Mf|npbqL`Lw=~vpMIKuj&RD3LQBBp6u;nVUWXJa? zMhF6c_HR{=`}WG)=GyuLkf3-t>x?9W1mxD`<<<3jd4&lpG(VHhl~tgCzm=odnQ*3s zuxlC@QBE{D+FX57QjAdJ(E%{h-Oo*^ry44dSSW6XOTdzmdJu@dS*Mx}mZSw~R+FoA zkSA?$%aF1ab|wbq9!Z;`zC?Kwj=q@-zzW7DQBIT=PChhmX%dmH0py~mh(Da6=vXBY z=0%*F%IbGs;A5&}8M1Y4pxm4YGs&*4Lf|5M>JZt=n7B;a^bFufnK{z0t7~cfnTo(L z_F#xwI^=VtpIqubt%3Z!JBTfCS5O@}Doq~~;Wqlj2}o^$y?Zo+7RpBWSDIezp{=8P zO;3>t{*}9Y5Ux%wsliQy?ldkEyP{7xflr0DdmYwGUOMDs8yarr(9$x>JnJ7QAvCDr zNmP5t(h%^L(r30YjYRS&TGotk@vt7gCKTG8$uv?QiM<+D}YX z#r|gk*T{C!{tujPcCz9pk1YCqJvn}t#N*23sp>SE2}vlk!BnMqlAL0-71|onsX$!% zq%>?3!S}7^CwV^w!+?yIKJdeLqOw}gsl=m=JAi*9AO_rzF86Lnbw^ZF){eLY3G%}8 z1G?Y)cUT%<@4FK>_CU1j%QHM&9AOJS#NhlpYc*07eR?kDv7y%q=&xVU6zrXJ_v;>b zXeMjE?xpvxKNf3#@23*{mECSrYvxu$r=o=4;f0cptCUXAXr*kNKUcuIFI zn2Af0WJ`6Z2PYT;8v7`*(_iKNntFOs8k{I=>L})b8rBa|53o&jIj&)!CM%@5#K%zd z%C!mZvnQNq*n@>Kiv*)}RtVke6(G22jf-3Ec$LiLPh8q}9wcCRNJ)gC@vy#eg%Q_% zPifuz`(0=aRePW4PAzFED=taLpY}xcqYzT}6gqxvFrgfT}ll*kCRMm|&bi z#4kkLy}i8^{l^FFOmd%kO<0(6U672f0LLc>S)nQtjI?V?dfFMN;(|(^B|=ZNiATzj zzF$s>qx4O(&EJG)4vLIGr8&)8{po~IW}b1fAPGW)f6PgaLrNVZ{(Ig%Dqs@*Bs+Ap zFvu{{35Hj`^=9d{5pr0>D(H1L)nxQgXg%3lc zPemjq0ZT==`y7RiQliJ~Rf3d#Q!SW=4c?cc7mrO5idLA&PwpU2>FFm$zRaUYL`Bn3 zZjC3O+<+W5UBviEcUPr~rUbaB`Pz`wwuLf|^#-rO$JB|2^)StWJNi`W#AWX&Tl0ER zdTb1zzs|b04+h@$_~sJG3!Ylsz1qFz`8=4|8&Z$SBZ#s*(B($nH{Pz#-rh^qclo$E z__#~ThLV6k{STh{yt%n<@Sfn|S790SYQ_$|pZ*|?HA|Y)DTMDl(CouUaFupb=-&Qi z*Mm{Ac(ZKqC-OXIaJG};ezDnCm#sAW>_!QyIZV!8XX17Z@-lT2r3=)af-TP*X59$ceQk2LP|#$wY%ZxILdSVBi9( zbiSA%;y4Ky+jpT&f;%q5xH<=2L7**(UfZ^9+qV1Kwr$(CZQHhO+jdXqWs<>H zoU>C^Ytcc#7wE!2b&FhFFk_0Jbwea{L*Kib;uY{mo^5UEBsnqWknlba+&Ed^7u>Jw zD8ndVMzLGaU)jJE5!DB{Fd1wuWt;{Fv6mJd4e**eieb=RbV}Qg{DYXR??(gA@xF6S zFZWiZP$P7q%Zp4q9tU@zY8XcA2$>&D5VjF1!*#?{qjKF1tl$wkJB}RR4y6#CmCX(G z3&QbI8Ea1fQl&JOtBgpJyYnLbvYyRm;T&+Nox&{5(f)Wl=0Or+0410^UY1I>o+Q;+ z6ytpYd!eCpDsKH*PDiw(FH8~!UPvfLm{KvPv+bYmjwij2oH|kYW5;kgGroLFgL7xQ z=sj%W?rRYH&uy@GsYAGX0gjy~XOQMN3D=9(xq-(*68G2;DjN@<3G``nDWnk6j#nBd zgfO5`ND%e7K{7}EZOhX+C_{BAb0@P|jyS*! zB^v$J(%{bH`S_XajY*}oc#8^IH?Q7)``lT1>?1y}wyNR^Jj+n*P|ET~bzllXm|1ZybWA9Lz*0x6Ym+-zaj8>h35{E2L8o zerb7fQ{<1U{Hy_GCoG=5(@>BKIlo*s81aF(ua5L|)tJ5U88D@w5>Yp%%@2_D zbwz3nsezI#F%1g;a>#G0^c4J455DDqA2PT#bc%IMmHR$j@?O zCOp67WMg%8O05ufCbz7BFXcv!i#sgQmT8V%cq$jOz^m6^HRv{$J|tw7i(t*7W&0|= z&v82wAZcPc_Q0Ra+mK;wWaEkj=TSe>l=z7twWkT{y=-1(Bf`bp zr#*2Onp6k1b7@S4&v63McV){CwqCDF!`9rlH>y=v2%}Lay@oj}5cu7fybA^ScS?pm zqAh0-Wb-sxJnTYz09ts7Uc;q`;Xs|<^(WZZJFAK<{nH?OixM;Qo$)yS&A!TSMH9^J zK1ngv)O1f{1*9<}w_N{TTJje`Y?Te*DWXqzhcqdFxNn|W0_j1QF?xZ6${o!6%2Z6? zSm4(-9L${hdLBlK`dUm>SSnIbyu4O5mdjm$kQI=KkrTp3qoR=*3)QuFk1K^=BR6p4 zRXu_#G2;}fLN>DS)h85z6ljGoxa^7QBr&hS1C)d;yXGI|?{{Jiv}7OIdu>6&>A@)! z>PTq=wkoLM5`JX%3J=ZnsZTr0p-;X)5-CxfLgfn&NUvJ{cvv~KODJNArbehd1!!4~ zj(|g50t0q_-6Lkz1Pv)~Ln|O)mhU!vxGJ;rnlmLrP?{c1uI2l?NvyLYwER@U0;OVCUr71H6{5%l2Z*Jq@Gz~DFSrO18j29gRmiW1EjPcjTg*$Y0yPoW zu%oh9S=|iM-Q{b`DC!eGtQ-cm9i7wU`p3-v^=O`tW713#bZNjfov+ftd0=~Ns4N^T z={Qr}8Xk9ygnbg!1t4GA2uEsMk(imu%w<@oN5K8Gc`X^KscoXEaa~pG!1u57-*vanW`88@?L(D@cR@vB`}TJ7D>ciXs4 zaPY7%A1dq{JU{m?ZVsQnwr?T>moZPso#sjNvuiSmFSf2LwHwa6=a9YJkMZ#kgnins zBV+hu-YUY9a-Dms;|_42eaa8?sh(6_@mU|j|w>JvpCwr#qX`uQkn+-}&TXz0{F+P2?_j<{w>q&!MVmF^f(Iy)ckCcR6fe zCTNs>*I3qJo>jFuv27AeY#_5aP$Bs=qLqEppqLw(?SK?TA6sC7C-{Uel9 zK+iC@Tro2B`^~qEe>(n6Z5hB7YCNDRz1P_4>1#h9TY6uY+H23#=u|rz=Y4z%W*dBZ zgEGagRzNZAxs!PhZ0#Cmi=|=rdf-%p2i%E%sZP3wzOLeDQw=6)wS;VXmONw5L7i?wFi=dH;1^fU>sFwU=BUM~U#wwBx(1RBm3K1E)07WG#5{ zw-~zWl~$>~RFk}#f#{HxVf^;Z}sO zpKd@?%s;I5^q~!iysMuyg{Mt<*=sm!nJ>T4a2n4t*Gk%+lN|f&+c8A&-2A`ZNnibZ zeUs;09F4+C=lZ=6TQ1YJjgEyC&=HqTKTXE zq0eR_QVY2VSERsKvJ#!@2M9`OW{KMm7tQ4>Y!Gknzu3 zrV7ax#-jQDo@tZT!@nnGQsk5<1$M_?y?v@8{e}UEbeL2_$CRj9|4a}GTisq`!3Z_Z zY1!QN3I@^t)_5$apN#~jN;JRzKaaQOD5fMeGynkQKX1T)Jl_9vvpYFEx)?dTIGX(P zaJOjc*5+sWpsx4QMLk`wHCnU;~ndsadRC z*sG+?1c>Qc8oCI6-*eu|B|RiBf6&kaH{d89M_Amnk;<^n%W z^2qP8UTYKRY)3G*t1e{!-qwcrtDirI-66dC?V@~paADOd%5 z=fUEAr_jnP7l=!EH8|CL3u=PIun_5Re|Q{3mVP-V8!99c>^8|~HSc0$ueUJMHGxyS zQanCgjrq=fY%*!d?W#j@s2^!q=x1sxjNKNy}LflQC_%fKUh7WsW@k;eyVAD?xu~q6xPQIfKp5dZTRX z;4FV`>TRn<5mlO|A*M|R-YbL-+~1qFA2b~oxJPEh#Ep3q%L-~>=;l0G3bD?lRczGSHi zu*Y8`_1*f^4@@DF^m&L@03q_exZCmX6SN`&jn>xDCT3UTRjIh+jLg@)XGX2SYReh| z4FqFp24V^C%Kep@w?vqbML|_7-*qMdA<#}@d)HS~(#S@L`y(1Msn<8h?P0PAbzmk5 zjYYbe!lChCJ$r?32=4`K3C7=>=-0ZN#KwaOkqKulJ>vhJzA1+U+ zaaX?ba|#sEoxtN^9{KE<-AD1h%*BS{m#sFyg|oA^x%Mh`GLGmp_X6T<4d zf=7%r_#=aXzlu~Q!5oYw)<_Al*q;E)NSqcQ`1vnnud3nFFzi#04;duJH2smad6hwxf3!7aBI@{aLA=rPR(``$AhAgUeK?$3&zUO4d$qXS*#ek~Yu4&#N(q~Or7t-(>P z6Y3OMEs}xkJ86~_j+Q}%AJKkWpofpB^?&+8&F1vH#eDDJZ8RUw`2&CAxgiYg`>}Mv z3$34}=vpg!+y%0pgDer^r05w^7=Pv z%c-^;hL#hp*m~o~uGUSyo3O^Pupe5HCS_b*Y1xf2822Pf=@;bicr`g>yciG73R{m@ zz*`;qyu%pbiBn48Mye~~Mo-T5>quu+I~AL}BRNUX)mUrdcK!`pTgGDzmFMo^Y;VKm z$DG+9R&hhM|BoU+n&;I*h5K7O29(RT`k>Fo`dNi|biLPF^>- zaHJLGJ@)zF%}nK{3iTG$J0T*nN0t^mAgm~i2&4GS_4PT_vY6!&h`%1 zWCRiO{baPR&cfnpZA+NKs4^>UcI8ch#u+hFn3gIv`QYh#ooHS;dcw$EiCmpaSe@SC z{H9FDs41kI(6*1lKd}E?*J4j6a?^HKJ1lH6z)+EC@Kqw?oY3@WJuz?MfJEv=aMSqV z4nN%x@n|_h+Y38F$;pdX8{;h~DgbM*uzzarpw1+^qddh_P~O?3lcv4<1@mN_5ENf6 zsKFcQS391USOp%Hcro~lH~Xi$iLXl5UHP=QCQm01JxZ)9I3cq9Nqkor(HcJt{{e!% z^WcMFkl+3wjCS~*JTL=?w!xD!-41T%7%P;!`6%4FHgn7Z-CE86;Mfp?ns zak;qk8%LVRn*^M`H76?RF*knlF5xKNl9Xwj0V0EjMkxS7S`df+SoWwKrRL2AIBuUM z9NGnl(mLBFwTpjoGSMxPU`ir`9I96jqh%R)J4?TX#wMeYxS_ZEcrYGkzXxvExbAXP zFkjofx$b%gff9y5U`zG!jY4*ygkeT}S8sDGv?m2TB&I~74^MWzo}Qq3Cj%3^{XZ3z zRZC4TCX56RV3Nx37#XhWhkPrU1UYpv7QP5j-GB>**0vCY<$fGBi+7-6H}gZXw{HGd z0{g{*kKbiBf1Xy?FAha8^XUAMx53KoLt!wq`8Q`17i95Hx#c_fH9;B3ePrfaJ66t_ zIc;X0y*Qy0Z$KQV98+wr(wH8>JEV1IBFh{8-`f0sibj9pP<+AuhuNBK&}!-J7F;Uy z^FPb&I9`^}uiToA83EiQ>M`G{eoQ?GJSX6q)q z&U+blf4t6nLi4M~H4j>Z_yl8iI2T4RVF@}dLTbuWLDHLLBl08o0i4`?#L#OA0wir+ zNkQsXFx>$3Hp36KiVVxtZs4SzvMk$wexcKx!(QYUUZ906@~&-ES>pN4^=q4Tyc@bN|#OUa@7JoBMgf6%P16RA?7T>~lu1Up}*Bc@0iOy49~Y$e^?zzvNhw;CGMGJY?(Ieu-CUdXMHso0fE z8$@!?tI6o>7iI)L&AtdP&lLda+; z7(V`=mdm@0qblfMCg#Nh0D$r@P&50V|FyG$p|#0>A~7v3yRC6l-?cgn3iwn7jrtwF zju{}0tqly9?Ecr=)_E*2+87gC5+%}#_1c5KyLZ{lYKfgBS+tRcPY6-j8BTf3XH3_w zT{-xV-dszU9QbLMR2?I8@X$IWe=^NX%A8#CQY~}kQ{DS09_KDZ(kwMfnwq5^uIT8O z+vn4BWd9_}F7r$*jyRIhQvs>DF3NyPfKkniHk|~M(Ku#gnOzdr+^&#RAb|n5KNS-Z z48b98NS;M?moL-g$vreWsU180;idg2{os-tTM_7)$OXHkp?!E73Irf;3ixCkiVNt} zq%-YYBE;xasH3evP*F4cu61)tB6ad{TDo)QLiQ=MO}nsm9J1ba*R+HBsVkwYYFtx% zCm*tKO~YqGENY}mkG}$Bb{>C@e&>hLIeM{ytAFFlI4|TGR^|idR2xxp`?P%H&cF}q z6lVs&8_e@nY!2gNV8h2~x z==>EoIIzPh8M0CB(kMZAvt$vH!yvxva_D1&f92xiWMX7R0N0OWuS02!+>Bg}ygL*l zY~OZq;>h2xp3h&^Oo6tw^L8PtmmH5K1N4&n8ad}By-QyVI%)yq=mrnD@GF=H;u?%V zdc}*(KKiZ%euga6iK^y6yt*(TlA2!|u}Y5MWjJmQ0V$(Jo$PsqaE^3u>E@?T`Lnrbk~*q&hU!&ZjV?#8zl+$fRfO{j=Vvsv3T^5VxLqP4!qEh#e4m`|^$~of|&c zw1FH)>MyNsW^uX$^wWf33j;C~wh{dZx7Dz91AkDsC^}~?Ex!alXy7xK<2gS+ZN?z1 zdf2TrL|0A7D!ZTZxiDU+eeU3y7JDURkX3ztPQm~SV9VCZGXuw2856*ah3zs$MM?E^ ztN9~&v$l>hD5o`&sXk!%bu~hRDICQ(6narwttVU$HQgNho#>Lah$s}hzBVA;0Q=&r zGl-2%^oask&xADY7bNOoMxkq;PJ_mk)qKbO`PS_iywkJa8K~P8hJE_@t-)uiC)?DJ zUhJp7wl}5EjMGR`?X243J25r6@S1?8g}~aaL8A}hYzRh(irK?2H?bCF|5A(!MHW29 zJZARl?2mfglGTq+Y14G59`0zH^hj4#ma!`p)Lk#pc=5XA0eRQ98>UL6zio+G6^Rz!hnL`(Bfk6R3*?<+&XwXd9|9AcA5oh6 zRPsR*{qI-qOyhf)^p~HTh3b;t+0?e``%r}F>E`%Gdo+Wbsgc~2ow`C)x5x{~NlLvh z!myjOO8SlnrSg4vIIp*}V1|GE@K!4CB`An{%@YoxzJg)-3MRl?r>;3iV=?u8l408g zWF$5XM&0@b4H38i$=t!6sqK zb&*^Fij`sLWlAtwJ#o;=v47oi#Db&jM7AJ^g5E$XRZNe2T?EE)`zp_5ADcy%IbQ)` z`n`J#D4Ld?1{!Fnw$w5M`g7w1ecc7k;@&Tg3o zm)>khJkt-mRMvu|Nr$$FN((-&4o&b-GicE(XXD&C0YgaItA)X$B^<18i(zM6*=mk_ z19I&Mqzg3^T%`{`b7B;-%JtAkio-sVImnk-um*|J1ZOf-)B95W?3FKkxwq%eeGd<< zYx`NSC74a+oIU;rQdbyHu!4fr4a>vIpFT)!&xK2G-U{u^Kp(ApG{-sjrhv^hegLmR zvkM;5I{A?lYfIy0`;gdc$#c|yZ7a}H((sx@>*yMbb3;H-Y*?4sVt=cOBekB0+GmUs z9cG31=YUA)<}z9tEqJU{=w@n9mD>9xL9w^A$eo%bS(~gPqTZ|fF)wKuV-XD8=gKWJ zmw9QRsblvyAOQhR^{5AmygB4JE|5CnVL~=3$Og~oQ5-(>Rjf1!2Q*5(%>(gyFh7X4){|DmgWAFMB46}OPUdFVl9n5AAE$eY$1hc0+VLYiLJ zNn6@S4xUXB_Qj6m^7TYwlJ7ykrQJ!&IQD%Db6k{AJQl9-NHN`zMgW4qYE>2>f#ev{ zFqELNC)@-=WFsz#`>j?*bE5+!=z}vc&3&kyr!|!-`VBbKa}&b8P=Yg8RrP*k8yh_t zEQe@WENLiB%SNybOhQ5`RkRNaE3^^R6|>J0dBz%&KbG)$FkMNEY4u3Los%W_cb;Kw z*atPNl264x<}ARVMSFw5-T`7tav&c`Hk`~)8H~s0coEb?+n~1)o1jL z{k!bVLA`-v^Cey2+r<6MBf3lbv2^i}G#X&UlF-9055w*zeD&e4&x~z+M45hBXv6WdZ7ae&;9A#4-_jeE860axjR6z7KswqZC z>cq>3Jwz3b*peCe*N(#uLGD&MFVdY8%{^*zeN-t}`~2OpZ^gs0Nk-wQv!(aDo{Td~ zP$NI@2^KoBgdqsJq^FBBkH)HOv#VGC?ab;PK{X+Q#rzfa4-IU=kG{GqA3yBCoJ#L1 z_h62Ra(3V60|IMKG}h&hqcb!W;FNiFrCBt4tee-vQT|0O!>=N+TW#mp3G{yo{;f! zjD2H*ymEeO{>Vm6_#{Y)1fyef2kR~utgg0Y^c9rav}~}UzOl)q{KIu`TvbEAE4&aD zzO79oskk2Q?)pdX(@>Vu2@6GY&nglnBr4S0G=nnAGV2wUb@H#5m)P*AEa_U&Svs9L zq1p*z7IlA39{*RHDA+#vtt|e`fiM0m#o|3Y=m}Y^unOhIjR{F=ScgAVKLm3 zify#Xzrq>DuM;6#PbnqrUKV5QO^M1Oan&l5k>VuJmgBu)Qg%}!_R1=dCwi}?U{ny| zX+iE`H>LURasq7}Z5yPIab-U_lLccnAsb$It z_JuK30LG70ac$jIi<_`vf?(2orS8;trSKw8c@1HeTf^$P%l7Rt6W*HS%zcy-#6>>%&C(;UTaB z3MoOazvLz0G9S(>_Wb}@SBP&Lqt)7Hx{-I&*hladk%r5Xn(=(~%ws%8X*jI0$ydPM zR}2Lm2!N%w(gZ71S68VAA_AJ~yq#4>>QbTqjESRJY%D$G|03v)?ZetZICsf}#Ux!P z>9eeB-52V9DD`Nw>+`~{;3{8pU)vpF?n_T==YA`|HW;%3u_(M02gw==TO??e(=DT& z!@k8e%kbVDWyJVlI-hP=I4mp>#`6i|} zvkS$~mTV;vOoKKu&oYqrNQxevabs3^S~3xTlb+RIV#B{#izJ7R^OCdq7t4V|e#W}7%hv7>?86J<+EgB|ruko+Pl`r}*z5yO$$WTaBB0|g z6X%A?RR=4UEgnAS&1*FiF>RdbYtAPFhWO!KUHhqZ2;p_`DK$##00;# zC;b20d{xn~^|xjP02tW&Z;agkI@;~*tp9Vf`&=pEjJ2Nn@rvfC4$tEVLmxNca$_`| z5d_f8O5_8ZdvVtn78O*9YRRH6p-Wna_B{bSnc?h-r{;*W1d9(% zRar7oi>-K3UHZP3t{puYocJDEFjUY*ZM>kil-cRQmNm5)y1B>txs9&H6;FHJR=ctC z;KNQmR68+d>2zS#P5sul>G594PVHPSxvRWAS_+`P9o2Y>;t~gr#{+xKTVC#m^Z&GMd zaxX6|v#R*!B1U(u=nLTb^y0Xfxwolm19XMBN>G zE4`YUsOhcFdfM<-dR#pSC^0w7$CeVm z@41vM1;}f~l$uD#pxS;^(be~ht^I3D^{>~Qo)^|+g=Ml?p(6Xw60@G=`qiT`H$8n6 zIuAGqI*5By$dM%Y+$nB*Fh(*a^~$e+NJ~4Ig7b&>G&g{_c`@}ePX`@@=~?w>>2;`QXdk!PSfAe)$G&+-hB z-B|_5W=z>4r_8@L64w$P+fVfsdQ(Z1HQaNc>m70BF z+*U1^oVvSB%~MUS=z!BMfE<79CwAHux6Y%BSumb!{cI_!v5)sWrnJASTu&(0Xn(YI zEb;wIUp@A7=_Xai0K!$eNL%@Hoqxc7RbmyRF#q>cCHKcg-Pe;9$4_0>Gi$caJWS3_ zjCi!5;ySN<+TNQl^KI%!!-LZYuyRpl)xYUmJ9o~Ev@d>*N1G-=i+1zdCIe*k={YAE z3og=uHE9s=4|#OgNWP1L=sIWlDGdr`tf$DO092vpinl9k*^djCdCz_$dy(Vz>8hID zQ}`|RhGkDufYA{wK#;S-qC0q%RxE6&L&~bB12czCL5PNjn^yCiTig3ceOzAEh?Nem z=Y+j_!&OUQ!Gcmqo$1fLVym`)wNTm1Q~SzC30CYDQ=^F_rDuK)lpH9eGtU>2qh?Yc z2u_)AK0BhjK##Ch4Q)v$q+x66*}j|dUyX1_C9v%dw~1=v-c`ve91p>vMJmikAeM?TD~Wv0&v%X5|FfXe9IdL zkpp{TbfcORb|K0x$*bSOs@cW_(!t1)JfvxiOM zihB{F&iN0M(p!U=W_?@3uY4>cx7Vqvjgi+;OLbDx0Ix+1WwiqK+~Cg9NVbR3x1`;b z5EdD!`glii;i2a}nf@pR%qo5mvQz}k*|Q|di^hHWq65-;Wa_!22$Q*hZkk`EZ%Vup zRG-6J450OD8oS&`6thhyhH`dvDz1jw)_;(~o>uT;FZGq0GdE82p1?@;%plDk<>>Gd zm{8-XDG#Y z01}{NC@xufso;N&`AP#SdW;F`A!@~-UqvGY_&bKyNv_>X$83=tQ9E}@{^sHV7ypg|KvfW zy@yq}1+PIPMAQSbdW3j0&-4$j-Fk2aQle8Czf3$oW$)6RkGqJqRIu+Y;z9%v#c~(O ze^Tav@meUx^+N6tLzh1dvMm;HWYUT}WEnac+>9I%H@gUTW9y*j*I1F<&LW#FND1=_ zQ${!p5DI1IP3P`J5n6}W=unI8V-?}}ITlLA#fQCpBY?o#BoRVKeuu;MDvz1Y zPr6Jdmv;&DE0GCS;yrOQ*b)-=G;vuoJX4SO#3f*0!4w!9*{*YLKiH3M#bD1@K1+!x z_+BSX>lfo``K-%dzRtdC=(rqS{o0n2c{BJd)=9J>b2HsQgJK0)siH1HTTAu9aN3SU z0r_?(jG=P#hUco+9_F>ovd8;qL>}DLN7bhe+eviSG$DQp9ae%i=wN`=cb6Kzz8%_J zqT{Rh4y-!=MC}h3$*uE5umwhfU2TGuI_znOGw=>#`AQF(m^RCFYSRi0&x?rpGcHZq zQR`{3qbI~0OLf}mXdM)_LVTG~8DI*o1pss1a6rU>i0Fl*A`pjgc>MA~OU__FcI(3y z?Fs=G&!)3IKGU-siN9Fe01p5J;Jw4rCjBj*!jn(aC37kwT^p&TXQq`-cO$-uPLL?s z4RPwR>Mi{T&Q@PSk;=0Y9aSFO&RK&%{<_flENIaZJU_3FIIyfK-!J!{D{{YSF)kv} znyT=l;ZMRWypndI_fkujv2OI%zV^9I$buE?7xlb*j$D>sf1T2}x}%T=aChYUNnhu~ z^ZMF&$ZjUQL|=XbvNV9llVA#HI=EJ51()^E6LcXNBT-ENg3P|+uy{n?7g7Ytza!vH zk&EVGPs=$3wU-iH4x2!$)X}T4Vt7kbl%n;rriZP^gi%PY76p^)-nnEja(=ag@n4bN zs+si_ToGZN%P7p3kbXiDQ$UZqWfJH7Lf|%OWG+wB8--QcQrc)O5(>V9bs_=L^9_Ur zw#LW<{PlkpNf*o2nVY=$EnxI)e=&=*CW|iOZI6e5*6C7kQu)&?oV^laI=JbR#wWrX z4XL3UpET9?4FX1jA(v-_1mZPr4M!ZPigEGdXb9=GVVP#c{{diu4`C5kV8yJ7}1^!5?OymVXGLO6Zr(jDdY7(LOa3| zGMF1>PLkx!5oaY~m{k#1Da^>?!y$dvH1L^+tWp2%4R=%E&W)#(p+napzMDVNbBu~Q ze`z-403WN#WE+Pn9|5FT;}|^}VTvsOlzTC54_PJ<^pk(mw%SrS%BJdvSBG&`^La|tbbm8v)UeFU&%?37&EbI7nVBXh{uJdk`(uo}2-mZ^oKfss{$|XKjalEA?+KvL4<_P~#0HSfz%$wgze_lm< zM-0z8LdTEEWbmv1A575Cvd(DXI}T;{ek{t|f8TZDw7g=RPj&!C+jc`AERq>PCR`O1 z5f;lyAR3B(cvz;0U}*`KV?zu<%( z^2abM;G-sbI!TzoF87l6%ywdsm5yJdkJqODGSZb0W!j2{3{+-uvMFfJr9K{FFpL!g z3H4ZA$o$WG|*k5us~orJtNQu109P4{-)`-J1!=O)#nzw zrNkD}{7|IAUPrNdBP1+*gCe9VOP|}0Qb{Dt7LTna&^xld{jLwD?jflrQ?lYX75oyXgB#5DMYP`Ly(vjSp5f6Brw22Yq7{ipr{#bI9 zP|$(P&506ktRNFDo&r$(=Nc3rv&Y@|r)j3%kt$Sr`lqSFwL9WlCUv4BRIrLL1R15e zvfp~VnT&jlUIW#?>AomxD=0MLJrIO!Jv|MxfFuF!7&sfi@)4JeI4Be{A}|=6R00HwfOlZuK&D!O=Iwwc34~4nkM<`W&?a*`R6)C34F683^0#J&&&4 zC=1D|!(kNVcW`?XnCPUb;hU@!V*`>EM}TzSWnC{=Ni>WV^2}&8eUP^def%|!s(8M{&Ok6%aan_ z)}xl94*ncth=3AZbUOH-`3NsEy+{+h>V!g=i_={Tk#bd7KaAcP9I>H6LaY1LL}|b$ zCx*m;XVN&nQ#DpS;Be;18hm=fB}Fb?6%OMO$|iSeKnAZr2yj?wGvF*~!L^K1vSup> zQvCJj=)9Z-UVl01Z!LDJM}`yEw>?t*z_%7m+YLUnd_fo<;0x5R?z(3(^ed!Gk%t=$>D9R zc64$0S~{;C+*2C!{MqLZ-TxJbHlA`y_zg=U9RXaWCgn8?p2vo7YHPQ7x>hK->{ZnV zdERCZ6118?5Fk~6gGJjE70i0*DyA~US*gVD0A~VcE*wX5Xw%{1Y!z;jzaqNsP0(D5K{)`xV>S1?W4*qH#nlC{S z;E{!}UGi3QL(>&lNE-o#+O0SIfkYzbHyLVR z`N)^fz#uiz&j6-*ys8^T61-afE9(7V;9fLf3nieWv=SyOdP6yEk2Rsh0vPoq+g%u; z^a;ojq`(94myJ78uMd#<$^=`7=dRp~+aXA@vuvHdhDjt+K`^xb3O}xrER!S%?%=q9 z3YJL}_UXb){Y{p5<#(#0t>QRY1PqHvaT(EJ}9tWqkevr)iAL%N9O#p)~cm(R;@6w=G z?@$FU0e|Lq1tIreqmuPO#4o{ZWh*=;YDKt%J4a7fi2@G@`q$|okm4I_Tmpl5oML7N z>Ih`YgTIU5t@Mbu?f4-dK_dN#ivyr25QJH<#mS6EWPj?RaO5+&ET}6*GE-76n&BR)DnB|{|-b^z2V`vsy2d&Ng zPm+N9m2Y0J#%jCAw--t*4hFQv3t~ye;2CRUU}#LWdC$k`f_Q+M@tDvxBBqqQfJI`G z)-NQtIK`m@{>&=Wb2>M9xU@z-e5&wz40zPYlm#w#n}K*SP%NX(KxZ2U8jGo3ogdWw z*2+-!Y8uDRf3441I}xr6nb2${{5kr$`sIF(CXw)RV%gmj?8dHXTplFB47slbaIS_i za1oQ+#)$KWWj|jtT>RtaGjB16gryjX`9t_~WWrD!Y7*XjTT7-Gu~E`uMdh~~5b8f? zjtUrEUmfk{f|G1`@dfUax3t7?KknXbq8Nf+ol^F3^Qx}Wk&V#mgXj-{thVvkE7@9p z3T*KghX?Kct>eV3bxQ!)d}un*b^+zAeIOT=KKls9d&jsB93FT?qq!Axc@Qp3_^U@a zf^=)ZFCqNS(|P73T9qFVN-yAsOpNIt?|@Vt(=dcwB-yTK3SJXG$4Y-R7c^GSVxSEn zY1`SZ02vxX!2_fgB0P9c#L3sYxySmTU4Ep(E!3=%IJ2tkzCQUox;m-HfWt|{-FH21q1WTVyNFLDM%dzEv_ z*H`H=H$zb;F?%}%2_{>Kfrry-b}(1+DRL~)wzVPP!q`=Sr=K~r=5lI~Er(ZZN8H-8 zqgnCF?>yVhT=32joOJ7oFspHmIB=Ybi>Cz?c2kZ!3M-fO8MO1^pZz^@#ekQ;qw8XX zcC`97y;S`6L}EOf>s7|-<+eL(ykHV_)uqxq#V5lz&u(W1%{9H9HaoYLI`{CS6+p_7 zulfkavRnq@gZ(njL$<)5C_IU$7H!N-iyHBW6fB#)1nQKepwtlD!|VC<33vzrfeds}lmO^w}B<$+95Ey&46h`>fltZww4VwbT8J2)?N|dL@R_SKh>F+_Frb{z# zSc>ls(l#<3Rg2M%a(mV8K^>+s7o+MR{}FTHSkXc6BL0;LC}o-Pe9_Hgnv z3}XH9F=KhM#Tp*YstTIdcUYzfh=gw1W535Nrway}x7fUGjQckL%(%;z8;0~88bhll z!Al2Zn*dpHdz>UVv`gBczf=VnX;kr`RZ)RF7@v)wpt(1x1I__YBAYlqTxPI2*y=Ci3@?s2qUm$j9C93(=WaALE$&Q@YU#Um| zTufWL@AST2OM*vKX^6xC<4AX`&YhD1o6{Ly-hD-tgWZaY?c5qSBBmGtaDKY(9;HIzChh z94;qPv>Q6Y)^5pdUTw!8RiB`9T5SxE(wf#q(Xk&AKfq)45l3>Y6+x6 zA(6e>>^Zva_F%Y3E~cU&UVx$Kp$O;Mjbi91+VtMbM(+gCblZGL_!-!xSNvG;(E3{1 z*?1SJHnv>HMcU@iTODM*Eo4P#NX?b^v@S1(NdbRaS4b;pc~QulyQ#;;3T0F$6l{9} zWSH8%3?fun_#im^gq?pyyq-nstbTW$6e7Qzob~=K>QQ3BR~ugFBi~JZ@K2k;l#8+# z?u@-riQJi$9y!Xa|6=Q$VnqqUHM?!wwr$(Ct+Q?0wr$(C?R~aw+xN`mCNmdFC;i;@ z(D|#ruh!Z~1>KCg4}KpuPd;VNCA!trwUX1c+F!B%0FrcCezGEyw75pz23#1?7aqG)1`2lY3uI z$NBZaM#8K*a@t!MvghMp2l8cN3ezAgI4;rVqt*;D)i~pjMyBwHFEff@AM(Ctq~^!^ zI;*GPLH?WS0NR^la||~t3mq2n!?&_#;#%?%`vjOG-L>Onm&}t^CPYPF) zQMBYxM%I#Q`?5G%AFJ5Z7eWHC2$V@B=Il3h8Dz5Fj3h+cXV$tImV_iiSP0@XB?5m- zXvt28pWly1HFFvzU^G$4cMU6(AL8T|51TQkJ})b#Q|tR#I|*eM_Zpd$_BjT6vyh;- z#;LWTOBY#{%t_mld2=~{t95GA+ogD$8JIOEwLGk9*#rNiinKm1rb(~o5X#mKhv|Ft zq039e8*D6zD_#qAqnG3rF<3nhLPfknv;x)kxi}-1GX3F_x1TyU$mEa-Y2Txq%0D8*x7u4FeJ>^Mo*BbjKWzQqSKzEUQRP zzc8_x#BL`ad6*W)Z^e#aN`+GmseYSI(D>I!P7&6PV9(|D@y^lr{KHK;i7pamjV-T# zJ=JN9GdeHUU?U9HQ-)n=U^VcAWZB*!=Xig?&*%Q)PCNr+SEE%j>3K(f;!bXQ!!Vpz2HlO7{j!#coE>!uAgHYG3}1EK9$7NR>* z{B>q4M%KN`agONyY&RAj{w$&`MPly#diIZ|xO#7jPr`UIX|QI;ge=hQ^F+9`vvy&~ ze4Q-qhBviNrpDsEA-vyVFmgJOWI(NVQM)D}v^glD{&Y%@YxXEo4m9s5>?Q=m!IML$ zg#FussGp!#G|)%VJaih((yM)CUB195=rvsA)F!=UivR;9qnYdoqMfv0 z8suH#TbB&1PhJi3O=Z!*9Q`BQm~2OD%<}GE3649(+baSW6V=1^t(reByM|^M?fnZG zxQuWI6Rtov(ym}CD;rmrs@rMJR)tcU?s=Iloh=-qZ6FP65{iT@ zgmyROC&_E&rG6gjrXAGLCJvSI+$5cgF?M`OI4kD^AE6y!Fnxb|t$jv7DUwllWBGKB z$?0yFXi2=-)4)JKf;Q6+-n{=0Bk z_jibqc(I3qQz_@fgx;9QP@IL|PY|(hSgrKcxhytj*$!)S@Qd`B_E>SZ;Jcm^U3w~_ zTk}C@*UzM&Kyka{c99o@itVNc)S23fRwB|3UMoiEZ_8C5HyC!bxYpEd4?TA6CgNYb zy07!qwrK^J*t-R-d6QX^Q9-F)M-k7~eS;OLSgtXxhBO|8XE0+z8n~j%a|Ra7*!Kl= z_oAL*M3$_~am1IL6kOu!Q!lkQ0gT^7vV*De!mg)361UF5pV!isY}f=zHlKfPsy+#K z&!gv|~)p)LMzOVC&E7rKdyST~2#Q8lo0bvlj!c^c&%MX)!*vBaJ?<&%1 zD-NciR(XrVwr9K?&)*@zO*3p!F(dV&Y|Ss0ke=qY|KR_yt89fim+?yn8^NIVjY!HcmU! zlB2I+ILPJFmvb-a_#ax4YpvCIxgF%*qr_18IX}X$Md}X+%xX*4;kj|*3}X+J+CQTc zW0(=``$w$<^1UtM-FlDoO0)O({reh4&0$KD;nLI@`a0>fX%!XTNvXNRAx5vAk@+61 zD;xGDD{41lfT7fpn5%>33#c;Gv)684pS-lCas$}>zuIz6$up%-q0fR1!E!-AO}=g9 z<=Zf@(?*9lbbSqIEOWg!Mx068k7mK`t^kh?5cT+IWGFIKp}$4hps(XR*s@s#M#af~ zh&`<0V)wk?RRPuTKt%!)0WpDq&si8 zw80Ff8aU4ay(?<%S>=q!F@(M43Ks2?DJGQ2wwA)oim;m?%#%N1S}AcUXUJ$m)qrev zs&(C7TGY;OQVRD?)Gpg1$qB&pp@|7VIV17xHR%@rK|A3n?WWn})onvU ziM^7AVE#2E{}7Sv2UR=Koeoq3jz8dqwNN~CO}frCW@Ba8%QM8{`&N?SzirK#CyJgCU2^Oh{61gy zY=7q@^{6X=>fhjU45ZxmQ&!S|1o?2&J}(s$$&*9IYkiej%?8Gm`VmwPsk5~PTXq-~ z8!CwTk}zc@mZ30$AE(#h7jDRJb;Y+Y>DFq9zAk_RMrW`u)=9felUGve<{W=#lZ%RYDFjXv~yF z{wY7A;yDFT+@}DxIldOURpS0eNjcy7@}TstqAf8ubj zx1@2p8sk2&gmK-Ig$gmKF}D|viE$^`PRV21pS^(vZ!F{0<^B*1IYoaEmF1)!e3HK+ z2K5o^R7I=q(;xi5hkl_os4O`c`?;=>=19tiY)}_vtcdSiXw+bK_=*DUA8oaEO*10R zlAGzN&b8_E+kmZiFNbJ9CBpw=WYBo3-he18zAzH84FjRFo{8cyr4)dkRxjVR^yqMe zA9qIQ23qet1X&pJCEnZ$ns!Odh7pniyU7g?^8%vap-Cdc4hvY-afIhD%IdCQDZ-Py z_yuvEh`WmND_5JQQIohl^A3q07FmB_rKM2(^MS`*&d4Af2Dq!no2VYForI!|#gSXB zN3mkYNTCfCx#oF@=@MiXhqqr={;K9EXh@BltPkR@T1Y++$`U!*rXM-fG`0nCU`Ddy zSsYDO8(I$GXE@1#UN|!K=6Q8r4^LmV_AOzwiZsmwgU3;8mq=!1JSq?zlv4yO; z(OGT{hlU+)RS4RVhZjUr5hwGtj2(VB*ozX*;Tt5-M$bcXWuAf+nl{y_EIjGvxWHM9 zQNE4dlbYm9)8A%yML+p-wI{+3*dF!CY=kJ^j~$_66nu9>$A;5*V5p`i0-UvU9QDva zGkK6Ww^z8KT}?@)TuSKxz5U1aMSXY%A!3rD*p#WA#-;Bamr~?L20xL1&d4cDg6!3c zJj8)J>M`T^k3%W*vOD(=fRE!2G8YXmP*fjHIvs7Tu|OvA~Krg$-!A2MQ<>M7rY1X%JQ_iPAk)kM@VY0id&#n4g z{No?>-8S%X5@Rr1tPs}N`DA&FkS{Cr=0@)&!|R7sI-~azIb=|%qVwo?i{BCN=qUtZ zv=bN^fm`T7OV{o!OhwGza;;`2<&lu-?KpsLgq(@TC5ihnt~J+QS)vmS1jD#zeUWaC zsRGg!i)i0rD{kKy&OmmXYTyZ#M{x(qB4?2rzaU;3SRA=$uo}cqm!LjV7cf0_xI%aX1a3?$+$DQ95v63u%j%2`YrX}fF8DBGdPv8w z?SjgW>))P~g5K&bf&oqx;uBDT3ya<`~U2)k~9W&rW13>@bYfdu-4hV+Rn$L;utvY2gh1M;VrDxB88q0?7-P za5}M-1XbuB3MT5yCMNO#unMVmGb)4sh@NWZ^54W1?uvzy1b316%utHn{c37P6@Dca zx3^^d+DUoYeb(1#%gDpdEAnZHAuh>N8e1K-KVA;y#0AafYIjac??!xO$?TdArj9&V zy&m_x*-j>#gTu7de>2_*UdiP}27bnOqy{m`mGMRWY`a2ikkd5QWiZ9HP}@I5?U;G9n|kYdhmHz=agd>B2QT*Ks!OMvW$lY z2SPR>C(2vzM@YL3GaIV^VC@mY`{u5Sb#nX~G>V;ojT`}F0|&(G@BxSM!uXIdng@c2 zpa0GbP(kVjdT40LUJY6pa;!d1W+arHRu6vBR^7Qg)oN%D1yJ@9C=6B@{0&jZV(Tha zXCj&9P`5GWjNK_va+?kdcV+25Yt@V(lP~@15qvJccV5Z1Ve`19pEXKU*S4C50(|g= zKuEhuGFf1AoAI#f6$>0GP6L;zO;1EM#6z@%+(4z=GYC$HY0lCU1&LbzY}nGw&D1>S zn!S0dbc?;XllAWzRvnRm&4su`yJwg#Z;0RG+Wf*Lp7SZ$K@@iZqgwlVuO5zp>zISC z)jW_lE+2!}zKmDAMy{TI}K7CrjgBZpw z=Mt|Q+>H3oZc{GjN%5DSeNErDZSu($YHOes6l`_&#NtL)aph9;jdtnTwFAs2)DH_?06>mh`MhFsVRL1gC$)%rT?l zJ?xoorNG*gIdbIJs?`0q2i57-^k42Zv;Gf#%xyM_c2L87fF5R-2k~3W2i8T}Wf)1>U|{Is?f1 zCcx?Q3kweZqJMRmiH+T65e|z&IcYZ6rvukbSVFOVHSY{c$*?q=M*-`7Du1`bcTE2c z)drV7)QO2jI>vT9YV>1e2j(xgD%ii^29pHK!jF@uznrOT^#Y~rb70z7UeXM<)J6<6 zfQ){SNsEWrZTz8l2Tu&LD%SO$EG{j^m6;Nu-EyReb%@_F>WH3DVOmyp6g7+F{;4Rj zg0O2SLX7wUba86O{11#|#iCIY0f9d*FKX{*jKsB}@yi?yisRTKNOr2}LK;G`27xcM zhYgK|xJ~#8iWV^2rl1;OitP9*vMNHkU)O;*Gw@oW~2IC=rGW5<(#!P!p_1N(PpxRlw%#Ou+r zo2uEE>~b2So-Lqu+)#;ZRNq)jIzQQ5Zj;tfw3{bj!aB$hqM?1NBpct3&G{x=go~}~ zEr8g4ERNd+^-_}# z;GbJwJMKh9g1imk%NEKzK9~u4W5Dd$&pjTX)a5|); z#FOb@Oc+Zx@uw6&8M;!ie`VM$I}aao0ZOm2A=c0)(+*grz_ywh3Y0p@synnYA|XW) ztkLFWlp&A=1FKmb4W+tj2v1-{LfGa~FA{1atP;1TTOa4f>*@)=ykVBW#fH%*{mjg=eS=0&;u@Q7Lxq1qXA7d9JgIW?A1P}2J19huCRIcLM$I{Ci zP{4UumoB5%iQacY66jCnYq zcd86rRUeC7Yqgmx0!1rZSMU#OMma_uN~kF!$$irOX-A&eGwM2Q?nf(@%3Vc_39-no zZS8{hNxQ^w`Dk?{a=jEJ1WXnfPNTT!T7NeeEUlq$G1h)Y$@|0-X)d;?kpH`-W+g*u7u)8BN?hLCFO-imrG`o;c-GV!&O3 z#)D6B1ZpKI3YB=vdO~O~+>o`49uqG7zPdJYTFWI3SbHG(m(Us;Fj3SKDDbYrEe?Pc znL7*us`*HUR;XhI}mh* zDFH_+Ho$qZp7OQn+|ow+l{7e|lNfPHh8dsY;*C2ilqV+y3OhOG+c5}wE9x8$VyDlW z9R~H7XVY34l|_ZMdvuSTKd=1knpbvCUFGRH4S{s@m`8+f6 zdQJ$CIgf>Oz1Avn>G&{_fo!NV|Ffm&I{VLx%8Kb}cJ!fjh=1V!q;X)Dbhb zFr^&q7ou&0ILQu_&$uiS>HN`(v2V6lEGY-M`1)_e!pBM}lDB^(6>1;=fPY5r|0Jr| z7}^?||1%8y|Dtb~xB>!o9$D6&-{|wh1=9h6%A3HY&fkBo@Sy zfkad7Ic9EUln^g9o8UdgDiAwfQI=IWy@sJsBgY#A4#Bt;bzfn8m%U6R5Zl3=c>$p7 zFI1|5WF{`F-anhrIs0uLbSIXlXV!?+hqVC@$?ZkS0?qqSv5MOD0voB^462gzRz8z- zrTy3G2BE3!sWjHRUhHfRNv?JpQ1338`VXcxVnIXUYlUgK=#~W4lpjTv$A@uurh)Rv zU8%4XI(Beg`V%dMB2|OGnD}DeNedovL_0&g-W=mq>sLxwX6*@Xg6s`w^_(0-@~{8R ziB8mR`{V!%0MNI!KatT%MDv99%*}`zh z6dYW%KCC2=6z}^)iL&UfKi!3`6C+0Ap2uAMFVG__y&vjasbG94Hy%hnmZ+o#iVR2c zJ^8U*S|BfG(ob(ykA$b}j&nw-8|q^M4y3;1UfAC#S5>7^c`I_2ssPF*ii+`sqiJ~> zc>aGT1iewvzEed~iQ5B5X$Jtd=u^(&?L?Yqf6u^dbcCpIVm*|jR7X5VI$8R!Oup*3 zOwf919k7Asy$R1zgThUY19T;4ZHFf(*hLKKH+J*mRJUNm+9y%|x(8Xsrb2wyfs&$o zb^N+#Xp1_EA(jI@K#B2=((Xm$BX1(Ik<8+gJyR0>Yxxiux*79P1b@^3F@!ogO~n)D zsk1a1!F4*d@VyZCkoMjVL>RjDq_pZ{dp(*ant;VKpP4Z7IkQraFV|D$6zZNnCzJt#w@>ujLGbO7uMP}Ga`xD0{mHr$56}RAT^P3H~xzytI|!Uml^X=%C)w ze944naP-}tspX7uOD=4>aV4vi7?}k#5(GV&K`mQ{?Pga@%nf1^3MI#++$20Xt331; z)tESSnwc9XE@|meF4XI7emkL(1EfrU`48bUXnUn%t2jT?@9G&gkbH;E0LkPE1bGek zZ2u`Qw#`$oOn>&IKEuCd3!42=0ky$iu`UzgYvP4EsS0^^q$lb$+|fr%qR;yM`Pab$ zYvkb6=3Bq3r0u2eGh@0Cq@R@*gc^h*vBnJbXzU=fg9O$!6&O~W;>;CtxQ~gEcfoM6 zr4aY7=(a(ro2;hL&BcmGk?(|N>?iRwpBF`Ow z%@P{^GRrt4)`gY})W{IJIlGx5cu^v>q8jYdu^HYTpPDzpYgbRrmfr0n({EC{MtQV$ z$UBZdDv^Rvd|;QHw8niVJdM|6F3wSsu_}3{#0m)K(+oWGf;4{iC#f=puLQ`qwTaBA zLcrc|M+r&~;l#OQ6XWm* zO;_;!pdvCOwB}NC_wu^-zL_%5?j(8rC?HX;ycW?0e2Bu4o;k}8YcuI|-*;{abtEXS zX1C+g(5Td${d#~W07_2cA>7AR<&xAYs?y!Jtbf)bVph znY~suCnlnjC3GBHMnoAh_PZ+2U(vUJhM7orVUy>T&)|B0GtotR0#zL@^>hD5$05id7>o^;s4<`^EbCwVsI*_tj#t?^NZ*tdVM{*(tj>& zhq%eWJHjvI!0*y?SqyXE#XVg+i6TRoA!GCrYLnu;0fIkYN7fxxoh`KDAjOBvvkrT4 zkg4xw2LjZRgW-k$y71NW2;GJ;0&nEWIT$UjLe@rvPu|V~44)e^87IB-H*w^23hCrC zEdjgzNVh4EulcFI8VlM_33ui|4Yz3GmlG(lQL55@hEs?H8sUo>Y(Uo_L=E~+dJIqk zs16&A0H4)g@lhqh9haKAZgN+UXg`p2eInjjTF1!pSoZqaKiE(& zE8DxEY<%aUoO%4Au6=A#N&oyft37&LYqP8S-PaGjj46K%={lw|X-k7RbPLl)LEJRP z$#V)b81|9IXcM6!y7qF4rDh{g=e;v_#+A&!f~^u02*~bj$_01~K{LfvWJeD={@iJp z6f{u&U@!@`#}o@+lD=+_cf?%9jI>3-^0brrq2r{1zZKc2V<*3cXiq`b-s%}f0DRge zrA=aSJ4Q+R%m^nTR;Jjh0}BMS9s{>8q1hT}KT`;v0m12JkvsBsvb8R-U#3JWVc0GV?`Q$eurR{DH<7B13AM)9(({J1ikf^O6nc=DZ^q9to{Y)(GIcd^E3afh^z zEQ1Dg%+IJJ0+fy%db%7td(kwl46O{XXdCoZ^5bD|B_GcS9}DbEfp3q`w^O*?&?S@4 z=LFRA@ICF~ieRb)<}c`?yuh--QSp#d2zV{#|G5jL$fdqtWtA?otMF$ zYT7#Y@qsy9w(V_H(4gfHm+mDVzP?cKa3H1wZ`;778?$Kp9$YI?YAc=(RU-~#4cgQ@ zVTo#Q`=%J|wWu`-vnYM@y< zp@!ntT4C?!}7*OzV~6Y!F$`KK9fVHTzfMlfBY#J6Mgd zI%~Yf|Ji-TAL?vg>a0HUtbg*%AL^XlBetRABMrTgr(;3o9AB44Y~Tzv0js#nL@QB( zqnAuMFR8;c=}{@ZJZ!J7A|yL4#<}Ehb1|Xp$|3aWG~!~FvsYHH?Tk@kxwBAk(zOb+ zENH#f2}b8eTc-T?zI;}PgVl7!OuCD~XNR-rWOR+O5B{8AXA4&m+XAkU7`c(71P-(2 z)%{22)F8h5P%^Qh&z#dSQT!N}Y8ClOo!dl3n?OqWv%<<6bmkF`D1n2X8*9>MunmPU zin4_)mw$yYd`=BTuMz!}+SuOa6#E&-@LOa?yMWHjg)gAl&-ZbEDoegBXWsGi@a*@$ zLP+MyVlH+6k@-#O007AU=f2It)X>D#QQyqY&iUW#|Lxjlv~27)IgoyC^aacD5B8Mw zKUM_L#*a&|b=b9wH$dUU2pEuTpi@Y5^b+3!zh1Kl!%!p@W3yVspb2FzA)s1DLWT)#~F+JAfolO&4((@0VA3EE_zOY%6tm8oG5%A+=YJ zLtFj;9^}hwr*^CwvI**vZ{8|HzU43gtHdoez7gI#fj=$H zsegd_uQqo}h}gTUIFxGVp7B}noU1sBA!#8}9~+hEWK-aj#a0tPQqv~E-A6Pz&t|sX z?6N?OV+1_uCCJ=XaMzxLeTbc+r7u?h-x1KER{+lEeKd__v>r0 ze4AKTRh_XY&gqK-GFi{2*J&JgG)^ViW11PqkHHN70h_@zP2c8 zeDl$q#bwU#G4Ai58I($L;ovmGlE3W0hVlo zg47ZA--+i6LPsYuUM06Do0e4^qpd1PaotEcW;Az)$_=a(>Ww@>xj~wq&^(Lj2G+0J z@D8IjpNkR%ivupj?hE(&hq{e9C~jJA;ew6MF?G^?tG3c``%h^7VSeF6?+%dAf_B#B zJ4;yEL#Ip2^l*irzQzUhid}XILfrEEH0LO~n_eF=87YFVN9mO`1$BG8>)pj#b ziqs%_4rq9(PUt^|Kr<-Ax7=)MoKimLKdTnQZmLzFmp5**yk^UDml9cNgnow?rm;&e z;|g(BTr-7b{7y9I1TZ=B0-oD<)0Q709DTSi|D^pF6djF1jsW^Fkwi#kl3oKvz&Sdn zl^S>p+3C7^>|~)l?J;|C6PKi{DZw%PnQGS^f|Ame{wrfwD$2GbECV3J(j_H-)z5}3 zPjpUWCRo})*2T<|F)`OO(K#Ts(8HK{%FLZJMWd5Iu+%O1mwoW_Yn^A$uXz1WvUTi2 z8T%C64Qip59kJYJ5}^_RYg1dQ@Uo$&F52F~+pEHu4`x)5`#Z}Chy=Ur_?)l_b#x&> z+!K}6I)J{r?|IrL!&mSJl45BuT1!VwTRRPVx9n=D=k`!<0@GWg@n(?Cu5IF`ionDbxXdWGwo`42!l+)b&`t@+VADBSr`nSDut3UP@A!0sI$Vlvva`dbQR zwU++7{@Z=sUAu68QMzv&oV$&T7{dW4gJFa__p~+%zdkd0L=hYXf2{PzI3=SQe3D%N zHf9Xv$?NlK6xxcIX~;WiP)L|?H?OB?Y;J*>lKz)B{`IZXtD>Sk621sj`Bnj8s%lJ3 zLHeE&nqZ#kBS&1%5k&2+=RbJgI-4lnvac?>r=MuR9V`*K$H&CW%>;wbzUd_&=k=#; zU9`*T22Nn7q-`ZTj+?0>Df|Fo_B;sdQ%Hj|yz;$&0cF)t40c8UQy5+Gidy~qg$L#Qs`}!T@|m?0l2Z&9r5;>=Y0N<0p0Z0pXKhw3&Sk8Ev35-7)S zBGb7jaxkh%p?Rg0I0}`%CFEI$PJ;;C_2@R)w~ZHRT@EaFpHXM6Tjfx9%o7YG$r7@! z=unw2?*De8G`jf1qR??$pVw%va)13Tp%&=n7TOLTt5~dX$lXv({dBZR?F|Wlr)>hR zf`18oTaA}W^?Si2QEqj=E61Xtiz7bO%n4YvK{N}9o*r2dlGV}>yzMhpS}$nCk-R>kl*=ZMT#R0E_9miOFxj%AE;Wmq(a4+6M!+vHF--RFLG%@KR+~focJm%ivY3<51BpCy*lBG6&wTEpfoxv~ zNG*Jqy@*W3)>&w+AlLw@j6SO$EIRxsWgqYAV0eg1ib^Rnm#}ileGMb;%7hWO%9b^H=WOX4=~mB1Y`9){{?cF-ZsSq?sBC9F}O_)2n7m-gY;d23@j7 zwH`o`n@|*w)GB05ZfNPvC!=?TQuv8BkMJLV6oY=2y8<&1_nV(bU|v&zCtpJsW$pAn zn)@C+)f5bv88X%7W~2(yH24>Q)%c|jQoc<4mQCSX1JNRY7;Xii_5g}d&g8W*qOkcPyB?0x zjF@9Skm_AKd$f^QA6?c@xR{S0VA#u2`CVp;n%i9Fa%71#wkzHB^+TDV-p+>_cJ(*QCf`F;YHkB-eNb?S-Q4Fgwwm3@rB$ZFz4Yavk+JyYk%BN>P0KpyXtWU}b? zZ8x=_gG;JiH)YY3Kn9QeVKx;vL3~AV7AN3HQ0|pBl`R290PZc9YUZyRQ5f6sE1!oj z{ERAL$#!+|5CxZ@X89>yF5KR51~Tbw;TBU5=5B@aZwf8HEUb~ijDzi;lE?-v&>f98 zql*Fi+zJG29h8KOg65N^dt_>v>w#uc##naPQBu)ZaFDpu@C&sSJnWGN=em2~^frBd z&S&!oc9LyQAtT$%qtn9yX^PKnxPmUJpiM%vQUBXt!2dd-yRZs|GyI#-0h#{SWaa`*_Hg`OSAQWMpUBX3sL2C)4-m(ToRMJz#!ABAfBxU zf{b{PVsgm89*{vzcX9g(F&MrHhVQSiG^9r4T*O8xhR(Te^zbA@T!I!UI?}#}>Z7ju zjwPO{EX+~l%-jZSBLXR5+*k4{b||??mr#Uipl=d8KstX={}UCY!0BKY;#}k7$`4N8 z%r+$F8R-_Flu>@mk>o@btW|^&nC>u-As_@>O^|`oJxvB<{Jk;OeMSD339j!A01GJR zcU+L%P@a$dh|&b(mrmNHRMBAEmcfpq&pUub?FB(}l@VcPOP>I47VX?6LCh(Pvj+ddBnSDY<~%=uCLJq+g9n^`OK%%hBL)p+^g!at zUDde%&S&F3kjdmMKlG>e9bF2o>>m@t`49wAeX zN)WWZTU*V9d9@2R1EU$ZoPd_eIAbx6RXs4INd+$OI4qhH^ zH?*`VN@+oqYurUzbIi|aI?Oq$SvvSksk_08hv;wZPRD$+wdLZ-ziO5KJm1mxjWm-6 z+BE|VJhQu7%5`!Ydi|87NLgc~jJZCL0qvOfr*z~56F3Fz^w+gv_t8B#EE@#lP{1)h ze_tF_1Pom9iN{z%JGVALavE+etn*G5%`kUwE_%c_2FnnxQnR%QVxj~})|jSE$w+vJ z9nD2j1Opn*=k^RahxMJm{?4{*3L9o_j5<;O?*U@MZVu8~- z01c9;InBlAM9iA0tA`$D6;2p6z(k~2(_*bEcOhl)Qh z6mW(|f~A}1ZO7689@JWYI`eE#GgN0B>sPDBa-=xV&v} z&5yKUWquT=5jy)?88-VJp{g#LxlQ(e(tgtQHpOxSw^beftV(OBPM&W?6Q`<>T&tmn zujSGbc`(Og6$>TNG_>%aQ;oJo*^XOOq|pL3LB}R_9<$w zhp`5Zp$)GHV~o|sWThlJXS!+sbIY7-ToPD@Gv5jldOY{Ec}BXVRV-}&nonc1#{??4 zkZ_Qrz`~7L#^a|h1PPO!yb3~r)Y#EnDS)|QHRnIzEI!^v&aL4PUmsmFs@GfO9(Z0t z2Bqva@4Z*T0GQw{^gQTfz|<-;ojrZ&mh$^XMulCBtL*ed6#hc+s2}2885n?UD|$(a zoJ=615Tv$Q+|1E=9yRUoH^fOw3~w>#DYDtzJY}5T$-tfXk7m`C!;)0a$<~*vo1dH0 zw+o!8c%7+d$&o09bW*Pfcm~x>!6ljxq&$6mq6@u0k|4kTfjxh?rQ=cVQSB9!^IYGw zBA9SxBCD^UJL*>%^-Bf%U)8^CmW0w-&05?;cjD^GU7_;J5@WvpCXBBWsoH2uD4dV z2Kx|hUDo*de_LUR6M!xh0@~zKiAP7IvlVhA0Uq3zNajTWV5hts;SiEWSrCvh|ZGTURrRE7o%4*&NFQ_{l9eyk$ z%06)21I}rk^Hdj^C*G43O9WD_l?ja6MMNy8HPA?{4PTHzHh;jdu!ZM5hKj)3ctV;e z!d^6=NFf$bz+k0rtHGfmd!B-6%UnCoTlm!k_`y3hsEMWz6F)R1F5F2m<`)~|)6?YY zG8PEjE3{&@XCf&?E}TUx93ju zmXIdR!oDb9R)xQBCvk@*uZ9|47-O;8h?#)Q3-V^l$vFgy`=FA*^ybt514nVFRDQfB zyhQ2mVCYO7*E3D8ZBY&VW`85q;lVmFjp2@Jz$DLic-OsR;vX4~4!7>lhJ(11=fo4v z1pI&(*Pyip;_n5?1;v~HiQ^Sx4S;UWCX6a@NE58jV{W*6i}cMq^;>)nzP0vhdGE%` z>$7{h%({ldf)dAspGpniiT<%+HpsaaHXihp6?hHXVGekn)+HsD5JYWXUvSPzntD{# za3H_E$=I5#Xov=JZ9TJ}!{}cm_Oar;LOoo^XA8MrX2DmA#>iIf=*%vBLL>i?_+z2k z){AxsyurcV$`d|-Rr-Mbh8JpqV?*$N=0?yMCWi&%SNfgekt%-40&0q&=#s3i=0juN zo|nC(N39rd!cxOl;RIYA#%@-_73*m*aZrWyPqG18om+`@%)%%`k21LhFCVrz5Ye#F z+T3}N6JXT?wDZjFW|IEwl5Lbqp6Nmklw3#0ok+k`3n{}GGPsKZ_RFyhSM%+kvw6zu z>uY_+*p!X(`_+xCvW6FvWs%>4B6V7Ljg)9F(SwBX^x`X`yQ+YuFX6KGva;r7$2Ou( z_{`1DAvJkkgCz&6J1zy%xVVIAqCJVu1%8o6*~lFbCrqmpV18SH#|7dP^;r2>R{VtK zmznsRz2j}dA6Ua&DME^2Jf0weE=bD|gB>kz=mecd*t^z1^paE1#P9oO_&EB}Tbm@R#=og7n zUqe6vxw*ZvZt`=ak%c|{BU2`5=#rgieZ!aTgiSp?O;sf5RnD!tQVkP}b`vruMpSE} zV0(R&8Ep!-_pjc~E`EB zB$t|jwypO{D3+ZlkJnhV^WNR!A~&Q}vYDvM8$j+a?XnI{gxMcU{{oQc;e`(~5_KGuSCyg>n4_5UzKIl_z{xFw`R0j;5+A#`kE9HDQ!YGullz2SSn?#7 z?!=^tpPii@XUx+M3epPDcSrd>`~`Wx-O%GUrFvQ=Ve4fK+nQK2z8w*Jj$JU%gp0ab9NX+2>;OZR0Gy#Gn+Qv-Vwr$(C zZBE;^ZQHhOYudK?x7|CthgIF{R$o(>2{ z%G>_lRic>B+ruKY@_&K`O1yVT|c||-~{-TNQ5ncyorirzKwLU{7QLoPA z1XXXY$@vS9CnX#_9GsXe!=iJCZVmL+7*+hjE=h)L^9FFL=#LW~|Ig*Z!w;YP_s3~c z6#h4BWixRzxY(fy&`(A8by+KAza=$IR?2I<7kxa8&PofzIFLu}t z9`3oL!nzHN3QA>PQOz8&0fX`WAY+>Bfd%{;Ioep#J6HTRb{GHa9YBK>arQNFp4K#h zAIFCy&*%64YVPUsS#~J@N!D4;P+EVIs2dt2|0HaqstyaAh9N6grO)rf=i}z>`5B9q zIc6{2kz5_#$VeTJg{1V}6~HJ@$BgM`(;x7IW<6Ml;LXX+WeVNR`|a%NJsIt1r1br; zN^eenD-SY^kcW{qf63lKqYBpZ-K-zDlX5ul&NY_o&JbPg)Ss0GvNAIis3E{a)kTVuBv0MocE%5Rte+;I_>q*LLi~7`f+$icjc6=KV#c zJHjd0^_XV(@gM9tvY=`s$<00p6Ndu?fD&*FfwvC{G>USAs42}U3?p!uK;Ro{d`Y|S z-ju*-#F_}2ps&~-%ImMFR!3|Xb3n6+1VN6X8KsVg7T3V`dOspPi!=Mi-*_K|`{&0d z5{j~pMCgH?K3h@eln%^A5g#Pm`W!`495su=U*A?!zKtS9(*F03akLwF|Wil2+xR2wtYnBgwXCU96x8u#YO+rQnD0|WZ~ z<8aT2WEL`fH0-3tcQh^PBta2>iIklKia9?o`qKP>whPEU z4+S)xN)s_bCFM5{@SmYci18K$cbYx&Mj!DXRLvU10Zszt#9)-$ZnB(U0EvVqDpf~l zmH^%*G)aDOA_DyJ2&k${aCD|6Yh)pvV|l>Nw&&GWLl;RS%=BVq76$Kv+#UcHM#!_tl1&L zgOsNwS1NkM3oXVp{>$r3#K*Ra*W-^*DEhl>j z%t|PaH2jehlEZHWXx{;{ZpcX>$3CrzikxS6@quWlA4LUw@h-K-%rE+2+@*1&cD$=( zmo#y4lR*$PFMmKZY9fFWO&@uLeV1t8*7U5qO3e z+G#1f7f`z;_K6gr2s}6aa7)+!%}jjc`;2-Q&?C zox+ApKH+9nIKd{8YN8vTa({P8P@F#x?UT<5m{Ki)Pyh{qAd%(OHW!jYwY%eY9;WS? z6N4uIWQRJS*i(PN0CPV2*+CoKD`ulag}uxd1BI#fg~G!5mYG%T$BgjLgSL6RdVCxT zW49xsMa{d;WZPL|r=`d(UOSK>i?5(HjRuBOwol1utcb6hmquV_*Le-9Ws@gB{eTm+ zT=p|xKI?0h`rWO@1>jG9zuQ#@*YkZ!6wah)#3o~?FIu&0_upgw=$|lkULvsP_N?w> z4MZ1CElVgMW*1k>_14LNvR6hO@zw4$7-Q!I!Cc%3#az`J?XN>*rq~S0_x?RkBK}D3 zSegGh!+=x~w&4~wFwWPdkir6^)r?h)FsfCoB}aN;6&X<{uw@g|K?`uj+fIbJ0l{KH z!ckiC8sJc9q?d-;D(t{ntH#nkaid+w3Zf3nET&>|pn>88D2^!oHIAaNr;re6=sl7e z7<@_v`_d-0mSr~`+=|gls3gB%21oHjs}5me!5EpV!_hnlJKu{aaiWa6RWjP2LX07ApFfz}(l&P#L;L=x-QKw?BA)1|cIZ=EgzW?d^*UjxQusl z@Kjwd#beA^ajR!mAepYw-HCM~3R^~h(;RZ=m!5j9tvXECywW!g@U{T{w!Bm#|%TZAH0y`Xh4~Qb)jFb|vh~fE@K+9NM?A>hd z-T||nMP}nwee4tN-fQpVhXZi_7poCA=S-T~NOboe(9@dD%kI|sT1kA|a68K9`6~mnK46BU?RS;j zRa{29->T510j(|6q`A~;wESn#7u&7@ofP|qx}zHPq$~x*qs%!LgcbZ5@I$l*)3NGP z^D4Fhm!Q58pf&4!-4DJsf>KG;Z&CY4S49wd8cMp%2Rp#vaF13pS70gQhR~#iX-rC5R9{X2+;6Qyx^sT1doZ>&pnk1 z2@ZI2ydT`U{dTN1jj`XPlkh(+3UTMUxq^;^Q+@MN=r=#HC`(i_d&YBi_S+MTIhh#r zdW$Ay3pK{aPU)YPHMuC9f{aBzN~ZM)we z@_iq1yJKm}8siIDZEK4&(vn6}2xz;D30pQbEb+BZH}bx|lF*bHRDb_k%ZM$2zm8yn zdq>?UGYf2hU|AZjU;TsUX#IxBM7mkpj5`O>MB1%{WJr})WNFrx{Yt`@?^NEvM3V8c z_*Ph~GV52gvS;gu(yhCMh5goY^XRe*rJU$6&3417Zsx{`;xE$%{H`RYXuRJ^^bR71 zXg0?SP`8Ulx{8(5=fE1ys$-B8@s*foQl?I?eouu}`GQfCRm=PpeuaRN-)Qy$p0L|( zM^C-6y>2z;hW+X@8qALZ=Cr6M^x*z3#4l6c{oF>Hii2l7mluF2+!BmK|JAN__&_s4 zWgwZib0=4Ho@h0`W-DR6+}!*lV!f{>1~*1qG!tNj>BJ2K3TZgj?o8l?O9TXweCz+1OUYR*1wC(89P}I^R=eZZNlrT@`M>r z(TX;(zazdP*@Ga>@gDBY7X){Hrs zjl#V%SP3L~x`7gocWrk5EeTSrj(Ig7mor^c!a8D(zd@5ylLFyek8`gF^&kgiyxi%F7cSHNdNISvR6jQFasKa~?NDtab&!+%sUJJC{=%s_T zy&tW<^4+0R1i!B)z8sE%VyYDRb0|FS*J~!z=Dv?W(=}hWTM%Q~gJa+o4M}B^?(r3Y zvbet^-$oMr8s_3U{*Kyh{q;CyHNBNyWtCnNK`?GalsvIhS!@UO0!ED$D}=-WnTk9q zl1!7{({e1GY)MPitrs4$hlau>2b1GZ5z#(@t%=i*GI88+ty^GluB%BW;7%gMt^)?v zh0oyj!4&>pU97=pmm&80MUJ_oX<%T$T9L!J)wYNR-LQd`R518;CZ$ecd*>)#j3*E-@J~9gPrx9!7rhGI(n`1F0b*jl_R(J z8jQ>=+VPppE?RGwQ1=g`zzX@TMN)f~I7f_g)5mXGytssXRR%s5!3U1Yn)yyM8#ME6 zw3es!cR4tHo1S2_PK9Ge5%l2?1z-sGE7DQVE;d6~ub8R!biI@|-en%KH&K0c4NR0S2Qf--RbGh8-W+gtaYtEGq+f_2_(rQxXAZie$b%!v~p$+oJvzf z+HOnVYKbfPJg79`aMLRXgX3gX3j)c@g#4cgB!vd_`}~$TePJ3 z7J-b3S0}z~R+rUH23d#jZJyfBV5UU?sI_Bi{`h>1?d6vF->j|K3YF2^aLRPfS_M~+ zZd$FI8o7Guq33taR68NK+F)5*AZ)R$0Aa7OsfGMmXGepmvBB}eVfN~0tM@kyy{$t1 zbX1AfrG520e;_pYxb`!0+jw=X10Ww9W@wrf8)w3Z1|OC$wo6+s)7r_GWLR7AoELS2 zrcf|1?^cM~o$A<~qjIop>THtkLT*%>-&|XSLU%5iv_3w8yJ83wmXlSaxRbf)E3WI| zFEnJ)*Zdbs9W9xSVgDlc-mjcvqbEYBvgej&Wf_uUiEy&5AEt`yP-@84`45QSZlzxe z=7=lrSmxh&9!YB~&bVs!R&3=}-o*o6EdqO?5)wk2CMSn(CZ)Hs1oj%q+k4F-!q>nS z;}4e#gg?_g-{@#49a;vbjtESq6*AaJj_4NI}|LA{O7|_2^Hkhdf&%?tDUFQV zvQXgP54H{w5D@zR?D(&J`WB{t{-px{6LwdlsqM76g6Ml%$6?9^j;WlS!~(B6WuhY8 z$VseEwQxg{JQxrz3>YTp-!qaddD|%Cd+PC{>nq;9!2^OMk?sC(i2&t-(5Dgh`I+xD0+_X?SE#q|HpSTVZ`DasGPSDanl2#i;a^ zCl=51;wcg1%}_q(-n3l1#%=6$C~4w2>M&|?V&w8+QJPhu%C+!Ifc!ewO8#&}NIc`S zT^dHW##*BYe@CO52ep2aWH!wiarpPHi1V>HyhCxox)r!58I^46o2*4?(!5AJ-P5&7 z+Bu>%0~z?JWokXr>Z-2GT~24j;az#jow!=Tt+|LwMn)#)n(Iy3By#J;tcg>~Obd}( zuJNXC6k}Y{ri#VFF515gppM@#JO8C6gm_|)buN!DGfE&j*A#Mo8jGeDtCXWIvI0%N zQ3XnXQ0-!ZAM!6){!%- zg2ko2Wg5_e(yK0xxnIhfwlT3z?tO#g9WmK*ZI=ur@-q%s@%s~SrwQX~(=Zh}NzD_o zDRE}k`aci!PofYRXj%YB6h=9QYL;skDnlLqq;RA8pag;w(6c24MpQP!@qt`;Z9epMyW))zF8s z(;#H&N0Jo2yO*b@3V}ztqmqH24Me>?9Blv+2lFTTs{xq5+dJR1xy>{wdRu4yQ8oF$ z>d3-oH@Ig~Kk^>0uvLI4# zpPVb)D>80BH&Hm9mlSfiv<&AH!VduWcK72H=}~<;1oRqzYxj0>csL7k@3P*1sRV1Lf&&&LR5YapvVzeXHITfB2k^#T}AEuFfXi+-}y? zPzY9HpD&YTXr9fE-p<9P!HPKIT*1|#RG3m589)AMC`PEXr6Pl5^pz%Il@LWF#M?%& zWsZchAENg0mp#oAyWrzXXFI-E!*-*nt^_iih@{v=&~{Pi{eWt(%k%xZ9$2h{>wR~9 zd^|_|`8jiGqZV1XN;Y*iD%8AUsSw}NSHC{rp&Q$^CFw|oB-0K39wL8VAZ|;csK;Lj zxd*%u>Ax-jr#N0P>w19X$RQ6Q2R37Ji0k4)sVI5wJ-V z&NN%pR^_8kw0q&zQrrONl4wIl`k1Ce4qwbeMRT?Dk2~7S zo%4BpeLey|vM)v**Kn-hiWlML;^gvjMD^9k{QBB8Fm}uLT@rd z!RPn%e(<;sIJRsnTS8CQc9tla8;VG?wu`dwN?`Cr2OGk3RE#AQ_k4i0TI2|1E_}Qw z%@6`?)s`KI5s`ID<B|A&YgiODD4>m=zmn%L{ap4A{ zlGqz8Dg|nKhQ=Ill~VNcV5^N`c;Wp>BYAGV{>4Q3y@vCCv;JMtMC=jITa8=HYbFn~ zw27tJrd%GsLv~83o3H@I{;l^AGzD2g?T3@$jUb3Hm5&K$+shmoSk+z4jPnvdJG8gK zRAel*bqJD#rPYhI-A3bv$$fHB={L-i&kvmFMOT9o!nmw}5y9h>hnPCaSD#TJ48qWXEP``ifRROZ*52QleM9^(Q2=k)prDv&MAW zlz6SgM~Bz~(FZSBpggz2++=4CQcsi4XBFH&!qojv&TYJ=@z#zl;h^O;pjPajk!#MI za78Toe-Eg5;e^%V-PH!B2v+Wd8@Y3q>CW_Cpkb>&qVS5Kq7qw;8y&Ln;e*&n#eua( zpm)$UR#!HHvA};1pj^2u=Pq{+^^_AuEFRlBe3PiRUE2FAmec8X_hRsRac6ep^d^r$ zIEZuwr;v}G?1xd*PmO?X$$gyTz-69jcDX#ie;z#^JTRI&NKAdN=qvIzNH#>zg_fO? zFLg*54jUYYCh$rkP^H#fvvYH-&I7{%)2P$a{u(vKN_ta8=_IB3a>fd~%Y7mw62>sx zdNU|xYur+8*m_gyZFk6$_`Ize`u&49bH$Y9^fg*!XTx~xrllmyu7|n&`!Xav!m@iU zDXNz$hOuo+w14qC@F82uABVlUK2f$>%#v{Xk3&n#FrV?{nY$_cjBNA36p;!t%vQ#3 z8#jh#x8^_jM&!2l5#RX7fangg$uf7`nns;_i-doY!X}2+ z9A^=;yle&5#QgKmuLP|_-S%P49&HttJ;$CKLuY3L%QxA0H>XBW)s$ad4B&TP+`Gwj zOdpy&6DQ5-<+lNU?jB3Nm&L(zWzGf|FjPF1Ye*;|?nC@{Xk0*%EQ6AJ!+0AgtJnle zhQnWs0E%3R=7mHa+0YPbReOHy3Y(hxb)u~*?SIjEhC>^fQxzOWhdT+dDWd?ejLnIm z8Q&zsBxGz6>#(u1_nlPDE7T6`V5isHln2SosGz&1i1Ry@N|5yJ_2Ng+scMNbQ?n_S zeHZ~%civ12`W+zS+qc=pyVS!g1m_YX6A|cuapMWPqt(jdF0}{u2s2Fey50$)|va<+4d zne!U(KJt>>2`(r)YKx^RX~li30IkydT2KS&&CJo|8l^=+4^sG%t-PG@EXj5cZ`+%E zH?a9?8(6G(Lcn{45}x5UrBLy_@%6rH*VXjz!>&wf$xbQ3%Y4EzE$?(P# zJ{V~Og%Xno=|rp~rrO3BXIay#@jyviBogrkxkPiy^zl4Gy+8FU@2iG(wo;8%o}FF4 zQ=4BlE??_@yw9Au*nN5P`f_8dKB6Wz9iCoynl9}5^62cgICwH>suthQaOmgI9@>%? z+$V0;AA7lS=_@DxuuISDAcjTA9CJ=sha&*ty z4i}pap;s#O`7yI&#lze=^kLA@;Q?YBIrOFJ(zk!=HQrm@pWz=S{)GANqknF`Mf=6^ zX-EG6yt{`Jzryr$Vb#c)VaTq}=$lr|UzO2cGUE*G*)ZkE57#T%^=M9_N~~XDTVH^A zDcMW6-DL6O#FGY!-c~t+J|5hf&b=8n8sPP_7l?6P?0dP+X~&}4lJ?vAvEUo9*JM{w zY0VokUQFA2TbcD|&v*IYF5M62OdGH0)q|GZBav@G(OP&r@m?URmJQq;BPokuM?J4! zUjOAE=XP(3=NgJiyB?Oo6l!>Zbf9WK9uAGQrau-f^8_r)xG<;1(s{2H^<7=cJh{{5 z#L`Xj3<~ATj5`_E-01Ometdq`w}*xx=kjo_?y`y=CY+bj`X1khbmy8INqeG$OuV-$9fdhOmP%)m4KwC%Hc^LFv+ z{AJ1SRU$X7@=2xoT_9iANJk?#Tw$I5i8W z+dSgVa4~1T81*$Y!f6}THPKx6for=Xn0D;7wG6?X@;TU3z)8Gk#lk1%c3uzCu@QiC z&iU=g8z&E$be?0?p*7BJFf$%lTFYz%3MB>)vcT3K7~qiyb?p2P{xGyEEmT z3nZ=Ed-5Tz={%n&K0z*4Oxt>taSeVK@Y)6IB)*gZAFAx+ zpt*i?zRSH4V&&%N9Sq6Aza*7^zm@#>a=BEyIAEv)%pm)xlibxe$op6 zGC$ludJQx%SMa%Qot#!%O}E=weOLf{7=f6qX2={IYJcf$Q?_Ab*gv&^vq268zlq>$?Mn$B}J=~0BG|IX`IIGhdMM=t))3e~? zb|!vi5O>y+uT^@Qlg3HrtIC_%lhX%`RgMR>vi%;vpB%?=)Q61I{S;7eaF_}MW+laM ztM(F}Q%>gi1!iCR@Rmht_F@L3F|3vi-@u#Rk{#Skc-4+^7#le9H@A1hyt{9Im`BYJ zh*4LPe6Op&Z!R@(C&JDd_WiE`{;sZb3+5}+MlfC9#!12>tZo+)dr8#r!LOXKMZVlU zt~bQDGCy^m_)uYw29w9UTIc$$9Dgwz9|q%&qWHsnzp=$*4w*}{r}-PE7ch(6iXB>r zy=U`fz?-wDgq?)e;Bu*1ts=ea zEB5W;Q{x`ZW_17vJxD&&X!$Nf2YmqdXtVX}2?P0!*{(BXwvfp6^OxOz$&lyAP1;>$ zLgW>DFKiCQ$<}J#y--^DWAM#j7cfGl)X^ULM-{Tsdgo;9Q?T&n&BEUz2(0wVE>F;L5W?tU_8Y3k5aQUC=2(J0zl3T zpwStUZ&e_tqkZ#7yJ58-b0z+P3%ay7@wuB}aXmwK{mskeJ4*!C4BuO-t^x!CBqeW# zSLm$qZ&Yyco=wA`L{CviLDuIxy02u!+?$ng1HE6(%LuR^f$aF&UGis)NvU0NamB6S z0I<4l)n!dz4%izB>sjUdU4R8UvO~AW?2T=asv0nQK;+43Rg=@fI`{4j7q3#d^QAi7 zE@gfCoc1KQpp@xbc8g%svU!5Xq_7>u%Uv1C%w1ArF>op9S+<63VK)~X03*L+P#xA- zE@xR?9DOgf!D9ay)9+ps*gn-H7XSOM4v5W+RagIkP-1*w!o9mN4`J+|weRPXem+kj zZg|R2EI9V1(hIT=@j1BDW6~29w{{bIChXR&a_8eb(KkD%X2;Hn{j|w16X-&Gxert- z#P<)$=Yk{a2I3E-@bdZW|y>ELev zhTg$S*h1I!NDstxe*}?%2TG5+gIYp||8u`@d0Zz{=3*d|*u%LmA*OhzBZu4QivElN zF4`@_?G(kEc!?Pdy1E-^2WXwd=*IV4g z(MCbQXn~d?3kmOT-6$O3+`MJ}2vIsxh*@HHVq74$u=UDXz|*@CTz9R4I>i|s{<~%i zrmc{1N}r)o^b);^of!z_Hr1DsJ>M?ub8mIY(9n21v$m(LNm1Uw0zEFx8FN;We;rt< zOnLhdbj79i%hN+F%|KH9uN#9-ugwQ%ub$B#U(M`(alH=xR7mg@)t6K8AO8@uvCDr9 zE9zoFvx`p)FgU4#vFjh-PPc$2Fko4I2!R6XEI0`uJuKPIC1JjS>;W(zpMcBnGs-il zCQCSB1Q+_Vfvz!fZrAO5)ZrXv5bxtE3-^3eimdaHTMx0lJ9~fVhafj{=c#bPLiOiK zc(qf3>i2*xvWxOI4su?a$A7KD47zSY{7|wNGf~n|k`q+OBwxnIHh*CXl0@D-Oo2Er zzOgNp=QcPZAFnni&R5siF4yO;bpJ>b&jA|5Z$5k(8|odW8gz%NUvEodYcE?D$`NAsUUS0NUTIKR)i?Bt|Cz9YcXy!#Ae9$3M( zReJyE36=Qii_01;$l1HpG2&j@`2BZKy^8@~r~Od6*g{EUpFat7#i0cQ*lAl}_=xjE zT}W?YVhL9+oF4T;G@r;}dztg6HzzOQ*!Jw(h5JjI*dPiFHx8(b{xsRr316E@liC9X zBdAUARGr6UaWqFBrL@Qf>ie$?T>JiE{QVkXWr7IbCPIT7wScHNujfLEZjN!%&j;0S z-5xOhtT|WX6UwuQl+ASLD6&R)XR({;8F_82qzrm^Z_B~wtnotLH;_3gI8uRuQAcU zOD3}u%KNf^*tT|cX>WVIAKeWk4gKm9&SVOf6(!5L)(_3GBjwy7p z+H!$bbOljQEQU!V!|-7~j6DR+kEID$t1iTe7l25F-ClI&18cyMO|?tz^8>@QX3d;x4`;5EkO$*RrYR&ELTGhdqmXL*^s?Wf_Qy4x$e`u@G>)yG_& z?cEKZHF(#f1F;(dBbW1evl+A-qUYMa*d5ihz-S8@>ffyC4lBliD`2wnmnM&wn`S1l zc(JD5NwC&LY-Ru~=g^xm;B6hZ#JjHoqa$E{R+!xAy0TPVyzN{Q`Ublxo zv;;brpnciKRx?K^U_5DDyBhu&b)B-ID&51b5BA=PF6&B|ep61^uw6Om@%KR|UA!lm zia~F+=4K0Y#DYLbO!+c7Mwe(~AFm@?zV`NY{Y7I_P83cE@wqnvdXMeBSy$UI>?;K> z>!j`lsDK3ylwA=l7}m!UwxF=i7~h?W>i0Sn74eKZTzcR^mS7T!=_TRfHG`kaHgi!SY0yn%7h{DV&Sh>N zuf#ZeRkb|(HHt0guFuzp;&J#KtmcvEe-rjT0YiR2yFV{KpQjfD8xpAbx+;*(W+qGc zPxo`sm41L_x~h-{_F1afBK`ic`{x?%aC`s7zX%6tU2DBrHoFt0pMpZsp5lG4Qe0F5 zvs2t>SIe!7K6VW^*j#IRbF)%7p(`E@tiNs{Pu#B?%*zJu*|(anM{UaF{KbCb@Yv9F zdn7#e1KU*Bm^kddS#sutyaF_;0=O%`GS{%pb(0(L?&yAlE!a2ysG`rp^e4*dyQ9nI`7V89zL)! zZ?pH2GnAWH^Aj37Q2WK{F#ixVGheCdj;V6>h5 zeAWHj=V7q)b51jMHw&xmUK|-4CDOPgO#RxO>BgRe7WlS+Y4Zsryx`a2nn#L+K#1Y* zFsfLu_2dqS-E|RY@((l7bRB==i?Y6-`j<@A)gAuRyg@jpCm0c-+eqB9PS!;ayqjv z2eEme_m1ITR;Zc%^m@O|yIV3p=g1Di8&vifcC4J!?T29<9tgwwC@S!5`>@^<==xvP`0nWWeaWzo7M-{m7`?{oTww@ ziq)_#l!!`L!);svQiHCGRlP6VW4oZH$~>zocfa$q_!AK7>E5^mmhKd7fJ}c_LHVyc zwCWo(NX3}Axg*HYxs6T%A#0%A-S9#hYw>8b_;{ok`J6W~VkO27YW4EyF&M4`kBCB_ zM$F`GF~-NO7Kg0{zlh^nEDG+T3!u12n=HEtN+}?e;Z`#9nJ>a=13+=Zv6gbbO9SHg zwrX)+-9yI3W8IcMVg2D;8TvCh-hlHxp-eG|wRd7n?z2Q$vEqfRJb|b~xX=teyQ#qV zYT=E#!_ZIJTp2}ZQ;kr6tK*)?#(qCGfbA`mH21#~&{juc|>J3LQgIWRw z-yjtNE7}tXWw|PiOI8WDGi8#S5V%b53xnHOkG;AvM*2XnuKXS>J<Pb2LN-FKW40%1J_A@d;SJ!MT2aTi^7(8W!-W0!T0bU zUK2+UvVHyVuzdd1*CYjIv@-GX(ia0yfU+J zk`%;iDRiKG%-#$myOld@$F;_KFuG!9H}j2c!`mdxyk`i?D@)M(*kl*3Jnm-5w^WO8 zwT)B18&AdcSFP0FP^7Sh_0s-mGaV-2dJ`a$mLO(zI~olIS}iJD3s)!b<;^gl0E`a@ zC?TnC&A01a277i%hf(A~xU*l9x}?sYyRm7?EjiCB+NGZvmc;!JYS(A$CaxI%8Ait* zOprc6lQS*dGPR*{i|p=JH{VcvIj#casTj-6DJBcN>Sw~cQK(aZ zP~(|BQMZoJ1{;12jd*u#f#+x=8#EKIfTM?9~#q^b&p0XyC2_mmmN2y=G+wBPbmHJk2i5wqmVb?A# zY?&r%p`Nn0pj%@}?V-Pw@1rPKD7>5?)`K}|(jE;Yk0u=S4a6jvAr0{J2Km4~7MWO| zSa!yZrc}sih*ObpMJ+VKu`y;C^*2O!c*OTG(LC)>$68C!+MHJnT?`CQkTz!nq!+$# z&Syl?TFyTMX(CX} z<|VE;S6m&Vc~wg4t>b{jRB5N1T~Y6mAgTeO@Q_q{qzg(^+pc>(3mA{+%O1?VCPBDU z*rM^|iV&m*k@A?Bn%z@JzUaTl*QbPn&aEU^05`qxp0|5%QyQt@z~c^O+a>c4^0l4l zPu1wm!*$54klv4%)&*|rmNnFI)(#G;v}q0vdfX&2%;9}G^$!z1C(mVQ-tlh3HJQ$1 zHEgAOR_-LrM8^`D`Uc*)KBgm;=KmgL9Tz&w<_+9M%yS8N(LGhPFv!;B!c=>oIzDKh z!&9PRJ)Q0vX?{iGYd#u=hNQ~3u&31D=}duF94u_1#4H$l8ueM&T$-RbScjm7%K$3O z@Me*-odGfc5Wf@>nXlZD(MY4SkVjho{`ET-tzGinEDdyqG?7aj^}?{UcaA5OMHRR6 znxtc;b5{}l>f0C}AScrd$TdNW^GJXb9`oPysADit1)TDy*IT+2fcegr9DEb z(1giC@Meu<>{+~=w7eBQEs6k!Zu{9kyxV4WZOY*?r=!~wXgM@{1-+tl@I3(@=yimx_KqJz|p1tjlS=nxYX5`ZrTr)w$5JULhO$ zm`*Cr`s5Sk{tOsD+R?u3t!h?Ray54kGoLAcx=wcL`bl9IRyR_|`5vtZuvR1hhuyW?XQ7_n-CORpK-CxLeZhq!4KGecDbI%`&J6KgD;3Sb{G9C&M_XXn7@x2 zjQZSwqs8&3+Gg0hfi6v6h$c~YLEV2hfyjDW{6^HF6GBpmNOG=n0?G&52R2uzPb^;1F-=kEMr%niQcCgs(@*|Ur|&-vB4-d(t{X%K#=1G0ry z?RH8s2>=mirI$kh9_BaVm(G5So;YyU`LO2L_s*qo-t!x zpH1W8>dVwk$j!c&Xfzih*XZ~9VGb{)$sCE94@7!I7Ew+N=mRFY@iJyum+5+pbMrSr z7jhIZGp}U{>nX+(K5+aBHn>_~J`E`YaM!v?g+z{CF*AEaL3&LW4l2s%wh8p47B7^{ zojWSn9f(rhu*K!}d_{T<)A#vP^=pe5ak)ZXSZzh1;Iw zpeL~sOyED@gPfS+1FJoZ%_uQ1jnn^c9#+h1%u(+h9PA%LXgpYfzP7&4svX+TAGR-t zQ@Ee}K7gM!&xHBdiYFQ$)Njr%DET0{+?Zx%tTdsW#AFv=;<=2LhWL3@4uLBh2&jw6 zKS7)4%(b2plgt(DQsrAuCxqT%DQ0wE+~O*oD?!jfN=WJ%HS7`G-R$WLzfchd16%pp z^%X0Fji|jM+}8vZaWq?~i8e?n)=|%P*mxALcF@%}7$lzo@NP`Wf1ufUr|GLu8Gz%^ zY~eiKqUiZdM2^4<(h#_|xj!pm5T17V@v`Mg(YaIZNqt;bhUJItXUrWzn)g}QK?eU? zk(pmC-(vN4qb9P+jBA`wPe0GA`CU(%CuCt?uO3D7Q0mE_ih~{&wtZWqJje!ccOBb5 z0l$lXRfDYs97xRakdVAu{h+~*8?-x8mnVwu!;o??OC5v|C8XSiZE7XK9K0XFR;!7ojk35s99) z+pao9??sBwd;PBO)KB5YcGB5qB{_MBTzHS*B$=N6Cgzq~mmj~34=1=F_-hl?uTMGA>c^@-U z;Gdg#+R!6&;5d1{N`L?=oJhZztCII%K;N_{EmXbd%eXSfC{;Vg zuaC@GR6j;!NR$`ry61=_lG(*2aEFUfWOa=lCqeDD1bg+dkteBC~ZaG zIysOJe6C?x!58D%Z{xS5{J|mhsrBFiQP8|!0!n8kdt$U08cUm<_9U{ngsTX3+*OFg z;=*!iiP~YzeXxhuFngnKB8TCTF8j19G;FSJj~NkLh>FGjBTj_t{YTMB+yJvw(we_w z4pd);WM&iFwM{`svy{Q*r01vxS$E)&J)4_BYi@D1jDQ;?!Gyr-qt?p4mfha;{9?WQ z*w`x-eg8aj=}!5cq5j>(ekgwRdut>zJBj0_2}+4NtizpfUy2Zi!W1wUIkTi?utEmA z-@QQl0d=L9qMBn?K(JHMK`_S98xFWMXM`6jOp-=_it$KYUKk!p%Kc*uL{L-&QA)kI z(hGJn=Jpdr&=$#*1R^1@0V|;zYHc+L0Fn{q@)e>w5mn+WX-knZ@a_b2X<>R zK;wmyTa_R!MXgJ-B@XlH{fIj^5k@x3td9w$<1Lu?9A?!pl5=l9&|JU&D;TP3-UK@I z_V(E!M@W@JbM5i^G=T$I*EDkMT4vsMXOHsN)k@mq+}v4SU!k9GhP!4$s=uA)gxz!x zO&DlL+a>Jy*gVC>IkSbJq6|QSr}iE{+L;1SD!CQpr~$C)wy;%Q=Tub)aAGFCSDL_^Jb^sMG{f|H0Kc1&b0c zX?EMTZQHhawr$(CZQHhO+qP|=J*V%)bac-{Rz$s2)?o=K2N=| z`VlCwEx1}>B;w%&0zqxddV)KBU*@DI{|=g=dAAKIUccIvnjpT>(;D+;h7CRk#4%Js zv3W34U*7XU$y>57LwHF(&JXuzHtY;DTejZbw~shD%R-T@Yeg-Q6uDd7`GRshIW3v` z#$2~|TV|BzcH7f~StBBe9Qh$pR62{YrN{W9yv8CuKd{-wN~5`jN0T_}5mf&mY5vh=SO0uwtb1FWA40z=L*5uQ|s8_I47$S(F|ftAU^etQiwc70CEKJ*3| zr7%gyWM%ic6p@vpQVlI{maVIz1nqD$31q+%u0@;vs19b?l?*m1&)PY)J8z?-VqqMD zbK%{@@dbXI)7yrvz>0KM?_YI3>^ncs*&q1?hd!n~InFIph0UvI-}fQE8oF_E<;H4b ztWeLJ3h$9`Kl@vdn4D70RqB9(aLhcRL48J`h)#>W5pKl{6oZ_YlTJoRJ=qbxTeVk~ z2k&Dh_-i(_E=i~*8PKHztKh^7B#?7&1W4V180CQR^sdyyb?tSE_vF`qnjf;eoUD|k zR0S=mcC{1z#c^<7uYTqon-nw`~tEkfEm~MVTQpC%%b%v~?kYY+WbS8J2 zmg89WYj9$TBA4RQe=;Fmx=d6R$E=Ooh zJR*FBrvq?1&>7E3O1<+!gbEUR1=;ytV3Prq#9_=C^t)Ue=8Z$2?BKzLVB<;pk~xQ|{=^(89_qm&mbCc=-;a|S|%5vRQx z0T@Av+^>WsZxUj}ju|jdx{ADNIeudn!`!l|kGS+S2B$bWiQSukz~pW)P~of^WjJX2 z@#)m*P&Mu!0(j?7NI$zzKsCE#Iy=-cDF#r0J=9K#EO}-4egwFU3SyKsJ)_cvm?jHP zXfdF~MD8SR_j_M^Pc#!7umwY$!;5%uel=e5`dd<->o?=PC{hARY#=0_y81oALWJUB zT)!|P!o8VZ%%GF1-gltRfeVOcs>#aoC;OA$j>Dch&UMr8Gb7KqfgjQJyXywL%SU>2zJzixR4)nOD!sBEX1oq%bgA^y zg`v9MB5jc~LDeYc8b^AF2!*xM?s>APxFPV24Cr#G6oT@iRcqat3#+dh<`ZU5>?*fp zP20?T6%;05#p#y$zDpErSawG#^{$@3xz@Lx!@2{WkCKoc!c53lX zZrPH0Jo!lbZVH6QFxc(a4Pgvi@xVV@pYYfYk|BrEbOBko`==T)Us`bSNUp*e>bHdl z_|$|i3GpjG&fTeh%E39w!?V~^??433|zL(@H&G(hwS*`eaxZ3`Jnrj`gCuqWK5S6n*jNstvn1-Bz8eQU=M}`tHbXZ?CcXP z6q=u!<edNmV1Wi0Km3geZAVF%)-YeaRLl~}q-ZnQfBsceB|0P4@> z#Mfm4W!46&HodP66MIDR5oSp5JW_rmW7BAcj{=j2)!5hJVs*C zu)%`+g70;x_opL7&_(ugk97jp!m5#GXKt)+t_)a&u(%@e2=NC~C!rA(05YTy2j51q zXKw$;6Ys3QhIV{CyMDd8R6pNFMvZ2QNIO(BQM+X zp3%UDNeWS1o65wRdUzm0zlf(TrhG^+!wd?fOp1_EH~bVKmG|+aZgHmS0GplYuPA|f zR&9v^IZ}nzvmGc|K1dUzoC#`(X%XGQAJ0BYDcUbl>r*2=;51*k+SKr;B!&;+!mg~& zXu=@fYjG%~$%1j01n@cnpDZi1t!4N%d%fT+L=MUr`(~hVXC12L?uS zoS*(SVdE;UYA zlw)d&)2M>LOukop4KbH5vQ*T_;-Oq1+eFgw+yW83neHgem7_|@OE^W`O|%a**Yo@u zMYXhBi}S4#cx-RQ0#jvbUf}4N1OaKI;mt+aIB9&Hzs}#C~(sv4XN#8$f5L?wq z&h9~#q2SavliEw`d^77S`=+_oHZQ1stOlC%?4+#P#sUs(SE_5jElK|KC7_7H)B_~CGcgK+ zYt~<$!PZASME;euwXobF4a-Q3LE%gyBtLxY0Y^@tVzF?BR(TRNdWEqc&8qpjq#oR~ zuSDaAQ-`c2-yL}&rwY|4-R`avufxG-JS=crbJG%&Wv0}!L5jiKK@lYgA=qSiZ|GoU z7&??P?>d=!@Az{&Z_2~T&s#XC@lZyTkVVe%@UEHmDeN{ULq35n(pWAom#xG;Q(B=~ zf8uXsY-M)t-{kP{r-<{^^Dq-r`j8O>B#B$L`Aeq^+Hgj_T8xMzj_OOKr^$pipclg@ zrIv!fNN#EB0wB%4)5sj!ZCU?Evl&5Vc)`&SOOT4cb?hr^DvX@9&OELmJe?Y(U=`yF&gUZMG=EBk}=Yx2!Ctwje*BdIv}Pe)LQ`Zj8W)F7)d zzmkJTv;uz@Jjqepmo+k@W^HDQ*D;u6Pvac16#LvkB;B$}z^6Wsy1k3TVEJWA^fJ)3 z=0FLhxl}n0RGlr=J{Vp=841BLN7X}gJk#+m{!B+3i4uF{e4Ic@PN`Hg_^sD;83A9> zHtyOpoCLH}*_V}_U5~e{3Ii5AIu#Kgq?+>}2LRX`u*D&7Mo-g;J10)IAW|!yaCQQs zT=Ej2;4Yfrj@zEih3sLoj=vmU55faP& zrNRL$}nB7!PyApP+i)bF# zPO+MW>4Qwm=l4iisVU89(g=#zYE!uBh@%r_9i(+~*u)vg=GC(MisF689|dq}9Rg%$k2$_$f6Q*4m*vmWHHLWGCiA6YcMY)c{* zO(tztAu)oQ6_CI!jZu6_kpS02Oc{N0Fy^VS9=@Y@mSj*222HG9!>!=tr$c)Zb1ILI zsEHwb(crrI{1JBhh20CIp5%R)?Ru;&MLw-!fiviJ;Z2kLpd428D=)0aIonHtOCz6_ zmIiJeWxV0I1KP`xCIZ-yS|Z!6^g6gNMsM@#w;VCT^Gr}qxXYY<9xN4$d_5XVVcktn zPB@L6g}6KB_knE1gEaCUbdB_c)rmL&A^mMP((GU*)tTeG>=B92+CeRo^P4k-#NU^^ z`7&YD7Q_-aJ>G7NISE(R7_Q1`HhK^USj3bz0e?{=WI%&1XJe50EK{ ziU{O2q{1ui-KxK9Y!q&Z+znB=Arv2V^ey@kksxLjL=d8BcuC~pLvZf)=~8ZFyo)-D zJ_xMp$S5_QujWm>RoHJ-j+mA!m@hQ#$}f@%@N)jDikejB28(%}Nw9&fU3$yE0I|Cf zDkHS7A649&6{KAwu|I>OlaiWAhh$fpZ0T^7sG_0^JG#|~MI( z$%@bkS7-R2aBfda3?tYVXG_3@CL2ZuP$F^!E>soS1Mx+25<+=$bt!-}ebe^AtP)8f z-nMRYGA5Jqb;Xh$(5LhP1*J@=>7$?n?Qt+204&e6KHw+p1PW2-t3L2o5`cNKok+0Ol%RruZb4X~mHe_2}` zYtWv5a4!@l>-H8h@|-v{#s@4dd0h^lgopY`egRooX2Oi#d5`QOOC2sU@xfvXlDIjI z1aY7zCs3AjF&SDmerCgMBPhfVXl57==^uPO@OKc!Hj*rG_KI9dB80*zLd7d{fk38Y zzQVUoRx6laYO1qRlDMu;x^otWXB`HS=4PydIO8PUyIc6?BWo3sNq)WfMP~#*n8`8P zQnX{X(=ZYkJ+X#++`8cL?{;mu4*Cf8^BSgwc@YBq=WBj;7ZGL;&rNjm-JwBFjt&4< z7b!ir`J)W5=EZCp-%O`$@KR=OiAuv$JFr`0HmC|MtdIcYLC{6gW3=jiS+E+Q)5?th z?n8fR4K|FH+>@YU-p$H<-1m8tnRodn`n410LDrFY}XIJ1j z>nsze9PB004XDKm*FpU$fogmvQ$#k^F>Y7*{R}c!RU1S8FpwGXLiIC30D!A+p=j!7 z!hv(kl)2@<1ze%-u<3F)jrwn_xi#6R5Iwy~G$VG`)+Bv&K0r{po=Nuk~gC8P3 zLQ6@XGCIl2dOFKuX`)(7ECI#^IKMc&9fKg3{pYY>oD0>XN>AqLA6cQoC5{TT;x>mE2zaV6#^x5Gnd(cEUNA4 zCleY9y<(mNRn9x(RDuBc^bTjFHJY_+u_<&M=#Em-@*`M%{|LB=?pDUM)2+vvd*g`d zOh{d@Ldd%*-TWK)59gYFq=Q6+>nm{gjEaJ!SJgeC&qrmcR=P6to?? z8c%MfcU$41sg+7jemIha6l<_~^PQaI9vzVtn+!9kE6%|vVf_*6GueEnSiJxxVl92G zP75nW6TT={d&NwIn}At6UJy2?Fh)C*LV>z{>~ROV@XGY}iYh8F6lb`$fQ3=JV7--r)@(}@Pvo7x3oY(U3XMgwcjg&12(`uX;_qvf~2T)~yAh4Z0(lMSe zy(jIC3HNR+y2oEml29B!z%pA&eZ9yWo)T*d^Bp^Fe;TIf-opq&C*~Zrc;rgVa^gYq zGa8komtP_6m(|vqhqy;(Jz9$SD!oGUjIGrkz!e|nf7>;yo*QH61zOml!D+QqZ~(@+ zehPc5%=@`E``sn2z7+Dj1bp%FxSGnl+wJV}`Z^1f_!;^A@d2MWnbA-}1w{F``H8Cb zXA&uana;(ACj&}7f1f=_-^-8Z)%iF-jYAgQFniC-|a#O~tT&b`&VNV?X9FNuNNaW~2(Y7ip1}sB(0wdu@o;+=7>Z2isQY~yF12}tT?~Iv zJW!)|J8X|-RB@kz3WceaH$UEE5}E`K-Vbj3nKy4j_&Bo!U+oS{4oj=-e1UEOksR&W zpbYs?9ul9Rjr~VF`Jc;$(&VjW)&SYfavuzMKh(VtS<#2-t$bDiG3wAX^lg@b2&@o?HW9}`8i z3_3EiKVa~uk#>mWUypp>=@Gx%1=GNFEO*~65yfh#*zyC2atfCrYLN8i#)t&Y0*Uy1 zWx@B)u=_8uNwQJ!_xX&LugGZ=uscqoh5PKb@R= z%1eP#yhTApDpyvF_%arFnUaNRAoe6uXwy6shE?xLj-|){uHz7&qD#B<`9eY7ZAR1q z7FX7SQ_@9;@R#@1LrNUUZp019FNIq2xo~A+0N!05yO_@Xys=s#VWy?cHAajlg%%En zRJihYJX05uFfn*4s1KEATw5JinMSCwQ>2hC0Y=kwbYNh@iV@UQw?KF)X)QI_xHHSW@#U-w3z;`0B6D2nvNSD|R=v@1;nOe|NCJm3PP`^sxveeS6x+65#2B zUne3B^JE+-QM}=qWb%fAOpw|Ufh@~q(<;YPL$&;+>lD#Dc;zPV=SQoM>S=}*wT@?o zO(d4FeFSbON=GDRH4E{izBKqu)M+!ebZkDYKr1g^yw9CfFnIU2Rc&M?#c2gq5j7hoI z*0)_}YbNW>dT5?XTsZHKwb(O-0XtD1JZXG&Hg<9Xz30;Lj~R2LT$2ILH~=Pc-U!;* z3SeK<8pV)OP3P3aHhG)^rA8r1qq2^dfkNqDhVJiRSa zu)J&(?WNhjxpH8m+^w|QbF83N=U`dlVp-wQ$#R!akb(;Y>c%=ut~Yh1|SOvzXeL_$0+gHd8rQfOAar`mp;igxl|tgD$NP z-i?K9%45A|o){<}4oZ)r-YOv3-$1a~6^#;Ya%}TvypfUBkWAGxX4uiMZd(}edr&ep z^bM3%s0{!>EP&dNEDxx5fU#|({2acL(@!160>kCFN4mi5Z8d_$1`gCvWz!}aO*5*Q z6s$O#3)^g{%b&ziRcZ@r_7tX7LZ0-~6l}vNqJx)od;Th23uG0fM?NS`LUFV_rO-Ig zemh4Gf3kK*uUYCHQs{ZFwA`u&FH7y&(`l^)hpy01vI73*$o*Hx75?g}b_!xPYPiI5 z_D5J|Pq>g$5|7A~C?BE}&GDE<-DVeJ`vitHeJZ%!3oQEGO~$^P35OPrrs&nDuwN2ytBj3g1=B{3tfaKS zURfD3{giF;jW2b}!5^(7E>@duF>N!gQfcsH!s{FJG20b^8FJG$Ulin3vg%P4$`NxO z(NgT$1gmgiWWjR5r?sF2r}KC(50f8p=lKs#C!gRV#$%|5c)S-rz}1ucqq!T7WEf8q z8tDwGRnpOwVrjmyKM+9~#K*mG)O$uLcm^h!%;cP`lm4B~W!Vaut~tGh!N$SH_9yY8 zz%p!)!qOXxlg*M&XM=Zw5W^6=M`62-zX7k(AAF4Phav=MgE(^mWHN0YVo3b-n1ZAe zKzteWWaGG%tOtHXqEro$hJ32L6KI?o9#*923WCi3lJr$@q8m%|^gn0{=yhD7IHdZD z%r-A?hcL2S933=`P6#m9l^?P`wg4JhDsK-V0^?u?z_K5#Go2sp2g#vRd~4N^DX{_O%BH(q8I>A_RUNforpJ|~NfKeqTXA(h=7+Om z@A=E8m6)8;??Cj9^=c!y%s@yUDn8n12dDZ}u%sy!l#G|Jk8KcaEw1N|FKE|sA#7TM z{pt+`Dn`ASIJf-mw(<+rN849m{b+G;-cnmkAfeHB+WMISl)Bs6s+&W7xR*Zy-8(B* zNh;%TBf#~^#wC-e3$SwGegn9(1kA=n-zZ(_-Cd!*N~x6b;GeOtHx@9!U<+3=>9rwP z%ly6B^#}kV6g|%MIU67pQFFHKE>18nZ8+1IbBV@Q=rcHj`{l(J^0Kj8gA)HmYn*?= z*I2^U+=!2p){@~nI9If!RfLTF*qSiIdG=Hm9NBWPHdS16)te0^u;O>QAA4B%2*=E z-pWj0!O+2<6-TS+5pB@r z#5O*$%G$ku$g@Bow6fqzQ+1osFoiyaO3pIg1u|j7%Fk1JQZ3-=m>c-*CymkucgAq# z{G_eBQ2@ z{*0QZC(Cl;EM7}^XtWU@GmE6t1C;>-mHK+e0j+#wTvM%x9O#ra ze8k8p5e}WOKIYHZ|gl6V;1&` zg>JlxbPytmKxC@#Ci;$9TSy@TKUCnqy2KksLF@aVT}SD@ViqXt$k7_=ks#})Zg7lL zNn{ixg$_{B%e-JQ0cVT%ENu~z$m*C0XvyT77NOM`kL`eEG3%hZS*az2$8UxV%F=2T zi^u{LcSgDlP~FOhU1#}Z>LVd3&xp4Cw>on>Ill~{F_V{4FZ`f9iqYWizidY$BYYZ% zSeXinYe;=Mj-UL8cJeGC%wTP%njvB!IX{4eE*w%&ius-6&X+dW7R2f1peB{W4$2HM zdZVPf`^6Hsov|PexTL8`l~|0VfyGZz0!0F_(z>qZ>)hz4O7>28gC{(%VARo zi?D*xnhP%*XBdf)9CC$LFF+nNl=N%fq`w}Pf|ENVF@;tC*{r_{tsi?1gmEZd384G) zlT&kf%;XJzB+z{r3t3V7AWX|v;nWOWyFe}01()p&>N#QPxW%;Nr!4jy?kOWySqk1dtA>>?vFJ4)|y1-o``D02?gn&{Vtp5o3KsIO!q;M^D`M+ zfYa#hI%}1#=cnn8XaEyTRBTK;DIS3u3vvK{Q+5`Q#nkTHtvRwx9yI4niR(X;6msK- zLyHFFb(Dt8bhOy1si2l3ehKvxN#`H86}MV zmOq$OT)1S{mR43nWmGL%mh{-2`{))|M^!9Mke1dF_``dcN|1eY>t0o7Vtz9mOZ>dE z6Y-khG{SxU!ZKGGrd)70R#nuXqj-yB?29jUuv_&%iz!+Obw+4cn@eJn88+)3$a>5 zj_!W~7(mfpydp3%#I~6Djxlx7J#t&isXV-lkDq9(xg>ZL66Tb%d$dYKxlDVjcRWs* zO6K*b)a#cdH7n)K?LoS!hB=avS+tIaMV&7;^-;T|?ws}sx?kcOT9I$XlDU!_xe%r{ znf!HQFe8&8FU+|T zA^WVxTSq-(-ixsW67_DCc!tF9m@2RjB5|3sG6EGJ74EIV$;%6RcOvr4nsIietozHt zxr5c%r7VOj-5;@WtN{cHoigib%bo!Iqg{M&1g~+&w&Hfp`Xf8b3!JL~T#CWJ@q_0598}1~WGYC;reAUr4da|@R`sQZE?Sev9J=8W z%I5kqLTk1CjS9!uXP!-c^yhbfTwgv@958pu=}MwN0$Q}v?nTgV8cW_e1T-Pe_(>(l zUY&#)=L^rWn&bRHjnfn#Hj4`W(~NqnD9357(Ffe8-|eQ0hD-6#e%Poa;z^BG4OHyn zoQr%UHwE4#sT2bM42{B}jiC&_V;I@!#W(nd;GFx_a%Zy&e4D-AcaH0j|FwWcVxrR`I##ILqkeNqK`a zmZMNfTq_a!0{;w`WtJxX>i)n4KutnW4kA(N2y=A!P4n0f<550R^bby*)cNe9m9Iv+ zMoFKlI=hmv*I#i&69`ykEE4;797}P{B=@ETjliLDb2H@cBa#MTj(b%{@ft!yjC+`U zX*AitIb|F5O^e|iSC-Nof5xY?B+YAQ6H^KM>n~AjTKuUE=*6}HG@13Kj(f(qUv4~78I09I?IShs7P$yzx#2e{Q9pujq9q`k&k ztk+Unm7uRyWgQ>V`)MBem{8K}kFq{JQ&Odamm{SdxC-acgM^`Z>hqt2nxfSM86{nN zGzOSuR#;mhsgIh-^;2~!Uk$)7vXfGL#_hX*yCnr$l06kui+UJ-QKI_FwDV1MyQ~d+ z3sB-Sgm&|Wlvesuv7Qz7Phuss4WrQP1Bcqe4OHUA=^U*f!b)%oZ3sHP4h0QEiN-Dd z;qR8H+E<0GQ46AQAG+y+UDGyGLd|1h3&;iknt$}gz4wqa1$#8Y#L7);F1m{PO{ zx_!$EHDbCOb+QH)+KIWb*k)FX1tGKmv!t5~y&m*>b+aa1wG)}sN-V%CqEJ&heJ)!z zwH&gyPmb}b&uP<{w~EdW+qWOmhQ{B1*i%cz}U7qSjSRW#2|3K>gS-o za|RTT3$%;Tz%cgC7QQyWgWQdW%QQ$B<3()dE!p}c{?tZe*A`$44Q90a$!~j9d7kvy z!0JyVtokPfOf3kpTx4Eas9t2f$&Vp-LQlUutT}8Hnw4!x=#&xk4*W0{>@*%8z_z(N z%5Nk!a_3Lj{$WXf7YED$}dE;gGFwRg440vsCE9Pnnum!sHOWLXAV{Ix_%d> zSWyt{#TUaPP2cb6is7x{=4_{`>w!yi_wszZ zx5KLSMlwIdjn?D58uQ*CM#yHQLow}D7&OT^yY{?nB1?T9xwh+S8B?_xCwcv*&xzw_ z(FITS_QVYPHdZa|BL*E(VW7D%owE$*1%CJW zX4P4<3PD?Aou!Y**&`br;@K2km_qk@R{s+q&5K9&#bPQg$WJUTpl(V@2-8wP!LRA`m0j%5bd$6a$5qjU=~?Zh5{ss_ym}BDZ39rts&P3iQ?P%T$)sp z|9eN^O|T<(Qab~#%%??XN*sJ`Zt&30+UEk0}qd-H~4ocrZo!o_P$<6=XYYp z%z#6JX;}=oE29^%TN8S{M}TWPTU6HlBzt}E$hi%LTb30NHvpYabJgkJ4*FgsJm z$lHPwy3mN+z4UXWO?YiDK)3Uh7Zjv~wcO@AgNU>R+hqtxc_BV+1k+!t4lnv2+L2d> zU(zBYdOW}gU|XZZ0S%mq*Kfq1b~hZfUmA(O0$?q2-li&s=mrIW*IJ<4|r=)?3WIvsMr|o32J~ zF;7e4J7Hk>-QV#4JJZ-T%)_ev%QO1_3hG~(SsU0G&{;a!*^)+eLHRSl0KEB%LdF`t z{*B}a*0e66&&&^hzf;Ar?d;OBfCbjNLkd{f7p1};rd2A803y?RSc|^iJlJe@nqcrX zDvU2V!g})nE1*tw4IQ_VM|&wp|KII3;8ad||F!+ELj14xHU<{f|1(j63NXU?6Tocy zjF7@v_l^v}Yu5__hg)>rdo>+ZUb;6!gVk^rLMdS`=h|0E##%r-Bb-HY>3L7bh{oRh ze^yXT&x2xW9smFcLO=kN|L*mlj{lvrZhW+yaKux0x&1^dUi?Er*yy>L8v;00Aabs?Q7K+5!OX-OkU_`7rzOQ5Ie~!_&z7F&BzwV~->-_@zoF=A|G&ELr z00b>ehr<_GDjOdiACEiEIBQg|X4{e`wiGqIy>_us&ov*p2{ zySvlnd~x6=G5hkCz7Cz4XhV-YIB#Cgh90&KckF#tr{}cAyXs!K-O9hXhn}J)oOPqg z?Dh(CzM9y(aNfd@-_9iMRR5l;-PUbbAN%IQ&rN-th;nv0aph#An>{@#o)xm^q+sZ1 z^6+~9y+-9vATyVJT4?J!dNAJ2N(-tvJQ?xkznL|G;i(yoYM+XCgoQ za`5D9#2aGX3i!I;!)_NFrj+Y0YD&O%vZ;nQKAzALDaKjZbZ zIcU*Q^REMAcIr-BG_;Nlxn7|KCcO75HvIghHDBL(>e7WV*A|uUbxbI5e}@;>a8=aq z_q4a{PY7&M-*xjRvUR=JPOtL*UfZ18Tb>L2t1iz49*^&brQH;d&-dd;RT{Y03i_NL z-^TCh*X5I0SKMpvzi*jYZnp?bhCy?<{O&5<+h@_PwW_m){@`@an-f=Tk_9(T7H(|VlWiL>wRqVk;ZGJV9YDiOtY06dZh=YJ6tTX? z*u>KY{17zHZz(&_<(C0UovBlHrpFy^T&v3dn(9K~Nd}*z5Ar@&PZacY+ss}Vpnv7J zCR!ELo2r2j%y#{1jq+7l%&t zz&@G!E{O01B-9mcIWVU%?}_&&tLapaYkSkHlQ-ko?U!1Dvnu%kQE zaIE$v-JRUtEX{p;ULh|)-{v+Q+<;LMqcKqKgA{$8$4e>krqo}q9A7^mK0Yw|;o-@r zs1H(B6FjPNPcDsUa763-4FnWy>`Xw4UH5oLs~vC;(XBPxg?;Dl^YGQ%=o|Bs9?lE_ zI7aGW2bACyPWFGEx2zYJt^h&oT)&>ofw zaN8V2)2?X$#(y^`nZGl2#e!RJ9UBYeDYfo?=ub;seo-G0_AN_Ty|}{Bi8sx^gG@8S zuZ#Tb5onHI=EkKKIu-Og`_ZL@!!OVk?1fcpDBC8KUgxMF7A6;?5zk0PEr&q>PC5h z6$S*0!2lp?P#FRCgkv=nuuRNCA4kEfe4kcksQFIzrY_+C;)dq}r_bR$UU!-X*6glM z3z75%re3VO>-$AI=K$k!G&>J+fn(3c&wZs)0P{oI@%jbxY3u#GzSSWsZIKl_Nk@hJ zb!>X4<&W-Nr|Inn`QX-}o~_L?@b3alOBdS{}%N=?-crhE%wM{ zh-fzDYQ{jjk(|?y6d7e;BDY~=CWviTnGXGp{zWPclYcXMne>;cCfEJQOX;!rg?VL_ z0HGnoP>re6OZypdX8Ufy5ez#jrRiP&Yp*}(DOo$uSfei3F1V`KU@^;}u@oZuKv2}4 zQdzA`Z@DE}=8DQJZ}weoKML=Wuo2fcV}u_9K4u76?R{VZ@=-{|p$}H{PaNWv^tU^E z57aWX=V{ecHmTcuZQ!j^aUfqt%6?WLJ>O$= z7;F)o-<0~!TI(F{^(DPRfk5DyLf+35-NetG3#4rk&^GPUU9o`4rjYNkdB1EFN=dlM z+ymj0Ze1|Z7wR3p%y>+%-1P|UHnjs-1Z|*@ofJFULoUZzEXmCncK4JHQ|BGreq>gs zGyfmJj3985aKTn|57LR<$Jv7f3GaPFQF^u`%YWqjQjLT`C(@KB+i6;dte4Ve6dx=9=R6tDNcYLqi5TFm|Z7Rdq5vE}fo?^gbUBb1?iY8<_}V zSR@Y^kurBxcI0}aY?960lOf93I6c{s((db*tet$O-rQXHm0Os=!Kc`e19}s_j?f=V zOEy~hDave};8j>;q7We#fI;8ajezg^&C_SZ&p}=!njyu@#m2t%q5xx z&2#Eq{f}75NpA;6suP!GFCiV>2{Eko~cVR z*R^`dp94@Up~E7QI$~3dVxu6sk)tuz8lq|{yp`6e{Io#PU6e2R;a`AjLkQThgZZ=i zT8O{Qg_+iD4wlA}_9gP+#dG9hqcw*n3;e_(a5}+oBdBojoEKHTWro1T!rJuZNhjez3QagnLHw~1p{E1sg4S;X`VjaEcS;n^%IF`)YzW$Q!o@#6E0>ON7hkHB^)Hi;tw$7g0)BU5YtkU3bo;V;owcYy@(k zUR|?kCB1>k6o>}10#i#F$r{BUz8G-Znz$XJLkvDQ3yJgEbmqafcH1fm?9o)fPa$Wy zPmPMDsDH5*q3cb|#$|P2Ku$5SIE`+Heg^xR z#2z%`#?D6gEd107D7ffGHNYmoy#?YrmA{i&vPk=@fVN~c%< z8l}S9GKHQJJ&ko_=f<1D>MHkvwdBPp(}Tn3en)$MjFfWqRlndvZ}%{<8ur-uvrsgF>!K94*T@lU0^u{=;W!yya} zVwn(7ZmC~IvzB84wSHDKsMpMv?i)w0sRZ4u^+&U^Q?SMAVyhx`3W%ggnCAx5gJVUz zr=NBwKP5G6eXInkU2*z%AFSGBXW)=7cY-(U3%Vd(mbw8(-^kc~#38_PfRmcU=$*HN zaH#lKKE&aE6nN2y&23l0dxi@Fo58by6Jb7|;a@DfYXL{7T+%acAtK62tWQ)^m;*T>UGu%~jbY!PB&47?yjo`Qs41B4Z)EejyMiYo~$XnfX z)Gnr|zuKT!f(CvHLeP;ei4;3<9ApdQi!hT^`4UB7&n#`O26|>_&VNff(`LpR;`~UA zD|#(@`>iDX;bl3U`1*Qd2B%Dj!L8@`P9;D+tlF~SrzdC)3q^#f#35M9`<)Nm(Nf*d ztoT1%ol|ruQIw`*+qP}nwr$+lc5-9ewr$(CZRf`5O!v&1?mlZ()zf*XhpMxy&i=nI z5Kk=1`6rfwA?;mR8?^q#w*(a^v|P3KRxAEr@y3;+{>Huy5A8yDcSxoz>Pi!0Wly^A z%cz_F;$|n8_Tq+@W^{P*M!(gM(*4TUc}Y7t*I!?Pi4MhYr+Uf(sg0bhsSfWaM5xOF zrGvH5uJV3N^W7BVG{XGUFqdatkA-0k8w~QG|nrg*x_?*t*o2YGMfedznbwt zc4Nn0eFc&$?+0Pdnaz^nA?tgI0b%F-i_67$NPsWJb?n21$;MMR`Tdf=Z_xYp%V##^ zOr3AVF+YoFI6r{(65=Oci=futd-&?uRMfEuFH<>J1~UC3=fq~iHN^HtyX0tuJ%YQd zEm;3d^G{xWKJ0$p{Lp4!;NV<)x4G@Bm&|8cllFXa9Sr<9^|BrTAB7Vvu+>g!1JRKa zEdJIx#-OihPBrlyui5xLrTK`>D1%{)5cZ;{FB2>b-hl@$k0@JM;PB#rTOexawCRVe zgBpm^)PbVE-c=mJ%xw;TYk+95v&J-5Ixsx?t;e)pbIldU=}B4@p9`lEOwoe7Y@6hS zV&kcioYWxHC1`3F|O_qW7_qPQEJ9^A0jt&&CfpHh&O z=A0)9Xlh5^!yk@!I)r6~!NVYqXE6{omVa@z@frbVX-dAbK?%Ta`uqgDlcSXA@Z;Mo@?;zA*rmxXDKn+NGpq6HpRWTk2c_W=I41Xdy@UOH zBhnnN+ecY&?BXq2zV6M{OMCC5w|8~NAgX13knra2TT^YE1|R{0nEd1aiap>asz`aD z!hBoo{nWmXxXD4xh26_Q)%ONuEg${7iljfQieu+_(3{oKpG)o4J!-eC&+5>XD{Uiv->cTMu zsgg{Hpc(}sx(4fyY~yW4a0osV9QZ~KUOA(vTo9o*`T?pR`%&oH_;URjzS;ktM2Fzn zUuKs1oYS`73IMbilZAOLcu5HcVUrEjytek1IFY2`7i2@HhUk{DEw~ps-3*E1&%1_%Obkl>+zT;IJ0CB6gB49lT7G3 zVvG1x;sM`FW{@LyNaSLn7>grxLQ575v10ty8OxwCIq69xP>tsiF6696c%rOQhs7gL z)~`d9pKzme-vfE$VaJ?NVkh|!2*RM)p2?5J4(0?vVix3i70y=a4OI5Wi_)|QtCVk1 zSHgTHaAyjqbJ^1sl1-vqKRpN=Fq%bfF?P;gA<1f*3$;*BOyjwe0;;> z$mkRvEbkiv%zU@;YbjI!1<5v$Atg~XP0EH2+V~DJu;e-K#U!(mVr3swNF&FPM*LmM zfjZFX-+Av8q1*uC2LcF&@xFTnY-T*bj71wngb826k?5g&rO5)B=Y3)Xm3W@vM5z^Z z+Pj-a>!KsD^Ba=K!|V-kt2O??#{vowNaKs8-81WvYggAl5FbLLUpy}A<}q3;Px zZGc3CjBj)@obWYnsE|&R_%IPvR4NjQn+eU6T&UI%e~icIVcbFRV87yZz*r7A6=gB2 zBE!t@ySVIOm5Ct@3|LfHB!k^aBxf3;q>QTz=u0?{C>teq1y<-nq_Oh{aYs3r;3P{T z!yAe?Z#tzf0mVxZ8iu(o*f;!9R(Hv><`jt+NDuBRuFQLJrU12ZDP=`~Q2RG-J`l@l!sMWJ}@j#>>JqETT>7A~@D>^#oVBg~d zl|*g~LmQpNh@x=V1+0DE>~ls2k>I$0_O(WlnVcAv-Z#N~NQjV9-G+pTsqH3;w~y(k z186Ck0Ax2afQvuFJLsLwoKz%Gu$D)Q9B2aZmkgn=mZ8Te( z=bm=;wEDZlvlmH7cz28@k$(HIw4pBB`zHG?I$iJY zWoRc{@+E2V;jx(Oy)Vu=!K#0YPCp-Ut=gzZyR*-qywD+#^=H?v?3l#uMGSjq7GqP&6@!;>&VE=k~r>SVHNE)#=HI2WV6J zW>GqInrx~@v$Q$#)LX~X-A3Pyzkq$newOXywisvLy|&adsogc=X5Od)J@&UfGWyom znlpZ7n#{ys-tuJVX(mH+b{kM!(q-po2ru+UBPZSmM7jwYAsO`ehbdv+ z>$e|*RvSH-yWL)`d7JZ~zFnAp5Wi5WGiSkdorhFwS#N%iK?aEhufjQ7L2|a`1h^?6 zJ7XPBS68m~U)$F9sC@2iHwRB1C)Tx0cj6i2vs?zD5jbfgyT#Quz8p#Lhr~@19{P&` zq33I*b)0**p$0;fE*^eaqy4X)VW8+Wz7sK0JP(QM)%CRhT=gD4md$W!+KYzdGopVy zZOCVxy6UX<*| zb`9{)e~jSB+Xt}~XU&vS@gxkekzFMtsaZ);2cvLk3cqe~LY*~`aUIQ{%3y@GI_#d1 zV`0Tjvd_QqT(#VU_cRQMm>79qcz|6u$qYxac{6Pr)6zwQqJ}R(&#O++=m6q`9 zx*IR89GBss0m>diSgM1wMaW#Nf?UJ;;w_KY7G?C*+n~2CShXahIXdmd`Xbi$PP|9^ zg>y7J<8+)nl6ltLgY+a`c32`5VZiPKft2Va(#$05II!K}On=fWaYGp0#S###Heptc zS^~sqPsAap%N_RBSCJk+;-UY%Fwo7Dbsu7si`#L0pl-NCele`hs%A z2~BKgE9&Q*x1R8b&N|5v-YOB(1Z_Bc60y`FZD-3|lG#vM>A%z~pH){NSQ(aosDxx1 zEeo)=dveN+Jh1vc7vHU@a=qzk+HfU|Sv{9U88;S4LAeH<-M0w~`H*SInq!x*UWZ0F zE&fbFGsRS#m+Wq_0&+@uXDqI{n?R#=1SIkl?hl860Lh7@C)N;I$7K~H&?Q2!h(Bsg zWW^m~?JA13=moU$Iu?O$u<4riwHUy8^Ojp~82FkkUrbiD&m#sawSam+15>^e$Pk#? zTxD;p5MtdkW5DXn4egrTI4GS_W2bZ>V3;aU>;{co6j|aXDmxAsl9X7(D5I?_Z`ja% zCrH!Vb1_$EXcw=0HLeyU<;W3qID>wy%EI4sg?hLEb+YB_hVJPl-uco1ZOMcYb7*q! zzHcun-|?g4wA1bXgbAoD=ZK%oS;rL325MVyPbfMI5OLUMjB$U@Aif-Vb0^#};4Pc3G{1h_`9st*#|M~kmLuI4)^)xJQ#6~p1B|Z`*f#fZq39G@XWfn=c7Eml@c-H z2uY;(ijKP)4h*z=r=g?LJ)RLPDUxKPjfjN%#bAdm7+Q#(T$wQ#cM>={Ji(6QBZ(VS z-7z3O-QCT#k+RtK3nr5}p4`MVIgmjb8i7IT1@V8a{(SrWy!8I6dvobJ;wQgs?EQ># zwdA5S^~h_I^mR0C1>e>wB-I(SUba#dx=t8xOS*NSFc0r6LMT~A2S1)gt=9oTJ9*8 zrJDH1*o~<2pJvu`uxFe9A_paPv#H-XFFPuxAyf{*Z2ULkH;U&IZ?? zq5EGw5TjyIN|fp^t5L%iREqNn8H&rVgp~87z|7U*b;hfnHUemR6>fVSFSeUTD;Hg` z*%#|rS4qEX%%+b%LFSQ*DnWSVRk$Z?$5ZQudL^W6`;4U`D03b-Nc4272&Dn5g-h^U z04O9$;CfRnVoVks=0d;TJp=HTEm)b1@CWW7 zO;#*y|IRYU!iLXfAIss@1@N^538$OIgPR%B)9Qnzk3C;Lm!@DhILKo!+*pih^kBrj zp}MKkENBIGyX@d+G-g#?6o;K3SVtcuB=C~b9N~%uxw{ZjSdEs-GF2LJ!aTaoMZtZKj2L5GqgEZri z%2>rsb&>TXh#mE&#OMMKb+nJ`t9SbKKsYb>`2N$n1RI?CVO}EB(c%g<#>?48E_|_k zLD^GB&&W~`5Ivp5T?7LvXCcs_jM=sAcHCL0Oc`lG#G=)g-qF8Rs5hQXM-I4ZPKzXD z8`B54j|_@@O7PZtruh@N8PQ+v$tUFQ51mQ|_V2*vR zM0?BOV@C_iF<)W)g3y3)e}yB`}F3bHO+Lf zFeOiCVvwV{n_uWs7jl3b%Uv6(Ka68A+?zoCj^)hQ64s4$D8_}`@Sq_DDU8vMLX3~+ zRBMo-dNTiU>dYw%F_iUeOXUi)k2_=IbJPGwTfoHOFU`P^%LT(>x|Es;WMXcLuOL=O zwb2dtP_GtquE_*RI$n;2ST90|(^C-=CWLW2`gIN%Qf$x0WY6E>Y;7WI=4!B{6ibwj znOSK4TUe?91dxUMc_)Y~7Q}~5t$awW`r{~yyyTiiEkuR_zYGmyNqWd8*Qq5HHp zx4bgsiMg3P>Gz{WGG4VH%j?Gj&Di1?Ob+Y6(WA85J3l`gleL;ct%m?*_l7W zs;Lm7;UMy3L~4%3HS+jiW-2MFo|hE(70Z9D4h4-#J3FRCr3DYuGu=#sXFM(5J&9B z3&)y#CqP#y1;lDt3t!n7vcvTXjFK|R?xz)vP7}=P^r4_vBwN*#z$Kd+(*c^OC8}q) z_CW7TQwtD;c1MATa0(5?)j(v>$!@}91z|%Y5s^ET%UH0x zcr4|PeeoEewdE5&P=^@UVoHM=-gAtwB%^%UVTHtvDMwTwEZ*(LeW2AGw>ob(^urQL z(46(VXy6^5x-GvSH;EatVE|>Ii<_eEZJ zug8_$9Bvj|aw8ur&LJ5b3pW@Iu$nfvb#rNTD66>It06~SUM{qwn((?V3^?CIVS@-@<5uF6FDm$vDk8d1A zpg3l5yq}X%kV3z4g4?vQD1zZO+q)Id(JZr0R}o6hq=RbPOsgD(+m;p4+)jg4sOV0| zn*0f+!?ioc)hvcO7gGswHf|hD2J;CKEWklYI+z%%lL7Rg(xD*_xde52MWugir4UNr z?lY6z~93&|Y2U zG?R3`P|}!i?u`&FO3%WGlqLT9qxEL#)7s+ z_FGVFT-%#D#}1$LX;C~K|#goE6`BLQ=!B}93;D5p?3`6A~>HO zh3Kxe=i?Y;z(tBiikJpk0BpMIJV-t6%W!qJR)XHs)o;>1-UTE>;(NX+y)frfsF)|LW`63iUC?3CdG#kz*XxC)n*-Y_w z&z@YlP43W)sbCoxdYW1YcTALVIr5Cdkl?r^E!5O4S5oH1bJ$PTx&Jp%TD`V=r>mM$ zwUT4^%0mq53t~yH%+PBHCmqZjGqaDbb-^=T_gpX9QdAcm+2RCUD&i~{G9%?u^H*zn zWQ%7`eoMyCLVt{qPE7^=(UeL5J{E-)z0UtGSctt{5o01{rxxED7BFT>SW{Qjgvkto ze26uwy@Num1pmRv!jTp(`dJixYBjn)xlAAQBbJONYC+FFFYh#CZ9R@f%1ecC)ETlB zJfC8@NHM<8M6EiF^&}CuhU1PEr+5ibb(}ae!hR=vWf2Ko`QG21i#e~A6snc6XXQRAqsfn-6MJwDH@U~nAdkTa12_eUh_7v3a~Lpn9LAU&arT+| ziY}t^b7l6u((9dMQD-9Lldk1uDM0^^??Szw&5ys0p7IWb3ij*U?r-`-zb?hZyS zTQgANwxyz+FN&!KXi8hmbi<7s&XOjuBpuE0{euPY3MpcqB2tJcLwJ#@B6-)d3%5pK zM7;ue1NB1Os^u$Uqzn?4k-VboT-z_-M@Wdj0-nWts@j|Vxk>{``dufGIl5|o? zw^umUkdj2VbzY8Oc%e%Ddb}BNhJT_S;g;f)%C7i4pP4K#?g%b@zrC>*&#bj!Cm9XF z1f_dHQM1b0o2 z3)FoPSqg&QE%BLHPL@f)rrrbK9#I5d18hoiyNb`U>g8!Fdl6eU4R=3N!tqdM|5UQ|Df;(u^=p=fMj zj?RB=poSaF8YTTCmOE6JQivAQv2N~Lu=cm3ol&3`9SvVzf$hB?)$it6t9*}RHm*Jw`IhYJ9l88Sy}0fsRZpBX&3-peZL{)q;QKl7 z{vLk&jC_1UKYhfSIc4~#nl(E^zH;WxW?N)BbGnDYG;=z3iR{&}Mlu4|wsg7-G24~< z%GeTUJNGOyq>v!M;{~hF=!fM!+*VHk&lfSMapHm-dr#}dZhK@JLsF>i#>&nmdLa^h zP%ly%D>VXLVK{lt>TV~z+slFcBa9O{q1IEG5iDPSf0 zYX|Rc50pRXz*B%vFhF+bBD{ss#YJI%lBkn)7$z@*#}aXgKDK*yL^M_(%8u8k zbP|%tH-D-5_9yi>Z6ZUuIadaBwigGt1YnX2YV$sOG9mNQxTFSai9bq_B!EQogYM7s z39_(i%!;z%1-JgP;-fu69h683PD!B)wL{*D6FM@jJOR=0zN+f>yv4&e&B={Mo*%JW zc&#`=xoK@c>_iRiX)lDAz7W{l+3}g_ zC2VkXO~)4Sr_D8Md_yJ*hY|i_X!)6aWK6tyiR5?f;_WETf5%Dl=KR@`y5^-@7&k9$ zsn&Af{ZWW2C8>{)wp6W(@j29)!^mfwh zmxxBKkuMQs*Qw-_dzosfQOGjLb&Rn>*Kfbr03GKJ#?W*5HUOseph!p<2X`la5Lad9 z#%*&=5F20nz|tk8RhTVPM$1GmnvB^X+e84o@3BFE&1F?V6FhWR9y>rYUJ5mVrpYj` z3br2)yUH=Ge!hhR@?_g1_s)uUquK#$9^%B`&R?e7I80A@i*>AczZ9S*pf=Ngm(+W2 zz0{z_P5-iQt_NW6pjj!?sFgG*cgIC>f+5ucB1u-}g?XZa)F_fTVQQvX+abt?KyN}7 z0lK(6NpmzwMq*@(VwWn>hO?It-d%a$(TRj+^c)NcCuoMX{O!PgO4W&&+!eJ@d71rfk zi4Gd(wz{StC4wG4XO#>b_r5rPbJ0LGSua#2f9(Yes=BjSt7EfmP$WThMOIP4J2L&A zlUd0L-Rji07)aGI*QRly#j1KC@3mk>Hnu{K))mNGM=15DyMlre@y`4Q$E=0Wo{TF6 zLPPwWO?{S!N;6kvk%sOJg8#GmC$7o*jert2mo{ZXMl5SnGkd3VXFRLzWqt5$93t1Q z_RMJv@XcgAJPamduVUWQyCI|xGPn%ukyv9B5nl~ z*q$Ac;rWD!L^U{=e$dJsapcXdr1)Wztql`NTDOxL7rCBA2uY~IoWCltX=U8fducRD zdWP=GnkX4-fS3f8kae#j6JYZw-7UW20|vD9n~ypV`x7qeEbL(LLAx!FU7~XbQ?h&B zML!2>gh?e1bgj5Ft2W3##dPqc?cxTU>ksWL<<+Ys=ySg&uh&=07lWVN=cxf7Q5~zH zEZp^)*1~|EswcC=eMak+NCYUMUJL;-zvuL8%C{T=t_J_&^Rr?|-6I4xVEpbImPNB| zNR`nz*e%I9f^nm%ocv(r3^`K5x+I~O5li z7ScwLPe#!8(+vizIOfK)A~BB!=m25qWE)=6k})<{IZ-=hCH%Mg_Nn<@6{4iHjWCsy z_Oj%$xCl1drrA&FKs+D9=P)YL@|AY~ZgNO7we)e5Se@nmcDd%iE>!FyA&{SMe{5a1 z2V0vU2L2SEtCAffW)q>HE{!b}iSNI|wa86i>`%Q3J^!;u$A_{zR!3_@&{s{Y-$9Cj zg0n$~H_-G6`}v~E_2M5YR4%%ZyT6O1cm5nXsXQsyKh^LeQRn9)-UA2tjDYgzE{mHD z^Yhl^$>Tc5D^Vvg0W1yS>t7O~?l@2=898S)mZ=fpebM>R^x@n;4v)11cRiR@>l0<@ zgtWK{k5hW<*)0)POojFIc##}_B~aq*XNdEOzC@Ljj^j>fyfCf$M*`MFAc_JMr!Gty z)MNdZI2Tr?2+Ek;`fm@hAu|@)SY}stdc=FI0Iz*OV(g0~r=NKGUUz|TKS~&|xB33O zc*EcOgMtOs8S`?rGN!>vuQmlg*ZR;xqV zX=IRDLl>s580)WuJh5Z>Nv%duowa#_(MlW@v)@jo!FwW3=YNN+T2}U4{uk>Poi}$U z5w{oDVKVEwl}R0RErlAuUuf&B?M-=~V`@9gv+A;2vKj44hl;l#nVm8DFB`|s)P*hG zv>bLX$8O9B}jZdo!rw(kw}LIr2omG6GQ)n-P+5-d=|LU%*4XW+VSALt>4sgjwBC(kqo4 zR!sH%ME?WkuRK`@+hgkBEn3_;wnhdY3e3~M_vNld78#;x@kR9g&kqrqqKA9UtJ@lL z{oA%JtV`dvS_%>g-B$ia7Fibpr^17-g19>U1V|8Eg`4UqE>41-OByLjrk5b9pYRY# zO2Rhaj7!}aGQG4vrpMGKt>=p)3s(Mb;Xckd(f z_gog*;0vMr9~{_O%(9qP3QtQ2f;99X(D)vevs7GFPe;4RH+K)UAzi=OW{1kL)xANG z{W$_M(TrprY(meIkLg`lpAmBz+d#2ZFI=2YUQ^F9b%lX+HJq|b^wq2by<*^#NGR2g zk5OA4r)>I2HZUR=iy#LF?S#l;F8~3M1R{F5g1FKp4~#mn)~Pr~+iI3LXs`JEm2R#Q zI*aoSU`y6ksbrlh9C7+Jky=pr${FNg`Ge?)^selo+jJ9>9h6$NR&Bsl2(=ri+M(FQX=FXO5S}`brMUAjgB`cUA0}ksx^;6CIo%@_E0_uB#$81V8b> z+gsj|d_%OiM0!+7s@;`6hgU9(1G+UWgey`xm*Jgzm>oB>rTFKkSsDgAU--#7#A2VEKjr zN+7bktc5I!BOxu{kHT;c>ru9b2O`DNH_pBTxz-}PG2AF$=L>>rQ=24W&n!;L+ zcm0!PZYz)rIkg>RJ7hBKd*#D^zN}*53dqc`6#26fpFwl+i3UmzJgfLW`Bx(8PUd08cZzUIF8b@IbK_y(ktz)tjEn= z)-ob?))PWfyV|Lh$^@vu4yz?M^18JC=hVI*#@rjGa(cgges%mS?fuQf$h$0#HV^l2 z0ZAFQI-*`aq$X;6mQ5wF9CMe%(8Oy8gyN(-5H%pQiPe;PEfP>Z`tQ1Yp1#I?e*iVU zKV~AeL+z+8md(}Z9Wxa+0N;_R)QN>uNY#OmHjWaHb_YI8-LQ)dMI2om*Tw>v93P4* z(^4oi`8Xa+DsA}@1XXhk?lryZUbq7;g-nj7$L_dAy)eo;1>iX9Ik;F|`nO)zkIO!S zp=I+Jcn3k#I`t&MaHmWEmj$qATb>oXOky;@K+JgYKJs|{U}x6GN_%!AVv2107h!Sx z{um3AO_kyfD7@m6U?7cU@1oe!kCi)hl8No!@AJI;$!U9>cCyZrHN1%EaB8wsZsXX{ zb`#|Gr4UybuA8$w3LYT|y6Cgtdr-G|w(4}G?np|YbvW;WOM+{ZyA4y_9fo`1>e_;P( zeVkWJ2vHu(lxFG2#}V?3a}Mem!`j|8EDkLj`HmE)|H+O9=mVc=Ki@RY-+I0Kchj%D z`kHcHA_G@R+YI-<7TE@! z%NOwocr>R-xx#d@jgR^$^BD3Wov}<5Blj1Toa6j;UNW{J2S-$jKYO>V{xaX!V?olW zGw)X=Q8WH7qe!)W2{j0fUBYBf2byVUIoBmo((N}3WO#UMGzkmaK%||7?ZXe~q{DUBtZDMk=w~)cnG6!h zTQBM_3}9~5<}w)s#Bxb`;TG8<^GW#Tp64oqc2)ng+7v|{%2#dwmw#Ex*Yx++8@@FL zGjg0T^*i)oSQO|iv-N##0z6Y4@DK51;B;|WYO*(%>{HI6*AM>x3^M=bi2;EA&*^s{ z{~qQ}riPBj7XLlU4E(>qPk&aW8(4lb*R!Mm0O*#_bhk6ZS-tMCW>Fb_A60hW2Dp@l+PUy`P?4P7;@T6Zs8GM(C;6{**DN z)BX0iXC6EIf`3Hu2R7;Go}@LVaUs%-U0pfRL-cm^!_g~}d>C6as&r~xRDc~bPeBnT zGmoItks9Vg#UWFHbqeP~-b%wt8WXAMdM5pUP`CDn7jlcClPB)|K`_dewah~#o`8a& zU$BnkEz6p`mfr|w2n_C<$iQ5mjC3io!N&7BlsyD@Nragg>I5}uM%$ zAua8hJ@lxgKcUl<*zLoR36L63Ky7ugmP`r>I&pWxEI^2)h}J)wrkumD5r_EmyJSS8 zH*g%*=iiq0OTB+vFkpO|P{fWW49@SB(v|0@Hm-lhp^tN;P`xl?iuA}OmY&P1O$evN zJ_QFHEiuP|O#{#Snugl63D-xDMiLNV2K{QC_67Eg!*L#@!`a1kYk-nvMD$F|ZhG#s zQYs~}4cbXa?3SR3A7~<~#)F91U8w~(e-lQRth(ZOHMHO1!mJwK^+2_k6 zkw7O!SqDeHV4`g()g>Ulzo>1ZKn5$Zk?2ij8kEyJCaU{8)UaTC8I9=I+rxowM<+iD zJmQl`>nL2kiaUg`C5G(Kn8f1|$6aB@6-Km1jvq;VSU%rY;Icc^?6{rEMqXF-_2n5i5M(Zz?8e!o#>4L^iQ)?q+1}aLuWgRgsu;-TF;-S6)z*duAdD1r#Pny?uyumqK@?G9tkVDV6to1%mBx z9@R@$p19YEya7g|&aebiOi~QtEWHDDSy6zOnb8v@dYZScN)5rD&`ngB2*VfQjN)^|&NQvGR2ip_lXg zRTMEEkn{tpFbm_s=wVYLLSZr$Ye9Sz_^yK357x}=+^n40&qX(z z?SWBx#Y&=Q(THn0(0X^>X&FFPHOnfia3i~&3Q^l(%lABfBJi;Ea&EXB?san~dx~#K z${@4b^Xu#0b4Kqi9E_HfW48mlVkx4S2Nh;Q0fS6ovv8YCW{BRCx;H1%#L$p?vKd_& zpp*MM#KC=I<0CxamuR;m>ZrX=#*uOG!p}_{aiKiJ&$Rhyw>L;;)CDG9$O{pNg!RNfr0ZVvPWWrWUO%d@1U3r(X`KHKgnR3wE zu%%|TPyks~wDLV1BUL|_J0#*ZV0(xfjLW`Iu$`ICW49c`T@lO$?$9Tg0w?v6-Ok z;GV@GQz!6s0pFiHSYjXR@X`HU2kt9b-~j@e?HEN2fta=$2|%ReyK#qM<@l>hSHL?0`vl-wFcYSh zn7^C}m&vd=_R^yCS2)r=b7&eYsKq0NuR!@Zz%aE#6ez21hl^=WId1zZAAcUHla{x{ zW!(%ad4_tNe=cEA5;dJCEO%2KNq-sHOpZt~5RDwcii<_C_lZuX<|UVEe{e-XhyXBJ z4*up|&69=E8e?Q|VGM&Zj;`pPzi9DO2oXi%B}pC`78j6`zkTEb-R6*hyILkATH4Sq zi)<41c^nel0VEx4)glIf+!<#6x; zk5dcA7mn}pRuYeam9&Yk0VV#Ht6KFq{!Q(Gh}_CCZo#vXSXaAWCdajw?Z@DN9+#_Z z_=hQHTef^mtgDrw6yrf_u)_|3ilLg1cnEL5bj#pAFMYC5VwvZM;M1jIR$5= zyt%;^;VxX8B9aC>D*Q<43%l_Gw4#)FGaqEoQd?KO!?@G6I{}7LX99zUxl>v|n;-b| zXtB1GGhF5wjv~J7eFtxNG>i0UOsd8VzSRQ4psVr(!S*`#dHUtpDX3b{#A~atJGmtiBRqs+TXWxoXkEF{9Q8@ z16C}|bB=>w;bJ{6u6rR_BN7{x?@l4!|>dNaWHOnu)ue1w!4vr?euS(sx#|*Kf z^+km18-EFa3-30D8fwm3mWjC(~NQV@m(?+On`vP*UQ7`x@F=^OX?E{5XoPg>Xm? zBKT~RdPv_vYiUzO-^JC=mU)qGTK9Ff@()s0l}|1_!>8;UJA|^Z&T42l`EKNAnF@Et zxCb3}Ef}31mDgqUg3=9Q#!&)qYB8X!{CIBjRCNmkt6qI*L}6 zA#oZ~f>K|5_2ZTSaI5IU#LGcj$lKgJn1wy2=d}}F_H2-0{=O$3@#cEyqX-?Xr?vvb z=B<>3MD|9^o$TIu^El^r?-`I)7KL{{wv+TvV2#VJRy1|@8)&lvvxkMLaoBYcI|wt7 zP>YJ!Oz$;Nz5iK{TbUjE;^6}Tq&WisVE$)u{a^R&^q&`f3sY-*Q%5H{dyi|~TgT0@ z_@A0me@pdv9qFbblhzCO`4WrF=L@6B7!v(wqDzY~5Yi!%e1IZS(a{R8Fs}ixgpYr@ zZ{Yq#SuPcHs+aI=5Y8`8zvK5Dg&jSza!J#fMKV9GnlKf4R%+syeKcP=Y|SCxs9E3a z;)r@WPN6lmD9I(CNQ7nXfkI>|5RSCdMvyi(d5d^b!#R146xTTX{3it zLqD@fm^zKqUb?UmpKUGFtHqseJhPL`xz-s19f`Dblc*3Oz!h(tB7l~CPb7a7<%^7I z&>XmZDAJHQIUA?@DT>E4dKHvWe z`RHz88-k^tk!vy!7N$ysIpFn7YsE;((cs#2FVYiMi+l3&;2R8eV!@BvM}v0kY0Duo z58%j7xRWQ>`+nhN#T!PXq;1k>amSNgn3Y`e?%5R)FF1)L8$z7WGI^w#wH3L7d`dsd z7SK{?141@%Pd01{UP#^5_D5ciR))r>t=mt>hBLmumppf``Od&QDVa34Vu6hF_E z%h8cPFkJY^nb0!@n#E%wHrE>)!p{ zhzgQZo)1IlH)zsHEVA_qzHm?+Kv`C%3)bbq0HvuhJOKm6YyrGl@$cL|=ml?d&uH_| zQleiNw0YN1sz4@!fF-~by0a!14bobGQUt}`0)k(3bq+}(FGBazHF2WA02Tv*m^P^R z+H{XNfwr_F|5Hb~{r3Pofm`sr`e(~Z2i?Jd%==JW75zEeY#E+2g2 z)6}L1OtOIFFuTKo$Eq=Jz|C{MoQ}8X0Tj10|GKW8Z3I`2!hZf)v24M{v&o!g($&-V z%5lc10%;$cL`-TffR8J}?G*#`@>()pI`6y0 z3y;wB`2C8FMdaT>Z^*7GuZ{5FI$px{`>*J^czMMy+@?1>p}$(ynSzD>URTd(R_xr~ zcV_S*;%HpV!QSLGUYPT{WV>U zl_}T<5{HRf2y-tUq2AgL4$hwi3 zoR$j5*SQGqVo$c~iy?YSb{u^A+fXsKe}^FLjO(l@w&W5E9-qn=fx9v!9>5mV0E&XI zSJ*uSSycL;%o3S#9vO5DtbtyM0uxW(-bgLU(}qS|RxdBv@6HczyVms<+n``| zoA}j;Yl;AS_$zZ`n$8)GzeL6~1i==6BF?crsG8aqiQ7FG$nr}ka+6qSpo12LX&Lw| z)aeXgt9&|I9Yd~F+v%&z>uiii;+qCEPa(2dF`hU&IZ1LW1ZV0+;mQLh!IixFW3 z#YxrgTlA2JAeFQAp7u);_}wurWskacec4yP{hEu(5&hWpyQLILn3Agd*xp?k(33%9 z>Gz)7b@eOrCuk z4A%do_eI`(mHeTJ-f+wlK};Nyqy644uBmSjLR7P~AT>xfNJeSOQduKa8l$=LlZW>k z8HE2VQD0WezwfkoArx|q#sckM=wOznLqzonXmo|l-)06t<5GpsG~h^V*1gNcBbbWB z+q}s~_S)c_(HctJHyWr%Bi_~_q$Dnjk^c_gBTgTAN-K<3d^gRA7AZrKRGQ7MrLS@} zIubxIhN*R&akMZSOt1vMaTorI<^jw#fQJ_W5#Y+hIVe}2&>IE7?NiF1C+u|;G&lAB zkvL0g+&j+6*93xw0W{M`a2{wO87!hW_>{xF7G8sLU*%&$a&dAl`VX$oDOR*#U9-!3 z*|u%lwr$(yUbb!9wr$(C?cV9zr;~G&nbgyKtJIvS@B7F2YnA}5^mdsv93EPQe&nq~ z1TUBrn}(|Co0v#H6dzv%;U@No){Wta{bLnS_) zD*U!8B8dv97Z_zE0%i5!knwX@Bz{GtrpQo<8eU;R61%vc^(MM`iby_0e^6q5ce32lXqAk)Mr`(Vsk74<=4ippKAABFJs zb^=h%=R`^{{8r)l^dLp|2ENAIJecOeNepa+#Jmv5_lM>jBqW-ECAkf@cDZ2pmcB)Nr5vzSt#{)It#hVZ7(G{E0Z3HB^ZLcL51oN6I6B zCaC73{t_d1|ISqnwnae+k7C8#q}V9;JZZSH;)XyB}nPrON@eU%=m zllPEhDu4HWdss*$dDROMiq1yoz9~JB`N)t??C0p{Z4BQ2e%Wiwuh*B?mftX6ZnBxC zb4FywT#{mTKCAP}bgh2g5JWUP#FF=x@nPWeVwVT8#q%!F(qM0&tZn@Uzg6avuEkEd zSC*j)poX+I0?esSWrGN2Iea3oaN#-mh@=O;bqbHEYX{7nWpBG++N%&4uZNh(tE4m% zihL1KS!6V%2V#Ytsbm^X0xlETd+*3C)=c)^4oV$%SHzzQ<*+(w#@JuPWX@Fa(h6m| z8pNZ$EZOVcp^EHLH|F*%Uz8b(u==A~ZBKkhk58?hm-5o#6~McmY=ZAYj8LhKdX+2N$IG2^uB&E1~~i&9idW42bN zE{N;mJoMzS4l?9)<;cZ1^aEKa#kW0-7$~;ypb&VLSw0?orN|{3l-9Z=RUm(|u;U2O z4$w)`95&87ZJN)BIGmO(0;>P$T)?{0s7XG#+3Sbl5s37z&>u8d#(bBCAYK!Daj^3# zf#T=Sc23KtiPw;Vi1%o(i!_ee&ws6qi7-u94XUyJy)JG?Rtv$zB_GBxAXT*3Fjyod z?j+%3XNV@{`D>D@6>nxSNFsgd3W$3%^l$?1tv=a!9%y@*xr)*$j0B6Q-sb^%wU8nB zXEq6|5XPB-b0=%YV2~eH!1sofSI*|^s%MSLE27|OMHQTT376w`9rg$eI1sgJUW^%X zk?=Fsx-M-6w5L%s5w5U*6x8HF3ifCZ&xTnWvI}-=+D}5zj-Iv>@c^tgMOfzM=9Wno zPBz_({lX}eqU8DDU_Z2LE9<2#DZ~f1f;YF<&TMM(mirOtnqez*)gMXTKvEB#2(a^L zv!a_-(&7zEfq8c+NZpREJzL3E^dz0#1Nb2hEa}$P!%IqMr~cSNj288@!D9Lq(r^rz zTy5wnxW^%GA>Ejf-Ih>n%F18q3gK&U?IT_PaJXp8Mc?u7<#cNHo)~$xE41p_)nEia3+`kQBDiI?Uu&#l) zQkFg+B6K%Ek?4` zj8RM4L)J_AKKskP*Nvq=N@Fug9fhBirTCP2i{epuopBIJtCLcxSy(1skVHY&=Xwx1ca5H)Ok?bu3WlzYTi zVimHHt07_JdxCBYqq*-m>0PsEf!ejig`}=E&2!?1pIal3Cs;f3RO4d<_Of7qT<;pX zdpGC`H3qWhy(uIJ8BH1er5g!jNz?5R68dDV?)__-_Rr>xrWjF!a3S2NU7fbIBjXC) zOIP0#&0W`r{F**1fOndREIuHaW{PMF!|(x1D1I8)Ryap7r*l0ucMXT?+Y=DKF&>L>(Q78Loq=tj*4npxQyt-L@Y3=~YJVaLmb%U^<4)nk0w;WYyWYn7JqpJ9e z#rrO=fLt(t-T^pDtZ2;&VGCMRl~>#Rx>S**cC~+5DyH>Q|9lK~ND?{Y2KB{2zQldQ zwR1j-KCp9+eD*{-tGJKEHPkQGKh!O^_Y zRY1Ga7O{DXw=mWs&bji_XfwGd%mkLFA%r}xOH)GHVEzPBr}DPNKp8o510Ue=1x?c5 zvMUPT8|5?DSSXr_#yfa`BPrU{jHfemI)+#m@5=P!=9u!;A=s;JWV|9%{wyt9>XPgH z3y`SSYqvL_mT(h)2l{6vLf7RAn=y-v*Iq0VA0#2q(iDH+lW`%b*cMN6g?qd=PZ*@z z#TOJ(#if&C>|uhTD)cC`j>{+dF!0D}h>amTHJ<1%&e1|VuOOFhgf==q>|%>u4C;KY zW!(`VZVaqoi_mGB1|95-G~LYLY;kub@J1#S!HC7b^YqV!VRP2vQUYh3O+!pVK+vDY zYY&3W3$C*rNh)YiE!q()1^v3}9iPl!W^AM!Y%b62N-$WbJCor&5zEE#ooxTt>ELR-!Dm$Q2nu zj4d4@gCa4Gl45jmEX7gxgxRzu2m@hI$ll5et=dz=6?O~HXSA&P*b=oBO~OG^<%u9# z-`I>+`!R+7`Zx>xGI!XzXu=6m1C8A9;E<81ixjSaV?O=1xCHjYgv*QC20SBg1@!vH zav4^GY~T-t))@s|9wxk035AJdCMFAW+X+Y*c^Vat!};_AEW@sf zxqYEJMb-2Reo&KmM#r`P7I6@edL- zsY2qX<4J>(Bixss9|!Mu)R@Yv!0O&Qu7boAbDH9+fBiRT+44Z?oA?flBo`*nOQrt; z44pK85n14J*c}AbAid7IOCFBMI?Hj>IE+jf&=vlE-~NW*p!l7$BuLLqsV(S;j5NQx z*`vchzFMy;)*bdGZIrb2s7H-bWEZ$z9<|*&teiWm$Gj@f5(LWqsqEg@6IDv$tuSme zSRNz-I~Kzu(a&tEX+RB@_Rh*LitnC#I6^{NU2+7Wa5s15^DHkdMF2NH=9wIVxGs0y zKo(!3ws1wMqPEIZwEs;5oo?>1AgL@*ep3J8GVK}sNooAY?ZOK-L>^8n0ZsMWPY$i~ zG?mwXWZAglRIZ)B@e--@S*l&Ko5rxs)HiIBT7LjZm6T%^Kg~%5ZkG4S|-lgJe{J%i%Pb&>G>jA z9!{M=N7@D=kiyL?3*~DDv7#l7qXY@daeMP7*3X?%W@v-@xowxM?D%byGoVlaClHOQD7gFQ8Hg(hoK9 zGlEWMq0s=`Vg8gul^|k4V~BR-5HU>g%cx3fiZZemtEyh^!I$O$>dN28ya{_tk?{QyyL_ zJ()mdKjm9H`ez@VNqRflaY*jG4)^q-%rlvHoN=<&SZbnj#1ycROk3yeVcleng2s_7 zuxv)#VP;dyRU4BAFWM6ogrq6;d#Jc;*cmS{}?K*V|vbWG5o^fdw7ts7{X3lz^>yD^K9pc9dhHmeg|AD|S#vVsHJle)JqRyOu@GB@XGw|_n9 zt@JHCyI7j}lefpMsGr2j=}uEe6usNcc?kwj`E5FoU%&q(POKvPD%DBVJGSfwz&J%8 zizX!%=;^~*x3;M0@C6JA*;m97qm0-MM^EypVo^Wcdi7)4K7n%*RbwQo=8yKaRn@jQ z%5eE2VTmENoP->E*r0e?{)_(dQI#P>S}QJQFWov?etum`yJIZQuw^O_WSfcM*4ak} zlB)UyN-$yMbdWd}N}I4)Q@=EF=xK-P=;Bo3S?`oEf^iN>g$knxbc9ufX#hHY4uSv? zTm~u&vdv5=Gn_>Bvv9zb)_Q-Rg9P8NYCCKH2VVcT^MhyhCjtBry>iY@9lwE>>Kn-{ z+IbKwUI{sZ6hhtr$udAPNOCEfcZ+>97oo|mC#aCkj4-DHMj5G;3#%TXAuR%0M!s99 zk8ejs)gpEAb5Dls(t$RH)3R<$rwkp#n44)rFx12}$PypU9?8{!OTIyx0L>leZNg80 zl*Bctt=jN8>-v=Ah?~{raA#p^Xldwe3|Q&!eUG1@n|1h8EN_?34j*n8@6D_1Cwr2@ ztE>Ho(yjU2uhIEs=FhmqbS27MGMd<}Ye$i2}>II_~)9oW9JO*lKxlj}|*`WH+4EiO-)ufu|()zR+M(C@;<{!h2i zw@p}|!)pS~2uwRKzDEXZTzF5P7N?+m~!TwJhv3-Mp7)HOtzS7`ta9s=X!_F4sJjyHCC<=_Hr6JloFO7T4b; zGi+5`W0tgB&6!P(#A_QMPKUuc;4r(Mn&ZD%f2|i9z`S}rVkTpdl8M60Tf(gJg`Rl{ zFPZ)E2Ej7uehM>^7}ZRFSFCR%ur#<631b&19B*p2?$H(N-EP~f!unEf`^&G=H4>iu zD4pL9U_`2|tr>=Tv-<)iL)OiQeZt^+p;k=_qR6LE{)%EA5h!`FN6W#)95uIBA6^+9 z?u|cw(BE(uXAe*${;>BXP2>|rw}N=)9`FJO?Y5zG?biZRY95yX~Kj_Ex{(hN`=(% z89B(myq$tL;!S>$zzq&|LGdGSV1eksXxBACLwq0wFIv)1pqFcQ_-=mh<;n+?p!@gI=HZct=M$uKH-ZdQRRaY zQ1}Z6IM}eMp5qr}VPVTi1J_9Kj5}6xVPUJ8#Fz5<`Rf zkO%V0mfRljkh#uugnCZJS3>l0ezP+Y?8Z24j}t0uz(yiPB5aIQOm$gp7|jh)`O-Hl zM5jv!(Qf!{UmA$q!7haF)a>2snO4uDMS8qtve&ZdT*1FtMf=T|I*`7o<5GXodgTzR zLBDY+oqw&g~~ z>(9|WRl+?Js9zGqDc{4{h$=y2PBHq)g7e&cn0g8|^PzYYo#3WllL9p=)nm?t@BVsq0spON~ZM!w0ToSZp zZ?o;`-X2cD)c^hS1y3pv=m~2qxDqr~A@!x*Ju|3`>)dS{EHCxC=vAd8_io8VYd8@& zee~SYg}C<)K&2Q+LS^Z*@F!Ctb9)4>imQ-oeD6U&B{Z)o8tz{%0+c|fa{)kh^ukkU zm_dGfmD^tJxlqt>oA>HwOK9Nwd?|i){=HyuzRH(^r{qu5+>?1>U!IBU%vGNP} z-=R7=49Qj`5deT!<9`~f{}rnLM^<{R^Q(j<*6^F%YbaNPd;p5XrScxkRU8A*@{Bt| z8y$HRc5`at_;A_S zjt=T@*M#nE&vXn~qC#_vtG9;g`m%8w|G6{kyD}^kMXr*ED{s2VvAgcnL!$>r&sfdm zyU7XgrpM;4W+Cs&MCG|L>#S&EB-GNf-^udyTUFK>->-(u$j58r_><|iH*5RE37Zm7 zZ4X}3vNw|#Q+8^i1MkMvPVD_;#?lnp<79P3nh)h{M1LK7=F|fR0R@fOa4+g{6}mQk z1h9&nW`Sit4V043k7ESdBVwUyaq7D}IDYEmyR-b1X`%+G@>~&L4(-Y3Q@@1=YkLRf zNM0!JX4Tl~FuxBjJxB3}$Z*hR0$$*xAv29>iH;l1V6VWBH~r)&8i0J1=-&qPS+;iL z*Xe!T-?rZqd;71|*3a9c!s-|ZEbj@i$O1)@(nv>U?5u)MLOeLrG_%@0!xT6v!8r;J zx)Zb1ylZYcv0?i@cT1o)mZH4}u(Q~S`%z&77W@!{wGwB>&WTg>d3W`RFG+?INNQG$ z&*cEx3`&LofnW>XN=PB>+d_dpvQ%Wu3Td)E-`=Z+x2TcAacQ6G2!s!0;w3VFah<r)e!{C;qxhx0PG?k(i38sg$>_=O_1g_c0ZnkyaI`&D1c07+j1d-B=K{H)Y7jb&N} z9?QDoZGC!}_70%DTQe2y1#c=p82pvP=DIeCjstnU->}BhIlgkqE$xUD#2*qBMH5>) zjHrUegD>F2*1qYp_q_p~!E0(7zOJguC`o%On1So*I?6!o;W{fKqgb2k^#S#|=deZW zc(DEs&B-_r(;-@5DNGqvk@El(mtRgE&21ASk(Ek&OdPNGxpH|YCz0#!RGU;YPPXB8 zyS}CNJO_e*bO7-p>XOzpl|2{*Pm*8=(-c7=n_jkQtIX1_S;rY8V#ai{1Y z+UO$&skOEczp8uf5GY$PzdzM#oq`dtkzQ*~7thPMCnaur-`y_I z_Ge+r3u~Ye5q%#7^pj54-3!#m8nWpvTz5FKK!C!Qt;b440w`Ma#qY$kQGrd@bX^Vg zb8G)eOIw%rB|^!!-|bh&JHETGrG2PF-y7Bz54OYng`cfEO?;agt z6H&aqQx@gu;|f3%9!s^?#hlw5F8uENln9^X~dWXaUHd9(syc{s3~?6R@m zrPjZi#8A1xSnEbuujXY1Hk+GiL3;cvbjU{$T5bxKPwnzV*RBPQWR%f?IsgxwLpVoS z<`2)W;MmVD9lXe{sF}-7Z$oAgLH+Yz?DM8Tr)cq$s>oQJ>MxQln^pm*`IJTK@X@S? zCOQ~&!qoE!Ov6#sWkjjdx`VftxtCUR6f0^x)SLRbC+#~zX})L9L6J_gT9c|a5Y)HF z7Hlq|1psJ-fv_~d7e%KeIs3uqDieuSEL^)YT`Mu9@cjGz&SvJ}wJ|4k+0P$NBD!}ml;qZVs@?)#bXp1jIJK>8iD&@2S~SB zR4B*X07v%b3d+LcN8td!c}g|b{g*eyv+9bqk!Zjlm71xr=bRQcH?(dec?!(cyYQSA z`9fnW!^TA9JV#Iy8lbQcC^Xtz&dVD4GOiTtfWo@_Cc)e%{m&nCX;JyL18+F_1G_$Q z?9_11Gtu%n(bYfQJ*Y(<=>R(Dq$Ah+#4C#I>Y%qwfPZg zHcClfQd&%PHu2RTS%pf(f$m1pnM#vONf0_czpl_dWyuvNLKJ*8qpn_)>1lX#Q5HQ~ zX8G32Rp(ooi<9BCPa7G~J8?IQxV!X|Nf`ISgW1J(!rv%|A6W$T^biKc(LR*JijB*= zcI8gQ-l2(_4yQWwU^K8Ru0ElAVd3VGX^nMEBt zCD%0}#T=+}3g{;A|N5jM~b7cMPNg7cot3e#n0a_5bqhs=* zk=iQY+pQ>qf7A~i#Ltk4cocN4TCA5-vINJvK;xcce`>e&4H4mzl*SxllkuymSwRVV z#oN=-BK~~>PhnGe#&-;@1XMV%O4~AK?8eT(0M|asXcdBb(ah>juCk=-aT0vTkq3W! zXJk3E;nw{Wz&k#nW1OkI7Iw#(*h6zNT;h0;ChCk!L3lgj>CA*TaGZ8H1mRkbUs?yb z)K^;U4oR#rkPmH;JJFMxw!^uiTM;EHlfEKf?V*=M=f41Rto_7>`iZgQWQja;Q<;Rx zdc8{Ejj)nPd$5xqvHlQ;J-ptpte}cfZV~t|?ecUA_2I1JgA@uqxlZ3+;xXd8Xki?ZbFE+oH9XNn{9uy z0g>!PI|1s1p?*p3%PXLr$uDFFrtSPsQPr(lHut5 zb*-B+Yx)jjP_qF9OnT2IxL<9JCd^kHed^ci-iTHvL~U;IEGLm$Krmu9ZthaRCC1hFLVlJPDI|ufcFy(E5V^Cu4f%RnILW98 zi{HkdUKkd$@t|%^Y5C0O;EEH6K~?py0dgUUNQ^Ev2>SBh*vYr0O}td?fu*9~in^GHd7dw2d% z5GS3)6U(zp1n&3KHmplY!LA$kSQgqbF~UMv`yZl`6joyiRt4gch;&yw^J^b9 z7obsSz9)2S&0&RVL-o;-#8~I1euEq0GB0r{JjiMH+QUS(u?w<`iGOI9|Wki4E-^rc1 z8dcm08k1VzJV@gX2W>B_?Dp&AxUS!+UVZIWeU5tELQGL-Fjw0tjfQjX@(4+Rg>(+3YQD(A#jJRS~b`ai$<;_|4&`nFumObuI&YoxP?|*jwI!LsI zhE;n$^>(BUF{Z0BAI1rz=PQ|XDuXa`H_E$E9n(oN4=iXciY5eKd?W~3SY*&XM|9kA zh^B$aM)_+Hzb8AhwWe#V>aaT$DHfA)yhs83T`4q$tKP+@cY-!S;#5s+5f~*cOb+KR zQeK<6)V!^P$zZmx6$Kt75faloa_ykNvpX ztyz0qkrG)Vhj_2*D&+&Tf#eQS*ImLbkp+Q+;_R!;4SyP0FAi7wgzxb;W?fUSlum)s z1cYG!PJ6oCBO}+RD@|cYSC*IaM#j;~!&Vx1azWFvNrp)JbMU3P^e8&FUqW$r&of2t z&s#)!dz(dtJ4bpl-{JxTroNmr1F0t4a;TmcY^cKIMrk5~xz)t@@39*Byo1Mz`Z4wz z4CPE2OY>e;V=|$g9nI?;Msb)>(_;$kNVs5udFTex=ZyYZ$b`(lX@VTHu_Dg$vIS2X zqB~*vr8LJS07y zv6{7Mj&QC|VByVr1bx7vg8PcMD{mA;jM0ZgG`g2pH@AJhXO^VJ6bhs!K}mcQP|j*a zpd#@j8L;fCQFY|J?(D;FPx5$u6SK8IqL~s%Y0~`Fz+hDI?Rryy1KFPx7eCGokIwXK z+^73rHohCYZg%%aq|x*3rP3nl9!5PHDwBT_ShUfjw3*m%7j*Rl*!_j0MAo%G_A_*86P>j-FMMoJJaOM;LIHrNz?{(26= zhi0;o$&aTlJvZG6se#@l>`z7xz+&-@3Q<>VM_fAj9k&OrU9tM;SN-G9;JMX>P|8yP zSR&W;2+!$if!^MQ=wZ+^_cA(=p5zog<%Y$5zqpU>dN=o3dPxUFy`ociz8rG(hr_Tl z>l4l^DVZEHYV#O(wi!8MGs)Q2?B0Tl4c0)N^VDrgaL`S%0dIi)i^8%9tU)qOA`9G$ zaYPXz+F143MS;i-J7XK31mcs`(9StzUAY99m7JKSBb#$es*LFB7zS+Q;*16D&&~$c z)nz6dQ;to-S`gX>#p@ueE+_e2^~?7nggbKlhdWlZ9-zyDxBhAug`F zcsSR1H$N<2F-;VT`TM7Coj%^zy=S)}BwaBeyD^`&g?7qzag^*kzmc2S&j{!Pu<4xW zKGKYB%kVf(-Zs>{zFe>_`c|n$7Eo*+glX_ZhTcoW*7QuaR9Vw>A=}QiST|sOyTw7& zPMZXGciLcs4bBnn5_CJy8;?bW7x?F7#Fdm91D82hqaiPShFEpob{N9gx2Tg8vzxFQ zkVo*uTK>MCD}tkl1%SY{eww3 zgbJ|u`{hNBupe9Haw84P7f&Y8kLk0Y*&^N3>nTrbz%Ivolut=CV!GDNuMi@thYDzB z?Dk8HVn%>RNSk)?g27+OM#s;2)MT5hYZ1^3{n>%5WN=CDzt-wE23p*=7?p>y*z?8~ ze(LKZx?CBrsh5%CeA5ULirFa1sL;g%=TBa)HK1d4$Fr@roGY?q#Cbc8oHsB>4Rr@r z3k+5^<~~}b8xa<0Y9nTgY<42E9so}dfuq-etQwvGY!(ip#-5a%$r+2sPJ*XpND-BX zEu>K~zor!sz2H1^kKT-I_%oXKns~V&so6He*#@Fy`6AnFD1EutB=(Og6DQj~8#Rtr zy(3lElClsJGCVf=ccU;nzyhXh%)-n%U&Ue7G$_^sx`gM1L6|R;(tkv<1_yh@1)IY= z5XU4kfb<_TkMZANYb+l8BdU?6XEH(FY<`71@^8$gAW85@(`fV`M@0~!c%y@vSiBU;miopLi&(_8Y>{!OJzIP0B=moX%yL#?e?g2?+lS3}KIXHIiVFfZ0Ub{?7 z_h#LLDibvyvs2|B*Pv!k)Dr&*`=pJIQ)wuvi5~D~h3S0nx()QudAeD~vT?k&F z{oZP3jKrCtP)0~XrJPMN%h~|C^q>lB*5cX#>x&N8V@(M4lBA$OE7;#|Qz=1?r1aqJ zt}c22_Y2+h)DVerA??$Rf~pT8NIpJ-g*BuCVT!hZ{RY3hvB0~7gp4we0?@>zNL;TGL;VK0Rw>t0I?p_pHsJqFe zMHKSNdx}TDyZLSuH`EWWN+`tujclOxLm3}XPG+r?)5EVPC?~t?iI!AB5-LJ$j|IDg zuK?hoTT2QTO27Q%fJ(Fm5^<3YmPdjA+~<2_a+ID-_m&0IPFPDwQ?{~!2r0Q(j9S#; z#&*7diaYr$rSu@RmAbAb#?wy0(64e#DKsJ%=b+zc3wo!*Wcu%BB2nwo~meR zXjCkjRG~y=$>p%xBs*~}KzvIxWNCK~`&3PxXlY!Aj)Z+7x20@m1(K%usn~S7y)b5a z@zFZB?Qml)c$ml|iDW6O`@@@{649%?DPMi{Iin#gL6X`$zh^@`Dw}ymy4ML#`yTKl_BkP>)YR}OT(IA7D)t+-~!pv zv@Owv>-`e%z}T@fnD;h~lvgTW#XVRyIi zhUMy$t$|7$nYw#&;2nz-<(?`FEmI6li~Fl4g!W{3zs2CbeMUXiz4$duZje1>tkAi> zhT%CByyZBB_XOAI#Cc$x;ro}US}(VhS>8k~LvXf4*}{r0_QgPJASQpP4sfT9bLSkd zCvkghFVrV%X0XKg^_y!A2bi7Cu? z8V4_Q6zdU0*9fk=BgY8N)_%(&>?-Ily%`~<*}1*t_zznb$f%dN}w3aUv7XkoNXb=NuRKGJhRh8qspjokoZbeh{E z5t_tf0`1trzloRR=g5(Z6Oy19HGmHp;l2In8Fj9ro=&>@b@Yru`~7Ib?K@E@rQ~5P z+Mwp?-=M-8DM34&(vI#)Lv9Q{95l2vh8{Q!Q(S8TROD$oXFpZP_a!^DWp1ueVff#` zndbX9UI4@-*TCLxtJCk#%f!{Nj@l;|s~BHj3COuD+fZdKD7NJ4dl_&a4BroeusxT^ zs#0CaxaL*5CnHWk#;--WkI_25jIm@btgX||Yb_AFSd72A?C0^+D~GG8PwL1;>aq&O z)Di5SUM*?UPfF3uiL{Q_dYlmxZ?-U7 zTmvCDKz}QmR_UNbR_%xgtF(0eHx|1ZX1-$KE-%KTT)5d0aKx9#j3n(B%RF-i@Zk4~ zX^A8ts*qBsYuY&^WDd5cZ^BJ>;M$nT04M$Sg#K{c--IbQlT%a|QY{4$Q(So2YF?7W zhh|=V%MQzaXR^Lc%bV({p8KAd9~>+Ab#x1S_H<+<7pV4vjk>DxS{xZ4Y^E3BNmP`A zb!GiH|BFl9{?U@B5nN0=wbGm2rAPq#+NH`#8)|_|_eG~J-=PG*6w-Q8j9GaD&w7IL zm$3KTSz~Gzl;?!{wFV2)7U(E=keU6vg(rY-EhupUz@sYiKk$JgPL+>U>|>aH@`TVT zDvW)XC`HMgcd@KsKx2sc$fx4)3F=o(K_y5MGnI0}YhL8|LgJK=gil#!2l7YF2# zN=Xqvv#9)?Q3B*eK6J#brioMgnu_Hg36Ys5|6~WV4)i#{9z|)#BYf5Fo~3$PZ7zSWblF>1Yss+WX#$pG#@@yXIuc{9Z0$A~ zomJC~Unu1=T(Ui4J6VCs=JgMpW%qfzxAHlK;-nEn(#~bFfIo!9>j=ecNBBE0Kr!m^{in*n+->beV@}kGv zzTKr-J=1^Oa=C#}96h?jW7ipOJ=GQ0z8vwAR@0?&%eh^6{6-TnT8ku7|9EKiTu_ut z+WLN-Yy;JJQgeX*xILxaMdFCw;KsVLYuDrkv&QXP>CFD*q(1z9edX~;IfBZLe0ep! zEnsrCu1mP-_iW8$Wzh~*bTRBqxes4w?rXt`KyU6+oiXz0|Ma0ifE8%RWmUuQddAGy z#T4zs6_u-SB;tx;EY#p$Es7&LhlSb#vNY2>6p`uFPVrFguJ_KDS{8F-X)RyiM9;Js zxxwz~+|IV3dk|P9&t}$JMtT!pq{6<+_sVL%z3OCToaYM*wl(?a$e-hHMD>QCLJtsb z$tmpYsb?)gVi&25wZF-S$!TB@6xs^_vUG7PUQ36g9Z@hWfF(;Cf2HzhtjkVdtp&TB zToPfSx{oqRuffRUq&cb;2gAY9mAwE~F-M5n)Z*lVSMKD0Uh$_FoAtT1!pu-cmA~OR z*-(*1ivip65DLxt7h^PU-zK?EnS zsbD9(mTpN6tsy2WJ`m^!RRZ%6g<%OoPamu8((>Y{qtSFB+}TAeQ-c6eZ1%Z>TtMV~ zu=bewAt}BPHBe~WGRm1FLgaNYJP`Xw{v+XJ+yAKRZ+wK=Z$Z6fqGm3nN^Mbxw)o3Z z?Q;rcYgTW~DpS|ig393T{j^Mc--eAo6f+Kj+fwjQWTu!DoxA1MhGk+ib&D>Z zk{er%bB}IvN#Vw8C%YiIJlbzfyM&|FTw341AQ= z5kCkrSk<7W*^^f!jyeaWV@>eg}{SJqM>v2TU?{B&)EBx$dZY#;(~G+z;2nRaH~x(5kX>>7 zYlMV}TeM)0qqI;c`61)wu~9p&JA`Pz=etqJl9C;Tm9$Ja3!@0mx85d(U!m&JutMC; z5M-r2UsmIY@7r(M6CtbsM`)Y9r(gCJy{*{@QargLD*GL1b?|Z$(@Y_N(W?fKxl%O1`IF6h7ZRuZn!|cklmp)g@+100 z)~n$DuXvLY08E)$-YF6rkK;B_5m-w@>L=bx&fBwwZlAC6-q!SPKF+wy2=eR7<#iG6 z3G7Fe0ca^cS6^yu7phJ!Ifk|a^Mp$rs=qw0UHW#DUE35Ym5WNqMx-{ZK<))5A#cp; zW*3}}$YJE*qj_`dj0?4ePy_qiL&1lFPP>fy!+0?bCk+m6pzF>*o_ts~oN95APW);4 z%b-VJgFyIHlL+}>QZu_kBpDv$m(r-E%Z99)3(!VN*T(!$i0%C%mQwzHb$urSWILMB zdi=8D=&E?fo#yv(ggK8pk-6WqKQajz-lKoZ_x>u7UPk-f2bYN&rygiWT0nAb8(l+% zRZ<7U_I_;}?N>g#wAsS^teF-!T4v)a+hb$xB)azcmIhxs)lI+Kxk12WwmxH>ZCWxj zhM1uMdhp8ZxNM4879Wp0VFg*+Wr15}j#UckZ_;vMjljDHTh#BR0UMr3-2PD)$F>fo4EFp4- zQ7t|&y1Un^kjy353DB8K?n+OHB_RHyDp%u3#b4|SvB6dobWW12Ge9G_th+noN)HK(5zw2bu{|PCmRnP`S`xRZL-xLyzl}r(Xm;i(Sw)fh=9!-o z!@drSw4gUG! zP1DuX@5Gehba!sOyU-NjNhF)w&Wl{yxQ3Zozu2&5AP^i z4b&81ftBr)Lxho1Q?!p)H94!ybR`MJchQAFTj1Z0aR0OWQEx+;8Vf&VcJe0`QGXB%}0us>gtLA8|Y@YAYX#s-Rx|NYN*Vztg7eCOp_vh zi+deL9Ckw*o~b^8I|tnwJ%OVzL^W`*!^s+RfnuswKKktaYUhWnk!tFvqkqW)jf^Uq z(?QtOK^OOZ$}VtVS98udKq5j{(no5vhwYhJ4`9RJAilApj{~D5X=v2Yd8s4; zV1V6Ts6({u@{&A&vMl&JE7*BCBO1%lvAQkT@Y?&t?&pmncR$2OB#9Cb_Q|D6DfQCY zLAfcjBuH^VYJk;GV}?yIME+)^NHiFxUetZu@0pmmTDd^GFU{;2RM4RQ-9xyBAeUk? zB{E=)Kc8b!!}(KVCtBFNmpf>v+>Rf5)nX^(vrTD7^D;MGBQ}GTzv$^DDgeF<>tB%; zrzg|EtdXsKOfs;fR7i;QBxsa*o8wU5tj@iG^oLIzM%Gpw+|QKV`j#~m@j#yS`0p_& zWspFXUCmMU*G$_g(ycp^K11}2f@+C4C^--^2G}~V2!SmQXC?=CbRxhUM`ZXTo0{C9la4U1C zTs2~I7-Kx?;{&|KMR#_+Fpk;=%u`~9PekvJ}ZI0M13_T!pM`76-MXfuBGyeiZOw|bsO z8}YCGxVl>bMxM`vBKDdMH%oI_lh1qRv-+40fsJD&b7At$$hj~eQ^0wN+I-bD2w9G@ zWi#{&YfhxtNa1d@bfN&^a%a0R6TO8KM#RnxFU2-@WFD@1_xpuEp+Ghb}|!vMYemy@&y?A?+{H_3aVZs=(1B z?zGOkU`&);Y;wEn6!=8#O9#Aue}pj63PNm|hd&tP>r4Nve&S-{Q(+p{5r|(`&4}#R zOKGf7lo_imI!`deezRQ{NjBp~B;?~%$IFfX!`3ouPNhhnafjcrRR|CHs$#G4+(z;h&|I$-B;wyJ0UrC2U?xkW= z#jnRHLN4wg5ln{7B)-rc_OxZvXRd=VD_=0NezeIfuNfW$yU?y!&w^XcZsSf*5g^6^ zfCp=#`h+zZX~jT^0gs*M!SC^9b$m{8C{^fQVlz8!4(0Zls5sD%c%Xt?`9S$rNq(6s z{;?@5d@Ir}c=)i|%|2zz4xn?)epA&wKzZ+q;vAefkkEvTI)-EHcq;|Vs9fuSx>N3K3r9PLi=z* z^~^phzYY;@X}E{mY+>dXXQOD%tXt-JuUlUA(W>vvK>qicw){1*adW1M(_mZ@w7KWz z%;5Mp%dC*rKztj?cXIv_yS6AET9nVyMUr-$Hf;7Wn{}D#p+^PjqqjK=#>m>9khmN{ z!3qO!BtmT;<9-v4ZD-peRga_dowu2-oq^A1E9;x}m?JLyn;h;3(jB~d z`sy2DxG%_R7&|Z(*O!58s;fQ$i*c5_HXPY{v5tp4u0uG$x{*#^N2N|9EOT!nY~j8j zZ2g-Rc7!#w3A{S5;;p2bK<~0(H`MlS39*00Ae~Xeaadt3Z)`UQapJhThO3oo_js-+ ztoOd);%yl2lcs?g8`fdP#A`sq;vM5%{hup5MaOU}HU@GrYVq;@E|%2%m1-@MYQ&Zv zqHqm4`^eSKbINbf$|L+H$1;wX>70(o_G$P9dTI^}{r z#a|IRAM>9QnU27=_?Oufd%K(M^M&#BPnB;hDUzT_fEADm*u&x5kD2V$BgCBAX^pH6ufVATD6;ghwu9K2^u_ob*Y#WxI zcg#{Hm+D>I4D;S2nTgu>^W{THjPi8Ywfv@cpXMw*XY&dR7gY54I0ktSsjCtFQlR{F z;@)H_{91pesMRTOdPbzj>nWMvbIyyETs*?|Q+BDQO&`}Ep6>CcYkRBL^{S#{Uc7LR z@6+>0;G0|x$^r&dHz;~FLMk0Z5)Fw?@Q^>z+6&DMu-Kmin>-BHoFiBz)3%=mW|%qW zX{~+Fh|TK3l%}s%j|W>>tiV6TtSRM}fGs~XNh=-AaB-3Hf_81b8$Lwdj;m*KKy4Y~ zaGUo-Eg3g`&!Qk)ZV?Qf^Z{5qqzcC4+dD+cd$53MUn~j+{cZxBo`>`7+EF7KLP9r6z;!%1Rv$vrd@! z$k?fDEKt_aEj3}PY5dgIG5r?;rH|X=%ehs6*wV1xMPZe!toFu1JvD0K;2V>pWoE*t zlvpHpX`Mp#wcx#@@Z^*rcPaAy**vCnuBbO{p})P)(7T~P-{8<)sB41i`^{3@NF-mP zweX&$Abqm#o>A<KB2ji3&u}v+lTHPLAEL_~4uie%{e7GOGr<2asTV334-0s(#58=;%o!RG( zjXk?AzYg||O{->)uf@$&aeBXZL|D&VvG0KR7P9kntbl)}lDt$(WFIuu6-PX{cR0_> zZup~=wVyGX{s5t>=QQIb9~BjoHq9Si@7=w{cegC%iZJdgz^zoU2N{05Fv~ya(5#a) z^p?q`=O9tBsB6W7{Q~P4I9ykZaaS}ATR}FM{j6u&UcHoGoqZ!fia~pXUzgpU{|0-w zs&=UN`W}gkSTK^y;Kx}q%W~lWb$PM*R7x!r9W!4nVzwu`L5=~an&~I5R)fz<=oUaY zDC@vWv{KbP6HazpzLMlBor#Bh6f590gykLOP3ypfrX*pFXFP*0(OS2ynJrD(7oR!LK9Cx232DX*u_Bqk+pG>I zv0Y?ecMuL^OC?pxTV2{Q3MIJ+0hnlakP2yLFXqkv6fZK5NqUYC++jrZ2ygigEST}h zj+~1!4!_5o=tp<$sFfm|c9HuA8@nVCNeDA60t$2Am?Z8p8R#zkk@i*cMY8KkOVng^N;rAf=o)NGn=Z9zjP${} zMJZ+m5XogPYvL$2sPI`a811(2_9FLp^lmUoxk6#__EMr?R2fK!*ocSI-s3ljn%4j|w|Wo~$fRZmtA;~AIk2nwYX>H`ZPI^8Ebr?769F-GHUyCsz`g1WOFA8x_27qIi;~ zFPwxY-{01tO@4uEC8Vklj5M7DruyYq`~Cy^JxMN-FA7J^%suJP^n`Bn1WQx*kC80y1J82C#=r+x7j7Ydkg_1~Ts=&SgTQ`qgewVs z)ivA{3W6&8gA!WOl{F}VQ%~AtHw#vTCs~FRbY(p}3@K@tEK}O0g{i{%iIFHE{de%8 z%h76cKoM3#w|~MuG#E|l0jmHfO-* zjAIZYOic6p5_?zidlHbMW^fQg?|#jYe5qO(2DEXlVSx zS&aMbbx3j(=M-I?HbrLu@9DWYJCq%)M4rT1IE19`c`)`*>^B^-i+B~fhVI%lpTUV} z4ZK6qUOJ>nq*%wz538FPyPZ)GdTuON{8Es&Y~fXf)#hE>EX+24yFySZy}j$xjxjRN z&NI06pyEX~MxL))iMgIu$rtYO&%ImN*EMz^N*t?poC(Db>kndddgBpG_&(P5dD{G+ zdrx@ZS*!k$OkxC@)=+_{KU$SEcG<9+Oerl)SudcjX*dJfPl<=TDQ@fS{ElmG!k6pz zg8?@!!bXQnAR&IrbUesCGE4~5o`^CZeN+v0n7bG?X5DTCw>-Fs%lg|;Lv6A}2f(z$ zM~UVhbw$X7Zgcm7KXkCW#oOB>IKN^B?o0VqKWPJG=+#1=6bh&-04>LBjF&-|R`_c8 zQlzsMOGGHwLEUW)Kaz8UWV-T@)%DP?jcg9oAoN6>8)VUZef* zRplmbAoMHm`FDd$gz`h0cGQOCPM!ZW2&N1tqK%8*y!YU(QM26)!z&|mlrSzrN~}_M z);3hcMa~32)FU`yuJqT-@Rba{u132sg+qy+kr`+B zdNkxYK5TC3(x;t)+G!?f5KgL+8+p#J$hY#!t>q zE#095QY3uQ7Cz@*KiE*eR%q-p9xul~PZD+_(_^@4rXJxm0fA7c`hi7deAQ&@7q(K{ zM`^!t#i*9ZTA99^qkVHNhtt2LmnS+h2K3K3$hAfH=|}73VtJ^d=mib-sG!&vuqmU+ z!oi&uy7mI}*SQ^_;Ju<5IZm#a22G@yhF!|_jp_Bq&if5totDS~FRT9zcn0>iv%voj zm^Q|t@vys0a+s+JkLc)A=a#g2#yND{f-f12G3`->y2bC==4woWAL@|@=h*Epkt)8- z-+tBJytv-OWNKUQe#v#(gt+Or(8Et^zaVAJcTvzG&Qug;k%+*+h8uK&dA_Wz?iv}r z-O@LV2alY)N4vAlQSa==(B|`Pr+LCQ7ArLv2>;%mg%wPz~aTmQy@KKm;Pt z$EGgB5VDK;378PsLo<{IfF1T3hU8z)W2N5yONKKOT@!A`(2IK_(bOv`z1Ws^{Tc@{ zBsCkh2u&7Y$>Bq0Yu#+nNGF@-yq|fXxRSSkH@V3dFGfVMFc`E;B3# zJ_tv3c3P0W^1yr)Ml**Dt+2^`j5p!rzN{a< zy?LETz&hDn0u0UBA|Q9GoNR2y@aJEqP?wGdR=aU6o3Za7)^ zil#FyW&d1c`R1FB&m{AP4m$%u*Rxw!troZc9iS0-uE0WNL6I{F`ZClc=9|?XG*ec@ z1J8J)-u>Tb)k>4GmC$?DqoX<@9G~X2OB60Pi*rN6&_=9ucNj$Yk{RZE5u#&~>A7Qg z2=f^$7)dE{DGJ<(Fgp`;15mIWZE;USGfQnNui>WvNlQ(KoyZQi%(fe%MS1amDmB~3 zpy_$nE4^x7GLOX$VGh|#XALoQ!6&#)_7IfLcAq#^=xahEb6DO!UOTo`4Dq|sZL;e? zUN+-zB0lh5RaE*uv{`voIjPxw@kv_=>^6ON_gkSKQgqLvRuJ}+m?|s*Cy-BoS z?3M~sxa6B3xEBaK$smLix*~ybVb;d9L{rCZ;h>V17Z-Fd-}MwUf8|b)WUP@ z$}Z0dI$jasR(xz)#hVV}VINv~C?F&sf}Q5+gxQa3vOHYX;AK~3-C%;N@Zh?*FgQb4 z^zEsZLtvS8dz*^;_iPsdyrpCy>@#y*y9`qpyasA$y%0_xav5)~oZbxxy%*L{$(NS~ ztbih1Mnb1Z(gH9cdCCs&&?PXfd=EPL(@qOucS%<<`9Tr`fJE9v#?=f0P^kh zBW2M?lAmW1YrF_%f2VaA4FAA@aQ?-5sKP>p9uOAPy2Rt9@q}iAL|+}?SvkC~CBJAE zMoHf?>6R#|pdmQuzvTi>+1GUB$|}cXCcd+t>o=?}g!NoX`FiD=$$JUK`yG6Sto{u0 zwXzMU9N5L=I#f%%TZ^AI5u79t(oe;uk>{1QL-s$04Sx)5t5i=@{QyII zMocf}%g-cBhOw}?B)ygnEgIRH6D(OD+OL)*cRe)S^GN#4Gr-22>hI&bzA}$-zFq*7 zUy?t@8EM-T``{W=_JGF5e*Ei67^ChAnR_S!uzHD7jCKb|cn^jXbx!RAXkIG(t#b{U zCOkXGd>gTzLncIg{`Wn7z{9>Td~cPI)ENM=i21>ulV1Ig{w@cmjD7HUA3CN~MbaU3 z_TDCuwNcnvw1MPSJE~Cja-8%Il4Oik;z&wtsP9ZW%TpDC2SSsDHT8Cyh=&&Xx`Jy4 z5sie8%NRyLC8{=#C53TDLknxt?dxDi)>w3m6B1_qH<(#iLc(yV8qeJgooXCpZ|7z| zsIxoLY^mVZqSvHyMi{ToYMp*`%>H)4km)eh9TW`v=>lgLZCrBsG2~rCcNSD*2*8*( z!>xu5i{yB4LFZWSAQDBw#ye6~zbpNj)>-|*Mv>{!m>g0vI;AvW#Hhzy=HomC=5&xA z%`OR8b7Rikbtr(heB-%U?V8>_6dC{%!UE@F7UQxYa`XFFWlf-yK7dC$foxG6w^^mR zO(ry1htMhWUOKQc<0xzIw~q%4>YYGyt+JX?dsARJ?EL_a7nO0EbP`UCA?#_I*8}>% zg`g~?q+yP%nBSj#!$<8==Ba+J{?09cMvmv zatCHMC|igfJKE64e>}wx$h)g_B{CQ?+2H^?W1u(FbUPkk(NY)P-;BGVfMBo|aeMSH z4AsIuw;G(UlMffzCuhEFK{|F@C>(naeYA&ix3piv1`T%GO13U-+J5E)Ms8}^VZHo} z!5Ugo`jfV*m7NTA{0FnRv{ubXqvOGGrEz23wSX9R5!%YW103;NHY9%gr*tl?1b%{z zqxiSTY3-+I9oG<){mBFPgQgUj%ub0ewg2dfdN9XlZwb=I-FHB<9~Q-Ts_zijw|GMD z1+|+703)7qt|BQSfkB2oD^!e z>lyMbIk~@j&{#Tytrof3JJ#V-73AMd(KzCA%SyFrEp(}88J&cPE)Snz6klgrVLjU1 z@{Ancq6796yAvc$^yK^O~*L2NsY9Nee^T#Mj;AOj=u?EwU=A zMBTJ3zhRNmWd3%YtxG-vA;3oXJP8RIZAMrut!Y8FS(xCw*qqwFDw5ihTJOqm-8hxn z{#rn^LY-N~ZxNp2y%c{S-HiZudID@O1gyY!1Z?80B==>KLdATP*s(i{aD8)1)30Lh znFWg~UfLtTgTy+84S*{?3PfB=i~}dFFmglubHQ^e?rD1f&s5=SyUp`W)uqlo=4s_I z_F1=bvZ^Z*{i~i9h%#hTu)bN0$&vTf7;Uub>-oX+XgHF(>ID%^L&>%ok!eDD&%O41 z$dqVvbP5gL-*r7xgvMzLOVSk3OoX{afSUu~zdie$^EG<2Xx6#Q!|VAx{q5%)`BMgV zEz`pX9Q@%95rIHJysI~JEgykKFIO#9b+F> zZ;@EIJEr-oKVRWVjaZ6dB`Asp7Y9STjUd57!F@ZIdbflHkI; zNUW>Umpc1j7;GSdOMje+K{(^!JD^mEx)E2|cl}hBF8>R*@jvX{l_EA#AtEssglJdp z##Mo6tRB9tXc!*m2_2cPyub;g`y}xXI+~mYjqN0_)>|GwW&L{>Y;$T^0YrW`79=~B zp<}%%%oT<2Vv{Y=$iAu;$oXi!Y6J8O>;ejyM>vO}^CGSz>Q6k603Y~qDCESwwWsiw zbPxPy>l@9H6J!oRMCs|j>2q3LX!h&e)R^859eHEIH8->${#RtFycE^4;cMhhq*$BG zRXw*-4*=?~RCNmL+>2c&$(!tcfv{#qcAbDqnS3O(XR)T%ohLeW;jX`(>KtbBZ#1{C z*U6|%Xs$0d{~WC{>8my=M5>F(NP07U@bbjGY(Wis6ncTm!$mO$AP^5qUljZyaj?6z z6#kB%>i5zqK^&&YJ%E?ZKABIt^>-IuZuh}w-{OX-1Ujm!n)9b9y$MMv+o)z=uyD7#Gr38oR7w>xpeZcDk}s%dvO+NNsWrSK^smp%WUJ z4To_{t8c#SZ{ua^$w>a;;v`qcYhQ40b#nRi=fp0bxu2zQbqu{@$pW{D;zGwh5O=;= zuD3+(nT~HghwHX;{*rkTwr}Vr=M|J`ct7~d1E=eq(ao4adizH0MX538y}0MIE3Ba= z5yegPVppS~9pTWwYjvR=lCk60s}?qd;tpGI zMPFz!(;v*Fw(GkxC9t^$&U?u555~$}YT!kYyFj0&PD=IFU-8aoXsGo8wU^YI*R^cu z2C;pfU)hFJ7OYYl900Um(++qxjM?Ivvh2Y;Eu#1=(otp z1GX2gBQ~P@mvpl7!xfpvMUf?`E1uS?ZxEY^_P|Y2|H-lFsj}YY8I`NCx zC%`~V6?FK*%xJn+s(yMEPy0DjGNY-o+l4IG{6 z-RvB#oa_yZOlWP4?CFL7-)D3l1~%3vahK`=I9K%kl|Mtu`i zv?BE34ZuG>|IA@fFhQjDj@#8lYI`W^99x3k2xzb__D%+pd8Ib6&PC;Ef*$J4WA21D z{BamXUL62)vawnE-}r#+O5I-b7?k{Jv$F03#2TthJ(sAkEg;h7L0!E;ja6~x#n2q? zDPcTolWDNx^H?ux*4eq^HLYH!dpKD-quUk|4qmn(CB8ZRYJ?r=R{uF2hg6_c%a$u1 zE+t~<=ni5Op4Ql`{uQw*Vh-F!A^FnSQVEJUe9Hc?84~TZSIY1*+r046piIS?(0}*h zqVyeJn)XYP~p-##KI97&!B`**Os4yke=UHNbtp$+UpOiG24t<~)>6OS6==Bwc z=J9?60GURq46^67%S9)|@b*A6p-e5~rqyS<`(y=-3G~viPP!JSq}(21r4y=Y*;)o7 z;G3P1T4WP^R~Nj_cjR{uC^)V=o%y$mTSv5oO)hYbPT=F;%*R&6S--)tCacCmo)f6S zBao*{p*(Fc-pzRivf~VkvcsT^Ef(sdF@WJg1URA%kZlXpsFU{o$UG|6jq6M{>6tDc z5lE6Hjwf{ztsBx8x%h(X+R)5}BNksvX~yMTM^nRSa1rS9hcl>ye&sN%+#gv0B6@z;UJaHUnSp5aDv;h0(n!JhtEni!2$b6$D|sD9ErlPCwSB8<{V(; z|AbP5UPG#g8m@D3qc91ULa-#U45cG|*=uHKH+>Q>0(1dg;Sd}kT@Dp0Gar%fBjmG- zwJQ%52sxXD8$=`k^!?Bbgx_p?nIl6<__K;cYYOd_oT0Ch*T&@JW^4EIb#(A-m)=>D zT0n2$ML4NoD&s2>j5RC~Jx46jSL6wodb1IT5fI^wjId(W#v>UK6}O1KUW@YI+}yM% zRGdO&pcZ2zVvlu1+lO}e^I!iZqTzV1~PRQuiI#(XEF-vm)sk-ZN1P=ndz{Mrt1+W#$oVeBJUYFNGH7xu) zekbztZtL+;_!Z0RMO^~3oCAfiJaT>Xk;G!gpleX|iJTP>gH7tluz9!<-9TG@1pc{h_LBCV`u;dr2)i!uLB2E3Q?WZ2uY6g!s<9h?g^ z*3laBpDf5OK0rQPTiAK1?}v>-EqAKd{e%gHgwIgdWCvIm)Cdl#IL#(zi7akQsJ|2g z8&3^5O@xBEvO>I>`XEpNvR9_07oI-UKap0R5jsM_=DI^kDf@q*N9 zTUv*$AQ(mA7#e-yUc5c3W9vji;%0yHK5VKH$DC-)Qci%IAq*VBTCFnP8LkEVLzh9) zWva;IC~9GvfG@E7?*R`1(eVZEEC_AAul=Q70APVIMjB<5&l*!^85sQg9m8PeqX-ce zhLRDip$Z*GZAnL`kZ#~S-h-6SlGO1N!BX|6N`69mdCBYs zgUAcKw1^O{4*>1MCO(*G!ec0$a@deZ5+ScT%#)sb1I8O;rl^LXJeR*b2edjJ=M0VT z#3GZJMW0X~3Y(0^8#-|7t|DDkmL_N)x1{3jUxl|$F<;8+r+NEtNy!f(tc@KA06+u` z007PZZ8vo`v9Y%{aQt%{Be3m2;uJ%c?reO&Ew;^dPBZp|o zTnr21Ccf~jt)=0g-IUA3Of7FVQdF7Ds?6PG6`VZ(gjU>)&Vu!{neOO$9-fokkR2kl zBv|>4a)h-Wp2QL*4XZ0Yyrz_X-x9}a>V%hXmqLm5ey1gcGsGCY7nB4#|&hqMmGRE?reSPi;>jJ9Rlz?`W-TL4X5pUOg(VO}6+5l4joYz!S zO-4r?KMJuUWMU_$#QRs#F9F(s7Rh+Q+XbL*o7>+k`SJ%e0b2%#&n9iWdG=rpDe$Js zTipvzfJG@r=%7S(03rgPO`qKGr%2s<79J9uP9M zo$!T>%E?1V1@amX1Jo!Xo0-nK<2|J1;4yWA&=3i2N`%?1^A35bNE06an|aj}PteDK zEePKg25#Wn>SG@lDSN`nI@c{>7#PDs5f)Z=+S5qO;;|7P9;YrELuF3 zeQZ|9T4AZY1Dz(4`mQ=UuLVEJvaz@A-@ZZUd}ie(CX?V`z{SS?ThWp-f%i@w;l7TculgA=xWmN*ot0x}m+h zJN{-3S+8ptmo1J|FsOjQ0(DteA(aaE84d(61=DODo&yn!gLuibNO?eat zJQTz>VQn+atHk;vC$6V7W2D>5T(m6W&QS~YVw(h@OVL(E!b)Y>cr?wJzSEqr)Ct9u zLgQdG1g3WVZ)I+t!5O!U%=AA49KBsDf~2FR;51vgQpBV%Gh z4UZ14M}{LKcg=02P#pS5|E(24;9^&Hz0&V;Kr9I@?<(tH|1IGQf17 zsv(R(HQ`p_fL;C34=dO-SepZcp2GzfEy2!jIFfYS>^t|v{}*56kVV#w$oTfULp=QS z_2l?)-G1Bw)GIL8CCK0sH5{llOP0Ernku=Q`p1BlRp{RMD#)t6uK|%38Wl8Y6K97K z{)!B37mUMoR3(hg^xIR!$|R%?$c~$K@39nP!gOW1**mm=hQNr>p&-uiz}+a`3#8}# zkDCA9B7a()Q4c(BAQHF}N|zJF3_>~nHcquzES&`zj0)!-n&7yY zr_fWVbsXHx>Ezky>ZZn6@;jb(@*9nz%*V$%}gO zqY03v-6G6&AxYt=OqR(5`3A=l*3}j{65185-Xdc--!=N8*Q0hM2*dl)2%DGttIo<) z2t)SsKEgr-ekyupjgrC{r)Q(>Qr_>f&KVJAm0Msn0vB&%yY@Bjy(zB}?a z@P9_+@qL|j!*3*#{MPrsV?h7^_5J_1l^WUEnp&9I7})<;wQmzE2puSZ5OVt+5#}yK zc-Aym?xc2`7}L-eQ&NLi`O~c<9eBnDy!&zz?L_GuLPKukF{N%=WhK%*83dxaY7eIgWh5o)bslzlO{w2P>q)~T8d4@|ioA3f?SNO9 zoy|zXzt!jO&pS5s1JFIII$4&`@RzSWctS=z%O?*(`XKqA)7@O44o2T~D8DgKw@ukD zVoLRiI`2{R{{+C5$Of$VHLghOl#l>Cm{Hm2je10GXcYB6{i6EeP$HFthZWi9ZSJL5S*eC@3r}^FWV@p&_T5i= z4wmUP@IM)n^W-d(Ps`80M_qs0v`@3N((xe2PiM>e&WY`9R3IxrN1vn^EB^_BAPICHc}3F^QiEO$h~-IkVV@M=6soCS_2h zb1cax)VfH6?*7G>atF2;DGz|!`D-5i^*7yEo-OfSAg^{w-_jYWe>Mfna#P+ zc#6J4P1-taF~eQyIhm^j+JoD0C#HCtWP0DDt3NaxITa3>a{8Pi=ULW~W3asN4x!Um zcC7YLue|3BBW22!L41b6id)&7oeg$};1b3P%c(n_^g$3!r6=UYQ--Q_$|;ik-VAp# zscy^e)0>H6yXZa`4I|kjTh{B0!t}4F0JcXZEJo+`CZ#o{g)xaoE=`z|S*;}xt1?hh zrg56U(-C|2`=K>7P*V=|n>y~xdt;Vee!M`FjO@X@i_O|JTti4$H3maJOyMUO$F_l_ z6ygW(9OzA)&BnMueL;&nkcXly#GqnaJ7?H_B&(n^o+!q=Nr3=)!mk-SND1W{h^Eqr z6X#>d&9-~3 z3tQdh`jsn_<}?`mq@L>FxVv+!cC;N9Yh81bpIDJ8FKR20&<|9nmwKz49(Oq?QfYY+ z@Cjs{fp}KF7GPxWTH>&|JHMvy@{aRS$^4}>B(7})4mu^AQi z6-nW_KF=Fhkp34$AJ;;JK;@N9Hog7C34%In!UTrgNOhc#z&xcC9`uqkhaT;S8=IWy zR7qo|f?Ab5U%Tt*Uu2S0MMrM1s&z88;WY1v62Ws)VZRsz?HSX=Wk;TlThrsFWZnhn zH{#Ti;|Ih6TyN)>Kl_QA-iZ0YOVDpROoE=TUby{k#w0B=k_Nj0=3i=2>Ds1~H zsMrMP>c@IWQ$t|Rf11ccai7x6Bz7&E=?8+mPAMtqnQ`2EC$#sAeV&gS zQhDx8*QIhw8JcTbh~vDm*{)v%c?9w85~ z%x_(dQs7*=AUntR@gYs!1}pffp4GrVpvh=VI2d(%{rF928FHs91vKG314x_8zR@^H zMs<7^g?mh5YgSzoTr`EdA8t1o6NEi%IKmb+KFwCX!Tyuff zJQ2+`x8NY_LJ6Z-ysDu7LXxP?xc0*$O2{<2&AgmJQ7H-O4|5QRjL;N^vH$^INOX#X zbAhuKy6}-zZh!h=DdF7|`Y{dTv%9+TcoTN#@>f3+#xPpBc8fglPvfKJs$qVO5UoxV zDrMvLU~~>-mop8(kb1skuo!{=b{*5uTf7^+qx=|jZ+p_cg8E4GkWC7S@F>=T)`9G1 zAh-z2u^Xg^0=T+g*G5G%EPBhm0}~U;Bq+PmZ|uMo_X_|l)-0AVeHa;o?B;4~V0^C?_Hr%?&Tg{CY`%O8I5H&sh!#h}_9&6I6;tMipN zaQ!1pmF%1lxQ%p(izWRzPJX^oO^>?Nz-tR?aR7$k($M>S0GBCHK|?pM(@m)p0SA_) zg#LN_=W!*bKA!@GYBypw-_eXT-O0YL_~s79rd&Lz{!vFM4hf!E2pk=ZvCkdG4_cO_ zEW0hj$1kadW5Y2PM_WWl%_mHe7S#5!fz|ZNaYMa#rRY*Ab4kt#+jUj+U$fRp7Sv6# zoKBWyq+q5x2=F^#YQ0dEsDj3-r%YE@F)$c|RdL|AHPxzRq;9v6E*f(X9T~OSp7*7kBG>W3$%mfNM9Zgo6uKXqliq`^l;=*6-;3Jx z)(5z)qzZqcI7`zXQ;rZ@&D-(gBPYmB{{N%^pK}uK!{1L|@i+Lm|7R3nZ)a?5;pFII z?`&abYv^KZX7axhpyYU7d0R=NH)QkzE;(mOsIxOaa*N=$ zg$jJFqgRiwF}zXEHPkXt$C9!@c6l~9PLj)G#!Pmp2#{16u$E1@Ae}jcNj>=f#$`SB z%uQRH;io&taMu_lESWVofWR0}4Dq@GPVB(-F#Hw9ypr}s!D=R_>-2z%LI8}^q56V9 zbp;+-RyN7+V3cGekT;e>Fh$h3e%H+cUkDy@){rN1hr(+ zu~@3FQXFx0%@VHU9Mb`Od!W9R)+)18Igl9VU%kHn+eb_2or(ngOP`p(^hx(W!_LXX z$nk%}l^s86`OBc9FW(W7FK8%2=sU?RNmUIL#; zwQVogTya_>RN?QLkflbVOj4QrnOK=BM1wT8ATvKeEfjzjhSlTflxB;Cz7iCS(PGnw zA0QX{7x*NkaU)~T9ol^Bzg;EeyUij~;6DhpQp}*_Kd%>*`hJF?-3z_#jdR14sgNzK zDW=t$>5u4V+WX1>GbD*1{6>0z;Y|63_J3F%JDE7TS{Rx97Z--a)x!-CAcVf~4G(vd zkjYimz>#I#kn<)d0R>n^m;1OyE;-l7M|a*Hp2Bt4P>5Vt1^a5TY^)&3p?Fr8ng6X9 z6Vm`tx0W1W@Nb!LX6--O7eeNxqS87ZZe}K9#71C_eV|AzYlg{L0u{cmDw%nTS`9p&|U=zo^;#`bI*A?RZvQ_E(6u0YGnVw?Y9}=dSF`cCII!VLo|fW3V_8 zeP8CVxzYDQ<_4Z*CP$MA3TD(dLH`sDc}$~|JBI#A=QLL-Cn}}`3el#fnW;0{mSKjC zzsR1A+3)CMx%05V8u81VEcQD%3|)I2hD9_f^;V29@70zN0z&D^HYD_wBx?8O#py z)EXA_Y>H|}Llf%v(`)Ez6Tx&3Mb;Wr%~Hm@$|1KAm#Wgoj9!9O($e4r^ zQkUfc1UsRX_4i8BF;KCk%=PwlOS47)vUN4SMOP@5Aexav?gx^S%90CEs&s2Q3$|-) zV?yayA~o{kV--TTILIRc6n+c@|gg3E)0U7 zeSHI?s3L_(CqB#Nq=T_jRerYELCMz&o=@X*I`Bc{R)6t2z`$r#7pl0Ff!({r}nO2)6&g%$||=;N~~6?$xu&dxQN)S ztmN@NP6|Wo=ib(6H76@t6jA^AzAH z^Z$0Pzbl*m5oRj|X@z#i|MS`>W5&8^RQKaGqiUe&NhAw7B6Njj$Fg1`2H-4Rqgo+g zHgw9&NWnIdK@^51Ls-cX$`S4@CX~{zlVYb(|F?`kzh^vR2!*=2roffQJAI0eU)JlZ z|Dm2=M~=La)ADa+hSlF}!exPOPOF|4iQiS9kr!u#w`Wf9&X@Iug9LYB&Y=EQ`^ z#;67?%yv?@Cns-r`-4W4L_XS3GUAueaPJSr!K1P<_X5Ci=^FaR;B6E0gae<|`Q}Yp zV$Kn(@ikl+CVTXX$WklLNXYD_9NbA1KJ%2mYCPC73+q)uO1kL@E*-nf@#1q z6LpW7jfrSs^G;hfx%pnb_|l@ih<`;noOvd;oNGcVrBlbNFM#+RM*Bz|J68z`b! zK(%7?iW!Cto~@r}tx(oPkU5wMnS{^}%8^{+B^paFYrmQYTj;(!<$DQIOHaj0e6->| zvnebNH2t1GvlW*YBZ*gIX5Pb?H8Xq2T^FFslzhxPymf zwXu&wucO-5k@1XfJLEa{ngMF_?gVa>{bhu+HaHH|`v<2^gsNm4m5?kVs{MXux^Eq1_)tb@un z#WT1t78Od{gQ;3JHL(ErkJpQjl`_bn@(hHb|HIWebqNw}%evfU+qP}nwr$(&vTfV8ZQC}x z%v<}Or@j9`K4gxuX0DiDL`;KXWv02}@%_&{B^mTo5`zE$7{>wtfcc#!7EVqsCVI>a zEdSXFHfvnUVo9KW-^} z@qGY%MEnE)mtE1*2_s$PKzw*p;Z%ORMa6X7v2;D7B7G2z6#Nh!X0%V8vJgwAfE{c| zd%c`dB1hel#FzMTbaUP7lnvLy1OL@pgDu~?x`_v^xo!I*u~WUhy=+`mp8?4!c|Has_}+j#K}93#8mYMrbrq-Z#zK*M#dhx~!h|CE z=TiTC)*J<_)$(sQU+n1a`NOAG(K_U4Jc9c*t9=CU1CW^e9`d$1X?|4V+@InH;%eb8 zdg-X5EHs}HM)i;cVZ=XD2vqj>D@X2%ua2VQ5y4oKaYtXjP$jIld z3Kc;YEDi-3t19PNS7`E%+dEI1#AMJ&p>!Qj>b=@m4Nv;3Aq81tHfnc4;UoI+{NM9< zO=suNx4VZ+cgN4!Te8#8!^FnL@!|W@#>GOYyY0=}-=$1#m`uOvEDShT?CcC&FScBa z>`Z-f?0id8vXm{V23PeqnB_$R(rZlyS$^7pZL8!Q-^`3(`>XHkxw|7fE}rlE+vfeb zI#cR5&2~f_hK{K9kOi{4gmu+4E`cpD3lUioJJy{M%G$9oyGs4@ZYO@%ezJ5AP^v32 zN%w}A7XS=dc|O^if1|L&+R4AQNPUV;uE)RBnHs`W`(rl?8XBJ!t`lJFfnhX2Pn#<( zgBm!LP`d%$l2KR3FuE;j_FCc?(SyRS`XVU9iF=}ahJ4@kc30PKu;y;Jz&-b8?t~F% z^lA*veRU%1X61W$ckCQfJlBwadjCF@2GDVtp2E8P_R=JQbAWWL4~Ddf2m@Xc3Ly(f z1S0BvaS62S?QwGmO7jV{(X>$L>RXvUa?fzAA4%f;{a02*1a;K~kPUlj9&U%G|4oDRYfAlEt zrSw3YF-FwE3YA$Bw= zlaS0|UiI+q_WQIk+QZIOKXw0zV3vSMP^bp!!3kcNwlD+x69sBa4ieG`c*Y{Azk>xu z6#ra@x@o(=clS$C#mY)_pvBJ%53S5GlfDzsa9OCM;g=-iHwy)rzUM5;)Y#`($*V&s zz_oa6PF|LM=4_6;!=$UHws%20&graD>J}&FIvrZ|#OnajG4gmXUN(8%-oluE>F%}+ z|5?wx!Bg&h5fxl)WuvuN^Sb4_1>pYK_M07xG{5G0%ZHITKQnKG_i*TBR~s*)h*_!a zG5MjA_r*Hd%NbG{vw|*b5ypIOHsqU)0OfQyN}u-8vhvcMj-uskiHsW1VUVMJJ~>Kg z9%lhAt;~M^AbylQKM8$HnN%00-)LyfJ~XArC`1Bpz1nRDJ^)(MneQsk{=9&P~#* z8%Cb6U+q|wy(c!bqx`c*VWqoOX4J87u#mVurYR|nX~{a_tiVsprTrI1j)8AVm}se} zpeq%2W933pW;2Z}V#zOVN;j=v5&1JOrI)hJ z#{HbtpN+MoB-cD!vh>)jO`?_n2(lBMvU-~N05}~@$7i5Psj%14WS8&_6l_ECB`dxK z(UxIJ4G>K;SC5c)nM$F1b{2Jk1Pay{_CZY^`N8~r1yE9n1(Re4WUFT9M&YdYJo;Hx= zhi-ys>p35BIhI;!SGicxVN-oNGdZSU#bjBu-Wi_lrOPvhvL&Tr+i6;pt=Dk7D~O`) zj4ISRc$3n36Lh-lant#?`nK>&td#zf{72>72rI=1UP&ap$L&P1={s_{obv${6P6&_5XFzQ_HCuLNr;^mB6yf(dv_meQ)pmWB8Z83k_4evZPmp&0ygBD9nKpSvy z`1Nk{=jTO_Q&Mgi6`+=Kg1RwczxKP$Gqkzyjx!n2DH|g?0Q$ZE6BkVDsT!?qkSs{c z!miHv?}pSZat1AvUJ2Ug$w{V!rGD{5rUN%Ddhk{`Tb)eu3Zv!4fcINcP*q^@V=qCS zscl}o<=aT+th~0_iVPq`2?lNZr099m`CKQ`rFbc&F3V3mz{n&&nfs_Q_8~ndiVBEM z>Yt437s48srzL3*88nhmIfFI({D(Dk6gSFZt2{m-sO=0+uQ%Xzr(I!DUTup z00;;J0Q`#E05%2|wzT#h%i7zvM`JEMt4hl3>Sf2`S-I8#Vh(nB%a2#&m3(C^=^%%I z0`--h81>_<<0(rB$i)2KiSF^utKxCdXqiw%J>$SfgmR5^aX!J`r^+ZApElDc6Gm;z z(-oX97oWLGp;)vj5*bi@*)(6wiCXV&NmHf<+)R9lQ{IyadzvT1wJt3zGKnGby@hv@ zen?R1A4q&JZco5=@zSdC%8g_aw1-$sERvt$rMNaWJP4sq7{6(x2@!*bL;)s>;(XC7 zhH}~x+$miEW8p)3$4+$O2NvvwA*P;`el3tn>EVsQ((rU!W`t0O{1M)?O6}R<>fEn)!q}d~nt!BRg1ha6kVz@Nedbrv#F=B@dz_rxM z#D#?&&jBz`isQcmJ^#O{Nk_g^1X=ND`P*ox-F5dc_Nwf8!sU!kh(lj}4MA!T=eTp%gjd9dHlycR;WqABc6l zq|#{xPlQ(>s1wg+!gJr7BYWFZOMSC2AQD+qEK##(GEd(ckX&VSzH{9nhjwRY>+Hvk zadK&;q`*?o30-or`Vfv(9wR1A4pB>(#y_SKJc3K3CDKD7=Cz>)z&XIT`eG)A&pRH? z7{hvGjyvxyr2xrwFC7-oz#b9srLZw#rY#pnMm9!L0u4QOmOe^cA&8aM{+1M(&;#>U z#XQ!A;DInR$<>eq!*zV09IyjN1vs14cw&z%9?dRs!=u6dwfY&(ZF6&T6HWxX=(4~H zw1cCSjg{rQ)D~ucuGzGle+0zM3|I3-1q;1kfD#q9|7)5S(<^0)a>4D)i}|V%7Yfy$ z*k9wzjL$Hq=aK$|rI_}{^y!GN0s=!x&kQ@j88V6pmA}4W*@_%|wIVDlaw1pUDm}=K z7q`8w(DE4Wo<6kMz6W?9VlewML=z5`1GcA0ouJaAecV-Mk#Md@`A-GYuGxPYDgJow zUU_TAgnB_mno<0{6dOn@J#!ANGLtLgYL|Fl`19@cN;%H2s9im)DE)7B7D`=P@@8DU zt2^*(=Hz5AqA#~W8~Y}sjQT^`%n7l>0PeC1dWQknU?;BmL)jPY2CF{-Gh?lIBZgB7 z+({-^yjtbD{zVs&zY*B0?iP{}Zr-yEafrunb;uj|IZ$ipF~pMPZ(_0u?aq(^fN6~3 zH?j1Fe&5>MYhQr--~7f(2VBl_ZBbu-OvZR_;F9wCY(%u%oPVsr=}%;d^r3kFi0)aV$i``=I9VrFX;UaS=J^B_ ztFet4Gj0Og=Dx$5m4YTzSmApEZel9)&z_RkWAVKxRBJ!UJ01we;jwwwarbRv#8f+a z{u0wci$@>PG%%#$T91c%=jeJ!yW8kp9@FKXgMOjiWF~k@TY!Y;dnt*1dDz6_V*u(|%d^KVKC!{~QyW)wD@lim7ys$e^ z1!^>f31EGVizAD^l!ak#ND=5d`VSyEq<`FQOov10B3eL>1 zA2}p9VIeCv!r;rhu0v!tlJDeB4)Btt;4q~@EfP})%Z#5Dw z5MmLfaRs=z+M2m{1r9e7UnMNs^MjEola!NvmmPtZ)%7~a&oh+WC@Lfnoz1vK=zDl$pT|s zn=_gTo2Yw%oFLuMjEep6)PEQylwMw~DppN?INWhoGv=(05{WHe;W^+B18L6_zI~v9 z;Myv8kPkRJnI8&4`(mP1X(uyQ-O05QE;1<)YM|ORIC>@L9bA8Ig^7y}W!1v)+0owF z&C;-Tzzu zQ9cA9c#;&c*b)DQZ-o1D445tjO1`@=Z3+i$=AwtQj48s*2|%m%0BkZcD}Lxf_N?L_ z631kA-KCdPO?`0qejsa^m}FGM3<5Z`nHZIl1TRT$>A2;H0D?!tIi)EnB}U$`kae7r z3UoZca%a}R#z-mi+@~VVp5;S6-{-6Km>CgsC%a_CjqFKY9`smleDMew$kv}n3y}1f z6Nwb7SZTcv{w`ju#hDqV)Sptd_--K!Iv^m0)r)yO-56R~xR`*xdR;IFA=8GHN=knI z;|aU)4d!C5lQg)G7h%rCBXtto(+@kpug+)wHbk#AslPF=@vnt^wy@qm*J?jSso_M* zC*-VuE}vPVBxj;Huoa;rC&AVDFxgwdV!K7^sKZ7uoEYA)As7ZW7eOfcDRY(*P7ynp z;@K#JTj;YnUtz3&ls-c;+URbc5S3IdX1<403+fiqW(jp)K^v#cH5l;!_T}4lNC{_d ze!lCK&xFHJ`))Spq@d_sbS4SukeAW;PKb=7!Rz2&bMMjlQOT~oZP~?6%G-CcCq}^W z#K36`w8-sc3bItn0yL!IYD}#(S`PVzSY!Q7ZqQTSQ=wf9-$~UA`^VBiVu-q&%si-# z%{=vo>o%v`Za+InY~vlfF9zR>=^P-^)acz!?`QMp`-QJ{hJ=CE5(KFY)mTKEYnFIG zCt2lyYKcbg(hwD<@OgF+Mdat|>Waw;MI?Ov>~R4r7M4!bnihY_nCi&_-o=1Pn;Y3* zviA>8?bU;eCYf@5OsfcfSr$7)!OGHyMgdr;+E1JGjKKvS_l zqei#K^C2Zk9qDI*c@{grc?`oc%#v?3xcfg@ubk2`bisF17is)kWx^W zbAHSwUxfH-d^>-PRR7Gys={?M%ci67Jm&YUY9==ogL0hO;q=Tch^HtM|*W!=WtWx>f?7l%^2@{ve;-%|YJEy_LDf`PtR2g_ zyyzm3s32YFP94(vwN%kYh;0K*A+LH{2nAZo@UZKz700Ih#uhJktIOCg=e7{ZKUMtk zf$n^qZ%{^Q?^u0c-}P@ctf?^IN&5$)_GJN>2Whr_>9KV@+v1c7sWrR)^q*&Og;0(? zWd)Mu@yhsV0wgte+M+Xy$vB=@miDLxSUjm(3Z{x(OA5ZN-&%jzBKw1V@8H%MXO8&l zY7~K_L{_bQM{>_K(YY*Zm$8c;dO^Z( zN^PwWdW%j36oBo*;yY-_%OFhpL1%p4ER7)(S~4K;(!zt22^;| zYw`S%96WJr?@UHaA`_BXPts$m;R{t(S0%{-$puU0LxE`i-u~-8k_t)AgXZpdY;i+` zW<5P?oH=*18G22zQ>ve7T3tQVZi;E5xucK(z5o>W}aa zrb76RuF386=i|OOwzl{#Gb7mbnbxMsPrR?lojssv(B}7BbMRV3@p+ne?KOV~P#zos zI52p#GUAy04PZx{?%A!7sS32>u3JHib>G&yI<458b zgLk)ls~HGKuFL3{4lrpZjRZq&(&KOBtQ=|E&clL$B^`qwgYZ_bByiW{X4Pz8EW3CS zbBc`;VZC6<{g+_w8{Qq!`F8*&QUC!;@cXvaZMdbPKDqNYFJZBGDf+DB*tE#)N7m5q z3C5foX)9XD*wzAm;AP%fa{oG7S%(WWd6OkkuuLOKyMLPnQ@JEwuY0ShhTJ+j%sBV&4G6t(2;?+6V}q@BmBZ+l_`wynksr3joFC`nD2@_j|97SDo^ zw)tkDEbe$ZUY<^9U=?r*`DinPGHr6Nb&d+x*mI|})M0W< z%P!e^EzvpJOxu3E_Z@yGZ0gpSYQ98qP;aVHG<>yd2_3R!)2ir|J%n1}1JQ271uT%l z{cAh>mN;sg`b@Le(Fo=O!OmWnf8YIf4LjC9G-n9A8A3LK;u+c!?8fGkxY$Jpb;yfD zW|@Yh+UWq@!ri}_W$~0p59J==WsMZ|V%JpnS4$vGnd-H$A8pd>&qFWZ;Ccoi*pw}g1p+x^o zyf(9XL`jv%Kkvuv-O7!_#zc~>(@@T)upRJ&VeHz!6O~HU>T4#7=~F6uX2LpM%43q4~~VB zlv8271+g0bOYBmV*$^Xa+AgNNE_QdvZ80(GI!MV9fGN6V3n|Z`UWu7H{TFpKGBIP~4{i_XeSR=as+7tw+dY7mI)t?nh z&$rjCxoT6ErpF{vW|w)g8dcg|l|W*3T3BSt7t^VrXwC|yMN|EmR1^VE6ApMJHo~nO zS<}+REd2f@u8es7Gq-c@*12*0f!p(G`d?GG;%&dp7v6tsoolVcd)L1rCnEO$ZI^8f zY%NSpoSbPbo$PFjHMVUxClGpOm8gS(m;S^Vlc9;kS5iVAmetb;B*deN2vm)s3?oHM z-q4y<2JCk4Qu_%@63gUnsh`}~yPCTC_F#N_kFNFkvEB%f<4m~U#lzT+-2M^LjD6Fc z8%R{+$~4jp+r01I zME8DxPngb+O=MEUq(g}D=O$ecDTVyJ6qhG9Y43Z!VMQ>W_Ady85fRXEG=NoB*Qwu_ z;$$MudGs9cw34~2OZbR2s+U4UV&p0+RjF}g&^ju1xAZDmJs{nJ+L;3>o7}W4rMQ%= zeESUxpQ!u27rF)B20V=`=z4lf^!B&c^S>m7Xr6XDa3zy**Ae3P!>1<_oH5-h0Iqg4 z2-*%w-FHmw=(1WTzj)n_(Q5VXibuey1Y2s8>!QoPj3Ju0_Tt+MW{Dz3zl=$jG8f8+ z$ywV+V__%hD&BE~5grV<7;k5!@X36cG)7#qQA0Fto!56(Xh~l65PGjy?D^8rtR!0* zP)w_zncVt{+{8Y&TvHZ9kV%H>OjA>lxm}*WNE_gA(mw2PPev64HcZCLPX8-z&xq%*oN@MrS9@ zQ(wl;&Ld}m1=_`hH*PJ?#paK;tZqWZdQ;>(ZZu>NrC(reZfPWRd9~({E~((^Skh)LEgM>p_bs>?{?jg!Fn>bYIhsR;sxX z+zO=0grlb()036w6x~_N^t( zt`6Rm=R79$$GsYHRWmRe!Gabc_woTScfxuW*5zI@(1acAe6=(!52%K(5q8t?vGsh$ zl4rt##E4dO$!BJ0{+T_dfaf8~WWxbklI=r{3R^+O#-#=Z#8!0a*z4f(fqNq8f2P8C z_Hqz-x$IFMU|>{WU}n3vIVfHdrOyJnu_;xlOoYfEp~=wqQrc8dL`AX+NH~M@ry`Gf zARP?}phs#`4K0Y14G`@%57-Tkfrs49PHU4U)iJYtYudAyB~>9&+k%D`KZDw@*pINY zpIZqcsc22Tf!IK0Zs0}-h-4ZtcEBBGcI5)oY*MC`*|&?AoPq4`Bg&~6Vn>Bukt?xG zoQdW0ok$dnuDiskF2x##ZUpbjol@TKBKv3hzo&?4DuZl1FgN)3vh8M=IUnxnHEv1v zsca7Z8}Qs%Xw|CQ#$}1l(0BI}!8NEla4u~WpS|`&Cp}8-v;MF=WJUyhiD_pM7rF?_ zH;gZ7jrT6t@r24o=v*^$b9=dx81ZAEMg<3|$Yf5 zTByv}I?@l~i`u)a6jYIoAW@qjzkbCFuxJU)znaKrWy$VZ_~TL!OQJyz`@n_6Lm)2M zS^LJqI+U|#d*IT=H?x^MkJ5wcxy|Mgzdri|Wqfb7An)cr`s*=-;lR1KU;cu{h#I`H zm6LrxBT6BNX6y;#FF~@(Uoju#qC8uL^QzukztI_{IS3Ok&w81BN`)PcIcrSc%AoV^|?O?O#6)#`n9JsQi;gXF`S%!m_sJGG7YbEV3($q8-bZQ`wrvIMF}@s|F=2T2x2)MAz9z$+?y)(Wf`MqfmV z;}+-7wop5k&#li2{K?=ww&KQ4)^j4CHv3Wvp5*~I*bJOmSyuyt)*|j;#5~vYw&-h( zOp&9r`(PD(dA>Q`{`nv3Oftm|v*4HD*8epM{G!YN_Kv^E8AG;WzwLi)+#ginxrl2E z1$DlE5j{4?Nvs8*43~1Ub|J-_CO_ zse>37m$Z%D{YcD~Uc0K2l0JIX4xNC_nl@~i(%|=j6A=Yz1bBD?N&j;>-i$?Fn*vN-R9{WXrccm7ume_y&62YeD(g%uHFE*dE{ z*x{*~gpBA6jYxel^@x^OTDse>MY!TW zU5WqsiXQfkc9tea&a}>UHrCgwyK;x@@V>Kp2FFES>?{;CSC9nFoTzO&#O!mZ;eV6~ z*c_1PWV0HxQ$Al_qO-r(w4B}Ywp&HYBuslSVtX-SPYvcJlgU~dWtIPph4pG$1e+i| z_Zd=YZ?ab>XUp!yE(E#v9k@^gN1Q6uY295(EOG34?JZs6Qti8Ai@-(C>j|HnC`?lc zj5V{$wd|*hS5$=;Cey24WH1*x2mi27giJ?SYt!*Asu&4VSW>AO_DO0aRTxFnOW9EB zay=_t3PQN{I!u|)fa~6a2It<~*sj-Fvv%sQnzdS!LZcej8JlSnxY~kU84VX>);VRb z17;e8#^gv6!lvb5QDX&R<%Gg`_ptNy_J~(<$K(iyULWxoG~wARzuI$aWrm#zl}F%e zFW8m1XL>mlJJCxL>Tum(ouOoL{PqdA@EXeZNXRZY*FaB6lZBq2`GZ^4p(vO!9?=@! zCuoggab#orFS(tYW5an;6lBScoS%{NJT$=D0@>ekj3*&C|I2$BO?u%`zMLp>u2FDg zAU!=qd(7|!Op_Iz3r!wBS;xXA8YZ^r0C^;#s?l7NkeL|V(OUG;2nB%mrMu5R-U#;; ziSZHe6d9!oft1PL>LffTG@~<3*#&cUCeOEyKFe zoRpPaLy%hgQ=!jJyd=_{Ai9bR`jlQ%U4s4RnuPJsx0CC}NggZ;xb!W0q>a8BqY#4E z@$^+fHtX0wNY&S#o-U(q&!BIldi0t#l-kG?w4JL0+N7pqFh}6qt?>?hCcnTSGuLo+ zL4mvspobNB0u_-ppU~Evm7mapR-sJGKEJOI6rXAR=VY+GYd!GqPM0VO{_KfcG% z4Dy>fWdH%rIR9(YpT!wIA*wY}Gb`prsn!!wYs9wkw`6T}+q2+(;C3ZO;Fqv>xGVA3 z!tZFhrkaXsuRJzxs<&2$9qQDvuR9?t6Phb1f_?o}*yKzY1;9RVA!tSHkw}t@VAjec z>ay<^+>Vkz2QqmE)AR9cG#tf7717zZp8s%eku?vym087pQ`k9)D6-T(ad9v6WSMlX zU0#uc<-cZ#ERBqV3wBmpMb|wZCDaP?n}GRc`g5Q|H+jQ%kh^?Qqgxg%7OxK>&nH|3 z!uHkTp@m*Nw%$D+)u1pvrPiS=JS%hc1W#iNrL44e^>$Cp$h&>8^Fc0vl2y94lMy8C zrDj|o|8gNg`?>TlV2T_12=aZ{U*D?LBz#R8;pXEMQr&JG@^wB60Zh4VXFS+Y>}o;1 z=g7veZF`eRw_Vr8*gYNc(CFC68_l4XhQ7Y&e(>C2bLo_mLCm%x!SaxYt+K-}j@L|| z8a{OwLOi&(KSst5PQPWO6@=1E>nNgcv+}inJ7_OrXWWo3cz~EPm^E86Z9F^88S!b) zql=Cnf^H)JIfuBOD3Q`QW!En5TLvI=fNXQ0K|W#sk=bnOjjzQHD)PHetVy2w|HAt33+Qtf`4-RbpdtA^rhb_l07ny73lldQ8xtB+6GIvX zrvFgR$%*r}LBF|-yKj({UKS$xAu89Z<%)a;e*O*mWi0sQtSTQbP$F8PBTG?eRhe5z zqaEYriuCd@(V05oAM#2Jv!8N%Iqo;6CQ9-5O@+ZhYMr67wh=R-$iV57pnX184Y*BABY& zr$ZY}V?GP1?WsA7_$RQ}2Cj$}9=)3FcWE^UF`?e0 z8%s>Fs;IH8-2GUZq^PN25tbQ?aeTx67H7{+qMo_+c ziGi-_#lg5BA(Ue`o5NX z1V1=^0^SkcvzeHV?iwmt=5>B%?Ua;P3#wzK#ktN=v`7lK$si&px8^n?Rb(hsN zaKjiYEUDdG)V4G4+8!^fn@bBo1ChtlX-ZgYICwE2-`JxH#)bC4D!JmMBIl#^ zSswmoT_(!N&O`BSZP@oHnZ1lUBdZGItCOFyI3j+U#2-E0C}UN~rQWB+vx{ATtEKL~ zqR{OzBTgg0VmqSYS9ocY1rbtp>uUp(l|$Y3=>6+xGsccY{|F_KP960?d{tH3vr1Ib zRgUNXbtQZhB-JjhIuL=EM~Zv3g9suPF;VPrAl|Zx!9n(gQZj1ZkttB~$xV`3Bv0KS z=@~Hru+P0<1yjc+EVz9CJ9HC_M?A%{v5d!aLw+U&r};FNT(kHRmccBRkd0&XVKvcpg6OOyEBe0C!L7 z$eMo}KaAz&T1Qb4@S03#dOv$y|937{ddj6gT~Od01)DR$p4jfLNCIe)ug?<2ZbzV4 zD$t?*e&_oY4o~uQF&5lzd;@$dipz6_?QDqsJ$-rWM>bPQoF4~Y$y0yYEnPT7&!KyXUfO%9#g~gVH!)$bk}X66 zqzFfX`l;N&ewXYF7T9*6Y{LFFr?L4)D$#E+=){G%On@fK<;RiAo>EqsR8K>&!s&U( zKfhpMnmU+i9M;!2e%%?pqsbo~=t&H(^^yoMmlCF|p|QBduw8+q`6twsmPeHm(Tl3O zD)NPH)vGEaZp`DL2_E5GLQP6wZYwF)ZOSaWTyLu4 z4$2^XjGZRj&=}kYo9D0zDnUO3YE4q}*xHKmwi5)Z^A4()+9`EQBcGjHVKs%3d3OUh4Go&f<<)EqrOqe3;Ks!(fkex}3OR$+x_fwpI!boVv* zPO9Eg32*Ka*40d^n4H2k6B8K*CvkZ2?`FE*JGg_6br<`SnQhtGSs#8Fl-R#3ZC!R3 zxT0ho6M*0x^hY(td08Z)=efasoW zN3%%%(bUez7SnK5=+@}D;=z_(v=T7fbI7)SEOo_0NN*)8#`{#n7l<~M34m*fDgjI?KDP8r-gP6UY2dT0;_CG4((Y%lD;pWw1b}nF)?k|QCR_$ z)pIxT7L{0AH+x{#eq8M3D8h|8xIX$abUQY<$qAycOw8Rjj~m$zRDPNildtkw5_6?`Bgs3NPxGq z6f>`$wFknsdE|e^!Uip>w>sV`ezL};&G^}vSJpv4Ymc z-f`W#JJFPl{=ez1TvZ?wtPJvP*}jt-m~Rg9!`g}9K`E;JhIp7#iq0-eP_$pEcWOfA z>)7+hm~r#^|B7?l_plaqKKN_L48c$RM^!(oEkW_o0zphm<}?_tn_CSJUX_=2k>K(N zzyfYz@#*tke4&zVwDK^Ez%;FfQ5ajiilQ}zQhTN(QBF|=1EOl|-w>@9#BGsR7{QfY zdgC?AZ$uoo&O`UtDq$x!1}SM|>XXkK9Lj)crkCt}hd@DPB8L4#sXK)W6ymtGZlTzI z$;TZw=Mm&Y#`2Ujw75i;_JA$oAxcA**x?8X_cRM4Xyy6V1)N$0+`ndtOLl1Bu((du zU^6)z=XB~t3jEc8vG2%k%pGO((z{N0vi63G|5|X3;BTYL;f8C&#m_0L7`~e7-&qto ztH9do9LNs#kB0{*F-7FuZq$6WC1%7w4v<5xQT%RW6nRl6vrCSd6==da`;a_W!Cb)o zVxaR3b!c>X2kofAQHDy-xeI(!HM*%C7BgzlS(m6VA2ygr5rKICqskWx3yew>54$sJ z*=qe>tKC)KpA0)9`)rxy7CorRS}lBa@)F~akYQ(ya+Z5u2#6<+iK7bNfS9PN6_Ow? zjtL>a$h$M^^F7q2Hc4!jJd|bUX_Hm*S4r+*y$+rC0Rlb6K#N?5*A74#|FULDU65NO z8bB(<>KfI!4I63lgi{8L1X>w;NuHg})Ywx_9{_3U6!gqrKy;wwF|o<(GP$5FY9yt) z=`YFMY=}fn=th0frv;`!S=MWx&KJf2C#FC|6x5H_p2x{79QTwTeg|{QPq%$2Pr*!o zbf?ux-K-)9>5Il~kDKEMMWqdx)yu6_p&o7-)9F`k4hN!5YzKBVledakwB1mcOX1*V ztXC_3-sicG4FMX92X8t+KP{QW6cC#kJ^0NK3HAI|jQCz<%*g=OUg3TPF;HlUnnE%lzDz&>MUHaX0pa_MwaeBl;+4Q*=KX6 zG(497-2SuF57pSVXl0;RxnXZ&I*~o}N4Pf2d6hvKJ_C$LXHoLWhfbQXf;NT-0cXZ~ zrOE!uT>bfMWhc!gqEsPO%<>#IQ?iAvpFE5nn3&V7{M_{LSEH_AjL3xfEm1w5WK3OP zX%n*5nh!z_m9ze+z?_KE2I1G5@wXIGA7%&~KB;in$@Rk4=7f+xgt)z-g41vMh?0DU z0P?z-J$>v}Ab^i97dkCrfxb{MAqVieNAG-nH%P&|%{#9ZeSGugS z3yvAA1psVvzDrz<_x-)==R=p1w}5C|5?)dV3ir5Yn0|0bIa*;vfO_XbyjZP~U|vQX z4upOuNIp%JmfOQKoMvpRn7orD9`16McWrTYT#iAgsVe$Ka*VejH?M=a{?Sn;(8TIq zcYA&?ecAh+4oUo8&&9o0dgKo%C=)53P!eQvH2SJmvYLQ#UQqz6ayjlcd`dMRc}YGv zD0`NW!yli?-0ynlj~l%?yYZT?sKiP`!qX_Eij&u7f~rHgz)ms9LQKt9^6CD^TZ137 z##u86FQHP~U`2m(am}+PRo+L1wN5jYwXJxu#cEEGP{}+&HTF3YphbO}d2D1Z6RXSz z4$n6vDLYA_kBS(LEUX*V0HLt{MAu3|=Q@9SFi)TplOi7t+}h$E_xUlFK*_o_Fn_?b zPQ|GFLrpxk4zxg0tXLM&#{^4U(FMzO%s(Gu2AOrWH+N*P{vNO zb{BpgYGP+UP0Q4|)!kGPuUyX~0Ef%`g_}N*_nQDWwHU9i^r2}zpI-Nc#5Iy3eI_FV zHhH%ROU-l%-jZR?-Fz3 zj3n(@RhU9$9D_5mVJc!U2zVIL!MV=R@G=NRK?~13>cC&wO}`mG$oK2y$<5E$OTNe< zo>??+0Rpnx9g)58WYpIy2u=D4$`hBbsg56uKfG1$FX2=Z)2KxVX=ce|dhm<>p?Im8 z1;Rb~>+xIZmP8zI`rrm#ME2bukcQfw@XDtFH>yI?IHVf)Pd(_pUF2a}I2#`6x=Ns|D^AbJ@x9H-37&VSe*dZ@N9%tU zFLw0G5py`1Mm=bF_aTxL^q`_0<=Vfy?)x{wWkm}5up($cW|}< z`}!+X2kst4{z4_|Xt{;dx8aQ%xaGusK&EraifZ-Hrt{=6#tB}GGAbGC5`IT-S7ABy zHG?m~S2uZ~K8QVq>|>1gJc;KLjG3q0bzaU~30-3+{eSavF~H^>v9xN3O39P^_@a$V zQhU>d3&X>f$-{=(BZ|dM;7tj|i@^`nx5wrRJ4Z37 zPZ-S6qKey~8OIlAYU;DelMXl`heBs_XTNp9cgNUl%2&WRVDZm z8}VTrF&JeUW&H235@rI zcwkOj_&DH%uZeut|Cwrg33pK=wq849TV@(Sw*^Zb>J|@be{!>$Cka`wb{ukNXBU z{Pv;HWe+68%^1fExI0oXQ$&+Pf|2P5OKJj{hj1BzX8a}kBBs5?YZ*hAVR7xqT- zQ5cXm2P#sKU}eIBXo*dOzzWV$o+B&>dQmPCSd|9A3#2>n`E^`|SU*d8z8&Zh7` z=}7Dq%b$|UJ;{pZ~_@- zMSwcK*DiZZl7m8s`(XmD6LSuyK`s7qW2=`ylGA?W4A zaCsr=*!`I2(L&V_L&dHVJPf(Rl;9F^^?&He&}GIBQv9)!y5b$Sev-?ufUMK4oRur> z*(62pxrw_ozzQ3k>UpFJtr%5OuKs2Wd;6tKI5UlCx<;j%OGL5y16rB?T0HV^EVw!$mJf`g% zwN9&xA$rLaIWi)0x~Bl~Fb>Dyz!XzUJbk8NEhI9Q&i;alBCMnv`65i4hdCch#6szu4#v)2r-WpDLx)CfUDylajgQh~TJ!h$(bW0MdGvwi;^L zh_Ez8t|vLAvLT<(8T*1o;uNelv`I_$pd@0cje&-}h_dS)BDdMT%yYZ;E+rz7yy0E4 ze+@Rtdd)@^X-vwB2|*onQ_UfW&Xn|8E8vmfZ-o7MhxlNQTMoNR)k<7!3B4)Oa} zHc`@{dIoO_P7D$8*G;T`Jo`F1Hkyz$P|F4;H%0~w2cH?DAj!mGXnXQvN0(30)b;wd!_@~E z#~0f7(DKrPSD^7a>6V@`1}NDX+W|l}0>AvptP{TPvH8HkO)m~bBFlKLa4aDJ33Z9` z*Ei0vzEy%pE9G6u~ zHt>^h;HuW^#AbI3mc~Hp>OJD*Ut~c^Q}{5YM^T?7x;%-LI%0z^ZEio;%?hw;nhn9A z;hM|z6xc^avoBJ^hyYUhcS?bLNW*2A$A`ai*fs+EXY{JNE0bc*z;ts1gDHc5<`2lq zw*m(mz}agkpj}TBkP&-H0U6Ph%z=hvA0@;(VJX=vz-C*Bh;iQNw@?X8*w)QXrGqI|1bC^P~;al+Q-E#!ToRme!P~VFD&vPM??V?9SE*q!k>YnGiMM`c$Mwj zwlAplj7OK8VK5JVO{7u6pe8O72&=Dhf`_PrSn;DIAnVC?;V8ha?z|z+LFPTtos5F; z>w{?p#9+in9hYi5r+9dJ58<4)#Bh#dDh2dow!Q;spAne;QE(?FwWj)Iej@k$(iF%^L*Ck?6rc@G(mW5cxXYNH}nICRwDL5V;uCcbA_##;0IFmgtj$A47sp znDT)tI6q=fg>_)2!TB^w|4!Xdh{tkNIhVVHq%Gv9Db`tV)j&H_hpJ|V5lv|3bYR3% z2)I+LIVbvLW+6@`lVmA&e$vR2@8T*#rTV95+;RX(p~OR~2vlT!MS}QD7YR#v?A<1I z{Ng}*m{2%j60iNLv)9)SUChiYsB`P;=C*f}iqjam7-G5)T7tnShPFg;(hOii9;Lt~ zsmC$LDp7|0!ZBUnJ_-04%8%CwL^F>_wpRsj;ETitQ5lo^o?y*SC@1RM7XSJB$6w1{ z1|jEk{_?}uIe;>r#Rw$Q*%q{fY#3ZF0{P46HV~Pu0`EaGW7!y5Vc(o_ICtqREDcgQ zYiE{OW*AR=ONmCn3L)tDP2)k%-O`(@AE$Xo9oW+eRy#8)soz% z)(lw9)$J%{b38Fyj$M$lp?5z`NG`wrm!`&#(D^q}lEkvC4v)ttxk0N=LnF|i>CB*! z&Y2f=!oE_lmLOu6Cgc%1)%aTj0gr&1TakV-F~8;5hQ_=WK|{Pl^K81@zkk)2Z>K>Q ze)n?A((4Uaps!Sh8>oRe7S40?eYMeGK!#Z=?-oy#-Xf{XMMY(!;PcUn zel-t|CR%>Z6Ca6Tt~2^uf-6Ufj109g>GE!Qm+9yvxl_lln31B=#i^sXI#ER7MPFv? zldVfM3X0$*msi-qC(G)|`Ri_cRmzVgRfvouwk3oUo0PHT6+zUtC8)*Hek)ivQA_B5 zQlc=%->JFPlzO3pUwd*bRI#sIUfM^!J=fQ7pNgMe?6>;e9iFc?Lw~l*5;OxMk0G+TlnfRH!Y&Vs=|V9n@VY^GP7}*+Rj>YXkeByfCVo-EE`;^e-O4N>!`UwW# zdykUz+g*s>fKS7gA3H?DJfm=K8+pl5-WRl&?b?&41=%_YW}IX*b#YEHpr?+o>93JO z`&7Dd!CZv};zyFac`*3hB0LVLuh(sm@=|jQd54}LTM9!eL$Wtd0q63{Z7iauCQ%qj zYp{B*Y-r~>2rvdwVsk~Q?NQkpQ8KnbJw73fUKXSk2gDhzQdQuDCl9PBKc_!XbM^p) z!<$+B865`JQ`=|X;EyEnA|K2b@1IIuuMYT0$qnNZ`36a3{G5}EP@~w6B{rNHAG}(Q z>kMR@Dp4~kSiXK^V|PrmV8edVd0f`JuhMI160lxHH#XXq892A#SIe~V4EEA2qz4E0 z0`D`-XW61ri_E*B|CI{Y8yEDeerlHOI?7^{HQXg9hms4%ix=ncHLsnkgNVryqnQ87^glXIR!gL|74;->#<1kQeAcJQL3um+#SMWWXwewh zYEPq<0q>b8TRsbkj^6g_-{Y>OtNOatwJQTIjd0j% zL6?b2st^erB{kCbs=2^qaYbsS^YBR1RRN1eeoagACK)I+mK?UQFIenKDg45V1XmO_6V7LB;0iJy> zNo6#R2cz!lPbQzsmQ>aWZjU_E_<8Pi8xXVmarjp&IB)M^Op3RCN-Q_-R+*j{)uFZW zzoz_%8Chu6mRE9HITcTsawHo|gX{70S4a|*PgDXGQ6Cy3XW5YRK%!#h>44;%e(g}7 z4Z<28>W;GAEWBqb!!>+xU14NEb zql8e=>bB>+qwygma)y~hG>?!YC8&232%CK!?Jf`>J5LF){;0$x#llV2gH zp$9wlgZy-rR^V<^O&lK$S(yvd?AH4#^ORUnUHLA3U~D6t>yJHEFSA?+ zKLjZl=L(Gk)q&Q7-jc2SuV&ArWdmS7GZ9_NU@VStB=1sui;gHbc??E&vYlC4$ZvOU zWU;S;_P)wxAi_;ey!m!2-Z!_j&K7*S-*ubMk{tZ6mV~vcZ3;zl%MNRNHz>TQ>ci58 zTgJo^@iuve|4!17&tv?#QU_gV5#3%Z*RU9@VAIH(MEz>qGHq>>t7AN_)rm`sii&X5 zCu{zN03o|jLu`g?x>lDM(`SDSoMedBv3|_C|K6FoHFcGr!v8GWcqb%}r&{qFC0s(3 zvga0LMVu+jCLst3!Mm!a8euw6k6ol%)D;5tm1w4JCc{f){FO?(N~hM5|2-}fwLFb- zvQO?RNxhoOurr>9^qqK}(6WWkJLZvDRQ0@tX6t!3e=u!b=big@muUb!-}ZBCJxJL_ zmX>mu^3f=P)ukF|n4?AYFW}^=cCjn2aX9SxcH%k#&EHFtn9t7LEx51v-@E2!bl&xP z?J#q!vm2lmUV~}^q28#$zU$=#aiS{K^yYG+VU4W<^jg6!h~W$4FznIBk^AY#8-NxUWD>#euBi1|PSRoS2N&pE%JTKCm)hTU`6USpdjpA*s@3VT?8|%)>6nxE?OS zY&N-&rrU3Bf+OHrT#R*_UY*G#HR{Yss6YNgwg#0dhEMvV2|4>Yew1rJoiImN6UTp! zB$&ug{+PJKbX`;Ag&QT$Nocpg)gmJy#j_K@5k6|xH$+?8Y%v#=ntz(Vk&q$RTgMaoPAmCN53`|EUR&fAc7h-)&r zp0v!#$EbN*@ah=jY!ur2lU3Nj_K?9W%u8ekyuzmX>z*Bom#j1^R#~dO zW2{d)NJ}wgNF7HN);T|!G8`{-pglf_H={=3Q@aCmMHKLwjWV91jRwM)T$Dn8lovg% zZtd^P1s7mp18JsJZJ1r;;jH3=pnTu;w(+u=PMmz`$AkYd!3R?zQQsE%)IMuNDLL~b|$ zk^m4FSHl)u(-TMAvd5AdfC$?~qKGyYC2HG1vrHgtO$;yKbg?Kh7Fr?Ap~_J;4eUy9 zh{9V)6A-SK0QeP9L+uh03S6x*_+e+#PEH(-xHvNYdaUV(|ugn{s ziF?TM)3yssSd50T$H^jQvpSBcp&;fIO%??ahN_KFGr9&AHKVYNo>g7GL;R0*^2xX3 zEj1HPY%zIze_RJi-eXIKVTFT?wu?v!O8`L_EQ&DI5vgf{hy)&TfLj$C#dWQ zL9NN_2U+_Sld(P|5kx7b7!kO3j1pxQx3$0NaY^o&tjCHRq7-Lxl9DEvGC>w7-{=sO z0(X?HoL$=7P8k-nXGD5(0GtGhfS5{M{7eVT0pYGtPKtsJ>j)vIrA6?S-6|C#Np2b6 z#G9^t9k$r{r;V+wlP{T*q0+3xYyLPZQ5`4Rcs?ly91mxBHw6;D0uvuw<0E*J&`+r2 zmInR-WfOvM??^3pb!TH>dTEa{R@VnzdSDXrwwSdMW6aQ^+Pvnf^%q?XKoeAt6d^l` zy%=F3lyuEwQN<-l?~@Shl@v30g~1Q?S{DHh%pFBcL1N{aiLAtIE{zI?df~s1xAR;* z&PJ->LTmP3f-%*+%`te6DVh{EJri}Mcq2B8?WOPecALyj+KgB`d2)NGtbq_i;HFkZ z+RW?5U1`dIb0n~xm~496wsm`EeK5Oh<<#?~@(<@rP9BNam!#4pbuTPVo|J4=?iJ`A z=CKUF#N{zAY1N=zrpa*xIB$>|r;CEIW)O!+hYW5qTLxGY?v?cWP_Kx7S`X=y*0OmR zzoc=@h%bn|$eP-%Ja0qgPbE=q%!r!-$2bnejiFH7 z5|Zt5;6z3HNwlu7)V!B;?7lF`9espDK=j0j!UOyAKA0@ZKKc~cfiJ$_=~4?VHt63B zVzK(|>$fK-N4x@hga8_-R#f~mZZ7O2R6mnF0j=~Ej}GIy<8qt)1i5h(HQlF{k@E7~ zJ8&&`Tw}ugYG2|rr0Jtqu3ipe#rss0ZMhWPP`>nqBZJppyZn4C_j&s<1^o%NA8K;Z z@Z`=M;2l!Ll}x*kToiuGMujtAN_PBhmNafm8qRzz@#-h@IXZ*wFZHg?Mwunut59Cc z+R$!5zko@v^2prwyvw7*RZXPATFeu}_i#1V7Z3%-(bn4?=zOGI*=_#wng8N>b>~A2B%!Q$B=+~!F(_F?NsUge0Ll$E^-91 zhR+Kk^xcAwd)Cq5c4G4FT?_ko*zfl5^0}YQ2;e7n;GZwVpZA}D#vk#Vo}PuRg|nXC zk9rRHKMeQ(@yq`z!2Rz6GXr~jr+-QB{;y(-*-@+Z9}{uAAFFfxSorFwcbDe z#?QqMHEo>MhY-J_^&CRX3~N;Q*Cn`cO;!?OPt?PxEri!H7oPM9?!*a!9LV7iCOtp4 z-!D3MG7}D3Bux9@ksvp&E-!iC9o^2`s_q%3!|6@q&5)CvQkgiW^O((~Vrw1JBv}`` z^M4(eQ%?}bzC<&%M;G8qDAzHtq7x-iYlkQRg}$w^y&G?PZxw4!5n7HQ)TrIx zsHx6R_u+ib9)G|(CR{fJbK#OAGN5INx1F8s=f?>dEh|TARmE-ShFdc1 zi#2Iy$ZMa$6nigJY@^ zPOUGJuEg8*>NZ~lIfWl}9yR3h26Uha$~^%F4*l};@2+b&F!e`9WUD5{706tuLi3rC z)|SK7Buv1naB|&+VK71zY=}2(F|{v7G;FRXkeUI-S0G8R=wlbgY={qTXk-baPZ)j` z0Ja7Y?T@DOr6va)Gf!t$6b!o=Qlr;0%_DhQa4f;lu>)Gq{~?=F()xQ0d127nUE;t@2w8JC42xkH^L8Cz!Ua>(T3RV#~UINPuaB}eY>OU|?)6UAC z?zu&84|C%7;PrLkvGK8XY7)q{AHDZBfNoXQ|@*(|8n@7F_NVBdfSR)7i(bHCpyhU<3m=m2vwJFGeqVRA- zMa3p`NJ<21eZJdV!!Wd4EGI^{5egXi&6;S0pH|lkR@_AG3ZPZAx&upRIXW~=j&iwS zaUg7DlAgcxW4Bx|-!L@569E;=6T~mO>_(nv^aY7!tTEK>FM;b%h-DzSTm!H>wM>X& zdfb2oMUhovTHpnlNW(;DA=fBlEN&gVPTt?}XCw<>K=6xVw{23U-lSQt1>;!m4W6sg zzW`D_tOifyXoG>cVZu-!Z{Qq-#(Fgr)3(IM$lRp?oUrC>&yXTqFwPypq1I5n?6ps5nl3~{W;w@OQ{8(vwQOWI%U1Bfuth6in| zNfSSVs}U1FQ$Mdcu7g2iZ*R_2SRKgln5A#QSgC3YG3!uA+g5k|697Xi2{HYu_@qQvozBRk43 zgF@Gr?JZ+%7ZaxSGPBASO9R&`qCz4-0}G7R43?&Wwma1SV29=1fpKLtq4e7w8~(&k z!)HhOY<~kK`i+UbGzsN(zll#EDUkLx`6IV*uF=#&^(yv(5ulJ*09wklQRifF!oa6; z-IzqD4OrDFj%Yc$hsh$3nW92ng5*yn$gr~3Qn)q-mNou4ZZIg% zKnS!>~QA-5kj%g+9(NxI{ z!X^L8Yq(1aqUbuWDiRAdp$VQJDhj9k0yQzEWMe5>#`s0%haU)9TnP_Vj53TC5d1T{ z!%NYs%0Hty8>k`|m1(Mx`(t$BJC(Z^_9ljjv{aplb*UM!e4n`ch%=I>V>N#C#@}1D zbI~nc61~+VxxE@40|7Rakt5* z)nJuGt$_x4j-FwOWaHqsp*%jO6fLJG?kd|DZ@nWeqQEF15~p-}yiM^I-@F`+wyOp! z^HZcYA}ZU;@d9I^R>NUfysrrE36RJ^C+sAMd?e{xnD>zXHZVqxRNhrFFQ6$2+4~*$ zI4l=T0{LN)vLVolAM*^uaabp+qxI!WBo|9NLZwiADDLgHt`${{%ZJ(LGvr%Xl%oR^ z^#Sw5%9!pZ8kja(`c{-sB?oePpKBnW9POn2V#uOJW!}>8oQv2{YSA6fzM5IyTqPB` z$*pmCK%N8HTUNYQMt@wYUYVnmmpwHk z#)qeu)sdxbsh9zd{JqQ_k0BwX`@QMHYj`C|J!pF^`B#4UQgh$gF>EyuzvvyUjXJJ% z3$?FkdJ^6)=ZN^$Ba|s1@%aUG*dd3NG(;Qgvv@ejj(%+CrZzB^`p6k8-17RC?K7fx zbuaU(9UJ0%Ibm*pbrKNDpZM|liuwNhotpqv-D``u?;ILZ8TTo4EavL|bsVT(YvjZ6 zFIm_mG}pV2&Q6&DNKKlI3DxPpS%tR@*CmmH);s5J)D6htUOn2 zLcox07Fa$51KBHag;)NfxU!Q$7x$QHqNPWL3w$K^`K>u6q;_eQ+qB4zE2m9|<>O;&@S7wtP zxAM=Tk;uCWUu87;0n?*V1><@Xa;I!&Cb@BwiwQ>CJ%78dbIdUYSmnO6RiIx8rj3ZS zH_dFDaKfJRyQZQGnj&(s&(fN_n@|pEGH>e0x3f*SR)*#Ye}g5G(uw6(o(d4l)H>GO z&RR}yp@)-#(Jb#^YmgK?_PoK-c*^~PSCiJ-(a|j-awEe+ql6FqEr3XmGVE4+^(~c^ z3P_DgkY{#jTjMUz-~}s5LQd9}utgbAr={C?wGgL@N*Fjt!GwtCbnvgzSy?rEl5RY+ zdQ=(c%_DE>T=C75(UspWlU;nZTi^g*RbG?jvcf|ageutWLiLu;f*yfPC5${B19HY% z!oP};s$^iB!Wgv*4MYzE_r&#>0&_hs{?@AqsCLTR%^+)TD>kONXl*L0N{XEBZ?OQV z4OT3z+#e=uoqhGApB+Ibf=6jD_Mse|?i@q+M6sy1jBp9A= zN*2)Li&`YiR$5sE!w4^uQ@k`!^sg8Yfi)yoa{VprLp%W+!6ZMLS-@Ua_df4BfBd>d z^)FRabHlG7X>`CGztJ>7^Z+DkCexM(X9W?n*Q;P-alBB-oI#+p0k~uwbKs=n$)zhTW3Z1y3!LU!M6l1H7g$R zH>ICpcHn}SK>KAIV1ogpl84(=XM8ePihj_29ZiQs5?}W*S0oakz85C8#q%69oWIXr z`Ik^iLNcD2aGsnLKhGRZ-myvj*TGc8DiMj=G25@GJOYcxA?+#Hz&HTAU+~nOWcMTQ zl0J^EOhgPaXbLGa1tFH2)?I)05~x*6%2}fFIY#+ecwKI(h(N1$nvrBx!`?6hT-`5d zHy*mibJp2yo}+gf-S>TEZ|t;q{KC{z_+F^tHaTqMNR`lLMhl#TGi;xeZI@4-IV+c- z8Id^Wyd>3A6l37**#R@>>0Ol2LX6Ww@V>;wxgvv0-L-lz@PL=XB0Hh#w>DN< zoJd+$oX#j>xToxbq~P=eqN&G>>h0o91oAIk(gJ+A+KB3Q-84q^q_({6_D`pPXV3p$ z&s3@cFa!&V%7KA4D|Abhv^>BPviHdP8A#VA69BtYAC4*E)x4n zvg+><&rrIP`-1vL1!^U@7Z~f^eg3P*)rUa>7|D#Xo8OH{%rXk;_hLjk@YidYtlINa z?Q^STo9lA-yIUrEk~mvNoQ0Z2|GMlk(-2Pl3mNE3wTE1nbl4Io+^tecccwi}t#O@n^N z)K{vzRa``eE{#jfcy|im>w=Gy{qz$o6VM|QoB)$ug4z4C0$L=&euAG{2 zY+uufDW*DJ9d%ECKV_CA59bI%Nv0Hw(K$YlB+k?Nz6kx5xz{JBVY4^7*8?)(#w8$D zd*@U?>gBlDX0lqJj)Tb2-$JY`T+Q;W$*fRXmfUGb3EUb zw4mV!dC?L_v`orHeg>_Fhg=YW=PR~HS)G^w9tm*LN>^TXOPD(H*XCBq*lYtT$xejMtq9|4 zM7`?RpOb_7&2)(ZMgk6hQCXSll4C1-x(MqQk@@phhAWJuW-U!nu=7D z<{c2{IADaIr#c=5fy+g!15Vb=3P^T! zOdVxYTv@&cCR%^w&E0}YsRBahyKWLXk`7)hlg0Ny%A8o$BlacHSk1<(XAC-->}x^* z>dCIc87_K}Zh0PR>_;xBc{5KqzM!N1E8b zOJ7X8xIcoQwqSnLuru@LxYD)iX^>#MkyQr&p9P->GbY`2!9cT@p`fVw^w zbiFq69!eHXu;3ot@ALPdSkK+7tv^x<+v87X+{D_8#!r9z?X?St^@I+##oiqoz5(yt0zeoCt0S=M3WTp z9rNJ|Bxs~v-N7`w$Bp~rv`-2bblDvZu3HN5cbWPX?Y45B-_!x}ch?@XwisCLv-i(^ z<E}X zlB&R_23Jq+=$QDj9X=2N`K5#(y}W~;$`$^Q<+U!Z$;qi)S{7_M#W0^-1rucHcalpq zXlnAOSK+_i%v$o)wUYC7^tux9?Jj#L`ycC8x2~wSXtNl+VsV3xpq5<@Hi01cO5NUB zjjlg0OyBtSVAq3-ExYa4kB`^8B?~cj;rhJLcT{%L3DVi47;$jQ6dw{pHFUJut4XwP z*N(QFUd#Wr>!yITegKiqc(bO-UGzo_5kg>ZVJa|eCp(Y;qGZGVv<;T{B=tI0LmuF^ zHcV@Jaz!&Hj6u1#qAp37-B60(M)0lN0~kv9yd`TFaX?B^TD!5r;OQ}8;!xcBOY=D* zmX&eF_znE;tzmyOdlTXxtU~m2{6uyA7iKZCvvoEw`u8TWk}8A*4K0PL_&A-S{q(fl zw1kTN)HIcZG+21z!SOMQ6X1W~AqIez9-tpQRQiL5NdF(_{wEsxuPgmG6x#eBC}gge z!D1iRw2Ec~c0_6$ODGjML;+?TK%mtysY(PJW_gVIdd(G=Cv!F#chS)QK@#mfefz>K zcIOKl$p-+OJz1JTB*hRd>7*Xt&a!F5P ziX69lUEsz+Wy03dsf1P*3F(Dan%;CFlitr|1+Y2OVrP&638W*jB%!1Wq?Z|$C2%xy zomz)yG>re%ofLG&KMXy-G}OfiX96z4%LpRg4E~g`3!#f2DcTeS+?N`BR-Of&nO|=? zxnLM#m^!TJ1?$B5ZA3$I@NW?ron9yJ=jpNDR5b43C>G(Lzr7E;<@@pOnC?_jpP4k| zZTc%7o)h}^C$ah`@AR4#z1f`vu{D1I-eJElL+I(vQIB@VLGxcYA<5{&U|vEHOTxSq zAHv~b(m-&EvJeZ)*#Qk2NlKtK{$wbtIeW~Cks|+@SF~BQyau!i-p-%Hk_}C9yu*y;*#R|==)j=*75cNRGZ%4=*Aq&A&4M&Rx zv1W%Iq(!5_T|sBx4d~#^a}s2Teo7`QJxM2^V(wt*yClYizGGh#)>2b;I;%=)7&ou6 z%%#q;_6Vu>8P}33>9jDs-w5COYrQvCu^OUkA6O7?$A8Q@I4#CDJy>9`lgy9rw6b5b zoxvHkF^{O~R~+lG**|cP9c7lSWpT6eoij~<0r9yD$maHekcr$i_MQ^+htAus5d67? z-`7QTC1PiMbQLp!R~ABIlMO!3GHwyF;7MY;R{uO| zVEjCHLP?^Uk$%A#kiAFaj&O@i*Yd)#w)FGc8*iAIV%Ac(7F8emXe8~JFpEO~=Yy20 z6^HGD3@XpRKZzEyX=A$u~a^%QeiLF2QC}2>pR0SmlrCU-5(>OJkl>>K$eIDc8*c| zHtMCMF*(otJz2cH^&O?t*!0{vCeo$}C~LUx_0f2p1}xHfl!A9P?rZl{-~fjw`}?-0 zhw{ba%gx^nH#5Gk2Js2t7d;Do*r}<7L+cS?#QiHFSXbT$B~jAZ)%ED&npD`mJbeuE zQ+O5q>}&*UTKfY2s6n!*t9f;^+}}!$9t0ZbS5Qq%sspr%61X_W$}?|D_j;o*I{yC6 zXJ4M&(`EG~tx?t9eIVsX7iEvOIch`)ebrF-7}uu`N+KMBN@t)Cbo!-`^6A5#@#kvZ zNksDbv=gKSm{M*ori9U{utr;uecs1fvu>MWm7yNGH(+jdja!NZetTk zO_BVZtA3-?_(_B7?#F{Px|XSGF`Xuh0CKmJpddO!yhG7@!0JLn#nNV3Z&81%k!-kp zxPj}vM6U^x93dOS((m%4#J*}WdD@J{_lmP=6{zOUTMb%+RB99D<|FDtFomH(FrA~y zjB?MYiArJAX2x+yMn*+v*z_Ai(x9Fr^4Nh!GhtdU{^aEp6>D*Q8Ki>Cp4J3-Y@RWf zRVB&a9>Ce#i+8?fo`_E=ceUnA6ngu!*+$P#mhK*%HbK%_*)}bbxh$)amEfk+z}pi(^b zS7L7?_0+!5lG^eemh4tGc^@@->5B+REU`-OHOlqpUwNavh;?yr>Ih}wAH2D-YBOir zV(ybCYQSLB2p=|}@#lrB0i{mx#H%&d5HhnU);)ZeJu8#fvaIN-e6)mVRSZlKB+U(%+R1}hu*oI|f|^Mq8Luqwu9bM?u7xI1Sc};oU#h-gZqjTt zd$sVM$4ar{wHpEMqpSl`YFH{AzM#IWG3I{RCHN=KO_NU4?)S5ry%U?KpPSQVS0|2+ z&c9TLsvQ3AasjW;yt&`T-`~k<`4OZC=tP#a zdeE(&@=59ff#8Tj*O0=w@zTR9wGX=4x zJdx5kb-<=GegJblx8~1x*YnU+ay(qfZZ3kOkIz->@UZKT#LKpoHLaqM$Rw7&-=Y^~ zom|zrlvR#`?0I59iwL?Z1(A`gjT)*{8U-Z#09Y+8g@#NkOVB?n7CZ?`95{ff?`|M! zN_~n|8!;9euJzUhW#=MbgdUS33uXsmNz$}nBBZyNJpNW~4~X}yn7b?3Zvw&>jMX`Q zL|yDf05_1+7YX5|l};q>1$DAat#(iS(tTF>){cZf1$6#OxC#chQDv@(nd^FcGF|GO z2qKW(L=*Yf8{5ErVb(cV@tb_E#7mm~By%!qk~A=gQkk|=7V42jo=02On)*j=NKE67 zMc$}m$IR%@C>a3G%xW}k^1HX-b<9rMvrYi2m~F4dm4a9~#4?F3vCwJMui4;KD?UK6}bLMSnFEs?mp5d~bf$DeolmrRShk(OLOlas|d^2wI zve!E`w(ti?d!X__-Sk=B_O8oi?%c6Edw8d|bmgEYP9rpXs>*h{svlF}jid9pbb|(c zxW|&mQ{G|hJ}lEWJS6MFpdfX?VtsOPjGfMcYVECN$wqFC765j3@P3PqCC1coTQP?4K80TpPdoDqy3xR$CaH-09CZG!t= zv%#DL-2?7@V6IRngjFZI{AQ}U4XY7~D!t@T-Qoqv_zeOo*g`0aY;xaQ|B_ZDXnpb_ z+<~T#$D=}DyWwYA2eKL6lVne+h-SQ-(@860xC}6f+Im@t^#Tmaw57K%$2VRhO;d&9 zi|%2il8ZgfIMZ5i-V(Vv`Zi`!>#e5K6%qbJX*~AJwwnOhv4^7?v`3t;6X$_l zFP{6Yket+fEHenh;V?Z4BO2w-h`_T0+YS`=ldS$b&~O5(XkZ#YD$o~zQsh2TfYfOE z_=vC@_X`2;j3Bg?JM&v;dIoC=7sh7gmG}!dgkC(Y$VB-N1&tln#CWOZZ4_SH zsC{3|wR<m=05i|epKaDYyKKE+c^;YH({#Da zX!(`!FW(4t?gvlv=UH=jKo-L8jMi3hi$0N(!0{t)OE(}2So0I9Nj^#+s6+TplmYgV^(@H zyP)3mjWE!`?S;E^>AGg!r~Tk1Qo4zAk!}8OQxVyZj5CQJl z#79K~r#h|W0#=3RF=k&h+WP{^-0qO$;rqrgvJyYa?B2Iky1qa>-@RuGoX9sGns zWNd70X~$_(jT=!VFb9tYceHkqw8kioc-8G_Of`mamgEtuw$3GyD|KO_(zR|zXHBS@ z8rXcP!`+cFZ=Cw`Cor}D31-$_x1Ryl8ANAgdx06h1t&Rhje($94gZ}`M6{pa>hkAA9n%As?vKB);26Z2HU zE~H8v>70C3cC4Vv!Ch#?%>hGN9Yg<~~PY-IPdTNO1 z?ht`A7_j(JgfZC8P`^!|(ks`8uXmu4KL@V)4|Cp-wlyzgg+(LzB{YFZSrzxv z12?@hG}KsYT?x*=MhjK}IFRb)/qNIs0qsR`3TYfxvX!0WtvOcgkH-`e-ivHQ;t zEtt9aB1HQgoIOJcxPq6zZierkw4SfbxiREKk7s)FXZ0B~BB!Rv`tOsA<7cNCIy$+r z*_t11U%DSzf|^#gHP)RtQe~-nc&G<#^JGS3vGUG7&Lm6+9~cdfB!&cCHs)QDtlkrZ zdYmhn!W)Ed)q&sJM^3RkJ8(3M2({6JjeuP>z|E*Sz%qa-MMs6+Rh$l7aAbb{^)Q0%h`SXfzw zJa<4J^f&Ic8#`JdY-OaT0vF1&f|Ty?tP#t7whG6LK?#g8hiljzGpDZ*W@%`AsyGLK z85-p{Zm>EY|70$gw;QMKa`}{4bfIYg$vcHTuc>a(D$fziuj1h-*6NyiRu(APwKBV> zx!_|lDU{e#kn3U}^aLXZngs-rw&YSm#cRoq#;o@}J#Amb6eud-^B z^0M$MJf6A?-<^b}<4oMzGXvb+Ko=cq#~$xAN4W8W`OXys%B{;-D>hpJH($5|w#!pO zx6rU!&ScdtavNYQ22a45?{+GwSkMOqbmAK!OYcYfyeN*W>t7O2?m%SEy`mdrs=spU+N z4Wp9iOaZ4DN!Yq1xySt30BmNtp<^05)+jTVtIP8|(J&}*Qiw7HOGn8oY z`9t^xg&obCnN>83SoyK-JqEA^RfO6*`!O;V`xUPQ6wH}(7>&>f>lZg0RDP5L&U9)V z6hD@Q4s5oQa(ke|opVRF5(9TQtpLSMOhhD@$+^h( zUbi){95}cVvf%mqPLdK`_(9UWwdiN?%%~3}J%lgL)L+!AIrB2l@pL5g#qk4B;$my3 z-16hcS7YECivwVX6r+>Pdx^mGA&t~P1vxgSTgQx{{m=kMn3D*Kn+4@6>&S}+Y6<8! z!Wi}fHUxYmTcv=?{oOWLZTZ$v<$sj#KzN`5vj+1lt0w?8OzawLLeXM)1D$vhVi^ey zeCUCgEhk7h1|H3R4A}UqFTbtBBaQmp7VloGblbbpy8hGf`De-hZ*uUzHV#ueN1OkX z9R!B{#u)wa?&Qb(H;wAQ>i)TJ{PW}gG$qxg?Dkm^y3W-Q&IZANRwcFp16k8WpnzrC zC6FI#AV5U(j98IMNhpS^cE7hU73)Z}94~q_?3TeJ4!GGfhKGM&zi(ykgkls$kqVWL z*eUXoMrunYS~|Mi24GMiUwSkr^+#fORUn57W!2DM;34uscna*ewd+j3hr(>&w6N2{0AGPjN~n(o z=Y~oNgtq>%&L=L|kJR{u+Co68SW~4nOWY;|{>0Z1O_hvV(E3Zf)Dx;C zEBuetw68&UZj^M8eHvM4P)M#}wj+#8vDX>mOF%t?GOgr57)mXLFae`12o|YY-w;nB zLO4bcm^K!1=omdc;fv=4vFMbwHRmQtAq+!|sp|q(j(U=P(Wla#1H+&0RD24sB^Vf~ z0QP$hW-tb3oAPbGCqnuqX8Q&b?+3o!n@?}whV-&{Qx!57mFMxI?+X4qL8yFVLzTSb z7Q?<^nx3L!1>D%@*G@rVc>%nADm0b1n@|X)wnmIjFFsL1Lou%N*hf zzPO-+s}M#*gw??d?g3Asy{tAKTQyT`$B=lY5zh9M9OtGidt)|g{@mV*E+K)i#r=PD zyo%Gk~-#dFHdnJ3WHOCyVp0C^D zVL`7SP17Eo;07m^D|~!S>*7U8|K=AzmgWM305n4y2hs8hO;Dq24NU-XQ&lu0?)~eX zsX1sry#xPk!`MPbz^u}f3ufD!VNYy);(u@3SBqY}z2^qq@UI{rQCzzMe06*q%?&!e z{_Cda$I$#xH)RJ)R-4fQSJt|^Zdf?g%nWMSF2z1{5D|I!Y6LeYB)Al~i8o+B0C18P zrOpqSF`1S?P2n^BOu|unT*GL(7(=HU$4<(XeH3*`oA2z=hE^M(n%6I;O?vL1GhDZx)&K_b+eCDo2Jo3%U2RFI9?t7H%tqv(6dK8G}ltYj@i+8Ufqx%oykbaSh5gdg;t@9_|vhzjwq&)SqMg%Z$zSX&ktfr$-l=VV8SbWMpGA z?nlCPPI~bXllP{TDAVE~@eglI5vD86-sgQiRS)>0o;Vumx8Pwk$uh!RpH_E2Z9P~t z2&{fD#oh@S5>@v)z7xl+ozYhC88~^|m|JRHEyMP)x>;HExeJ4XZ z^Z!6BS6SM>JQo!IoiA#Lt6?x0+UpiR5Qh3iM=oGH9UZCiYP<_X6bJxa_J~BBNAz;%!#GoE&0VZkw%ku*%AZ8oSwTJ&%uWpiD!i|W{71w z#AJ@Ad+~A^m33Qfga*N!!u>O=Y722{2! zo0JK+dEUgnKxmq}90`!-+q`hd^RyO=>l%9)(tgjBV1~D=NvJ>WO>9|+WAY1h8==cb+2M9Qjs6NbZjGLUOyeg5x zm0c1-kW8;w+akTcIR`lzdcE_@DV7lU28SIlKNNe~?DZ^cnT8+nGVOJDY1OpI{V}o z@R+LzZ8Z3kF#Ue3>O5eVpTe8$$@fX@ozlWIXIJ;*yVwhestOsjkucgfF(9F5)nr`l z8xe|aIkoAQ;7Jb~ITA7>U@bv*OVSNmHTb;*u}`G4-c~M!XDzb{Q^C}VM%aMGbQNeW z5WR3_!#!dLX8%JaR62o2XllRf=m7n%sh@Rp^3{NSdU@ryT{N+MZlGJj)g6T48cmDd zrT>M>mM3P|rEFaKR<-I=)_bH<8-EOHk69oFKB=@&wQj)D(guC^4DBe1>N)CSWjeGAhuldNMhT>9 zj%qKc1SX6}_c1 z0R^F}SRt!QXqt|XRs041b+F9aW>CY*R7>~rv6?(mP`FYrNIPLxH?ifZM$(S*R1KsK z?%hthwE_EeNzMftBcQk0%?GD4H^a)8U%@&1`;+SRqkT=j=tEoVWbXGP8~WqBCh8HJ z04|^RgOt0!PP|j@&b2QL`Nv2motuyrkcbdRo>`N;Igf4RdxcWHjFiCJKSuW?jrwbR z{1hSOIQMKO;6>ez+N8=fKk|->ou%7z?L+$JAkUFFD<<1#rTID2=K3l6l}dkd+~9^X z6IVpDrxy3*FAUs`B6Anrn|{_BRzZqlsxg&8tPlq{scS6RyGR+n<33rU*$D@Kz^ean z#ATILOgWs|l^s)*9h|D?aP=%a9qGX7-LI3Vy zl+x-5q~yndFmnM*AAlMlOWphbKJxPQPz#?2lR{APngT~SGzPQ_W`|49Puxx_3;Vmz z5bBdR@m-V{tnM8m0F`4BKFL3PGjQM?Nk|QD&FOY(v3Yg^s@-jV5>HeiiQR^6 zW-Ce?lmA3D+wXtG#-P(Z))4;UPtm`swg0&J|L6SrL;wH5N5=LJrvCwry4CdMHy9B7 zX3FZ{lPxS;y5%kgMWTX(DS$#*WRp<$p`VV|bFKhV%W-H6ow_ZvvQ9zv<>Ii(qIVLH2!)V?c#r5WUjtfV9*7 z(g@;>hBSVXoFt`5F-}C@|I~1mJv(^Cb!RB3RoP-Un?3Qjw-uhLnngPR3)XnFi4)`tP=RpGG9ZAwle2 zt8@z;^gJGRz(Mn!6^Ikk_T?w5_!n|9u;EOQ|}ms?emg5*yTh405xz;};Va ziOWr~o)zJ}@H!p+aVE`plmfP1oMO=Ixm&RFl3TzIQ`L9^2YjuN6~hfd&TRQqh81|;-Q495-vKw#DNF-4}k z(tZc*T5U_ExyJkT-tgFUFS#xM=8g(MjhTN}!&l}oL+&7eZQ@etv+8Y~-7 z)56w}i2Ga+dCvL@E)8ml=&a}lbps-~;-h5&V21fXV#W**uaYg|>Y* z-pLo5Yu%Vjo`XY;=~GmJ}mu~ld_yG`0&Ua5dD z|4x-WeBv*^rN>qOpN%=Y%Z6}hC?Ei)9|*v2P$PhgE1iwKvGvf_Mk3CN2kEXCA7~hR z52JzyAIFF<*bX;NI8P)*&~IoH=^V1}%bOY?QWb!+3nBKR1d#|^!1cczub;5y<=*GK zR<|4d9*?hs-fxZXTf2{ki73BU{@xz%)^5M&=T>>YuYRKi^mOubUme8Nct_ zLBG$@4a>K>Gx-g_?-TgCGX0j%*R!V!cKlNKjdvTLzn_nTH+i3B`yO{)Z)agI_P#!? zhppc`_&c_Ko^RiSojdpTzsu}hefV+aY=1vLA3d<&d^>yo+^4nHwdDDJ=HYMG`L4Xd zf4*NY9o=l{_pER8|GaYg`95sJ^Zty>9We+L!j^ME&{;bz5IlR%W_lR9V?=7;=7nIqJD*o1cige;azfe9HfPE&Q(f@c4b= zo%_Nsue$Ly!AF0=;<_Y`fQegCa*X1 z(UE+l0B5b4>eZ`it%!Qm<*d!iez4x8@8#=N&F}G8o44_KT4MEVnOcT1O+%CJURx{1VzSUIcytMq;(SE|?Cd+UwwNiVf z#v*h)?ZeaRa@Mz3=jH)e(-Hn|H#Y0J`!vm{DOH(qa-#lnf+=poK)BV5Q}#ebc1v%G zZE-zL)P9ha@82#3%?87F&$OvJ%#quw)0abLr?NWw7oYtHL-oO?o%W8((efL_Aj`F> zy6rF*tMT4zv)4x4({|01mW|8bnZcE`-B?-mYtd%dKg#lhd^6JBE50&+(Y=^&CKQ&R zpKLNtY%ww&@mgHZym~FE(p<}{cZZD@b^my+d_CV!E$Lb3rY$|(kC|z=wr*Uwcy)Je z(xrXb^ILsfGd6VyX7^V3BaPw^M$?dr}wf?++%FDI& z`>OAr{%t!?X5xYQYG?Nrm|V-cIhr>%oBy!R3|ryT>+WpSZEUhFbl7ek+Nv(q%ZBVI z%UqeHu^2DZZZOD3OzRE(jf?u(CA9pP4JAI=pmEV{k=B3)9&&t*b6r|;{oIpz+-!O7 znk$x9d?r%qv$NdOwu;Ut$=i#3n^@OOce#e5FVLog6W44tcDZow`nh_#zZ{&t3}oUSi)d zEV|OB%CAA%T&cMkHLV;LF|4C{ajlK2S$*%&&-5&hY_W{=wMgBDf<0=XU%E{4y?VP@ zwWrGUq!?+rc=oX{&1tzJ?e@f3RMlp5ogf&SP7*R{Vy)3^mzQOIkN;TKzizFqv>(}r z$KTdH`KnYc`Z7lLW6xVZB4M-9RbMe)vyZ)5uASIaeQ2xoVW$#0X_uXCxH|V(ADObO z<8Izjl1b&9xw||t_jB+|*;CrfMZTK|xXy~=Md-fvv^yE@qsL)R>Or9_&o#uij{91=8$vZo$Xa}7(-1LbiGqvwd zuF2zniK-`q)8l)`)wWhrc^hGy)R}3fSSn|F7d*}FbctvC=Zd#%Dw%SlK<&FS->+uH zmG0u_IM&fa9r6(NvRsGn>-*%?v#xveaP9E}PV#=VWpB^l^|sJ?b~V$>%len?z*1K( z>)Tkte%-Fcpq0|Or3Lxn*wW>m%=CNvLwl|4T6;@E|8R(UHtYNGY%VVc|7FiTZCc1) zF?rdY+PtJ>-&F@$IBZ>hdiiq1@|G&w%3Kh-apsC;4aS9Ee>rbz6>W!jU$SPn;`vs{ zT{$}DT)SFXiwk|J+;!Czp8q=Na)t7zwC7ansb5EyuY}p->ypjnXq2~wGHrU=D$*r8 zvJyY|(Khd2GsSs@{kbn~RCCK~&5~+J%ZJfjS|ggxiQ+h;zB7+1G5FScO5m|yx6i}% z;D`CiY&Deo%f-dYM(0e6ltR0Z4R}3Wqk#>OD>7_N13VR zh?DlCcvNK->g|i*53Q1E=aH=25A<#{eYw{cdqB1I7BtCJ~2QAoipY~{o9$T z{qYf4(Tj@n4Qhu$4V!zXlX1{0 z%eqidB1L;y-pClJZZoB{)$ps;RBP=^^s1k;1iZCC>$-BqJhvcTG6c91Fz$f-)~i;> z_!#xA=ZE6j zXi&j+Xho@##U7tM=9+x}ld$HXs6EnibikWIjz#3W2KL~i*wqe0f z!q746SS2@2aK;sCi`gzq`ynx0FVG~_bbZRL&)aO>&@#7}_sXg>qkgBk+V2ai*_XAB zRVpTbahNZXNAxmt-kqv_W~b$_Bjpy@mr|_!a7edQFZ{Te+ zSu;NQ5ZAOUY*I}AgIk8!*78=>j^bOfGEgPKl1J)u7w&2`1q3*TLU3e^1V9g}2}C>bPbF zn*K5l+JGR~kgr;7i4m0Z*MREupnkWTm5`AoZ)HhsTLq76x4Yn=DFI2g(3ioMd3`bW zaQ*&-Ctr3{+J)_OW^@lzTF`mQN^Gb`d0y96)twUn)+!j(Ub%-XOj>QMK^WWH#}Iz& z8Re@2i}S5iBXETJ6pGpNQCEm+$=V=zaYt%5tbhcBID6Qar|8~DJ~T(69wLZ5wFT#Dxx|H2>JX=E3s56+pSeG3h31Z)zl zW+9)Kd$hL0_cOR*a~2dDg#n8p#HsR1X$@Nvf)zA;-hUxF(`PPZh%(g_02SJ371lm) zr9)$1HSmTvIli1z`UDb54LD|eb9Vx!qt61mX=Ftm!&+SS(7=F(AoEmgw^>%>DY2!b zvcG2@=Qql?kaODet|rt3Kq$7(>oOFLZ|NGIV80nH95*XS;#|XCZ+ir1xv~R$MJlmU z>CvPNZ{PIwpzF$#$UDE%^JVM7-R)J!+dh;s$CC8&bY?BPXeGz#xpMlaWZBJwgaq6`*is5a2IkVVwD9zqfmyQVH}gFTP2hs)ZcZsarWc-sjpq8pPMxihk+Q z)JJa*MHIT}*M6PwOQAL1DHljE>lE&{=*Eibl@hH)7eSqp59o+%xP$U8(ImNy*BZzb zln|GI6CG8rlf%f2?Px>us+{lvg7uksu)4c^1Qjg7K=tz}o8U$C)NQMjAwY(<`Ds}U zQ0XQ4*d4L8Vh@F$%#E*y1?h87?h>(Vg!C||eY{V@!t;g=$Ps-Z2A2Z&60`SWO9d;T zF6hVRKPYb3b|g`4fgt!=k*WqgTyR``5HUb1=hSmYlNTn}wOiijwO_3ycFKNij?E)% z%Xww8N+VwvQk|dI)BRJOAFY1ZW$5+W&JSkQy!Uen`chQm&%O24D7{aP=yD%*G%Jkg zg29y;Z6*ZtophQ`;8GcA4s)t2%XfLE6{kqy&lS_FoD69Xj0ys}Jvl z@8_es8xlYb@a?mNcgG6@p_*KP4Mz5W{xlaS74mxXx=S|V6zU96DW@# zoqpUXDK9E&6@Xmg;;+WRD|AFl1*`IgQADNO<~veFC0WB1Db^Uax~0>-Ki68tFHahu zPYp#{HMw1uJ?jci)ZUEp!)^SXX{O!=!P3W zfLpQjvkn8Cf_4KDXr5)mV9{UUBPXIL~WP zTmNi2!NyJstQ34beZq}A2JjS*tephgA!+~>c|CjpU=Kxl=Meh+1+3EU196(Zqi^vp z0{$t(uO+BrNrTAaE4Cw9Q1LYn=MyELrkoYyH)_LT)IeJ$3lv*5c_*b6Y+His8dFvoUp~RiXJOnJ1N9x{TY^04*N!l#jIzt9 zXOG+k|B*0dj2x*kfYHbhc32l7m9lv0)x+07;lqtoD-QOLY1FBr?XhRYlT&tQ9p1)? zb~{yNWyTm{+XCTvk}Zn0>_8I+IuYWT;kHfR?J5f*9oDK#Eqv@fu!K6)0ov-JTb!tXwm4mDpDWCl(r!(Rja65I6 zFck>#*S0UMSx(xN+#g9LOi4g9-(m>0{>(QeHncDl6GtZ+7N{#bo{^e#ULd3_;&Csq zOZf+-&wkBPFU{dD-#{aIWO}rkE-bZ?ipt~$JAyyw#+gAy{(DGApN#vH-e z0m%s9U?|W$6a-=ApTm#jZI8YTis5soj`FuYi^03hy4s-c0e+Ka*Y!j1r7 zi&t z#f5!Qt~yWsB}$%3jI4ta+qYv{JoC`1)(rNQ>ezSD^R*$xzq}P#0e#g+S!-95a|;Hf zV}ZyPTT}O^A1!?#Y|n!0*V^=nh0P7@ZQtbhZh%c8cv?viAt1G-=X4?fHPB_@*r!v& zn$gf1M+XE~__`tdb?B)icr7v+X%^|Vg!2Ib7tt&-Z5VQ}2xm$6kQuw>`k>^Pm@Rz- zzi4y>6H)JnglX=UK;gcWF*OM79Lx}Em>%FJG|@<+BO_NXE?aaUO63yqoHeD%qWukg z_RA41vFKd1)4- zN$^jiy?I(k??QqSHHBq2{X&sVwIDnKpjblHeM2WUF~FCiBi(%~{sN*iA82P1^vUby z>J3Oj!JjG!SBidTgW+LHJaf{I_1A!|CES$?=MYJ#B1oBr?KJ9LQ{{_rbV%-I8}k2@ zWP7d}HOQS%grddL2x%yBR5yU{0mYPD%UO(u6`70I=Z6eeZnTT-uS1q&1du4y)SoK7 z^`8x&+)x}5x^<5N*NRfCt)7TC!>y3Q(JZ7_Xp3~7ga9MsZiuptAbT1VlS!AV4@0rd zvl=QP>+EkrLK_I_#2+tp;gy-03Y76Icp|X| z#pTb_izJ((i9vvVYWoH;0mjbo8xGUJVZpzHCzrE3j*E8`q`(~0iN%SSh@Pvdo^aKK zIw-j=QQNr*b1Q=Y41kFDY`G%a!P8E^^(1Cmx%i8DSp~yxBiEPwA4#%_mzQeKK+{6~ zG1ZTjDB_9OmS8#LjSf|(qHH+2uuS?r0c!GK8SOI>!Ud_vtK7A}Rwz&;G2#Vek#7Ic zXF^qh!b|}cqan6jYsG{upky3K9K?l?b`%0v8A3!(8^&SC{}_Lc%mJ=(QjefC)luwa zo(3XFRXKaO4sh@S#$?Xu5XyRx+m*WzKoO5|t3QONl#l zGxPeuFe>z-l81K^(O+3;);U8W;AHX#0tCuhA#_k;qZkO2s&DRgE#NGpFRvC&fkh3=ux(((B{k=PzFr1#)O}TVsSuRJ6G^0lI6umJN5vN zWM7Yylkv4AV@=F+0?d||lq!0Yg2?X)q_$w3Z7sKQf)6qpKnWt?GDXT*DSS9ZJ6~3!Clwyxk#zsXxFR6Q;CCBu^?K0xv4yd-HXi~Z_1MPeZs{=LiWkz!RR zRbc5yg5$VWXXxtOl_MU$0Ch>y!^$+fQX2^#~XkW zEH1pGjMOxi0Jrd4lndY}<^L+Qs^R53Ef}s}ex|L3ph!OT96|`EuBOmz}uR5SA zjK~rHunVnPrYto4DGLAi_FLRwEB%JM{=(^ml930ru@jN*!=}NOhx@A$HrQ$@;XZ`# z3Cu$li3Qf00p~}bE)P-*)@2TlMPp!;)~U9Qc`sla0=KpV>tUkJ(I`H+6k+97CDC#&v??Q?TI>#BG_0RYVbiql?SLjkiQ?&C;%a=wVJ zxOK(qSBeF@WT@@aT0-r3m1wDZbgz!XtU}*h{nFZ6Zu%Pr(?}A1RPdp z*H!rJ1~Gs^`y&SJt}95hNM;E#A(RnN?YU2=8_kXchU8aO3W4I#na zjThIeaKH<1U^x!qpRe|(zEYT5XBEEhSjS^@!Hns8BuDxB(rg8TkGk|d1*{+W?!^2f zsW)daZ)=-UcC1Q%>O)8zT@WE`U=*Y920H!rLfX-bT|G{yeoOc&g3DNX&HbBQ4~Pvt zF28^rsI}BZbqUcx8xLLvJvyP&B7pI64Jm<(>Fq`3_Q7Ubj|7YguMEmfI$I#{81Uzv zK{a1ljoK$3eedct%%ZFZprEG*tF=X}S%I3Hv84)8TE%lQgNWufO-Vj?WtF3wD26Y5 zUA6`GXp=(rQD*Sytf9H{KvcdCfAXgs@($1jWzz!i-P6DC534gzbYl1s&dbZM2TH-%P;KX|=?6eJ$BE|}1z0I-wNWwj!0~h)kH!z* zGC&2dFwh=S=ah&spPi~-a7^6>pGbg!80f6TETDT98(`xKQRglkzs)M()>$4wuc@qq zq!$877{><6cqL}zb+v5y5H^LH8D<3VH9x^09UXgf3S17SfJr2T(rxF5SW_Z`;)Ju0buG4q}~8{E9si<11Q&jYY>nlLticjtfE-(_f!HrgqqWFbfAkV*$Nq`%sbKcm*_?RBH027{zcaUe+s z3uB>uR8JL>0JjJp30bnoe=k@-mT9d0dJ~Cn%KLP;k!>X`WZtQWpLv?m%52jz>)SwE!3ErJ?Xp!0Qb92*+&Zw<0c?kQ!Tl)vijDsh}Xy zhf%YZKSV9Q4S`|W1nYqUQ9N-JypVswNf^OYpsI*UM21@V!hIkfgQn7Fm1-F%^UJAH z90rew-e89B9xn(HjabTd^~y31B5hU!xMX`Xkzk>OldM*h&CF?_xUq8$qAL~k3n_c; z6JnbL3hT#a;K&}BM=GTbV5ch0f>zkb2vA;7CNedAjPZ9fE5qed(@pk+9XYoWemBS= z7?Z&~fsgtad#?4Uo-%pZ2.fcq-^T~N@xlE$A55V1V9UG!01WA*m}r>#v;glP#k z*RB|KfqM50R=x)h8Jgn;F~_Vw;tLQMmW?)t13*W0Y+H}3PKzLRN4YQHE?Yne6CH(- zG+<;K5SFUkPT6-~bVPEiR6nPvpbv5N{Buf>8RN;Lfol_hb{dvaVQZTXGeTG^bFUWf zg3l;~|3%D-1nb`XrtJg)2sE#12vG~kc;9xQOVse97AYwJ+;$j~)eYvfxG zS1!F+T85){_eC2&uFZ;vFgNBSHlV_-Wp=8?geOSV9Fe)$Yn+UuW z;L?HvZ`*$gPl1$N6RxC%MU_h-rXzu`@PmA@0Rry9wE^xsOfoP5@}R7qd?rPt3OcNT zR{s8L|7>5%ASFaJB1VN=atP8XXR>w`xj3kQDyYvUvQuX+ zt5&|!f*?xQJNSL_eB9mE8ZibGoI*SigN^VQJq&=<ovM1z^LTrt@%o2#_BX6wlVGaZ6!I3E2``5@GnTP;o5+Kf)!Z1VQ~U zP+Y|tQd(33`+Sf;r#^oyV5hD_8O&q;t{l61tL8GxIBx;L%J23kYsFOX4K`wah}-m6 z^wFQ9HDQ2gQ@{wa5989Q&cQXfKrojQ_`d4o)d@WOHg?+iuemg4>iKS=NadTjjgwqX z6$BG&07v!9{i}A{KI`%>bY?ih#ya}{daVMQs7H}OCw z5u5X4_KjyLwp%5_IACsSj>&*^G6=gf5EzRJCCe^OUB|%Y36OmW$d>UF>7k3=w^Sop zFwd;yWW4T!%{-?w=9ilOtW9zN0m=G}XSX<)--O=`Z>HB!0XQ2JhdcXC_?H!me<@ks zK8d~`1Np*W7c4S>RXrTIKzxvd7JC;DSq$q-FHpo)7*A3ro|$xUJLj;&Paz8V1B(Y`6+kl`+DF>^MLLo+ILUl$A8LUcdMJwKy&V!j6JV?WwUqC-zf%4Ec z{S`%;)S1G;=*JR-AW`uUz%)r&1d8`Ms{k>dmLO}aifU}Vw#*k1H){OcQx-oR{|IHafrC4-^EQmB>GaR0FteN zYL5~b1`q{jTMTn`M8axtP)^TY#bk0GJg+j3npi?m$y4Z~*Htqvh$^sPPTvc$iH)xI)Mi4Ktua1YuLyQ4p~ z1T|<*F#&xr`FXprL$y1e?E-w_p^vF<7t+kqf^K36BPec-PnqKYg@`y?6aCJ1Js}24 zd0i{ThH|GIDqaN&0aO?yxfUnD^dM}Vwcd7gV z;>t?rv1TqYzC{tM<%_%FnhHIGxsl|u1v0-aAbH&MfRXI5;PVL)dJaH+b?^h_mx~Q@ z#^ZNc1$7_rG6xV^8NGy8Ej1D$w|#ZjW1vA5B}0O@V8v1dYuABk*mrL>q>pdTS(6DP z;N!@o9FPTQWgk3*O|&Y6`-|~}gsRXMDnU&XmtlsC5JxvcOzMpQ14t-^KQH*zXqGDk zv+MZm&2kvB8}1({Febql0t?Uc1U9byShJGIX0Uy?+IPHs{OrHZ0=#hBf9OyP7TV2I zLkCwk@Qvn=X^*kyjU`iNQ5G9>b$(hzs-};LEM4B z4p%Ov9VGxn2KfTJC-bFdK7mM&5QmJ;mOqx^i^D}xO_X#9+W;sqxfEVTS0a%B z7VvCZ^?jKNj|)hJU;ctHz9axjF@Te?rilGTQx@K|%U;Z}0+M-)t^dWwk5f_H-84+l z(!o0<@?jH{6x?jci-}#_4kM2hNWgLwg{>xnEoFwDPuFa*0~;ivWx}aW!VNmr|VQVTEx>N>+0D&MeSw7L&CSxJM(o4j&wN)sx{WJQZ;qnnY3s z+Wk)h2v#L|larZJwgak_Nbi6=jN#OPS6jWDlv!Z*pDnOA1Dt5;c`nt>-SpcKH5$8X ze{ELm=fy{W2_e~}*tPni_v(F2BZZWtjN?6;Rwz<2ad(_~D^Y@40c|fGIM=DKIF| zzMDT6TgafYOT%!IZ8!Lw3$lF~2rn zmq$@74mnZ=Uo!Pf3*_7er5z1-oplD~C%-i7f>-RKfn|dAXvVec<22ODJ(#i8e}-N@ zN)0nZ4?3N==!$?X|7;@7hW30cF!2~-u=PLR-%a(t9?t`IldYL{Edj}a+2Ri&djtfT zSP&}DfP$4l~Vxj<;1V1$Ll%#gt+4mOKcYHgtYRh@>=%8R^uv%xJTHuhjhyu#& z^MCskKJ5=zp*1Wl5(XlnfP*N__g-I*f)#Jm#G?=J6t^w;WHdCe0TUB739Fu^TbFrP ztTf*$1T?hHHd-=apK1v|WUxq!PR7w1ckBIZ_j?)^@_k$C_}ozM#UsBGHs~JmFC~T9 z-$RG#_olF0R#c&RYx90D)%v(V;}o@f?CC^WA=Z6vp(?v}Z`n*;5E!9_USDZKk|8kZ zV+%6;{T6h;i4on5v!aTAbTkMs)LDk1D6q(ORve8Hajmu)wnJN=lsQpUj?hRXKJ=^d zZpau&BLKJ1Yi}X`gm1^P!2wp|FCcSqP9YyoT{{VzL*#d~(}c`Tn))VHL^UMdkKVS) z+xN`2#Yyga0VVA%Dw&lSh*s-f@^3q^4>qN;F z&c(*V7}*d1)G8>5+@%ZvfH?+61$kbI9UYZ0S4TgB7#CctEF@s0f`${BY~~eI^&|fk zDTPv%&n3sf)pM^d-5aUQ2sd4$By$%Ga+HI9twYqrtJKR#lT+LL#nwKlpU7##@=GMlAn+RxkHR-mI_ zw>W{g&X06FCP!_xTjgdgkW?6~ay??FI~etUxjyiYX9?36MAe0uVzR zsVL10T_rTe0t6`*g%&xBQ2+&zU13cRYhDct`Kp{u3xa#cCBK(Sjn`E~(5D6ZV7bV8 zxuceZx<0;{Hl)I(i4Kxr1Nto-i=&Z+{kBj9lqWcVD5B+SL`_F$wY@ojbRi^^xM~vY zHvI|sBVsH6`;(LO9WwciOuoM=BG+08PMl({m1x09h5E)tbq+gj8j7@MF9OCDbw>M( z4F(8H{&5T(&mornvk_QU@U3Wg&Mj14Wbcu+a7&iI(eK$1lH&^o$8g}a$qtvy|~p&L{Ph%p?D9i5hD&~ot@nZU|kfJ z3IpE8Km0d4e!vJU#d{GXLk;QbjJVzE}8I!y!bVXve zuzy4~#;d`^)2}%f6ax13JSCZ&6d3hyVXFZZ*Ed@%>;`^J!LSg*-&abMxE07tj_ z{Ho2cOA5%5gGjLuw)iBwj4XS z;PFq)5%#1&*r;%)NG5^_##I-FH@cD9@p)&ihpN{A6Sif+M$=X*J|-lmHlopkR%wUd z_D&-000cMa@4^dmW8YCVfU&rF`T7O}`*vJ(G$1~Ak_Q$hA+5^o>IYFG|k;3FhXxv-Jro*rwhp1soKne#=py z7@);xFaf3oE|jzMlSAA0+~nNHj{Z6-MuLZ5Dj1Pqt0?Bi0ElXsFnz2am8U+<3b6@A z3y+<>2~2Alz_9MaY8+dY zTShfRCNi7~%{30xG>H>Y)PqBJV%P*CkATvG!?$VzT#L-_$|Anmi!#(OH~@Y-

R+3bEjVWs&%fKY&bAr4 zKhk4xXXu@SCDHAI3Bf7N({Efl{Z(i(%HWHxU;msGYKf1iv3NfxcC1I&MggXe5nY89 zrE$`468f%;*7awq$8>XkGmOT};$L}WRV+15i-cJ+6ahG=e?>Va4v@3J;ed_>&LnTE zTl!B&2mjRPlw+{mi?J=j_4Q5M&obr zly>c(N$L{Um=KD*sHN+&PB`Idbv&7V9t^_3)y? zS>#5q;3BRQEYWymO~XJBjYNnDcLU>*glMYpM2i%?8+SwB`hbMIzprn?unSW4jWo>v zVP)Oa1*{4SBlnVhB$^dLcA^4#r}7uR%=q&lT3uUx?yyF>b|_yjYP^-#sc1T*0)YTW zWtJBi*iW8>4O6o^Xl4!xQ>>NgOp0L*YAQ++O{d7y$kw{q@Dx<>sW914uPV)q>{OT3 zZ8+^eLjXTI*3o8HFfJ;NpA0OX8rE}K={I@gF{(|%C2I!YNz4#pEJtDe)cht^kx~Sq z`OhNs(fo<9f}SLeBmO3dXcmS+=1HNxJ!M4`y7MrnczkA7W@uHC$2B(|WU^q%r9ag@ z__UvsfC$Kmw02oN5R^>^k0SiVebcrm8z~G>uDxHtJn8P({jD~lnZdnAFxtF4o8a-Y z&%YzW=N+PhJa-iY!Hsqb7A3M?a;+@j8hHueZ<1rhCgH^e5k7oOFb63prYKifI!i|` zaZ8y<;c&<$sroZMxfqw@iVG;t=zMhM(Ldc_fuq;K3S`0dCi*6Y;O1qD94gHSy=~R3 z_V|IVaX0#5S`;aiI2tx54^!Rfs|?rrRVmQ}Prdj?!5p0U*Mgub2`@qAWYAK}Rc2kF zWd9Iy4MF05v3!03NmP+1sMbC{CUqqpOp5+TO;miD<;O&dxiQ?-{0k0JU(+Vioq9a@ zB83KChK#<-uy?2yVUNU(6<9RNxd;H<&A$|JIh5CM)rN)PBGkFRv?%)fMy)Dty#@el z;@UnE{|%zZNmO%8ICVyoI;<7KH6XA+DolTlkxo;*86_7QN^P=U1H^xsn7oe6_7P?#s6NRq|p%g z`YZhN_2-=9&c#O^D3B_;1haYr*o#D3CeXn3*R@+kn^QUbUjT7Hj=$pbovZT)p-|DX z85dADu$&}bV_+KFBV$^YHq)=EM00b$`cPSs>+n^sauzU8NWSVCsBn*$I5F=Wg&`_V z;!>_>7z*&R*)7MPLnDEdU4|%t;?ch0L*2~7qB7Uo9azclghG)>BK%`aC_Ykn zfb^K-0VRW%Q;nHOGaZLTmWySS*ovGlIAU+>tX$3yD}6~Bj#r90FLz8DNeOL?K(IKv z=^D@gxpS?L9s^iy4zNMY0UOPfWC{^k2+)B|7zh3KW&EC6D0|NMl{2(uN>!LIs1!uS*q^=(B{*Md z0#l;|SMNJ}is&mXls$dCL@RSwop}%=?}}v7742_DRK+O~DlHt`1G-6jWHy1j$$_Q0eKj+c80RMjagL7B zeJXIhpqrVT7xO3zF<28k(Gme2d|=azz$fB%qca8+u23k%-tlOTa?L$ChJrJ4#E%)} z&L}F3kbvmAJ@#8H&0`t2Lsu9Z>P}+8gA*fms~$n2zP>g$!;Q$x(?=o7aFS*%hS-*g zhp6$849IHWOa(=?&#+Cci>z1hM(>}o`zz4fk$Q>-y*X7b)90I>>&0@F)cKx&4HIu0 z`PU#&C=22=WuMu6l^{SccgWl38A89eO}ZC_0yDW?B8eY;?3KhXOVA-djic2ZGxga1 zkgnzeqW}=p+~BBKHi4-wQK&!yPq>VEI&H0B;{vpphpSC8pkl}h(8Q9+kBhY_Jb}0d z-J_+AJO_<$TQfw*N*!65TX|hADe5PW+a9^EJBnDS99MTxB^Vs~lk4*&Q9^L^F`vJ9 z`>LQ&EZY?ukf!H&9A-L>5a0_yiR3~F-X|2ne4{9ea}gDSnBphWIz&593zH%$-q7y3 z5-FbK_bts06pCSoaRR17xyLWpRb-zsc*C@^v518$nWK461b{8X;!8hB(x)Of^(9hY z!VHR~<8hbPMOLExiWNA(nywPAWj;Dv6iXA6Ap{bN0hiAJT;*>=&uoZtfwS#=_fp){ zLP-v+-2T4B}P(++)G3m|)#OT_-VLcv3p=!+)tkwvg zVLZvk>?IRKh%KTlCO^3o*bpSUlQ>o|ev&lV(uLrQo8hIpBHFztIJ>x;s^!p5Dam+? z6vqBjl(0FWj}h~kyXCfKeT~b9T*IUsC)}d_afp}*x?SS-dVJNX{n3#;9yVl(QGL-A z`r#mc-8?dtc0%lpcdNR99GCRc&Zdu5a4JvaNzR(rFqDNm4xWIa_(yw9QJkEmR%kI2 zFwH+qj9fdiLRl$S>WKeVkC!}_2o9ABuX^P49P-=#78nYN?#eaEJGbQ`gGH9yltLh? zlu`Y}gx|Gyx<}(WkO0wukUvCqM77)1ELq&^j^igZbG*vM+ZdWcbwz;Ku&T68JW`~D*#XBzA^|nh>EAI za`VVaeis-DP+=r!Yz{y59%PsE$Vk;xI!uP?`lv% zct2iTSOeFn<-$nxKy)C?6DaZ{GuNQxPA0uKyEtH%>y(YOaET#h@He5>GyZmD4Zjx* zMdoYzjXEAq()kMS2WkX75N$0M-uZU8luRrsDf!g}gf#IAru=ca!)2BMLp|l1(A<@@ zvD5PHO9haw4_YE{fIXGzKq#mWH6Vv6ubm{-{}>o^?106OJSFaZs5`YLidKY`(Y?VD&7mZl@WY+i(4b2ccMXH7Lbdn&0a}P zAz%X`xP>#fkj7_&lL_BXU}Jp zDRy#Ak~mb1Jqi1gdym@vFcixw(V6kN@%r2^2&#{zAjpo@G|&gDCC%shelr@3U~@+i zp&k1%DqiB@D;P@k^pqn7YJqu4aJZqf2uUks4q%yC${BWrV`27ne& z&brNx&Pqd{SR1bgnZr$QG_wkn++{-;6jm*_Jya$gH1~AUjJ7Uo0A24BjFrhcP*GwT z^^BHKrHPLf`{Wv{O%vIN98OBu}}a{R{~S2=^_`4H$}Ukad%h1NQ^Di&G`TI~m){t|8YU zDb&nfDI49p7C$VWedyMYbGV?h>0&K_M#b?;5yV|#&^d1~-6HISL(!K&pfX+rzU2s| z);4Qk65D>!I+25#jI)2#66QAVOxKUVPzL0qc()`_E@*~zh^_=P1mw=$D7gVev^UtF z>jI5ISMj2PovHrZkah(_K^2c9l;FvoEr^yZ_l9{JFesgcx&%UJT|&%I(9B^*WdhKu ztwLC!5AhmJYk$LfJPbpb>H<qIm!#~@fy@xciU~4G$)|ph4c&`DfW*L%f`R@*GUS>U zO!tu}^3~fi9)qD&j0T#$3z{)Gg)W%B(wL-KqbNJ1CQzgzA-Brs_7Kjte0ZF4N)(qm zUO7X_=*P2<080%h$SYT38%JeJXR>FY(jt-W38j+1y@V;#`fF?mi{u>~r>lIV@_Se3 z4}zg`7q)3nurAUu<#`1|REcMr5iAVEv!61u9KjjuFPMdRn5j6V0jTc^h61QRWhHq4 zX1dq_U^JElkhD*TH|K!2xzJ~fLhNBw$9h$~AnpJ?2#6?A{PErU?2l1Hi3K#6`9%00 z?GAJaVCO(`UDg^3mVgwD2OBr)d7~c!tx#vv0Mjp%e(KBP-Iq)8Y8RK7F`=o-XgPCo z#Jd}zMQQ?0#D*6rnB@L!eUCd8+-syvF$l>_fbjUpGJZ!G%Aj+66Kp@3n81T7z~;10 z+{y@bkZSy(T+;4C1m(7Eoiuke5z?GP;#K)1lOC6#xH#Iy9kDdX$XIp0WE=_*-y%?y z@inG%s1q@fBVsNcKta&03V?cp)Ky1T^83u>$&maoLSpJ#J8Md~OCGjpvvJUq_Dxs{y9vq_y$3d|KUPml|& z7=IXy!EV%7qOy2h9FL89zAJO`ryB61>g#f1(pCJuObdsG2w2sp$Rjd@FP8BMGkLa4?QCC zKX8MJ36Nl|=3XJPNIcCm2F*7Cs3Rm`U&f;?Np65PkCu&RtcSgedlLytuFzrB;uDUg zTgGoIRLaIsF7`18=(+{mA%bVFJ1Mq#GVv?J=qDY(waIMTc_yxSUaGarsA{ic>p`SkA-yo%G3f*X+K`qBg zY>%Xf=t6bX-v9m&P^Dz{YPT5g!n;VEbig>8c2EusW0JH&dP71mXtoU&E?XJbl>4*C z7{^?3K2eG2Qx%X?;E9olGf?}U{P|GFh`a1)Q1=0tL3UMu zV9BVctc|HRrFE3ttD_ki#Rc@`^=P zCaB+ew|KyTml4#Vxg2hA+GrB!I0^tX;Ef+h zB{H{iwz%RPKfb0BR@E6FG)?$vrgft&Q^tfO}ZJLKmo9AztguaFVRPifPycX)IJW+!N;y zqOrP9`4M^r1`u&pWWiVkiI_ZmWm;h;RyB#ma=aR znW|i^n&_$rZ+^TupA61JS6223I@P875rMocItV6tYFJNtcheKDB@!EuZ`?}^S>2j8 zcITyNgP{n`lA@?kP?;o*A0|ikuuOoi+ck45CZd0g{H9$hc zNt>(QVD*(D74dsg<|>IXI+OGy0h}-=#y3iDs!8)S`*m6nk^%4AxZWmz{M(!5pEXPT z*_Y9m!7-NjtM$YBZneH!-mFVw_-{Yy{`UTH{x$l8uk*)0*N@fnzCK+)+|Hjj*65et ze*5Lu>gf2@UzbmOw8?%vzvpzf26uRuF-J9oRm(t}RV+#>`#5HeNdQTTy}DSSpKH{Z zMyad{KCc$$tXhmlUwt!NJOM)mrgHEjaJe4CSd~S9``EnF--=KwWLkl3ZU<%d9s%0v z{6M;e6hoJCk@-SzUwuLpIzQA4Vhj{l8#LFimQB&>9LFRVKeNa*NWq|J0%|6!-ak5C z;qRg!izn}}RsHQq5ByA131Zg9tJ+Xf^_V(C&If1|fuCj_hryTOPYrQk8pqHJuAm90V0Q?x3yg&-P}Ar_ccs`vZOweg z+=a&%;Syjtc|X<*3D+urdl<)p2WZ@7)a&L(DPFK@@Yz?!&mhRe@uFZji;c+vM#dNz zQwb*Cm>Zj-lkj3omIxI#{K%Erdy`RujyOmK;8uStbX9WZ7R-$@z=^&Rx52zd=}8EH z*#(HPOR5n%I}tV?KR$k!t%jqV@DYm`n`8ZeAp~d+4;JB02^WqQp`F)E)r(!uG+*!# zeyx#?(M2s0bejU%wA0?ts&boMIJ_@^>NaC9F~O>$LKj;%PgXq`>RdN&B*a+NCqckPGV&|< z2=VMioM40)+Zce^hiA9JhJN0@O=kas-}ph2b-K#w>NIs)TX*e+2Hk&$Xsg$}%&|CJ z0=NkVRMh-YVhdBOgs{@qXqw0SyPM|^fi#y1)A@wkce~JMtNHW#<@x5ex?q3!6aVUa z?~=WVF{C#g@JDbf@H?4Cc7~jhL~3<57Bc0VP8GvVt!M5-g2;b0ns>|>_wL)OoK-8) zxM4LfM71I3z(sTHCY&6UELnv0zow`%tL*W`tnnSb=& zuuL8`6{w@iFgQgxdmuhk14@6wkA(d!P+oQ5&c+2YkMqg?Wj0~hG+a5mKRkQK4Xc^L z>KDVaJ!Pr{d%5!}I;` zkMoB=UTd3noXS^iT6`M1}1K0`rq~J zr{Dd4w*KpReU}gZRR4>&f8XZi_V)Vy$^QG7UtLXu-qk4hQ|xUFzCh#30vHMA11lwC zWGS&aE__q>Ihk`RR>%M%01^*uJzkK+NOs2er9ay)cjK!8c*ejC!NU1H^WA5L8LShg z>T-aT0~hoQZVc)3OqAdt1Pnp=4YSKcR3$~_(cSyq;2bs^fFClhg#Wt3oZheu9t4YU zZ*#AO@PK26LS~w6DPSMmlVX<|_1LZzc(Zw#FK$Xz$$p1j<5IUBM@><5>|XNYNnI81 zs3s#7{U#S6h(PkzX19UlEDwa=HR;8!cL|B#BqdtF5VH;23Gys9C%%o>9k z;5s$Sh>V{yUwK7$Q?3=VbRVzW5mq#Z*JK)_A1YT|F~SUZPL1^^D5maA_TvcpoM!ZB z=oZcmZgy~vT+G5%yRKCiC*})%HtIL_OtJ&@G4!G80F>VZ1wnyTcd9m%DOzyKH_(P$ z8uTaAS;t^)51H8apcf5ef}J zIpRQv4_cS@QA@J4&<(v_EZVqyd&${(Yhg%vd>B5}DLwUBtc+rQg!WqfvN5AcNO2NX z(b7F~nI)8Xw_w6x0JU3O#d8qZ&dwVx9P*}L1JWWP1p0#f!SZqD2%VV1lENLhE^V(7 z=$?*=(h==j>FAB0(x}>)nTIEegZ{^yHSy541AjYczO93Q6>y={s%b@$K6^;3SLZXm zKH6-|%?Tac**eN8uX8RJRl3g9X+tKva-U#p5F(oX64#j5Y0p@09l`V zReWJ97$Lc^0WYu};4zvuateRfgxGsP-oOQLmUz8ueIMdCzhrEG+4 zm}UU+?HV|+83OVQEEYBZzchluBGdq&YDcuW!C$~CXO(-S`w2hT*)KFj4?;jvH*V`sw18Jbu8kOgI@C)|7E628_oU zW36LFWrFPVO{77LjrgMDXiCNN^4P^dmnwWJP}BP!_Bw>;_zi^RJO_dU`c_<&u19|D z{u9|&-jP!m_)q5t-`|$OcC=|#)Zz!OOY0>~;tsFZL7HG>nw->{P!R2f%G3$x3wuqA z1mSn0s6N?KxuUr9sSXRaQcf2Q$2i`F@W?N;4AnIJ=Ru$VeP7tv_c z$7DO?t{4I_v?!MaxZB4UOe(R$IA$+KaHK8xt6a*JF=P}QWr`Zc2SJ2Jls07!Q`5yD znVz^13DQM&(6Y4pq~i)19awDiNqq3Ys!R&t>x!80!7)~(zs>0HD3cTIWy!GdoWrJ< z^K!E!`lW}lHNW`q1LNLUOWPUz3nnpBZSeKXw&#FFg1K{n)zB>J#!cnSpiX{VP94Lb^lOTxw-j-?4$X1nz zXW}|x8LJtBdvi&g)G7ncZ$G3+vLd+PDegb^MOF;Qc05 zE>}M7fC-e^9re*Tiz~y#3?j;;B-wj?S881PK2}q)XUCSOROf)vv=rZy?GNay4Z4g% z;IJVlvARpJWTxqOb+S|gf1yr0Y*F>)Tg*dQcHp>}7KAWH{bgUxK)(k&1`l)zJ9o8- zR8n74m0+Yu$N}}Kn{W&q?!thrKfg3g-)vy{e?GoxYTS~$tW4j|I-~xD`H3|EnnT?) ztkhGxj#MQmRhYh0S8|$0xs9m)4nCY3ykne*Y4K8lihw*le5_aJ<2f#OP#H7?^)7;} zx(&&TGj#0s0E@|_noS8rk@ADlaOX|@boRAw^T72wytk$f@QSIYqT!_=C6>I(Nmzv| zB)`IY=iW(uOcCJ*h1-uQ_l>BWRI>u{bw42-9lyIag3AF~7*#4dbGghSymJ~xJBi0a zvH^|eTnk1vM94&)1i%q;4_1xrb|;Jvqj!*Q)4war#IH$O5~+|!5LKh!h!X=Gtvm?wX6}GWj8uPkTmUs z7G_V64J6}?7A|-skVgA4shF9lBP=NNT;?hY91cn_+(EW-z8i!GHM$qvrzv}Et_FSl zsbmWbr7pz(hF2zR!NIGd14qTCFau#LVMixZSQqX^ zHxl;?u@nZ4=Ko+JASSR1CD^}0c>WB4&-f<3VG zz;_C!j~t>P8Td^^-Qz32WK)cxzsO;kL;#!}y&x$*M|dPb=P`BtL%)8oid=JMP36_VAz`hF1zw zHL1ZcLdXre45u>L%k9T<$oC?Zl2nxYsy8_dJ?zpepE1Twjf*ime2lGFONhS1lUJ}U z>CjT-D(nftS$z1>}M#; zL4vylQw38`kc>~Ne2M=om**;4hckDFYiS7fk;yeqSnJ) zgb%EUzZ~@@lSr&FRWS0ByaNh0I&iJmdhNLG9AEHG%m^rV%y8tVLN)yq;`faCaoD{w z|E<$%G=M}`1r z*%gcTOni9B03nJa*$x5>9$)2Yc_LEc#(|oG#!WhD6OF>Y7P82^Ms0~_?I2Fvy6qEm z0K*PslflZuH0GdXX)k`0P0b|__l{P_m5YfNe=!OOK%_}q+NUW88>atk3I*ult{BUy zjbph}mNF1*WlgMo-zpE8EH9IMJp?G@4zkjS$fK%|YlIvNg!FEm8}T8AVQS8W9| z#TY$gSz0I96o4~?7>3p`5c84*=|CVjk|%l1$pb02HC&Go`e?r8T=4%9rNHE?b{?^NyzVKc-|9)Z$Lz)Qxd6G)g;5ogJU-*KvT* z%<@SmY!b(#+~eNj-qP7)e8*FhV2;Nk`=rVmRUPiWs-G7GFi&=HyN~ zdjUi)a^E5rl@rB6XMn6Z>U9Ou=n&nyKZnAOw?iV;u%~9ehV(-MA2;fJ)H~ZHREDe zHLDTi{fsbb!+~@<$M2QP^DCMSX9P=y;DT{G3iia3nPrtV0Yz_2M^#w5Hr7HjLJ9yx zfO(xoGdQ(#Rob?SK8cpMaxPmb@Y#=2;gFFm{FGqctTz<|s+XZ*m|;)688C|M@epv= zCCKY&*83fQ+;S6PR$Vy=kcR2vH4l`McvEyl<|J|0P40rDH6j7bKc=ZAYf2Gnsg7TT zDvW|4AV(^=Nx!OC(vi|cV;aI#^zu4s=3Nybhl9Kt&X~=&fhdHgvwsy@13>jVUPU07 zvx#Lk6iknS=STHz=S8Vu_?Yx(Mue<#*eXc1Q{cJ+l?p{+ua8dCe|#Uk$jDJ^>gwl8 zrhB#lKu!R6O`00j^oqL9p`BnCgdK1*DZ1_=d>ysR?tA8;8sd`~y!ua<%k+CgJ-EJT zO<_R)R-K{_qYq1``-+scmnZ2q!9r6=*i#<7>DetV3#6r+`N22Vttx1pL{u&bN8h=p!orrsyiU+ zz6>=rL9OQ4WD{D)2}8n*e>N2!X_YM_n?wgK5_lv&`H*FaVp1?XzH%G!$9A1M==Fp; z2AFQ*m&tiEDoUx$uuVT?gk}VxyN~rakc6>72s3)dBiEPkWdRj zPc=z#dNdtnn4+X0zT<7BUktk!=mz@G{`G0yJ{nTJ4*^j05s69SJ_y)%JLB*mc56yvM2bdZ zOI=(zB`sYd;LS_1f9uAm9<|{Sw!}C*J(?((S88!&>=jX?#9rCo*{nB3^h1x@GBOZ7Bk;j|m z9}T~J_W1O?OY?UB_4b2+ZlBKW-$gXH8dr66(o!pfZ}y zYrzO)EjrX<;bhSi&9U=YUlj%JRYQc#!4E^1xaoO&BF zV3+%4Y&nu*CQouz%fm$(K81_JU>Ssu*;wGIjMF{WG^Sjb{=BwOX4v;YlsH_3#z#ox z;6TPl;Q+J)*EA8ehwq@S4`(1&a5T9nFbYCQp*N{2cX%}G2^8cqdKjOB&Jx2NzdT^0 zV^Mx^nJan)0<2Kqh8NDB_!HmzkGhS+gg$-rQ}y+u4O2RcO@nD>2KUB9vK7<{I=A4- zm+0qlVsS}^?MmwB#KUgQY|HG6kHNP`IjcT?<0j+rL!jpW8$ncD2CI53$)rTw9o7^>0gn&2!R8(7V-4VTLQD74PClIgq9?Qcg@(ai{twsz;El2^zS%jb@h=~fs z<1(a%FB@R_6JfF%ylSc`T#(m4JbZ^uHqrKE@RC3u<$jzcd659!5{c)kfm3?$aHx%t zk_1FRjhevoRO~+CL~+w)M$)#&?w+>KUfW~CFSFz1^+)e9ArqL_ni+<@K}0YU!^M_| zf(CJ^E)sqM=*ws)eoyERnoJCC4M*bMRA=Yk9_6h1_|0}S@%ax8GP!Jm4UI!VsmRJ2 zsieh0nqzlnTe-8b7s)*Q^^tGte(mSt&V*k~P9chC4~(ttJ2iL&O=Uuwz)&N#Xp2cZ zLj6-ICmYDtT57+1YDkFj8vunY{^QekImtLUVAiE>NpiXSfz$(0Dn>Y(NR~I|p@|Sz z{^3n!)$DFF365Lnc;z~1(zgg%(=PjXYu61!2GgIs+k~86DZ#X8F(9Y$PI!tztEY3$ z82`G$yxOkNgeN-VOgGI-k`3U$pjH?ayg$M-=ka5t zzje$wVAalw2hSb?coLAKhhg85{D5)D)so zBDz2x`RlsWDAjgy*z7GV@uYHkxwR|8`1IYQ7m&le&b$CB!+j^(Pf2wS6Z^X z%mySz>AG95w#j3{;@sIWX9ENPqycPzN38ME0mzIy7f65Kk72yznSI6iinXHYNZKV6 z9FUp(eN1CGI}fr1ifL$Jzk%4KlpAcwXT$9M@c12e8SENKObf=MFOla^!{}UXBLJeG zsZ5UBE}Tk}ge(j3bAkGaUPoNTg7WSABJ*n5X6vu>o7=O0ajw+^p{AeVYq$b%fxI{SyC6!lTwT$#MlP*Wlu0|3gUhmZ5Zll9c;Nqe`5fuohaV zxjA!|<@(#QxST9S}RTUlGvBb^ZYDI zBjrJ1=x4j5`}Ox%0)$M?6B$UUlq3=FqQ=2dsXoorDr`fv6P608=5^c2yiH#a-)}*0 zra!xupu^_XJ=na-8}(yII&w}xG(0nxE?iZqPV};%dV=d%92yRX!J0vF941OW7mab- z^}POTxqgT<%0^K&JchI7Y|7#<}wQFp)!j`(7lhv z?|g7j$cl6{>EtM#@cXqJ2F=Xx>+5w^tx@Bi_C5@}B8l;57|I+giek5?e<48fzT#Zt zZ%z`RtIuG^_pJ{M0v)zA^&PDnE6@z!Ia`JH&Ug`**Tt7+(cjI{My_eD$u#L2 zFEf5wOTdh)n)@+(g_bS7ncGThd*tIm{5dD__DB7A3P0UHTK?rd=cSuFpX1*OJHqzX zodPDbT=8A>9~8sX62^cEe*i17vl^8*M6wu>YN?rd%K6sJ(ly*-%-)wdr&`;|P8@8Y z$gCK8ZqQAzf;)|nsjgA#rn${4k_4V4A0}rc>GLM$C6V_D*;82OUE+t$MBk(5 z#tzSzmHn78080(B5iAO{KJO;2Q4nWLT?8(gr`1$QjA=QKfFB;eOG!exGr<$~R;hl_ z^zhO%QYKq5?IanI#X(fXvIAWse%Ba~UVA-gW^R{du)BifJ7D|vSYLoy_Q?G9tlY7G z|Bu_3`HpRS`||K!Bs9E5PtWs*n~&|5M=^dy8%}nS7Q?N^Jk3yI&g9gSgGmFGI@(>qUU@@M zC(o4JgnHG8U;fsIr|;5I68ZZ5I*tOp#GC%&-hm!8cKakJ!*N$KR z$J71Y<9gHZJ5NwdPN5I;=UpR~EXj|*dhhA-O}4zg+ZFZ(?)tqqFT>T%&g=8x_3Y2} zYZ|W?d*kNbx9ClOtWLWMLH7&)>_HywEy z6Dh}coYIy6(Tqh+sQ%PgVgDo-L%O(AE0XPEYDu{q(cB4+tawcua>QzsCy^-{I!US= z6d>@RQXsQkzNm;{KBkI3xEYeJ{x|e6DOn$$zDq5j1Xkb7f>9*wRTnpg*CYeme?o=e z(77VUr20f@_+@zYDOK_N4|Ra; z*ty%^^@*N*q_1DS6%UX1&-XiD^8Cix!|hAsZ!TVT9a)d-|9M$I^?xSqCEUi0gztv&8p1gVEG~8iom{eH^VzT=VIhd%UZV$=^|CSVDWH()GyoBw#x^DUr%p;wp5(al;~Xc~h{Ol`0^X7^je$ zl3mgRz|EnD8GZ$$vgR6SbGKkM8hPYz0Jc9<3h|k%Q0R1fQ{E11M>*@q4W zz@ZFQ3Y#X2qw}u;osvs2lx%~*D*U>3knCKLE-(ya64wt^66RlxJ-rnhBDQz{U@#+b z_!T5rBlleKW7Ok##Bh3uFJH-FzyrevsVL8T-luHW`YSX(a>%^T4ZHN;-c^CkpY8@{ zU+-5hzN=lA##3+L{9+mD3=;$Qz(~I%`zCA{1W=-F{6>z|tj3|a5XJ?1=r9}xoc1}G zM7^UR`1UpDJ>D)=^G=6c%KlDVJ5F}UHW-{(0Kv(1o4E6Y*+xrHVKg-4Q>7Hro>^}K zAeP0HLwd7%<`fX)=zL&C8V-=_&jl6oHWNkjC+r&sffiI*2l2~m>DB_XVh~fOXoUx0 znCXrM=&-Ys_n zoxAPp?+0xiDES<$vHkq79qfIGWs}4dn0&`s5ImvGFcntSVEq&3x#shYMOz)eMdLc& z)+V!j7frbtVfp%tZ}9s4W9jGHV8RqDaUk;(eRJr+ev-it=n9_zf$4M26yk%K zgB27{%wfwqblB0RTCi>d3Fol7N8Sd8G?p0*l>J9Y!CD~f71?kkAuy2G#p_O{xnfag ziX=FfTgUx;|BgA-M?pu9%zN$U_Y(g5XX?4Jg=a)m;bP+=Y6O84Y0B+@@Fh~jL0t(^ zgTd1Juz3Qd#Saf3M=XG3W+znb3w{4sW*}`N`DoDiueojM70|;>x8u}7^$Qm>`Nq6g z1Mp^NUBa`GYLl0TpY5CXlglJ>pL})-@?^}3A=uQ{;qmEXK+Wdle%vKGyy;V1(6~gh zIzvDA;%hs75>*Jjn%992xOpAA(2p!VB!N0nE5+A#6TWo9AGIrqH(;Ab@5|-NxkA;6 zqZI8IN{Z6SIIhgF!(P~5f$V5B>$`h8{G3RcvOVpwZvhn3g)`a1V=&;txIzst)w3p) z+Crg2IVwUnwXF%4+;CLV1UlrQCh3f_zAuFKD+_VGT?pS<9=;i2Mi)nTWlZerryETO zgTp_P48(fM8xk>=G^uAnL*^M8znsa`rdSi3a%O?Nm};^2?p-ps;uYs=2&z10-C-li z@-P&4PSWr#VOUt9dQir^a*;A(g+!xpdCO!Dyf>qbtI_FfW{`m_m@pvRn z65*vU0obEoq9bYoxZUa!z+uv;$(l4%%3Bd48aa+9f;d%S=bj>fe$)<)#jN>zR2xAv zqUP4qYtR^3GI3+Otsepe@Bm;FZWvGJ#u$|hMBOV&P|H-J_%{Eknzlq1;qbC&M5XeL z0_fsY1`P^Lln+P*T|fx{CJ$RAu1MhksMKISRPo>)wiPL7)r_e)5HZc@UGAo_G0u0j z+gUm?LvoxrM>X`+Z-!Qdk|*`cfTJAf?AcXT;0HKx;M=+SjJ|CB_t8S||vB%?Aw#bv=y3d9A1T=Cpy2Vka#j*_;JEIA)CJ=?21WiU7tcG=9sS0)T#5r!z;-zO}8O*jnFeg)= zKUpX(njHW+A9@(N$2U}trwUFVpE68*v&h(&uiX!>kqEexGfa(CfA%home*Z!EL$hX zZDNs_6f63plI2;50tSuK?1Bz>yT&#&bA23j3J7jTlj2IythFN7O}*8TVU~LX;KF2m zpG5W8kQpX$(=%?QK9T!i)k=+Ze80v@%vK+;&>xj$cI=jt1sof zS7=MVR9DJgN)U8l1w3`vTigM|7-J^|rq!z^x2G`e9Pa1Vc;{^VV+gtMM_^>adpXMpa5GgM0iEQxGt8lTRRf?!&&z}72BcXWq zIYPL*S3}Jmv)|^^^!-r(98%~);-v6G=|3}*2Y!&67?G~veb}y{pk^U)zDa-{#^1;U zYcWYI4})CavfHxyYX1t0*OE2oxpMCJLpWAopU0roSdy3%xj`okJw@1ul%hK%)YGx! zr-2Oc9Q==T(=ziLuPKm!|6M&#psThp?gxodB!)vy^4tk2yb7q~TEKFOM-zJ>M?ceV z$6#ellkCRYqj%ZT2v~q}3Iz6jA^}xBlP7IT@7PeQ*;`r8EH2kH#WRx&@szF&kRzL*eP^5-WOc2Z9);A zG7Pb%ZEzOr_-r2@zDpxM=LRP_U#uXtl{g^0YT|o%1tk<;BN?P9dY8`eM0miIDl?SG z@1*|$ql$tC64kQBZdM=a%*#(XF|E0KW7KIwzC&z?9PvnR*@`WLp~zLF|D- zME8n*^dyW*H{85o_ zDw8lTxfdInZoLzpARaXjkr?onOH=2qoJw*aCiMlRJdqf&*0Vdso)IL-9UI0z+0|@R zqH(dzA>qY(x(2H8D>=m|2-W;@;7kL5GV*8O20&tDLq-^nS+y?D;tjD9x0Yyzeew-C zo{pcPo6C)f*Z1e{u%)p1^?p)sxKkt0cuH?C(lQ%ssb)Z-8DLC3ARo%ejcJ5V&^u@t zYCsKFFYR6lG26D(*9YwGK%QUM&sOlczQJZ+rk`@W{p;poODyXn>|tPch44ktnNUE- zi&aC!w+?_P9UIIcH>*pQ){lZL&WnWpD;FO&DK^;ox2L~;fBLiPiJO5PSBz6j;2z_u zj67qf!qtepRC*ZU14b&L0WF$?gq5Fu0l0$eqGMRwI)%2!H**RCIFI6q*4eNdvJ$~1 zNfFQ>u6@#Q&`i97wuqUcH;8J^p)L!9wjCclCZX(^npUBU#+!hf;lD*CsnQ8)h++=3 zAnY;MN`QQZZHfIAn1$qma^{j}Ul6xs0l1|glmfqPquwD8-feDvKaD8u=v_A*o{z@o zDPP@fM4uUI5l1l3A~+`Y_NZt?zHUbDPDtfNXz+$v(gM8EhK=Xn9%YY^j8=`F(22r0 zi+WtdBcelkh}4RPhJqo-?ktCb+)Qn$I4~qbxxy<0H0D~x;>IxQ6oB<2os(w3EZ&<$ z+{?8~)RU8nK^9{U5j-wawxTPvORohAmAgSint2bFz+(3C;k(?gp4UyFT1&r1!aR-- z2E3GK7H{dyqkx(4#mITnB;mHj+s$W#ua=_Y;0i5U>>u+-4q}*6bCqX(urgAHe#Tq{ zjdy*P82IP=d+Of)_@Rgu2AaGl+&YdJ`fC?5Bmy%@Wi!w2!LqY!)L@1iMEG7(ml-!_ zm(1{dy9Qt0*1&z`xtmWzxFfPBm;69xOL0*;3_?}&L0dx02=|R8F++PKCd40312!!w zewF-K_8tg`r+{px>XS=*lml@rZ~a*QDEt7EpyHl}_9w2?5vcW2ujsek5;$DSG|XMA zv)Q=aI9jn&stXiXH>V^^?u?N3~7m;&U zqa-vA5GN~0Wodk&w2jh2tx?V5z>!XP8Y@$G!;-R_rpY?Rgj-x2ftTVUjup^Ht*put zMnHtcpIzV%+bqz6563_G3SWRD$k9P1srP7<)MmOX~i zKn1m@%RMEoG0_l=!;#hYMq-)6h>LR<%Vz(M-n`r`x5r@Jo9q9@$y*uhutlRSkQa>v z06jBK1og>ji)~frHL4?uIh0=ek~S8i2DXT+0dmIsxAbilm^rnbz2WZ@-lA2T+o6U6 zRaAE{_ZXQf`TUTLSw_O&@FHVu?fX*&@QN2(SyrO1@sr>_Kpz8bw7*n;V&|Rsv{~?J zPmZS&fu{Wm_H#^vU5vNQS9$H(&kLXsF>S*dbl9t`A!EExw2mjMK@!%*@-{nkMh~4( zUsE(}I@qpJFYiz2m-(8Hh5qeFmjhQ2d)sDRuOIH#uO9%$BWcvfSD}Pyn{$d20yw^5 zNM?3il_}5O3dkT0I^HHjQ^Oz;nVM+~GT&pQ0a?ZEe0vZGTN?q{<;}}64t)$?C_R)G zYArM^qkptn0FOP7)Q(n%VBzoPe6>#Mw^=0$LD`FA+fPn`?f{@A(39~_2gqY=kAyK2 zoyyW0SeT72JYdBDM+1KCh~HR<4Nt$#78#z z5DC!EIrSuVALa)E*FO?R<=@ihT^6#HQkc%2wylOK}Q!&UKx1c&M!m_?}PG`VTrT&DV| z(jF$n``IaO?#ruc&}~<{E;3_d+7zRqu zNS-$}J5&k%!kI^VmS4qgMY)i7G@lh;H`6p+>uM33b%2SSHGxtC3%?bB03Hkb4Ua_k z%PtWc$-%EX4}2AKVKmuDg>Vz)GM02Wnu}8iW%ZT3kJ;ioX5G@}q>Mt6VXV6xCDq9X zH3&4p1+&N5&f$*c!S_RVDP3s1J?4K{f==Tiz@ZT&4{o;`V9pF`W_D>7WbPaDQ3}XR z<^VV|39D|p#Q$^h^9Bw#rF~2i&b#gU59e$RLRG>kgFXZ0|G1rd>{)3~Q*2oir3jd9 zLJmG-U9s(=5MwT?{kgJ-$ZzOnSO8+vV$CG0gwHu)_M-g4>dSHVz66aMSn5fYP?>tt zpdA$oypX!zNgYXUQK-h|{G~WaU2}>N=OFK}c}E28k*(TP zm_qg|2v(V(;mmwDgVxy4Qk8hwErqE-iV1?f4(rjSGDh-GX$?|Kww+!83qCo~xY|Y# zpcJZdwF^60PywS^0z5Mj9igHUa$OF8$J^-2hTZ&be^A@I6LX5sC|!1p#SVhJJ`_B4 zV7d7ZykUBcf8aYrvg41uHKyg~e0qJlez%_Z<+tB{`L!~s-}Mn6UaMG@vn3N5AF=f> zss7}yzvOAXeB|N4AI?|T`GfQ^s4)W637dr|(hS++8RJz*f-J@X+!=*cECfQa_f(tc zib41{I~W_4+PT{3w{3N{-Ac{I;VL4q8^2^4)6ka5$Vj)e-^|90{rS@P!OZ@=d0KCS zFcJ@Ful7(b#H4^*F4b43;Bn1R*yOz_$j~N*=_r|eAfI94!GjEi*T4@h{;ZTP$W@gY zhME%GV*WRnet7l{Z~H?3pLpry9I*pRH;fF71j)b8NQw&4Btc|&*cM^c;?o=b6A!2& z-KVA7E`*@%?y5|k>XW|9pX;tWi!-~6I#N>zhg*B?92LQUaIj_?$@o?A;M*tPrw=vz z$34o3gLWl>DXa55gDKZ#Gj|J8cOmixd`EK_O14^v)xG0e_W^TGa8cMeG=n1i@RKu1 zjHU`l^#ust7rE3>#$Us6woBU0MBiA#^ zCtAl$IS|S~fbY*;MgCsA{d>o7p2Sfw)RpLH>ZcjNWHa1Hi8Q%JVq&3r&f;0Ue)sjd_Kiy58isxg|1a1fIy$sOt&riuyc^ZNNt z(m-O(nC{ipWfX9ILYxM(a|c>v&&qONi|%cPYeI@7TPg?aLII?Nr{xT01NR2Cu5np$ z`9(VKUyZiZ6l`#X|C*^Y9xNi2z37cFRvSg6LKzT_u9prNMLibc_$;w}4!-#KUlr_d z7U&dS$LD&P{jZr%qQi0yaw+KG<2| z9`oVl@+)&WdCDp+w~jD=2~fwsNC+xokm17U)4EJ^54AKE<{W(j%tviTF4Q!d0|LC`la0h(T)Ml7q6{Qr{mz30x|H;iBxI6}z_V%nwc93@NjGt=>!tN- zaU5q+)Ua;dLn>7SLh1cXRCY2yo|kMYGV@X@MoUBm5RsFIw=(*F?*hvh+?jAE-E!q7 zGE&>Rb{}`45AlCp>jF=l2J+YyfHCdI!6!(7ZBrXK#0+%>VR825NbOoPI~T^ARHjAK zCaNt)%SjaX1!7;8>=5C;SSTw zAOAXG$CS8G$wDUbqWtW(lwbWXn ze(F#VGh0c+70$RzL=~KFAvxMA3r_OklVQ*^s$=y}4I=6FV)t6KgmcEIN6F^sVig+` zJfk0BM<6&yTZCjFLBlf>7b5!DdXhRJ7iy+Z$n2V$7KVM<@t63BCA_2(sE|Ckr2Qb) z!QxHLsS=PL-0n;Q9Un|wAcJsE*QL`G-+^mI+=Ut+zAfi0PNEhM11W_oGbZvbb2YCd zR5VFHGS$_W3BieRU=jr!mr!{)WSGvSakO)_M-^i}H@qV43#ldk5r`#;bsU+(+37Z> zV8b{SveWkcv$HY;!TZ4JgvOgMUw$|f25-k##9C}n#BlSE`tpPc-K1LkqIH?B|D^H`F;-2WLDO^*_+9mMdijoq4uB9L#fNxV+ zXPDr6lGAqISbLv}6I9X`K@F1KdkgT(Ix%0ry$){``J1%JUukh>8FfVW zuJ4i#^P zE8)Ffnxv~EuISsTAO2d1%NF@fVJs6_na|hdeCEAb8ZYT+A%RuqQ>qoXi4Ta%wkqb4lJ6;QAn45__2U^W)ny=Z zDh{4CqUpC+?4m_}NiCokx1YcdC5O3aM1|ZV+_tl<1Lg#-OVnaBk#wePbP$6(P(M_E zcjNx30kF&It@Y9j&efHHEkeE`I+cqYk>%s(_p!Pi~4Z&+_U)Cg-WRj2sm=|t|S8iqa0;QyhmSVT;%iO z`JZztn5Rd^2l!(7{-i37Y*g6>@xu~AOJN$Q+sec8w!tGgPyGUqZhn8kf%a{d3bhYtLB>)++J@ z$MzkV8U*ar1Q!(|12)<`l9PZ(TwlyGg|F(m)mDY*g=;B?PFRe?Cy^a$1PK)%M$FhX znNuZtY5ZV#xu#XJCL21fAHgJcxEVB#RR%I63AESaOHA1(ziIlFz5r8QF`X2r0*lXa z`rvd%tQr+!HB{SkB%b?%lmsAO_N&IQ}-GQSp))jL5(sn zv_dwTa@&haPe>Ab_{$$F@@dz&o)s;gxN5+V^l~i$GAKma$3GN%+eY51iO-w^R#m! z>|2MoihL8rMJ8B$5Pc&1 z?ev>sarLmdN&*h>bs^Mz&4b-)bB? z9d*)6Dx+tXRXx1s&jsY)hMZduS$en4x6o7Qgxx6)ZFXX?gGKWp{P7sc&trZLrExP? zvMN_pQ+iAe4$@N*K(G#$l&U>Oji2Wmq?gt_}T2Lsab$svO|z6 zjchK!=XBN$BBP5!&^Xn3l{JiQWglBfYXVbYi`P3v{=OBvXpv8@g*Ic^R^@Xc0)o-k zu6V$YHlBZ5ox&W?vz!LABXYd63V${*c z)9ag!;FwHU)EUU}X-&w!Oloo;G9huM0n<-ex=R)La48gQA+ZVpfIT3X&wg+aFq?NW z3K6AEYn%hfME1zgMo~M#`}qa7ihOE;>FahsxNUi?X%~zmL`dsYMV>&Z6N59}l72X? zXq|8(90~Zh&MEMr55uc;Xx&aP^7+8vzxw`R>?a@!TJJVhEei3+c#|^CZ-CtJn8tI; zf?%-lX{4RUmXEamAp@ApGhs_Z8k~brqozMM5udLG%ZiCK)Pvm%j7@rxOkbH>u^}*< z_yeBLHF3gX96pKU)sUK?18q~)VJNLqmjoE|<-I3PeBd|1iaC9dQm*sC4*{dgjWZ67 z!vG*Vre9);eE6js>VYj-PmBrkFY0xOIN^9Dw|NfgS0I1Gca@$lGPwyn$?$iIjZ;?Q z*ej9}Q1YHjN#|fKDD%W1 zI9Ta;yR;*`Ir0kO#bqzbWAq20XM#}o)(Hl zY7{gAQzp!^F0P;4&zTsTlqK0q*XPGNFjT?^zx=Tx|1qe1aR00HDZW^Gp^;#5Ev|yH z%r?g&?XXw~h_WVAs1?Ji--PUM*+j|EUL&oim2(zg2!f_TFv#8R*7g$*=gq%^HxNO{ zw51s#G13GJ_=K~2Vhkp?j!S*`_5#db|9dKuFGDhE44B$NIGAFpsJJ6BYqssQK1)L% zVCs^92xI^-{Ek6?W12I}az4G9d!%W9ykZ3=q85qIg#>a*00JA<&v_D>E)|r7q0Ctf ztDM>#YVIuOM?BNp>+n{Q@5dle+!A&d8kr;3I=c>idwyZ|G`lc6X6T-hgRGQ7momN} zZB@#c^Mx<-vQ6)OwLBX!%j$?`C@|_tT=q{g@foF1k?)E$pa4QLmrSQ69-MMg7d;={ zfHURI)i`=OYP+cpaeH12jG`?-KVfiR1ME0l;6lI$4rxPv8Jl4rh0wWACBZw@$Igx} z+v(>6`@aAz<4EYpoJ>l8{uKul*3GP+;(9SYZa?8g-X|rED2<(q;;)6c zY>{syZIvhX2O@%9LiP{~kk~n>HR&`u+>AIV>^-*BCYrC^c9;K zD-1KL5yBnRZ36b#D&<9ZrHJ98$S9x0C_Lk6foY|DNS#ys7f5Rw|C=jzStNgE$ZDD$ z&8p~6sG|TLkJ~A)bf(b@{Gy0k!&7oBN`RnYWt}jz2hKk}i)ReAXLOVj`8Ul9;>8E5 z7i(E49o2N{55W zRyjLAK`@5IQAAi)@uq>w2|~bjLRzh%2`!_#)?Mn#nSO`e&O`D+BOpq<%y5;|I1owl z3)5wr-lJ4e;sQ~eUTO#f)y=CY4vU?F_v^sAonGYQ`|-*h5gPnP1-hh>-+;O1%`lO> zJ)DZ}@T_W5j7mi)oYiS*bl13|ak}6arWg72x2ju*HTaN71u55H=LRl!-DW4PljBs)mLl zX|3sNC>HKC&hY@6W20|5_X=@@;}!Re@9iFCL)*k1ET**)~k@DVAh6xU-5Nr)Hb;IjD7rS~NiJ2C?s3 zNIt#fbaKml?}x<|N=>P($0wwtilUKYyw`WSF?l<5T#XW>)8uM2M-LCFauAWO;*`UD z=*u4~@*gaxCgP4t4zR}gk!CP|m!8)blGra_25jyoZ2tZRqz=)+`amfmh2vaH5de#u z=`Ege?%p6OfJ@brGJiQ*YqcCUFEz}1Qslis*=@hAm>&K*u;B%cFp`bKaf zG?7d-q;+f1jFICsLAh7CA&LV7u|sAg#21>lR_Xev3U1Cb=#=43Z?D5!MSdz68drCK zrc$Lb43WetH2Bn{9W}43UQU@MagU+;BxTY*aC4%AN!zxN{FjWNsd63YHBCNyxav_V z8bTRTuLx@lH?7Aaxv&M1EdaYLuCCE0s9sX88ezhNH&^4>=}3NLB%G_lJt*dlAm583 z?TbwTQcuief6`VuBu(FyvSNy;_Ot9b%0h+FxX7mrp8k5+6VE1%*TG<#C|e$3Egpli z2B&2XQk@`?0`-nz2i|E1^|i-?f%U*vJS>M6;=)M2)!+t86|H2dZY6Tk8mSi%qY?E8 zU?Uw_I#jV(%CbcYCf7*@ZVvNP>8oQ3ym$TXD2R$AsLK=9epHhpl%YN zs}Yb#;MLZ-Cfe3bQ&YUXVizs)>8Blh&~bPk6q1`EGld(1Q{orQ_S;_}O+)tPHbqxhD$x#NQqjSmu#olh;cdsnvv2H2Fw`4@2<| zQupV&onGWC4u}X5W#pvyD(kA-rItblRcYjYBG0BwKnhsVQREpiq22Y=jgn>z;@i$L za;Hy+EthpXsz#7`Galpuy1#628WH`4(u&HARL56`X)8E@wWZF19G{!aXP(~)i*fiQ z3RK`6Q!*o(!e+Hw0>6O@pkr!%*QuW|ziwRNOcR~qPKM&36{1WZa~j0ZcW0{b=!c7Mf>+~`xO*P;745I=M0q#vtbdRe#sYh@yNcA0&!v2*w`ihXcbPy9F zqm!}>14ffb_&C~8iZLcUw5#Sq#ZBU7yYLcoE4kM6_*+H3V8`jL-?iZUm5hUGnj4xz zBBti{*^e4y)H6g)y4OlEk!r-*=RC%Cd8Mfpv;p@A?y)09{-c?=2sQtlM1Cu-t5&FZ z2}N2B^B`4G002EX_)vwDE0a7!sat%<-55L>ki6*~&f~4Xc0)PZPS8W?*-A_$mCzR- z&50`C(dmS6Cgf0ULa4kYI8uKVM@d?E@XH@9@^$$xxo6sZw#MYd!qO<6@*Hgi$;b0EiW56Q#yr?XUv;BwZ2nS4eu9wvI%N8eE^rmE0&zYWYLM=b zGeM1RtikOLjJ9_n-YWBxp=jqB5QYc~HbpHQWC5qi`)tjzii4%SAZX&5q6g&UIkCY~ z9?SUR%tA4}%!iJx>CtH=J$A1i3nJn-X@ygXrOeg9AEGw~I5s0(Pb939kWHzpeB@X@ zVL6Vf0q99hYC#J)Z6VO=dtI^m%Az;|i5jH(5?8PQI~aJTQdP~6;KY87107$Rg_KO+ z8Mh+%9}FI+ukApBZR8zE)@y(sO@~ZJ@*?t$oC%--osKyapc_6OECf*I;8I+;(l?Kc z;zYq`+t^BF0yHZcf=5+O7;OtjF2L_7EONnC)w)h@;LkeM@%=i-X-#K>38vUg1f?5&Sk*-}$Od+EF>}U9yXo`WR%4?Jb^Exw;}p z%no=*A?Qe?K~b(M>pw?pmQZqmpFulQM4Z68f5>b|CV^5<^ zd9RC;bxmchf|Z6qIjWPBA?^J4f?|C+gfvzqwV$+h7X$R0F^P9*(2^iExhKFn0d{s2 zO1^M3B1n0t8Pf^5O2&$I&JPfKS{{N3M@r1Q(~f7 zhK>!W09A-OUl%?YFd+W~&zjzYN7g&E`A#KNG|XxvavO{;3yB`(P6?&9P%*)j(-)f7 zLIdhgD+J0zH+qEFXNwj>+n<5caNmMoZ5N%GWCRQvI0CZ*uY!;`Jsl5@3Ca{7%9-JJ zJdLb)Dl>Jztg=&91N13*&+q7LVv9+&&_ zU(XORX##1?n>&|7Ui<->z%V<))#*H;pb{VJJRu(`h!50=kH|eBhsKR;%*KJmI8yHu ziCJtAtzu4DtH@VZ@nA_>h4&}h$L`5GAfiIeM^@Ue#IZ?7h)BkY$SBs?^fQ7-&fF3( zIjqKcT7>yR?jW3yneK_Kq&e{NXHZQp&_29UdEk4oME+ zHL8HCKfe0MPv1U%`CTTcuX{LTI)X)fjGLY(*nG{D@um?sNeN8rss{myKvD~_V+PvV ze4^A{W`OtSQMshhUR@$}pLgPRLFx!&Y%)Y)-R)1Sbaa3?5S03$=m^~~+JCMA zAf2Kz1~A*yg13(bFdJb}_;T~5LEkzZc?`}QXS{?;Wf{;Fx6Q+P18czsuRl}g2ZQqx_{0y1F`p_tH6NL7^4zNKx5ZbgrKVX z(HB1@wG%Do+H;(e$ucIX$i;U7!f6rbCU!=v5ONcqz?^~Ns)4-Q{4u2!j7Ju-rML!; zoif9Pv@`X8+SKdG`|4@gX^xOqMUhJ6pJM)0SGYag?sF9p8z$OA3u!7E8!xWbtwxs! z6{YL8ulgEIzdtgb>nge#IzgSE;a1nw*Skx(|e5fVGK z-)L0I4n#zbqaBx=dF*<)$B!|@7i;*TU9~;E zW$PN7{c!i4$0cmIGM+3o-7)BOz#O61AdiIOsv>7D=th)f^Hd<&YEI z9q2aeu?*s0el$mVCp-T89%Yhypuzb8xip)(A?~`E1Lu`X3djJu2QXlzK|r23`}-Y_ znKP|;@q%Hme2F2f{P00WO5xmMj9l7dHnk_pY)HHUBT;|S0LaI1dJ6M+bS;aaDI-HR z{4x}HYnqN9e~FndI+EEna)gW*^@^o)HgoYbo4}2w8!VqsU97ux=roSXRL&>ClO8kt z#)tpwYwt(FXQ8}*f^NHv$=2U`+XqmfE6pGdk7!Hlj6<;gh5r@SsYDtig_M&rprR`Rc%jt`=ZZOkb)PS`RF zhjws2dZ7+4(d`a_aD5D}OY;kO%QS*^VM9<2b87&HSo+kg*bZY`Q7rNwecBjuk#-0f z0x(IMqcXzpo*_DYy1TkxnWSwu=y*{(gfnCVL?fak00K^NkU%V1^W6E!=~7*8#M^3E z1MBW@4=&B=IIhSzwKvqkB>@~l`^em;6%ZRD8yDxM8Ftz$Sp5NbOzyUV2aPNosxdlS zB?ZgTIVv-APLSU6=x7E?OXX;CpCjY^Fz>VZt|*=Isu;b5mjUSzgv5%7VPwnUjk3KO zUw{7h>Zb#|5?!;a&LlwH&JD!D5XmJ9n0HwxV(2TF0-Emv5UMA=i+mo)?kY%s>tk$z z+K!&-ug%@+YAH!TjZ;o@AdqXI+9N}Q;6|Pvy90_=xoJiL0gH&)Gg&3>W|X^fQB7+ohvg5UZorutU?| zB`6|>vsKYQ&J&m-ITO{9hh-t0>chO121-62T8v|;wo^|c1E)3?o^XWel?glfdfS0# z3Z(!uFF(SEAlqQFx-rs8C4rN~i6*wH?G3{XDO_zq~_C&*6 zuq3Ij6iUX;7!aB6mNCcD|;Z+{Hc)|q>=fVHRsnmN2X#-Lys6;h@F-;yMDm^{TR zs|JpI)2X~9%wKP<0A7Njmlv#4*>h|?pH#!@ybfp|EV0}R3_adRVsW>uDGwseaAKB_??wr35C8xy?c*WF?(d%}X|=|a8oUU333Ck%p_M$-p&@2D0XNm#xGU8lm{;KEjN)G3nJVgILBXX{#H zcOJ!NN=7NN?1GvecfMkP8>28X(e%9ki!AfzUUr(hVgHl?WbB>zJ@m1!FT6N1+sH8a~B*NNw z!6PXITsd$CZ!j}OCU=nY>ChXD5a!; zNmP9-g1k6jbLb89S4GiIHF9nU5y8C*o8Tu>^(r#4!HrTN*xiay)S$dfkeaHq>lkW~ ziBx#P*A-n+mP2D7f%H%awTc>QRxCHpfK=l8lqLFAK*|5#S)iBS+s{@5 z(u`DrWEeCniQ6lsFQz_DAIt~kr_yYgTqzmY(@S|6(275d%9xGVz(oIx>E{n2l)x+E zV^J=ar*w9_i&l^l8>(mzd(GQIMX41yNG+RYTn;7*!OgZjWp&;SBUl2G1~`}ZIa->6 zg0xPP?qdj>H^)^-kf6We{bIZ+8N&NdXXm-%RmLy^WF)i|zWrxqLc+fM-!vsq}=-gWL0HLJXUo2$km~but?fQ$@~5 z_^gUlI0F(m^94`v&2619Vfp7&hRbWQ`_i(Uw=Qudpz7a#6g>u=73JnKa#av}FNiUz z`BpX!9gL_Qf5`-%6V~F$ZC$`H#hrj=*l1&x3%5_o%ay z*F0melC0WbwzU)fr2;VDneanuvdk>~86vIWZZ1hW5N}HGi5d@g&6s0PAE@h2*Tb2W zPUd%Qhh-}uEV=PAaQkT^3xrH+#_6vps8J|_Aww|~#TBV69>;X5mCslS1IGb|>PcOx zSx(@o5OM$lM=gj=U^s4`-`Y>1KQB{E?~Y7!Xtd;p|h8b8xk#I!}T4rkikI;Pintq-Vxd2#W z%!+Mp{#(|9joePoxlHJSeMVnDmEaj6e@3lID}u@79MCZUoF(T;YHf5@8j#X%nBrXQ zMgF6qH=uJG3VVb${Jj3{+mD8cZgGcmA9vEw-O1eH%+TX!J>-4EkVu@df}t^b z5-mLjS!YSP1~$ibumgnB>YbQKc>S9nAZO)|Z}MUI^oM7hLw73Zy*|8yMlonX<(V+Y z$R|`Lx{>+q9+-TSK3G|6=Ot85;>l;fn9V zp)_?WMYda3xDyjdZ^8J!#VRb1$!tDT;H|%W@}XWOaN9KX?LRW#c}s71hVE;2Q-D1* z)Ds~BCe9Vw#)wP%g;G`*I_LC)hCtG(OW~zn3P%TQvNwrWe_CC=et$~KSDoMRoewkI z{CrKj(P|TGP&$+P*7IWiwBB7Y9skRGoANx?p=cu_NhS^x@+Ve4m1={ zq2cvVpL-b~aOxwKJB%a+I^@mcXs#X6kdBL~YFq;lrX16p_7T5re%hF-{Pp(_KJ1PH zb^gx`BHuI4`Bc^98QU6p`fxAMNvU9ZB|)S6E2koG%&8*L$~D9G5kjRQROO~(VKwBe zgHq>tA0FwS>-Di0{AG;}Z)_VnINMAicqwsz*?xL)w7+N!mfGR~m9PW|qSgW7A*i0O zT@1(@=aQDT1Wh@A)c!M9uVh9jAbyxePK1$rCg4C@EbuO`Ygl-yP8Yf<&D@o`)eLgi z4&SGPt8l{Bc7{0D)luc0>ZK62rF%iTVXZI%Xmg=K!97|})f}x0j_`I==~KA8 z^uY(V_RH`5Z9@4(N%iDSXLb>q8lYGn4em=89j-~cXWD_DXPt{y)PNlZx`NdpW92#X z{>ds z;`z>(KV>Gis5lvZ4KvfUM}qqjXrd1KvvsD=5qBP`8)ca|A(o|Bvsm zZ$GXd?2qlceeuKM`|hyS6)l4m7IR8*f@M(I86xL?aE-XU8+72$n4@q;6tP4K9G!qw z`FYuw@BCXY-^2~&(;wVn{sGDoWQPXGRssSTSIbrjCjgU}ts-p8;iWa;*r#PCk!kq2 zQ_cWhn9m5s64`fw{l&CoYyj~E6b)8g2psmj;Q&rtAeZQ2AYW7Uj2wxSp-+%(q1spj zlw_to?R@o9)Q#vaHkgbkX55qF>5Eu}8&v@8)QYGiRhS|ZDDMbr1zrk;v;Vbs2&>T| zkf9#yQu<{7UjOvT^|-yozJI5Jt|;kT1fj-Ugr-Wo#5WaT2?Y%-RkLh_kWe zI2!8k)d(yFBGd?;Z$Da_JWgXH`++PS;sX<93ouB>_bh~^xLDeCcrwO>x7;NLf%oXi zu5Ewk;NQRhNlIGnx5)_N6~MxJmEr@bc8ElVxtAs;@P`tkanTCz5rm;Rcd0j*4@NQ` zT@;mcY_q*uNS;1@-Ii}JruHh+CAH64%^vG#yOl)%e?RdL8RL)X+QzhKA|q5Hg76dp}S=+!T~&qz%C+ELeJ)fD`9{*Cw|M0yFGaemaFC7S7jt1B1K`h6T(zq>l0dKdPt;iemVfjSl zvXL3k^`VgI3r!(zoAo^uzsJflUSf`3Ag%VSiBlew#tn!KM%BNmK-d%!yosW*bOzth z3o&=ny2^wYn<-J8*OxfWcyGYe9vNi^gAJ=<*uL+lG9@dKo|zE(hQ>SxwtPvVT2Gy1 z0Ds6n!Av>+<|k~eV$EED0Mu+^YJ9y=s&T>mg&!_pG9(CdLP332XlBR*j?N$x5bwC5 z^+8_-4IkQCUq9xajmK50{SinE)=_r5wwzHyPplgNL8@DUqoC$Tw@FNiw$ zoRzDLQ1f}HXZb{&ZvmT7Ir?h6^3`A8>Ep|HQmgbm4sW?m0?AD9>4h)>nGdjB+zn(x zZA{D~`+!K;Tbn;a=`Rq+ux0Cys3qPT)oBp2OEdy2cCWcvS7=4b6pwhA7$ad!hM;^> z9uyi8`K@{qWf?mKi3(Ljb6f5USJiqST7?s~AEy)2+b}htUhXC$9bEzhADoQ|Z;&q! zI!8`Ys#20MkO^r`(a|gDm&Xh#KIFI0AG{qq9;hE~R{sp**Ux+S?fG12#NcG0 zA4#ZKFVkx!ljtH~oTA5d;?fAng!Gd)N)4nNccLh`IBohpwOGYT&*$4`R#b1^%hx@) zJqcooBTDcNvPv6?dZ!J?rg1B|f<=Uw+o5lefi;Nhdu~WKw1~@ZRNvuZE@I5%7IU1- z_ft-2S;zuaTv>MjB6M>UcQX*wmt1()LhTgulFWV>MUAg>WKL6qNyPk%o zdEVR_f=PBI833^y972&| zn{k<{*s3hZ+>tCeOw&-X9r+d{g@e_RU)HE_p!x|_Uaz7eZ};234!VDfS*81am=#(_ zVFA5YM>}itb(fW&vdT-{7!-+|(0tHL{t>mW+C+-Pe8jAlEq2CmWrFIJ40+H_ai4NW zz47W=Kgkm|L61YT$xOscKA7K8`C1R+a1^&_6bJYlD{zcpRe9uk1o9B#Jh*t~q=p7l zQG|PalID|b4f8@(y54on8%T&&TahL_ZM-8gs87#c zK(L=ETdQGWO|lZYqC7ChsquRjWxOv|2#D!&woNaGxf{jA8%C7619s&~b~i(Sja9UH zH<}P{h1&pL+_Xd1A2@aXlD+%l&lNdYUyNqDE+o#8T&3h?poT$cXN*WCufet{6tUQ; zThj>%e3vE%H-!x^dq0Aw71yw+b3zwIzQ9=&!nOQ(3MrRfTIXO%>#}hKFzQ6S9<`?U zOp-fC2`E|M-psV!Qy1ij+)>3ug%!2k|A@5(S> zdGmPkavY6b)NDIHwL^|$bu~Mg`oPOV3FY!o7D%4cF+;@c<)ZxbQjQp)e9GRf?J*zj zl&R+^V4YDCK2p1mI7z#vcK`#SaA4UsgsUxZjHBB}!7cz0Sl^gC95P|PJL~awxdLue z>}B1N&eY>~9*BMlQ4M$@2FBdbiTUc@VHGx)UFIr0gF1gUj%sq@zs%7-RImL0;lDP& ze*l>M<^xHq*>!pU6S+99!mmijeF-jGpT}~1-8`MmMhY%B_?N(duhhj4%QKXfL%`@O zL2OGxW)BV*qU>wj4vCY8y}?~LlEE%Q4!->U(Wm-2e5#LDX^`9;Mj~ufX$NA=3iJXm z+{g~relt5aJ7(zd>5lFcxalh>ikg^|97!6vUyn|EDaeh~IUiTOFNB<^(g9ME6Vk_c zau7_hjl(2M;Y%ZbG& znRgD~#%bwPsIu}a33_ENDVQS*(-z6T_!G!~{cb;e{!)m~`lk=$P4}Pw$8PzrJzKBe zR;~WHckhjPfR`WR8-GQK?fHjZ|K#=W{k)Ok&|9(l*M^_vc+i&&jTlByDVqaWju&S^l>hemX2{8&^Wnduga=>32sHgD3H9hyMCtQuyAjQRR# z8$wDk8+46yRWb%u6D)Bsk8z@;%=Ali)ZE#uhs+^rMLGK3ae`4&T5TUDoM?{*qUN8= zP;KFyao9=0CxX+QWk@=_45tt$Y+&wx9fcm9!k=W4>!AcGV%uhW{{vB-P7w|e*x`mZR6#4P9b?#J`=MOXYu@FJR9WR7-AH!f?05X_e%aD z)jo9 zsIVy%*(98{wpz3I={wnKoQSBE+=!XHZP1{`QC+SUxQKqx_<%bpa@u-=ct{WDLTSfL zsvtfRTR90>Yb0A1+D4jzxqShH+XTsx>j-S#w*;q1tb~}&N!g$$suh*WBbAXNha!U4 zac(dh5xo<_mrvbA<)&^2GbAPy#yU3G6t#UU_Up^RFOix-7$(oC5$SnmZro^^2h94_~tZ;_gpKjLdjD znAG!8->b2L7=V#%CiW#*5~KjKo2_+$F*D`L^(GQ#D+*l_)CIQL>@i}cS(w-!kg>}a zOa@@B2g^IdMnD|{!PF+Zg%KOH3F-qwMpOhOklWw=6oHj9bCC;`EKu)ai+htBHlR@F z1PP}yxzid$Yxo9?i6lO2Xr*!k);MYdHOH%?3YdN3>6;&}Z>FKvZ^okj^7)$@`{_fq z;{HI=S0S$y&dcU}o^jSL@4EwotD5|MW2mz1>NQxqs4owAA1qJ-^ysT;wS^nB`7ytCXSPy8P~DG zu0d!z<`u(BF3~~Wu8Y25qcdyXf#$PtD3|8QIRa#N5};ARG9r0_ACe5DuBTq0<%o); zw%z^qr|>pdQJ2;;?WQ)}covK<#LLb9Bs~Iv>u2?nfzY3s%#|JtrBp0UA7d#T!D28% z2D=k`M%Ug1L&7Yeuf~Zl>bQ67r#!IJejnVT za7PthaW{$pQb4W055f{9`4SxIKBR$meQE_oR4AL9;4v0=u(4{srl%@AT&?eTdYe{8 zyk!_QbB5GU8LuvGeja{8glp;^AzXNCBQkYEb>=`(J^4G9<*ga5Sk3iJDzXwG(F?6- zaRJv#1yt5RCE)ms8nq6_D6p)tjg`%w?TFdBOct=k#{z~?o6y*T8}JE7877~8>&#|Rd{X*L_($=!yO z7cH&6@(67$*I@$@ZWG|qMeDNPzh3W+c@Z2;f{M+QHA76#ite6O?^et^3D)FQ=F^ea z$P6;E4~qlkfpC=3rxzN}$Rl?vsUX&z`aH-YLwr-S`$)D^ngwp!+>bP4cT{)v(D<_!=)HtkVsVPx+7g8Yp)4m zjt~tybO?SXJZ!XH04&Aq^dsu&O1dzBi@?;%3sT7dqcm6!<6RRdkPz$Ja$JL~Om!2_ zC9&*5^bwIOOZI!$>zxrNEN9aDP+CvQH$(Nz#GxHiGd3JYFsX<-p)e$|BB*2`pq+$S z52`@`zUye5MIL=SofLMK8!)4Pp;*&Oz$w|W(DwH*O2Xh~C>P#Wx>~grMHvbfSAfdL zc!3)Ks%%ePt9J*U$QvLWV4^5&oO9IVLOGM&H{c9p%PoEdjX)7gOBvm6+KDwS_ztao zGnNmd#5C^H&#l|5JM#+V#R@S2^-_V`+Af=os4fM&?eBS#6e-+&K9dwx4FEw02m1@Z z{X5V3_r#yLO298Ihuc?j7tGZ|Q=zlNk#SR$nYuOeL21^E(NB5TiH%7fq87|okssRda*k-)%oA2=6!vg^ z{MrkpXQEXvx^Q5aHVRE>6_PU6SQUk)SYYA*je3nHGfk0blP(32W*dzc#u2wVW0w2! z>e0f4;?g)nNtH-gKnje~7l8QY(Rp0@6_J(7l;lf!ro_P()uGFL_re^x;ONfLaT(MN zMkP0^ zze!P|!Sj|8TX;MaD8+r!kS`o}ZV(-(E&z$i^Cm#^o$GS!xY19lBu(MtU!x=s#)K~Q zK(wT+tI!t#3hrVYin5#3Nem|?1m}Fw4daWyA?9Zs&JrSYVV(nUk(-PWpjnN+d+@ua zqY5AXp2^fOr=h;Cg%WB}8Q>Z@VO@?!jHac>S4)gAldTbELR=F_E=$Rv^lH06L`W3^ z1E8HW8N?6*cZ|{=gPdYcZSLs{p%*iiu+(#JX=jD`$P(1-UzTAQO9Mo3_O9@51%wPS zE$D=R0!TyMNZ@_KLL3ve5qFbR%TAGmsP$!4W-dUI)612=uBRf|b+Ble-A(lSPaSbc zK&xS2Y?Xs5k(=q|&Bm2|L+#CTcu&sGU6>Grx(wk4p})mbHgc2slx&0qR8&l2Pz5aD z5H#~}#5AVxhGg35 zCy1nWykqt&N=#6i21WVC*@p=S+&lKAp#e2zn-XM;ePPF%=_696l0masp0X(CHl~gS zr$)9H-`2s8reRmX=_#gk5}GO)qi_s=pm64l@++2#a=aRbyLHgGpU=?FYQ&doY0mZ! zy3;#m(;%#^jh4lfJh5ODzG8=Av;q#Ytl2~QL@If;JD1k`*X!Ml=}NF%^8j?~poT)% zd9NASnhXwGK)`9=%dk}KxVWg0INsqB{{N*p-71G!fX*1xJS$=b{H8>p!T{Fdegz%e zV)%LR9nz(>qh^&g_#EeUbdUp51;%~_R($trO=wJ)^t~8!yNkI>wdGv|E;^<#p(=N? zQKc(AGCd-j^LDb0;g!6=Wh**4#*L|z@x}D@dYTV$`fNjHEy1cq@#&>p?ASAB;*5|| z5TR|Pwjnccuj0GG+0b;i-4f&Y?)7?SW9pJmrj{5>bH%xX-|+6aY1wzMd)ynPdYe2# zt{U;{h@f?G(Yfhdc-xJs_jI~A_pZHlXR#o!ScaBC6(|t3pga~5({>ciViSu^Hli2| zZK23jWqwkgj3}2=*XrGkY16I@^|hr`N^-Sw9lCQ%fCmpzWT<)wS>-OO-t@3eHmFh_ z2_qd8X zbhJt-qTaDu?`cf)nayQR2nR4{8;{V?5iN(?NaAHNz^lx3WUQo8(pDjmnxnZDB2|}} zwbht*@=aeQdrIaU>Vo1Bm95Ud_06d4sh(+fpmsVOQ3D>||L}Hi%WYg)n#b?wDJtrw zu4I5`GGh8sIvgMfy402|g$^_Ae)|6Xlaif~h$N6HJ9@%nODYlAxpVKezV#iHZ$;U} z?-3DX>UjDNR6rUVnN8fl9CZqDQR@E1*JQ1_=r$I1I$3|fqG`#$^||mRHT+n8?O(s zpEf*`$e~YMn28H6k9f!mmlBPi{Yz8gCoAQ%i;o9jxnf{}vvHht$!dW!yx!czk`?YX zrbblraq8U_7(*l~F=GjVc-W8wYy<*g`6G$|wq`NGaKhK6)0DrYD>DO=Udq<*?wCX9 z=*D!bR8#R2(BPCnO`SZRnEOn(<+kT|Zg6b=S@;NAu{jhratYsj6SO7ci!AH<1@jJE zID`(Sbvt1PY_f<*qV1-;CgQmgE<8ASn!%_m&*h%6DshweIA@HKU3o{;+7pc_4FpS4 zdr)!H>FRAZD+ju{JyA!>zS4Q$=!^=cuPepordZ&vl$c~(U$+dG^Xo+hoN`bep=@pf zt%P%gmy;Wl=nfcQ*aZ2i95NDw8JeD!sO=>$iV|BXt<@0=G1ZtVzq}Y|0e7%NMI1hS6H04PqxCyLEO>TVUg**(&oS=|)HT`E#r{nLWo};_0WY?}3J{yrC|A9t zeV|Z7d(1OOlBg-tUYG9dpMU*ct4sJ7fR^Yj9Mot#*uCIQy(ha`rYM*fh>NO6V;J`w zOx0Ty}LE=(~u}i=huM-^%%aI(0?5ZwjZ@K90!<+fRW?9MVyuA&sCs-ZkEli2kRo*NI)au8kWUe-RgY zUcSik0-XuBbN81aP+UdzDbg&I!}B(cH{Es2pBwKyj7NQ{H81TF1M{%?vOe>P!o+rz z8Xg%T6$WJvKrLw-4fr(o5y!?Ecj?@QhZ#6TBtR`v{95`9iDfjI(} z85H9j4vna(uqPENNQ~$jG-Z72xw;LME5o2-kYMs)hbGzrV=Il1$fdwN7fvuexzy!n`RjbOSrpgkAMt~5cUVWDI??V3@b=sb;i zU<>ZWYZGh1clrylD=)9o@m)H!slOg%QA_7%Xmqd$=p=`FS`;Mk+}t`G0a`43I%=0| z8!N#F-J7eUoGd64F7M*tYjp^aTrChCB}`;UQdJL!ipnI?iEd141EVSSvH4EP;U#$! zt9neCaCDbmPi!{fh30o*_y`Vn!RD^6IW1EVbORPf;d;d1C|D#f7ZzY))kZRVV^Rax z1yJEO&|XPTkAh!Fh@i6e-XJJC-by0Y)gTM%w<1F@G*I-V^XIo!dd~KkQv2vGJwru9 z3C*bfh6KubFj+uX_R=froI14h3KaGy<-TA7nrxJ9#xM37ys_V3*X`vX5- z^K+hHhI3Z#Fv_>`v(Z_vxj?%`z4z_haU`^&4wyPWxb=Dky2CgtMgn$BSWTSE?_UdE zJT-!&#xN?DOVs7n)f%-~P>t6p;nYzJG1aBZR4ER(bP>b?G+sjz{fBZ)QG+IDs~VR& z%j^VC1S1C*Esq1)yKt1fa=`3qlWBt>ezK&7+9UD1?XkkTLN+4hTJO0oh1iMyJ|B<_;c% zb4}*yc4XJ5OV4_FNu=*utq1Al;+((djnSW-0D^^3IN{Mf>Ppq2_Hll@wEAQqT%N^( znR+LpJFplRU;cS?m!5kEs*m2z3yB7+po8RiYvdi~6;md535OzV2Tv5Kc7^PDYs;2; z7*h;f4Uo?znXX)*%)rFL$j;)Nr#^A_DokiTHK2l$1PJgbfk`{gv3R{zZfu92Ei3Hr z_+@#aOIJMZ9N*fJGlN)@^bCc}qp)9~kE(QD^9-dFooL-;&Re9sC~bKi7B{-zr90T> zV|~EjN8~?{)q3EN+7E_uXiJ&qK#^<>T7Yu6JrMVB3Ly}OIWisyTxX&$_;XQ~cIi>J zl#u!pYXz?$jByF68Dt{gBc546Kmv6*u(Ut}1!lj87t;K3Ge$LZ>7<(TH<3H}VuhlN z{^#hEK!>AgEQqqq>_|VFJ!j%M5K9>-G2C?N0snNY{G%txl84so)GpoYCce{5L0Rdv zGr{IZRFE~pW%3q{3haV|dTEEdtwM?(uit=aYiTY=A-kA7>RK_Ls6fN51Evi z*@^RJTOSu%MZy0dsfG`d2EP>W+Y0yjvtbyLK*M$p)o~(# z_OyLl;W9q8@{@iR$=i8<@>-qTrRN#wUR7s^WwxVJ6&eUz8>adI1Tajdtp-cd)pPfC zD+f5B%ah06?%CHe<5&=A&h*K-<@hmd^&@l)szC3$9>Qif(qNEwUviy`P}dw-Ekvor z1NFU2i1XY@t9443F5skC9=)$hg@GFtgJ)`ft*cVL2P%Ra2Yx@;@B01yShs9m>-1svg4i5i{>E*KcCOj`u8>p{BlG}Lg-iTe7$!c1In z&A&+Ib^jJ2)np1yFT6KQGpt?%JDGwsP)8YIW?;-B%E8UAFOZ11KT8+xJii>+uCDG@ z1NYUNF2q9q3f#?xmU_b`ll1K2BC5GQ6#i`!aa5*-QEcg5uK}8;uFG@;q>@s}1HLc| z1-O$U@egrg;}e*plTN{ZMHU4dx^+vX5|c%8tyMF}-a{`gz5Vmc%FXIQev7}v4GDzv z+GxSO-USzxmW`89xGxg%Blyi5PX6soy2UpKc^P-Vye<=6x&l@zH_{}$0*f7+8;=_E zizoxDOFxV_iZG|{K}^${;$B2j%ap;fcriM6jTFVo%^*PYLZY?`I_$)sNcy7y-3TGIu`E8_0MYB-?o@nN{9UeF$1FtmzvV~&+F+cBkU z&dM8}EynQbY3(3R1f54N#^KIO1&8>rltMv*L!tXSF=~M6N z0GNTy+Eh1t_>F6P0S$+v@$JePU=l-jtdKE_Y&xtT_Q(wR+MRHGc0*hvMZ!=U7;7n# zRfl*F6N_>A<)24)=>^)CieNxX~J!)It)Vjp6Ka@_|4BDi;x(*VHM7-s_L2jcg7 zFU(>Tm2Q?`&^sYuc^u-2U#knF!_YJSPG`1%}8A=JRyg|x0ZG3SBRJn)9h$zVu4Uj z0{+p)dfsrypofa8?3QpuXlL~6UcS)Xb|L4K{Vv@|V#zDVCRZ&dyQFw<<(vbs=9U>m zrp7GxJS!}NOb9@z`2uRRE?6|!a~i#qS8GO>p80`T8}a0MW$@aZ! zsz9$y^=TJ^qr{AyyjCZ7>8=g8u1d8D9|QZW8Qi)?8Cege&;%Et;ouBIhs*lauX>(} zT1kKVtQObi&HSBp#)RaW37*iAa;e<;4*($Zql}R#-}I!^wJ>T8sHqd}lO%{DF1?3E z|DT_@vrg&K-MpmORFI%*moVr~eE_d8%Q5yrD`TR@s1Arv@(a(-Od8WV^#Gpg)fiPI zdoUJT!Z=V?W0JdOmo`ov_KYEeUdsV)IIBA}Gq!rVd9(wqfb8Z|7iOwU&-07Y8k*x< zYbpXU6m)=z1WELpWo$Sa-P3%f2kNe~$c?9cWeC4_2*m=Qk6vu!^pMmjHHh6!Sd?!( zV~{<{!E-2)C8^P%Ds~SMg*}anZ-%foY+!aXG30oDT_&kJLsgxZkrI-nPEv4)uQ%O9 z(CI9Av(JZ^q4hwpg_cte@S=6OK@G;zk%4+Kzd0p@l=(*dr*t07ZjmsQv}-zN#CFY> z0b)brLO-G-0$9qH!4@4rSLOf9>oU=$qxfsiA=hXqEwZpi^Lnx)1_IhbBTIzybRnv5 zftUsR110r^KBLXEH!&UTV&~sdw*#-)b8)=F6?MarQA%|VoP{H6K^q1l5TycSi4Sff zR7`K=D5K*(W+A4!bnz`)g>nhf$e!9U<&2$DFzGenUj3+J79b!?Spn^Bs%wlT|zF9MKr&jNgf;K~CQzKUhic-@{X2dqF@+$mHS?}vCNkehT3@RlQmZSUSJovLU) z@af57@cVWYglh@NZV(wP@^3q~_I=)Q@ zq@k(7(vUT`7`SFH_&%m-6ikkJ=1B;`P7+2Owr&|wz(@QWXDb@zSX10E9j-8co&eNR z1A8mDg9fSa*+4%mm)mG~@E{^_bfz>ca;$(>qMN#yLR2756S&rQ>+(XMt{XNCQxR>2 zNoKCB7*bOv7Ip!F7pv_YHh>J*bZ+VPDH)4dR{7QAbL5r?ZNUYMvyNXxN5xzy=PK61 zthFj+DXDUrMu@y|<=D^m2_`U*2kU~88K`gME@=LkRaq=e2Uq}#MYnTKHsUn*$EIUI z(XS64EfNF`qtK^t@s(79l`uPj816-ZEqj5ToqqFMPnK39OEH5Y&w9rc2o4pt4+wy; zS)RMjJKik#`EDhXQ5%_r4vGst)woa4xDPJashxWMImD!X=oN*hTLPjC*mhcHifqG| z`v#M8N4bX3-xNOC_CH)+BAA?wyQ!39w+nC}_LW!8B#ggiKWwt~olx6*y^Gh~IjrSF zwBjb>$D?pE<3T5t6en=cY0VhxH<@rqMjctH50n z$Z_T7mr#hB<<>dw_+HYh1O7d!M`|)GH3CB&lu`>>On+cjY$!6w-jrd=Kr$;$D9E+D z&Uir3;9fb1204FskukaZ>EKBOG*V3HGu22;ri+OW0Z@U1s@BVkrm1k|g40}mXmm8p zQ_cs67VGq8U7e@^QyMv?fZ?~oN&O37kG$eby3M9v_v3WH_&&$CQy@5GRjcOz<8F&f z6}##A1cZmx852YI9-xyFPtk_rm?=$u-imv}3x$J#JZwYbE+TN}JpF>Q8pq4^c;a%M z#C#9#VRV;qyvY{i6XOQyp63Sy*|n4wz=hmpXg*|U=uZe@N)3Y5v`4$sEuPJ19^l;F zxE9?dPHS$2dM#B9LJjg|MZV|O+)^Rqwm}=^jY&3-SNxy?MSPj^!TNKVj&Ilf%%)x9 zOUz+8aT#5+{6#B^Fl7j44rgEpplV0cGH6P$q$EBVNgzw@;TU@71=WI~I#~eG3-W_; zjxjYFJms^pSI;rfdi3pOYx54XYh1PX00uCo*b>AYz^AXw#5D&tQMOjD(RJzaXeE)K zOvkh&Ye==mJPTg7$QH;i`SSXK4WeWy&%?L_sWZ5ozh#V$?HX_mV*mwhsZAhUNN)5i z@#RJ=Mp76(I6a7^jFDUtyo+(2VLs)toV+lTM~-m>=Tg>C zx*EHfWyj5M;R@v$>IL3JH{&A*;Zp-QzrNhA?yl)?@lF&#o(#G(R%qlQu2@9UoIb5q zsCTbV^dpl)XgTIF6b8U?#KKIm-+5&VUM>TpP~;8P9HtO0hSmdxDck-Cn5AW!sS}E) zNAT+mg>=fBee*Q#A@e(SY;S#8k+vqJTv}XxHY9sU=W%TZ-M)U*;K|twKDZLcrM}dw zyC9K9Obw1(iOH5-Ap_2PY9e?}rJ+^d6-rEVTN79!38Wi&&vAl(=`_P0{V)Ft zEBx7i{`}kKlf2a9`*^*e>1eR5^3&PD($73Z$xyciiWHLpX(J)0y^<6i8^BjVWM)0* zRo^p70rp(ZpZ%Rt^k1wpNP=IZ3b7e<*k@6eeRz$A1EshU1*!2GX}^_Gj{Sb(-aBTFwPZ?) z&vXki1+oyzt1(TdJRCFHpwa@Mug+Z(&OPiIJrAay54uAqN>vEFJy6FSu~JL>{n+uL z?h5t`lxBFZy7Fs*Va+1eq&E z4dNk!1&$v{25kaEcfxpLP4axc0Yo-t`MOML9%ycR9M$aU=GOrFS-5*S74#}>I6$wD z6i1xCd0abXvDt9m(yxLiogiV!Ew(qw9hf)&3rl$Z#}ZzxE#djer<-}3(WzntRN@qo zggvNS6_?Ii14M($LVrC}9`9G~OYKumW0ueHG7kUEn1g0uNk@BowleqOO67AP{@K;Y zm%0y7A&LVa+Z>agr8F%~SrLB^gy#kj#xIVyg=_V~>81s7+i0P$65{I~%~&s~#oS1A z?L({YaM7iZTl!Q^xpaoctd487@ZRYbG(EqWrAsj)qjNHkZ+@y1DjI|y%=olp)dCWQh7k&p=P=|ZrEc~<81Ce!(|Z8#DN=)^2zEiaZffR` z3w%AqbbCM8Aqye0ificQ%?T1lB{XYat!A#nb_^)rv(#1KV%cwsex#NMr7VZ2AQkiV zJY!TS;)RUv_UwRj5}SXXApsWse2;U9mIAHGBRdGihcoIRDU?tFz{?{HTg&0$Ii(FA zGWl)AlUL`36U_55tlK1M_wQuGFSUsfe)c*- z<`CEOsrV+@w|C^7h#yU}k=ek3@OlP-NWH1Lh~BCh3zkHG@JThSm?Apa!eMSt**S~N zJFYZ9_EKqIez-Tru#U1G@reI#v%jf+gR{B1|q_kf~I5>1*si` zFp+gUW*A_=cGqhX?4r30rp39-Yc2QiOuD(V1&#zfhFpKR^_~(s23Q1i{6uwO)D(wq9PJ2~@M>B}b5XEV*uK*b??YcBu7kGgq4yjY`MB zgUgmP?1~8r19ciysJI#9KC0=8ncqO-5nMnuvFJ32!{CHB0YBSP#Mhqgw_~X z3psKYnQ2^)cTZE6^ZC1nk{syUV{p6ipwT(Qn6pyUQ)Rp4v_|CCX?x|ty=K+e1s*LD z9S02k>(R^e001t08P_^yvCOs4a0pb6xeJ-)OjJV$R_BFk$b?b% zosBm`T~Q^h4;^fF3p+RJA@EA9RkxUl?Nvcff3a_si5qNC z1u9#56A9xZK#w>JljBKH$tDfV6I*_k;vDPKv&3<%3M zDz~A_#bC-|l&&#GunR91!NbIP{q16%K*m;@nK~(4-JYoXMpfB0pronVls$R7K;Ge? zPE7v;qEDp(G7Uo-Z0LRaabu4~$ok_s7iB?4j7Zv4`F0W$ibimENb~_BqM~qvmAO&w zwWE@trN~b_Ly*SY^_i%K3W(pD*k&SZG>4dzwYKq<9>ahHq!4dBFKfaO8{a&BATzjn zv~b^yGL;MqL^QXnI{~A1M|2W5h%r1vcA$a*Z^@=6zGkVQlG4CU5e;~GqoWCtCTH0< zQ4LKl&os)bbOnrON)nw5!3z1=6c;7Pgg$~lrbJn@w6y*|suj}`!auh>vgh6ioXlqd zF;1_*iGV+#c9ujL4T##`Ocz&xNY=gSon``0bY5xeHY+b1TU$?56oG)EqnGD}YDj;{ zSlI(GPi7is)0CAD(#ro1TFRfuW_8@;Sew4yn@xf5;PzoLX7}DGN9Yiw7NHy~wut8+_UP6^^AJCl2UR1+tda7`4QRi;gpm$#LtVDa){BH> z4Fa8XYzFp}Cj|^JMe0ny2YDS{h9h#?xf!Zr75nDr>{~F)=>j;$DB?1n$hKQW0DH;m zkZ{~5&;+3B_@CuA_veCK!QlRgX@7plzuW%0`?R;w*FT-z{3Vg<+c)n%KfHgv{dM@$ z-|pq@hj+id{ z{&n%w+1bCI`I{a${&zcXhrdL$`-e>*^Pg`%Z~pn8-~ZuvzRjoIo88-;ul{QD{`GTj z=!bWH``^3IA75?Xy!PjxeT!fHy8FGP`_G>G>vwxU`|a-SyAQwnrGGwsu4g{Sms8-s zKKjR-cfb7d>>54&ub=t#*V}(TcXDh#{0mLH#;kLh^rSb_$WWiO!%4;#rJ zY0yNNf1REunW-9w;U)VGywxUHUP-{5+hC|>hOPeUX?q_$jYNrSw&@C`9z{;!X{iWt z>+yOe1S=#O#LfAXNu5EyYKnF(?_d z0(1|=qU9_AzI9oI(V|jxqioRe{>^)!1`6eGzWou0kQUM0$nlPZd%Y^leI<@$>Z<-vP2hS}8Fc^Axe$Mi?tbO4rgCzh{n!8Bzv+C;vw0=m z>`jJ4Nv4x!F+x+BI!WNQ@wm1e`KbW>AC)EchT6--xZr5}4zqJ-KM0s?((QY+#6J7M z9j3Db2P)ki5!^%ZfsCLU+r^lK#l+O~G9!BN55SrnB5>(Bve=@>93iVPva=s?id{ok z&zLg7QlQJxR5{@QSq-bR1-d8e#1f4bR6Jh8Y2F!Z&$I91HceWx_sx&kTts=crCMnh z!+{lFaWSO4M#g$e>L*9MnduHPhsLkFV3o^%9V7*iHp3tF4m#3-P~6t~@{N9aHE98l`s4zvw8{Ya3r~6XX6sJi<4Zbk+qTZUy;`^JN^>LoQ%B$hyAKb0o8qi3N z1A1V7B48!W9D=?(JvEl?s(mYMU9=>;M1`GCyT+5_4Oai3Pb!rz0@RPVb!B`!tQ<@R z$7T_xCaM9q!=pkV2OQd=C?8pj!XC=7q{QQE_R5KGe_A%3 zz#E{K$q^u$+eoRjM61_E0hZU;l={ha23W}`&CF1(H#o#X>m8Q&W44Z2rK)D}HsgPM zyI#TMQ1t-1s@uZ+aYyCXP&$Ldh#ZXIu*QHw#?TIKWq_8{7$m2OVVmuJ`y=+5S)zr2 zO_B&ez}=|;lmrBv#UUc}_XdRrV18w5qHv6>uxPR^5 zc|fy{vX}bHcW>T(*t~55_Ge4;@%N8Ezkjp&^!V<>+kgDWfBfTLgTUaqZsx~znXi9) zy?yuFU4kR_uhQ|<(H?s;oA=IONsnUul4Tjo7bm(*aeN)!`bRV{s9Ul zrx{XeD9ok&ME5K1zsV(@F;@a-B==pLQ^us?Bjpg-xuMd!JE0BnYS;t^-&42?lSUW| z3`n2?U^X{2?FN4r?*vd328Gs^CB+q6GFU&k89qCLC-361nOBZxFqrj8i@VI#|U9*g8Vk8NifphcR%7S#hj&U0r)uhu-@FS zNFSVl)Pz|BnGE??Fcg9JhJ^`3hzu;sbtwOM9EPiXP{zIZn!eh6{N3C2?wc#&;xEBH zIavMr{@n-d!k3@=>ci*Xegh~hc=NuTHpf5`$NiLx#jngA)%2EY7Xu!xJK_Z;t9ioE zP?<1#SSU?YWI{~H8ZsItvprM#gH#LZ8ItJwRzH7Trs07ce02;D`EjR~!-X-HgW|?D zQ;tC4AoX_08x~h<70#tbNc-mkowK1-RDoQ-+a?%_QJonGJnNu^zrq7 zFWsVCzqad@aM^4m9n|p_8G32F-Tm1tM{YySt`y2K$2l%ggj~X@>kyQF*#jDXe@)sS z-}!KlYJLOdQ0U@w*zt}mByt^ZS@M1&3k%BpN@<5|5qju1nnX!)Eb*ajqvMU*CSAz# zt*SjQt4=+n=Fi-jxPh{PT%gIQ-h9W|*Ou~v8GvU2U4BM+_XZOV=&vjB^K&a<$DaG) zFYQ{^bkse%a^A^xoVjA>U{+HoFpTr9qL|PTZAAx0h|NTnJfcEMCcM1 z5U;_F;23&NCo}~TI1x2F-js_f(bty%1ZWP#`u=BGn%W`k|6}BDnyVkSS7-nBBql8>eUd zJ!O)jg{>ku1d_{U_{+Ng@s&(?`WlBjE6ArJQ^#c2fs+U{9;qR4xnRR4>juwcOjHv3 zJ60iiS`{C_c!6x0I$+#W;AZ@YWAA?TuwGr{Mp^pf(DXR{ylwoW`PQbAFkoU6iwMkq zE_Y4BaVD)aFTTP+HhFdtjsZes$7nF&cY>~XmFQ+k`3>|^iF@Vo3-}0-OKCls!;>DP zO`E}|t)y2PyRT-tG`;u!XTK~Y9r(K;U8&gew!{P)GBBM5RThp@ufbMUa}6Jh%L|61 zk1r5Ax-E)kU{r+1z@|_C^XloNA1>^0y5MTkkjwt|a>Mpr6Q~LOIDIR*2{@j!yXwTS z3AZUWgFCN`^s&i_&qN0D12!2UCQ=55hGNZe;$8EgublCjH|yFn3y->CMB)YGc+y^H zy~4>r^Mvg*H1A)(`H_VjWtPqzU5ih}pvH$b4&ZcMZ7K{>T6hGGliwUFw_r)#Z9sYBHLyUB(@s1nPRjoR~R7_p9TJ z--OFVAOy#A=UrB2Xf`IC;GLFw_%^R%i{<~Y{_@WWa}Z=kV5X(!!Kiue>4`zPp_2b^d}dk)PBpML>0m4oFY3Y}x5=$Gaf+>jVVs@iDu z9dt@omm9m{LVG-Fu3)4XIxm8ibCA~5_n*$%FtOL3bW)Y#j)vMrN+Kk%z_wG$NcBSp zrRPdr71;3ol;e6p zvnAG9`cEF8S{YOIvn)*~*5EJ+KA*LsUzIW#A-0^0#E8I~W=2wEc8mpL6LI@OmfUl8 z!V|Z_G4Gfg?*qScHJ@X8vt3cU8(-V|h+7I}IB8l^eJ-M$YR5gdP5h(w)XoiCp9KeI z7xg^L)h_q_HTe3^iT7YI8zVW_9a3DTYgeYm2er&}?pdZ1vSBXJQ#%cHjjjWhf`Wqp zG-JXfmV*)Bd`edR{Y^SoXB>^o1H6aeTBB z-RlRx|I~Y+OV1RCk1)c%;hkw3g|pIv&8=gUV~$7~PI9=bn9y{q5;P2e3V8=St;fUf zL>voZaxX7C4qbr$-b+;t8oZmJ38Ib-mJER%` z4+OyYnQ9GVKJ9F&=%TZFCSr*QozQqSxsC_7B9IO#ez>Q}_r5<1j)+PozY#N3mWA`= z9%Bj7M7cY}Cle(m)+d04%+m&to%=z?iJky83>DGE_&wm=1o@foCw8{te3>NF<5p_1 zB(lh<%TQhrJM%c^rjQJu$CxVI*@%B&zJ24Y;^^`HeRyO-vf&_%*DSLcNoBQ~mTNu{ zqz;senel~lAs^pB4kY~Y;Rm5M!Yv45^d9iRSQ3eHzey0(Vyp3K59uzM4mpn^m%|ns z=in4j*Nf*gfK*xo8*urUQ@F8Q>(gs6VdjiTRHi-Msj+zp7`|N&T$FxNDyR#s2HXc^ z9$`j)G!niS!z{;-zS?^ajENWcz;CWr_jFN6JQ!noKB%IqeEXcsYC{AzWr^cGCrG_FO|YKQYy#}*qf%GUIiPi#MJSatzj*dq20Xy?Mj;bx zaQHp&&Y57Sf6CUvvC+t&EQ0qCtK5UG!9tw)2y4-BXSIW5goKoW<=M{Pj%20wL7t{d zVtF}1cmr_z&n`rv)AmPpcUgn^lhiv6Fkq4aWE;duB^PVoF>gV}wE2|mufY*^|A2(^ zgrWLBJB#AmB#1oC%&V_GB=92IKwyZI25fS!QEH>gIh1C;>w)ITeHc}i zIJ`J;!a>c73OS8d_$!Y(sx{77`a#j3ND|NUX(IUf^-!y zpV${SZ zS1+n(gioM#p?8xvNieh@F30x)&wEA1%T4*KZAAHfesko9i@z27kt)WhF`^UNkU0_d z6O!G9{3`E&5iAzH7oxgAl>q#Ox9P^AHz{H947QE7#@`3X<~tmoLnqFR33usS18ZXo z+H+YqD>DSgx@?*KN{B*y0`mC;ITdHrpPlm_vQ})$kDyw)eIFn}ijExMc&0jQF#IXj z%^<06*y_00D;LB|0u+M9wU4esdb4_wWwA6eUDeeXP=-t6sZk6$p!3@ zZ8P;dBT3dG=PorbE#ExF11}2`Wt<_;+miNvqma7tMuP*ZotN_BJ@zR3FEG0ZUJQe* znFJIg4>)g(Pa0mBte_y^I4C+ybgvZV=~{@B^#kjoXaD@_d&tMgB;jJ(R^yCeGmwdH z1^YuG!3QSW0Ye0qI&9oj_@<_A)-3Q~aNb3lZhUE%gFQb^STNh14}sVShMG3Jur4YA9fF@g$H)uHln>Y1`2Jkr^7mTTyy6fqMW58I?QF9@~~fX<@1x^O1I~28MblD!70gb80P~^Q(Kw+|Dnb-%bnKO@efbW!aPYnEkmP zWNE6DDoUDKB|t`i(-}29krkwau$y@{`OVGf;IeoqBTM{Qa+IL`Y(%SlBflciDrgy6 zt6mo)ChT6LPq3-4%?98m8v=b4+zG^Z?CO0Jpzv&}!qdu4#40$KN?ayAEBN34gJj5^ zfxME-LZGdkYNC?Y0dFrtuek)6th#6&O`~Du2 zWrU$W-*9!qJMg3#5u;|SjU902^A2@tDw5!_B*-r~X6JhZ+)IuGT`?z=#gBQe|1etc zk^)VO9&(Wp%ZL_9xPVHX0XK#IZKPK{JA;A4OE)qFIG=t6Nt(TU*n{4%tn?h0Dk($D zL@K&>Cr7#|4U>5#nTxJgJ(2%Lrog+=5Gf;wn+2)rk1}mnm`P493VRoK%{h|PWAQ!k z1Awt3-rmVflAkF2nDZ8@l-kbGiROOfdQAGYG$wOfXwoI#)9Bd`HE@cDTu2!rm2$(1 zHkb{TRHz+V8&RjviQPV04yV`x$0)ORmvwKF_V5@+kJQobCs>F`w z)RaIOcMXViL}Joiq7i8WxSiTCGDi@Lbn_}U z*i!KR;qO0*Yl4YN1qM_(f;BN)qs`M4p0; zdZd)-Mirp)R*ftVD>mmsxOKLP$ee>`;l}BR2ypD`{g7&}D%fn#8>oqlr0WxL-iI(M zx^PBInJ-PfD>EA}!v!pTsZDYLb%moVI{NbhXi=aY-x4(>vq%>-(ujR$(5-?|=kYYt z=BZWk*Uu!n>;Uy;;isu>RMOA%ThFiCkE-@cmW@olH3L98S0qd8&;y~%m=ePd+}uyf z3Bo{6-Zb4&Qa(QmLY#@w(q%F~|2l^#60BZfJ@LlE2*!gWKE%B?4!LkD663Lbye49T zUE$-IS>of-{{zYuU8tKAOLu&=M?{7Gjt-FnnZiSluH zIVP$-oejl>qZfi91yZ+0uHulnayEMbn8F&MW+A5>^imY+4ch0Y#$gVhZj9uhYA+dL zZ<^nQ6RRnV@aT@sNf3D4+Hhocg#-|~5k5}Zj?B`PB)&cQO90e~ld)m#E=m{I&88JH#*MX$_`iSVbp1NTKDydNn=z*ZrRs!P_Qh4j zD{==1W%nYgg^?H(dAglgt-w)c|2X_^c16&*+Jm<)Xph;QujR*?l)%sUK01#L5%uLd zKrNa{mY^4gqx`Y-td2`158{o%Ke}6Nz?WC!g=!Chi)@-6gCvM!i&Qx7As8jVyh2;t zdI|#}{N!Xh5+{@BVyK5FgUxD>jH^9m*aG+Wht>H7lm%nVycP(NA*NO+&0G09srBs* zl^udV^yli1HD-`iu|_4(JX5dyxfXv>wa3IftqnpXt^;14S4@`*fY&g;%c;Xq0Fa{8@*}B^H8-CI781sZs28?bDBeO zUn~n$rY?k0l`O9sZn^}Z%EZ#-_zr*D@rRQhV;krN^>!m9<1@UX@mEa!FLQUI+{k&I zVfcy^k|4q5YE&c$l%`6Sl=0Z9+<-yu5v8dJlJCzVeh1eGU`?Nu3+Y@KY?hWk|@uN7i5(f0qdMSqT|FF zk{mpQHG>HwXp~p)lXjW51lgvGU3i3J0Zm|eU^Lk}1-0_LI7{{7e(_oaRn9&ixQtBMjEtt-w>oJRDbTz_gnnMFS zp}TZ$Ls%`)0?4EANTH+_(k6iz!CmHDJNkR-l!yJe_{AXj>VC4UkK(xIK6eepS;P#6 z*|`|2vn>UpkjcMV6o<jkrst4 zNGCw=X>Ib-R*;uEIXtKNH^HNO{>JmnL>0?#7CAV4Nz_wzJ~@^~$1G9tX){tpI3?sG zrAv@)_;qbm=%O%o-4fPZgQ3~qf7bBTIrm-K-jv60s`jR+?2>CzO)iyG0t&SL$XT>` zX;@ro*$F2a!YmwDdoezjV-T-5x6e0f=)9nSgjP8DQTckgMo|p_9TXh=j*NFu^|ae& z7ke`T%?C@m z27mnDiz{9?4hw%zT$wf2f&T?R9*NuR6DSeYBT}T5Cj~c0A@ms$oRBKbAHsvi6|dT5 z#U*Jq9&ZfKe9>rRwd!6dJ6VIA9?@4~&<)7b4B9Zo6{{jN21P*X$5p z=7_3a9SS_Qlf{Muk^;0Z&DmvxDjww!Jxcfb2&1|i3{zaT( zLDb!r_)ti&unpW~Ok{Z&5N~7Tam9;Jx4g+Z*m4f6orGTH_&J-F2aKrMLXFZNgI1eS z9b6Y0QcrI*4~%zUr)%`R_^4A$cX`E&QoSX5vZHeFwKAGZ+lA&h#Ybg@i<|>qGhFZl z7HEV|D4-7_B!9FXrD@$Qf8H?x_Y7aXEbwGr8>ZG7ExE1_QLYX46}u+Oaz2349(xyu zI>YFWvvoMXkUbk7}bZa1lKf{;?`h0oeVcL5&3BATdvR0hP@D{`0EMa&grR zn!6Gk=|SM?Y<1`qP~xy(WC;S1jFkaQBmhH}G?W2Xjz0cqn(dxh-V}4$=EcZx&Cns) zHepz0)E~No8qM zhsBVx<&3Lfn^L4jo1)*R+0Ud|l?7iv!nr}B%dbg*3P;5{d<}@Yp97JC4}$0_P-t<3 zbVoJ^Qfo?YQJR`YV1}bP80jV}`}-RF!A!rR;w519-9{M^L<)f5j)kiUyjEPgic}6_ z$uTBj%_g4>Y*9~2zDmwM-VyMG74t*}sBBChl(X?BeH8#>Z>xgtjXVPI>fHy@q4ubj zz`K}%Pf9=}Z*VWWo*@h4Mz~B@T`*<@2oSg`D1WqZUPAK%7fq!2!lfL7As_Xk z)%%c&7cvN9fWT2<6M?`s+&czw%C{I@g3e8sNQhib%rT_T67Eqmxr>;&!$C%6n~1E=tVb%HTdw}> z)pbhuHa@RT9{1g2_*f9jO`%Ont9;=#`Sj@|O5=)`c6SBCL5ZX3j4@~;m(9GP6v`Uz zxo=Cc>z#)sD#}%R@PdNNVD5^&j51hHU%Vc({LQq*P$~0bYwgmBBuSYke~VlL*rpv< zcZ7pVdoOnZ@Fr^;+0j3_A8!w{sh8z9l7ZdE2tX~bm^KAKE-*$l!)Z+jQUS{f32PZWo76P@H(#_A+WYb?|hf-jhLJPgb zJSiDKl=q{G&naIK8UHW{R$T1byntO-hQTBRgkJ$Hq!KshT4h*4!cM2M*Fa9affb^6 znW*|N)Zw||5s=jOHkk9N`{Hz0#+S$^%1l~ESb~U`oeV#j-p-}^Ur9X%16z>@D&qjycjmmiaF5R&GwD)GxrR?Hy>0z zM_L#-at;k>$liLI1b4VU-{hML_$?z3sso`OVggSu#&;_ED2mKgRV~H0pv5o&O@lNH?ytsU6&m{D zLl=J-rP(8{z8cayFI%_|13KaQG$%d;__t}S3srZf=6O>}Jzxn9a$v1dWDA(0mM)lvoAvI+mK*6|)P7@$2A-(8X%Nt~24x zw4Xq7{#v`s>%M?R6inSHi#HFfz|8JWy9G+%+j5gRiY7-Yckik%Gixr&1q?3-W(=Bp zXIAKTQ1|q%42A}GZHV3z=T5H0^dh`A!8#_{^siiQU=7B*X1Tne9w9t?|9(Cl7ca=% z2J!vdxsVe_sVqm<#md`w$hn`w+r(|rU>dpu5Rf5=Rs+fDR9|;QwBz;Q_rF=eYz6yL z#UbiI2vnj&xGe5?4lqEzjoaMDKzPy%6JZ97g`7(G|sRboJ^VpbnNd7zCTu@uE9l!i^e=gi(g^N1|`YWhFgW_y+FMyYkO6K1QLx;40&=2*Pp*(W?$gnU#P3;+h zkt{(wHaB0F>&HKq>xY);r25|VjsEUG&lo|W%)cOZ4)|4T04mhtDub}0JNd(t%MOLn&(=_6IcajDtE0QuOMvcFvo=^Peif3b5~!PS z3wDk6#D;ci1un6XrRmeDvbqCL3+kXcLR8YUDQ^7aXD9j=!%_`zl7O?|JJC~;{nP{> zx$dE^-pzT6e9ieB@8c&X=ig%^4`B;GeEIZ`{jdM}a%m{Xf2|M90HN>1bNlA_0s>>) z4O*bGjy&O>qutiVv!`K(<<8Ww9?bA`?fO)VY*w$sBKaI-80Tzj!pgqA*oo{GaQ~ zm!>s6?VF;s%LI#x9D%+IoTdq_%Ogv1XB@ltVlBHkx{{|olN;8ODi zKyb8?UEW_M|1u*gG7{JmLpTacA>Y8L;K=l9(0{~4t2Z(!>T}L+|Ns7%2%B$HZVkr2 zOVsxM^OsMDsle*pPa+8g0skTo?e%A$eJa5D)B7*46MyVW{rMBDY2{`&Xh-CEqu?=w zs!V|nEsfd>yf-g>|?zAXUGzjf>=BPNYTM>Y2iU(iz*VUA+(h8p5=^%cIioE`? z47|*$0gMCImrzw*kgMD!=1Px^0`??jA{;?BDVJBm*O5aI`Nnn+cg-d#R1Y9O{5Ng5^~ZaAGs^dTNc?h^Da1z&Y1egMWaWL;UZ z{p)-C!4Yf@>RA*}^InPqv1Q*@ChhgKIzP z7KyR9A7O8W=CdV~H7GMVR+v}=gzSiF6}Hs~gRh)v&Z|o0oufAv3mqUy1<m7m8GP^~%O2J{|44Ffi=SEK$ zsmk^c(Do8=D2f=@n9uG0+OEf|aN%}EROc&bkbzi8+@9STMHc*rzQZsYz~gQ}ssRPo zlUDBm;_YbkFSF3mnq;vvi*&T0&yOE-+paO0l>KmLP=mM!7RVjUI})C$aj-!&!HvVq z^nwE_^5S@U3h6xnk?2G>zyBg$JZCq%E=N%1cwl2rhihqB0umUEH_3)Z-DAe`|0=&d{~-Ee&!Oa=X(G3e}4Jl)0gQ+?V}*GbY5i46yxwJ z1TOEa284Qtvj~e&-U54Il9{uXnDhzfElN=Ie9s&bE)_$o^267aIJsF_(cruIim818 zym2T=vN6TtuM5279PrB6w?a!QHFr|ucS9UEM+RGC`%ibcarZ|+Ws)hjo(OUg(@h12 z+7jykGlM~uQWN(n43VfULP(zQkO81HupT$oOjg77*KdA71*(1H*o2x_pbuVUX2!X{ zJ_b%EA1n8vc{WTJ!0{Cw=4~b~LlHmr?Mi+#8#z6Eu+QIwP8S*aJ@)Dh+&dhYl{@r} zkI*}%h~Z4b8ViQ=l~=qS@&=_BQ4))Y4;tJm&veySNh2^$T;b9?hwte}%Q2*;p5CPP zH><&asmSP{@xN#@IRuD*q8T>K%s8 z;ikN(Oqr>HSvr`qV!*Hkih-j+Vf=xV5m&~X)`S#{H|Tp5A@00ye)mhV=)jC#gaWXf z(_Wa2%;G;YK{6&8vO1{UvAQf1^i9QpD5+@_^<=O;4v3c@KetPNhoQc~!v5a_5N}JV zv7i)BfIje#$+be+O#|{^W0~Gk?L>A6lrpJlJd5kVh=i3S7L$v>Z}Q`B{~PKu_hhTw z3%oJq>2#YwB5xoSyIKIK@Em+=H_QXCb!<3wnM(&C%bPY13|o&;4;uIT12)+0V?JF5 zTa3U;3sDhcE}Eb)*T`bW^i3>*dr!9oC8kL6Y6{vc>^$J!nEm*7KV{cDKN$rkF;jUK z$ERLS@n8hp^$7NB`mbF#v~lX41T#4POL zJLo41soi2k}`3#Pt9 z9Z~VXVs|Mv+x%+G^L&m-LDS5593%5P@-h#!jC{D6NIlbvM>DL! zn}|?{jtA)gfdDP4fHmV?Lv|iLucO|=|0kHjn!qzDmYkwUQeBOH|LT0?MTBL;TUx-b zLNt|Z1$y{7P6zHf)zBlBNK`(JDLebUWsdB4g(lVil zq!Co_dX<)hO zaBhkOR5{@eZcBDgti@})P>#M+eU;d;q?AnVXbl7`SVd^zjAXyVuk^`L=3lpRl zEe>H*V7y6hngDOGGMmxbrOR}G_v7bJ|J?oiWbjA?QC79ZuEqXI`AgmApPMwZQ-F}h z4R|zjjrB(g=)Ge#B$&^lY(^9TC*t?0*`-Qgl(0mVr4Y!asLrQ*)#(;_EgH}_WKhC^ z0u7yDevrW!O$fq6iv;anx+L2lKRrG_{{wDGV<%nt@uBPpo!44_s;uuRz8c1dkZ`kwvHOK@*SV zN8pZSRES9?$d6bFm#)a^4MP8^PUgjsr|JOK@ZRJ;iJTX-hy`Ahp!|V!Tlv_>$d+yw zPeJtAXoHXhnSU*3$YYn%4BS%nG4p3D0B)1yq+tQ@l4BiUxk%OB__`*JRRa% z`?7pNkPloal#odB8V9^G_NE_K*_MMYzqbC`%J5kd#taRhB{{LWT}0JXLR_ePGTE^;>>0Oa`&m?RJbevrAY^ z0h>fVJn0EB97f|oYzn0ahf+70OQXSmEkRigu&TeW!h|3C{J9X~OP8V@1MB+EbwGFq z@b@Tz7{5v-?M`{8Dpv)QnYTjRr(ts7tJ{o-#@cB;=JH*EBfr zHZEh8pUYnzgB!mo4=ejrbP~-N%MkrL)0AKknlOS*`~al_=Xl=|f^JOQnjga+hFw=I z(}H%|$cNHZn{Uc#mdhglfq*lCAqxSec8=yvJ={>BqC%<6h4lc%PnEe z%4_U&=F&i-FnvuAI0=V_v!cmIGg4;4ifjONgCVyS97|&3aXWp#LKN~GF^jz;;BKJO z4%5=iwbT9UJX}O|UD2BIRRus0cQ+B_bfnZK=Mv%N(1dp_S z{<|=>+HB_7btWotChbPkynYN!FI%Q_?X;%?g8PPpyW+9L>ss>J1F1P7`(4_AoWMFHzV?% zDGX?@VvNl!&*km3E5;K5V)5H)9H{ugtcDF{`4C^w90r9?P~(I}mff!kK2KBPREWs9 ze>7NK0hdceJUrgu0Kx2x(&z$nMC;|0b)lQYp;89wU9$z|W}o=1oIo`b(;x4|WV0~6 zBBwWqITNy{FB_5znUAxFh1)IaYhm%^_0nPkatq|etkKwDl*AUzm0>?RKicUvu9{m+ z1agm@ULN0yKQ(gpt3%^-YrG+bdek_Q83=a@^|N{hAe<76y+1PB{Q0>&YQoQlBJCLDEkez-~%m_M@Dr$vmi6=G`k0m9H3F?$gLP?r-9#k zB7XBP@T}5G`w2@T41E%mT2> zQ-@ha0gjJLQLkiSB{I$}Mhf06Hz;vWyguGU#eij@8$EPb>1N@=TZdK_HW=9uxMvQ! z;L9*mGhQPgx5hdQ+u&3Ayi6iWyb0O7<#W(;E)D%$R$KtzsK!9o+HXG$m5nbi;B0f81B*b9&K(qNuxF(!a{^FuxjWK?PJP=IH1h zHYz~aldAX=Smc3mdXysa0C61b2?^Ci;7$@Hh5M@Png=(@4K%|8>y|8y zMCl<@gAbJH3iL3m0;cLabHqZ%b%^XuyaCBUfL4=b*r|oO7F3WYgr5zrOz1;b0HBrV zN$}>vag>;Y7`W(Fb7msdBEWPV=&k{JZv6M7V%|t*`P<9o)|O&;L;YBdQOle>PTQP~ zgNc-RGpkC>20uux2&v2K8ZQs};4YZFb5AVOk{P}6cF0DcKNX-9eMcL6UUTdPhPqJ9 z>X@;m%tm~D@Q3m-JzO3uK4RCcK+Y(d5>B4am%77iCKIH6(%P)C}Orkx>Rcb zS8YT(XVlR;S$(`lLNQ-32xwJb8}0siz7V<)RPYc*Ax2c?N|h~XlL>W;y+N->se}20 znHUuG2s|yxu5wsrl=_jFIW?(Q6G)Ny< zB@O?2iw^|9%i9L+@ABoCpV3!aB?3|>6Zz^`=<1Qz^XkGj;)n`fG8Z3kLqbJGMP(Ql z9Cj8j;~{{_#%9GGTavwNR+-5IP7jIj19m$Y)@Wn8XgTenCXnFMsZ{&{hLogUC*L0M zbheF}0Tm=`qnWLB>TYrNE(c@jZZ)V#Yed&dhTfA0~o0B!%FKnIg&=T#qJc8yBV;VIAB; zRg9p7c__NGnx)U;>){P^Tp7;mMQFm%VR?P*kbU%Utrcfx!{D)O69VihKmjplEMs7h*dEH9`l|9_b>fKP5S zNdGK=^9aT^&4WfW#1&BqqFl8ln@wX7j6nq?z2(=X(+x(2|CRng;{#lR)pJdd#n5G- zNyIBhB`}j8d@dCl$hJr%uzc^_bQ^tTwowC#{J&K0xzc1oCWX#3Vk+;xbbua;K5Ga_ z83JLjE1G)mLdSuAV^G20UJkr%pYdveklrT5GH_2)_sm~Or(v?DZ~`%%5RRky{<|oE za@aMPH|4g-S(;yswV(ptgrialreGUYe2J<_QZzUiMj}oJxi@^AE|hsWU(Li?`b1ay z-oRQ1rim#^+MCkTG zCr9w77V2uIg6`8`$74eVtRhlmm>AYE;M&Toiu+TlOAa{r1HDs>Y_m=?;Q+Oci+S9_ z<#+DW69O_La%4b725ZG^G10W~H88-Ww%F5|kARuY-T9;DvE;smnZY+?>`wyvCzfde zsNfr6PX?{b6oq!AQpFg+b!aPzRnTudyK)R$3mJ_|&B{^D=)FK68;1lymQ=i}mgb9RCK18lU>_TC}-^x|aB_si6VV8=_+X zd|U(QBuIBO393djWhtRBAYUt3&*iEA&~CWEiD)5JN1(!*-skP9upTE2=Z4FI2NT~` zjbFst!C+DW@iiG+w}bts4i{Dyox*KSrDW?}u}D{g3dEO+TQHfj+B>2dHJAX@^L#LS zW-}Rsxx0ZY;Ld?SI!VN@7=+45RX7F}Hp}lOY3)0MRxCH)k!5iS**vU$8tazRpyV~} zHgXj-i#4iWHSGvrK-R=Kdz-{wrk3d}Qvvw^zG_t<3ce?vqEK(zM#ftD`jW88d~_+E zkm8QuuUbk9J4V|ok5SbFRDf=-dDmXHH_kD))0kP=$~*doMdlXwjq|Vr0SfmUvnCOw z`R+MeqRFzQ)iq33z*9?d6{wJXo(^3Gm+hQmPVkeb10%srQ{s&0g;qi}%&JwPt`Kq( zv$6HL#@H`L;R@LiH7FZ0g{A%kt&jGVC^e2Iy_Pq_A*O=@!5AMHPS?$gcup13w2%mJ zS{Jb(BdrL4vK<{B5w}#$x(6Rc>!wpECMEj@owIS5a+(;bGS=RLb71A|kC_VYspX); zb7aJ-a;jhV9bK>@6}02~a7^Hp8VFMtuLFvRmI3Mv6Z4WniB!uJZx^q~=?%hC8ovr1 zz-BDK{oXp{o&Z|{dqEAo)@gP)G^Bv=0gdX})#3(_Ki!W|Cr_~JX$?~WqU-txKPrPs zEurhOyXXQHTYFsh>SPcURJHMn!FiqCyZI;MkDMhhUyj*3mJZmI+wQ|E0-JEH&Paq# zHiyWpEcrybUG)nzpv7oFz_rq{-82v{M@$7IgB2;-gBz5ZQ-fTxk4Xf|wTnL!C`2N6 zElH}+q02FsS8FPJ*hyeTZPDg5p>qo|11c!ga_i=)W4~E!IUpcxD)I>I)F=nPr=cI> zj(*PA$i%1+6U9T(wA<00Q4MjwLQA#Dbk?tnj_cZXFF>o!;|;A$TSAg+8Es%$FF2pT zc3fwtsiaTARod<|D=-Ht;PWfZ_!K=Ny=@A^$Gr1LE(4iB`Da^vk1er*P;DKXe~wXw z5y&~hH24TqAoE(zSYe~GWq?Cgp;&{NFph(8A(QQ3>uu)_^^Y{ zYqk$K11f;ZV2rdaG}#bow=_1O-FEEd$p*>h<7ASTNVf!a^V5V=ccPu#OJ` zTQRD7oj^{$8HERN)(hAi8kWU6tiuJO5X3Zzq^TQ2U=5FXm;_<}wjgI<1p>xgOA;kP z5`fQG92P(Wh~CTbOZ-x{JOW?}KD2+jH?xGqcL9oy$}hNtLH%;(hPT6mYV42KXghiu z_L)CtTA<-+V96IF%Go=ip^n;pn zU(jPnk-ugPZVX~vs3jctIN;9}F(FL;kUHF|UYtSHP(&8`RfdPD>~wxIaY|P+gYZh5 zkioH0nEJ>PCa>C2OEeHz;pIkxeV+#J03ky7=e7$nq4u~)d48F0BLyVVecgO|F%%}y z-eJ@LYtnudqmr!3lt80Y9a17?Papkvw#bW>zM(=y*sHo2S}JruI+pyyw+tRaj}V&SBMqpnz`tFZ;l z0IJDiy&-30E651)Ny^P_GM)-ToCFYoT{*~>6_jmmmf|XZWESx}CM+~4UjDF&@>I(k z1DJ$Ac;vf!GK4aZ_tW_tiNS(1mJb955yxnw^N=y`l&_6pGHp;XxlZT61&l84r`jz< zBs)W^j@4D%gYrV_(d1YA!8X1=(s45e)yfAH4O74)We6l>d2{r@zMKdc4-~^6vXPbv zS+p2KXnEGo4SR`E%*$yWBGrR}y}jH<<1&k=xpYmgK^I(ky5#(#x(YQPv_|e5QDL@Q z#7wrZY7sJS){s(UC6Drv4W}^~2RI(Vc0i=%%9;BcgxsPZvH5BC_3;?^=m{^GPSHK7 z1+710QgARxK}`$m04v0?G$4DOB)R;xK9|D_s&5@Y>;tXbN^ZonK+ zp1IC25-EvN&!cjiYahQ|l9??6r=-SUJmQVUm|#h+cUD(4yKn6E`Ok)bj4Sak?I<)MJI>2tbs-D)|ia0BC|5;&KgZkD|X zF^Qh%klkydn(}?b{8C+EMBuK#NCnX@{9GdFKfEZ(PQ>fq4u`xAA%TWS7ZiZ$l;nL zuwmXkVgxB0MT5yGfj@$MV_9v5mMJw+795&uOxf7JZCy9YsH?<5UQxMtD^R}8sUqN0 z;{Z~iJLXw@wcq`q0`c9n;N*hS(QXROWeY+Yfg5g)G~ZR8ff(fZs7efZ(M3QDk*nMxekZ@(E`AqL`N$pUG`JUFp)Q8QaI|41X8&sQKq$I$XHBI92Osw zpUdoOtnI9`x-thtdUT(l1sXD#^gQ(7hsMy%&G&AOn-=3Z{ZXuIsn8G397{X z6}-|XmFtuL$#)lGOG$%-^G-SBVnaUm{y8=nH{R9_f>0(D0n6!1)UJ_Yu34z7WdJaH zouW6x>+hDs_~>M3HkrMx-VkhD(3nvo^LB+x?CzaZFVD3p_R;WgO}l7fkHpc?A5x_B z3R~p&=w~X4R4S#)zN%n-Y6UH01{|WyobwwHKtQc^#WF4EtK$%o-`Ky74I>xRAYT>H zy1}8tZ#a!e7*Wag2b>7{fIcvgM-Amb_Ixz4XPmTrKb4NVyGi5X%VBPlK`PoQ6ff5@ zG@bD1a+R6#0XwQ<@~APp={m^#@QQ`HvaJ?D>h02kFzpcT#l=HJhSm_n%Rd(JLnO_E zM_Xyg2qjkdtMZ=wEp_kRSOOiqwH){>k@QY?lo2O8F_^P98V+ggRTnrrnk51$wRGv9 zJB8IIO)2-JWR8fjlkBP!i?l!nU^0(Dt!ZrVikZv&jVC`zOPo`v1Cq57xltPmn&oNu z`ofS!aY!IJR<=R5SD;yvqe)8mgE9aIy*SiRER?ituH=juS9?Q!Q%cR;PR!G#^)}sc zi@T|A^%55@)48UaC28SjI*lyfx{T=C{L&Nvpc(rnqJZjy|22zB@ecMsTs_g5G^yLs z@$&HDtSleE#)eVrDq=Jp_1|tbeDL zzicjsHH}ESl&XK#NDx{OK)i+ z$VHKY#ep&9K)?26>f$)Az#0*PoT9<%neD#;U4sU{!R0G5L+N;L-H9S%hjAlD!|Sd$ zg>e`%R{}U_qG4y6e^o1MC5%Sv0AGYsk5y{^qhCWQmQ~5xX=%S~8x_V7Q~k6^IV>(O zv{_}I(K;4FqC>8z9Jn`IpRaVuJ~d{+-eUeC`$xCuX-PR~><3rK zmxn^v)b1AI46nl}(P40C*m%Q6$fak~TCJ)7{7zJ6n7it#r`S?RQRpyURoLlWYR^w zyIo4P1NU%a03K!=r!(#JP;X$P#VpsPq?Uq{!VlaT`Q`J1h}YMsRs;tbOz$q3 zV>^x;G^JZXapuEgVqcG;@d8hSnW9@^*dS6AGv>uLHJ5y%-KtyT3|SP8BoJY=IwSZsNaZ1DTfd?^QlX4j!Na)1wsC*+ z$oI(v50eJ*7QspBoRy}wHL1Ms$#dIG#yjsa#gRoS-~d6;0t#f$aC#N4nAlUI>UY4v z@>M)a_;;QxO*8tv(js<=Nhjn7vW4`ez$YosMGW5~&d+$Dh)lQ!Wie2Exq<9-Sps{W zMQeJxM38%@U6*l_36+dB6dY_ec!}ACCLe{7s+p;bE?JN>umXyXlL1Y_6C%T|Va`Ai zFrRN40lz}skArSCKCmkB+Y!A&h5hf55P&dZIUq|hMQP|I1p#`)XwUuXjG z+pb!t1;~Q3*{H?4gHvuS1sssn5T(q56k+BNbS#m($X&eOvJwKDG2&g|YP>PBV5L{Q zQ2S=l{1QpA?*QJOeZqLNJJkQ?9%iemOo$^0T0E2|GG3?|1PB5w0&%-$rLKe){JtO< zs2Kwufd4zw1r|88QY=TTSm8lBcnlC@0|!#Yr)lmBl)up^{Rmp1H(S1+hI&ptg;(cB z9*{e%)P4%R1d36qda_xxMH%6`qY0(B7Xc%@BN9DbG&u)Pn z09X=z>X%ukx}->yjH=zHg-%lvMu_13etbVYuHasgM~m17xO!mu(Uitu#HHJyG66@K zI%cO{?P<1`kBp-z>Bf_+G$jn0>x-_N*b7F5*13=KE@9)vkQCbup@AUDq@3u}Yp3Fp zuB7Dlfd%DV z3dPsfHr75u7qkz{i=lwc8_}Q&S8ky%Rt87Ocv}@G$xEC=)aJH{llG9tHLc$q| zhGZ0osilLlL!c@ynlj>HbO2nYGgyJXI8^9ZX29@}*X=k})s}u(Q7@Okasz+FS>wbE zqEzTmcmpeygvv44p!z|Wy!oj4u+M;U;u}j=?ypZ<>z-JTxr?^aH1VvM44f=`!|UzQ zhKi8`V3&}~nS((caOO2xC`t?{?AEeZklaR%rSgo-W;x=5JsD<(LgSZnZ@ji%peSfO|ytJ98z3j&0vFK8quAq3AkJ4 zRN^*c*hh5cW^JBS+PjK|80(1Xqu2qlE(dLIZ+N=08H;ws6}vjY7r@*YyOzTPPZ#it z!aZTNkrC8^@Dr+`VqMacyTBz7_Yc{YxeZp2z6E77r2tZM9`dk?3Um z?(@7w@2vt!6b?lx=}vr*91&QxYOOiH`5h29$f+Jc<;oJACT?tXvF;Fw%Piq=D{{P# zvTsXfD+olka{&DDR^gb>mf`bYTqykTFMn6YULPQ}*G^U$QrwbM8MD%6ILLKFNfRsy z+6oxT)?l3HCInD)(BlMocOY;Z8GD!?$1e*d@agS|3A~723>w}sYHXBr{Eo zaeh0r%-6jyge;{;D=0bTdL#!d%dYu7G6!(Wm7-kc>56K^8A2CJ|AtHA?vG!|tSi|j z>Y&1ibw(hjVjr1z08v1$zk|&HPdy%%ybFl&L%5EL+a*O%FfsrboQ5);0Ltk(f-F53 z-8C3Q!%eWjh0|#9rHAsXi!wWL4_=G(gxak^vSTtKcZ%w23r+|wK9~MnvK`729vR6K zt56sPxf3^y4dQpI;p0oU+Z=8n9?G#ui;6`obU3;>Wm)?r_ZAOhGK^?x%&EL(2kj!p z`m^znB@SP&Lz`~wLR6e`z?3dLkmwUVVC5~u1_?C!AqVq3IJ|urseF>M+|86n>o~TP zEdp*1*a_7-&#O+va25`*Y)I~fVGR5MpV$DH?*}HRa|iM$UROCuQ~;i2{?e6s_-Y-~ zdPCdFjzOpvp28Hz0Zo8v%2>9o2ovQ@W{e&tzL&u#kO%GsmdnX#H69Z9{4+daXg=4H zI^bz|I`W)6CTuV1ZD>OnekyZsf8M4>jrmN8E;OZx$m?LNGCX3v4$ckm1{wP5Chd3= zKV^;S7CoIwj{*>w?FIyfd6rZx`f|>DZaViL_nEtmCZRm(;WqT!;IC9(mh&E&Iq(>+u#ht>-&TyWubG|c=1^1; z!Qdm@KN;oPsU+D1@-S{r%gtL=yFhEgnP#*BT`vjsJ6C1WL)?e~XFah6NtY^Qh_Q!9 zq7hjc4G?|=TN~^hm0O%yPez`gSq1XihC8Bi`)TgEEnNQV-A1td#m~1tpBQAhA+7_e%>TuaLkbG6DxcfBU%gL@LFRFQzW5&*c)Ah z)*hMD8Sgv;C7}%QCL1-zS&_X#{Jf)#gvITG1PDO%-8G>4b5lmiurL8?K6XR7yP8^I z(zu#Ikv=XK0gcs_+wfms+nzjs_wm!m^*aapHtxCuyr4|uTEKCzgPYE#Q<(E@my_jW zg4mEb>59FI&MI4vj>~&|eKmHQOHMVl`~H+ZI^KS#Kc8Lxe0t3OJ9~kHa72uztEKz7Q6j4T6rIWsf7r&3bbyv}strI{X;Wqr` zOA-4Ep~{>}J>RgbPS0$Edegj!Ji_)dKwbf-%-BX(BRE$v{RaC=n&gdd&yK-e$nBo_ zfUz{j3$GSkygtkIoq<#{Gfzr6pm@!ZpDFg?u@E*YOiUxan{LhW*NKx6 zD+IiY$``FKV!(2A?-|nNk8nH`Aj+6l1n8OfFG+Rzu&QlJ5Rb_u028Mu4e1(DeQ$~r z1CX;(vLFsjq=0QJF&A4h!-3(mz+feWaTOhoc??k8G>6lbU>P(M#}~1Wi_-L+v37!1 zVN3+R<9KkqN+Qk~{XeBV9;jE6D`GMXof#mQ*Azvpd)X_4FF|VM{xEfLZz%6l6lLRV zDn%5y-y$6>4{<1yBVrdjI<&8*E(<3bI&Tbm-B`Zk?5IL4L8`g>7UZYlb@c1G$4g|R z&1pm&k^H4YO~Gmw1&&Ig7>m-O;;kxvpgbUFOYsMcH?x>kO_){X_87Rm#}aL}Xu|?c z=vZr|UFMLQ8Ua@~Sefjq02m`n^-~)Cglk3vDh+32v_8jUXTkNx@)VFeqnnl3!(SI} zB_s-&6c}aXpviaL@<{SHm|V+!<*kbVj3LCXPvgkyBgb?b~a4j`F&6y6F(n*`Sj& z7WRjy<7uHJ7{arQ;xq?+WNN^+58upu*NB|q7fHrqd@9!}@aj9hJ7Jx5i3_x((@(|U zC8#D%T-zpwp5jjHscA2u;BpSdvc&ef1YlOotd)OzEYhLKTCrbf$Bsu}qcL7NN+fC6 zQiP_;Q7*ysrsgLGt~jye;R!~_XNsM_9$`gh)p^|?j~3|(oI^y<{Ilp?Ngteg-e{N= zrzuo+vsLGz69^~LM>2d}(Hv!xY&EG?u($VEpo8Hw%r=}lwVu7r)5J(#OM-QSmZ;ow zu&_4B{}Qzo2VSj%zsP!o#}T|wj`{o)VI#clQ$Q^tbe2bWo71m`A=Sb_?~T&H;4JNy+YU5Upr4 z_;}N68);buBn|B#cBk)4=Nq&Hg8fQZH6iiIEI=)%2ibmWQ{C-KxX zKul&(SE!{gje>x1{`8zytpTS-Ql(t^lDNtM#c9dnl8~`?$FNW3XXnx?mt4DhF}B~% z4V>X#7lUfN! z$E=r!CW2>izUrp3>DAvhLUbC9xgEgklY)zcdgGF@rDs%f`n>y7WCaZe^wt?&mar3C zG)ITGc{~JCwczt*9s^CyU=)3?Fkh@5Ud*p9#spJEA(r1QIy>ClyaS{K4^;l+l%+4s zO_Cg>s@mL>TExG*<``=#HC?tFM+DevhG*d=vDnJ7Ib=>{f2Bv)vJBoD1DZpE zDtJsfS1B%)jy{af0yQv@-1_aONGb;6UEEG1-JjXML>7>Bmesf^+Gd{C7#a>rQy9|@ z??B_v^Nwqi$gwon@MwtVIR47G>iKJ>GH@uCIX<&$&q;Az-BnNMKt~ye+hSz^2Mu6I z3{jp0=>w`L*I6I)(FXypgdH}K@G2uI0??(;N4GOrR4Dx9Z2f!#uSD>JH@}p;_ z$9M|_*>vOLiczADfrIN{@9WKqzQ)!5$*KrF)b>GbrlLyhs|grs6o9P@SJUX_qTPI z%CEoO{jsrg&wSF0ABre5?^HqHX0UF}1XM2!cO*q7AH%48LJ_^= z?AUOE*C(Sd^KSS8Tlbolknco-uMQxb1rO1n2B050uyb4DXGLY*X|qMy-3tW z?CNSj`(t4SxvELzTO4J?C?s`J8>GHMr%elNuq;ALkii-SIWRB{AmOwG0A%~>cvTX4 z#q@pOzW1K{D!Ut+nPKozOsR~9VqoA2DL)DJF?p2eUh?uGOw|qFNa|q1sxaEijn4I{ zH`%taKE8i=c>n3y<~l!aCVTYWvNvENZUm_^rLnTk^z^eufjTGp2CoyFXwDU+P2>@( zE=`N@#W6sg?yHl(zle4A;kR!u@4p>s4?C4u(wF1ukHyG0%m)3rC*mbEehKU!?Al3S zXyP)7U5%6EW+8m-dW#m$$szu4D22;fWMq#AtSxA99T!TSB78r;&W(Z&) z$gqKV^Tt=-=O z^Jl#UDB_uAsavd9HOrXb=7M{R%8XgJ%1ogMR+14*Ak-CT+n=J~qJdZeZY^ivIR+gW zN;9rT^~R8e1RLi#3twM@J&!h8>~d4Ir{G*_x*{1carMq9$u^z+hvF@OkGzef9k03s z7z%`5L7n)R?cj=;+I(Nv@4k94AJ&hL&zpx2S#2e@*Lr#SI1S*n zzz!q6VhbRsU4HbyL|kgPs4o0(HgdWH zfydaL5i1y6X?=Vea4z8vEo~O*8hB5f9yki^$q^G`||tx%M-n-C-lq1pC3N_@~=<%e}DRi`{(>0b2qson&v`a zl4=056ZZ-^kG%$9!t~o1$dQdAsA+_`xc)gT#ndlVr_5TOiQk^@)4FZn;f5cyM|u?P zk*>%UO#egJ%Bs#BHto0(6A=lulsu~RYew5aV6l{v(C(EFtorlXK}hv9vVO6CgW!StQ14fzW+XrvnEaSs}YI1(ic~5 z5z>O0HRaF=y)xqskdA{I{wbzcF~&O^UB_#I2=HwsoZnSOTQ~UQ-PhlL{qpJ0&(Aja z;^)P8*x&1aF4vzQ{g9_0dC(^Q-||L0oObN5$<)}vP~s^`P1Nj2cQ^`kk>-jCV}9kc z%Ny=ts)Q(x6H$zkV(#SMi*83)=&dir04%0`Ba_Us(b%3ss7NpG27JVrhL05oaL?AIZ#?AeK#ZVo4ulp0IcbuyZ~m)T;#*D%^hH8Cf(|9)ta zbx;o>mLcCIJ<_u;zh|YA^Ap9t1vg~I5fjYp%_AN5qi{Tsy^SHX_c`73uXhVZ*I{Sr zkWxyHL{(LM0}#CJjrjD(hxh-(wXx^`lPE+P&l_;>G(qYi&Qgq0tpW+x&NtwiAsC?r zO-XK4@D_4>ri(G9Plm6tqaYSJ#WNL6UskNxCtx*ZHEv9)WQjko&A2rkWT-=@$Hro% z#=(-jClXP6j20Dn%c9ONh~+JF^G3A{#Hz8U*%zZ2q!@-L zM7q>f`qLDkR~w*ZoRK+G7ySXc0F!rWGolX9m@Cc76T`EJO$Daa8zVa7H*r&C2!Up0 zVtw1fNQ&CE7CR_;U|AuLB%8?QI0LyyruP}<bUuILgsIP)$56+ zc#V9wi>c?A7dJab{HArp^_;eK?Km(MG-)t7ak;JS96<^U>9b0wxS%U4D=9@feuSMi zW|G5_XQa=`|A9+ndu2d|e<3Z@2Uz>(oR&1)ei@vRkA`Q8lorX*ye--lblHa5bMc;2 zWL_uEJT2X%5Y0`;YFAtfA9Zhh(H4Lxn;Z24bcr-f@8!rdNa4E7+GB0913$;S#GV*C zzquP`q?Oz*8~jq;lkyT($(_(rz`#^{@G28N<0Cf>)Ne2=G#*#``RtmH%vH^+x3Ap( zToD`HxTuKEdY-O9QLk#k!QAmQw95k|IZa`Kzt>| zLh=k)v!|dfxfjGV^keR^o6$s=AC(QeM4S8nj1*gx2o3a={$hAw-8zG~4>lz{3-Ru|8LWPoG zQH(Z$d--_vglbJVKk08q=qySX4~`)LMY#!?g&?WA?ugTzgv3@pai!?Ip9w5_6mvJwHQ0f zHMI~?nC{4nRx%_`p~8sV)|(=G*p6fRPe4S}gbf0$F^+a*23fdb#*UcEeiN5DzOff) zj)>Sgi~zEiDgQpRXXr$H6@%(b)l6eidsxc!a=KP^j9^j@brVbRoyxU>aGg=GRlsa# zrVW!3gfWZl6Dn` z59X^NLAb_iWWEJ1@u10_Vw;Rs&J1}5V-Zm&-yHQ2dmLiT=*5{wE48I}aS5OzFoQ)^lTe(Q1Isd8wy5b4Z(43Y#* zo24fn?an+OAr)>nD|r0om6sMA`N))h0vG8oywYGvgEjN0zXoPT{6KAXw zwynlQxn?u^GKkf5D~bFHGgYy`uo0k~{7w@DPpSC$KujI=WweJ*?hGf?b)yb?0O04? zY;i@v!@|{s^&|wKxJLvk3zqu$5V79Amq&TARQyMwe;U|pY6zgW4!B9vE7{3q|0-Nb2n1E4=kw9HfHj=y=Pp@&Nqs(VAzM-#Li|WSYo7mL|%jH zlVTm21MeQ*P|&UI=@ZzcHF;=^4=+~WU+L?6mur-qv=(&Mx+bF@k!S2BpbsbjhRvB9 z7$7sY2ne6UlhL;8V%SN$`GLk5VOxjdxuw9rc-4s)7_NMcq(c- z9|p$t@bYNZ^Hn~LFr#gIZE|RGiIt42EI9LWGXb!-xz&ylbR>dWXg<`_r zMlBE{BV32$@0Ht=dqe;eJD#2JcadM59KALj;Dman{w`P90eK48;)&%jL6L=w?C6OW zFOQYi7w^r=HT|b^#6f3-qDR$7W(4MwxqaM(kp*ETApS%iWSOcN*a~f-*_P^fd?UvW zJRVNEEO47}ScT>)A}Z1>W%j8s;l50~!u_VKW@m=O6P*AY@4hC<0Rbg5-oA4Cmuoy< z0c@&hB27O4Yqg*aKUQ}FWzW8rJvC~T-|osToT~|(P8L1rdQ3vg#o+iWTK~uR%56hR z!tWF7&AX^)K(*#NrkgHpW|t0L zCyjeRfnrM-K55fj?Zk`ED8%y|l<$O9LRBcuU}zMRLT1s$Y1)B0#fV`Zy06R$WB?m1 z6QsGbJRWaOQ$xiv>}Fayl>5VwZqS)6iDz&>D2 z+Q@aYTw5HEy(5Az&&@6}RB*+x$x=5lqyu7BDlZA2QEpvMco-Pp5HXAp?F%~!6JK}a z_Sa!Yxwe8R86C4VC=rWwkUa44TL&ktB{Y*Kmh)2MuN2U`K{T|L+-~?VR#A`3H5ByY z4+I4}@1F?lj(|KK{dhPwM4KTBNoqgbvH+HVuPz{-D%=dkHRW5AT=c~Xypm*lIe8w~ z3S1;ILa`)SW8t{O>t8W2bzCy^Hu_gW^XW30&v{<+pQtvWqDja`xrTOj{CPmLyE;kU z=HWyC@hIRm@LS^#9)YS6O}=C@11^V_Y97i=-J1_Ao_gi`&c)bKu4NSgzd|xY>h=^s z8+Ihn-HegFgP-CJWLKhfiINkPK`3@*8MOT85949Ew(@jFIu|jMtCLeUQ8ElN)!znloiU!nn`%-)_bS;V^iY*Z%OEn))v;k3!5k^wF z0`+Uj_{vb_)5~Xb&4FD_hvrLkxH-}TXdHjCo<-n^Hp4T_TV5G4tckY@rJVJZHVn~W z*yp*;n2LsJd1B@AdgyyD-dmMxt~<;b6aaoD_qR^i;`b?CR>=8wdcG<}ZyB{QmC6pY z5;u#1jD}4eHg04ysX1`c&4a9i0-Bu)%2&jPI7vV@N>x%wP&cP^Z^*gYfo8-kk}(jp z*`>{|^5wkihzvz^PhTDL0O-&8f`U#LFV0fYLL<#gUCb^}sVv+3>=eAC_B;X=8$V#_ zm<5|eZ-z-Jdv1#0rJ93h5!3V{_dFoiG?q%Nz|>!qbq;U!I;^RR$1Z2I zl>c~VMzD$AXwO_hEU$6JG|~VkZ5WljdLNcpp6=+TcG?{O?E%lu6|>OGMb_}nI5zQK z*MK}uxxK+GW81ax1$1TladIdSknyxNfpkuN`>AqGRi1?=&n&3=mF*^aSIz3{H-T%| zf*a>hb2h-AaVESq`mNGU`Ob~)3Y{U=^7z+*mD~zLr$Sv2lsNmUBE2p98n^c$Sr;PzP&fm#j^porn1Za$`FFwqO;@P+je4dq-XDo4S;I9)8jO?4k{nC}!t|fj*6QR2~gRZE4r`#o0?( zVU0l2|W^{c($qp?E~zvGP8Zv(g<#$(N5bXxaJTrhq;;vLVvr+LaaK@!gz- zFns{)!}Cf~arzG_cd$>0IE1$>N4Llo6))E`uWGq~EbW|KKCFE^wHDv0Vsl~PJ+W@m zeS0#@3HhWC_ST*fenH-%eo2k(K2B%p%NTs?8f`hA&toz^8}=D((CXwI&n6FRWMAtC z_RDdv8Nbm%arMr2b&xkHBBHqB4tIi2Vqmb=E0oYJtM^tVn@(BPfjLJXupkrVOy#8< zq$x#c<^0_2!ALSk4H-n74)rPd0nks}j7v7=@G~&(i~Ey@hZBS~qXs(|7e)=Mfe~HP zYhWe;eTQU}{Gvi>yHQ>OQdsd&I=aRtmTv!=%?*Hbf$50l5Cs8m)o2zeKufu@y_86C zgN*$F9g0XW8qlZV2kt94XS^O7-5HmZ5WF)4HZ(z{vE@b_jyR8gfIGr0S?DwoMTnqF zB z#Ik)krXrY#{hez$P6QJ`@etZo2Ecst=^O`<(m8%97zr!XZrn)SWomd088C9AX2I?| zo@pW2_m=q;xhFz}X-8625I1uhF@kl?Ibh`F?}?Zm;1p09AYi!GA%I|}$?rdzzhrp9 z5`k2_}pdtN6T3Nx!`OsxQFWvcNM~HSpllad?&>f z+Q)R#XLNBv@E~{y!K%6qWOX{uJ@X zdkhtE>w3kFmde(53>Uy^2sZX=F!rh?oNrlic|xl0XL5CQw_MT@mJQ~HHEnX^_r?=4 zAZIGFT++&6N4+Vc84DW=BjRUYejkA>R-{;P<0F;?nds%k4hnG3XhJa{-1MSvutgE( zmArAMv+A|w%v)|orLQF8d||flFfaM0IQHs0>ABypUXcO*zW(bA9pI+;@59~W!>2EQ z{Gw0z&8Pe6|NOt7{_(fn>py)qq0Pap2rA0A)(Z?p|=fY*NK{qnb0e%bE)@c#aMuF8>*56E57 zSgLy7syGO40D6&v60aiwXkG2ta=FUf$E(V}F;%L|i$LvU*c$JK?XW#=A!}HQAqenO z3rei6Uxmu*VPntdtOv<}Vu2Q=swts0*xCN&)IL^n4+-LLAwjzYhEdAff6>aX@H{auB^^W6j>-Uxz;%dP1y!>fKO`P;_ z;4;}GN@KHEw2xstIU}Zuep+n+dmZQIz%`~`*1ta6qtArCyl}pUwcCgH%is6kwPXLT zeZ;;qceflR2JfD6?B47~n6B7|{8tT@JYwyb&4v#Hy0+!r%HSKwP1yht9g?JzVJ(xe zTEFev`wj%k-j;uALtB#!HdF~WaYU8)3QKy<=A?0a)A*=G%f9n2@#47L5b(0Nc?$<6 zgRnD$G>5;LEfTs}hf$shJC^#uRaXTnh~2`NPo@ZlX?LZ}2n1lAr5^WUkG}dI`&*NV z(wW6Bpu;pbDT+fQ(kK2Yl#a01gwDmsMG`!zxyS!-BxH#LL>%pJ?cY?M&C7kVnQrsL z<3+?vaOzSo567iK1O$Y6%CJ`tWrVENI>jW{i(d1$G0*V^hlV0BWu$N1-eUVszkK^n zl3XUUMif!e4U_w*Yie3)gW)#zv(ZE(S8!+(VioTL1t~@wp$ue9be9?GhJ|eTd2g`i zP%p2py>xooOjEP7qyRE3VVnxAG}T6+fHyMxwG&0|AcEQ*%m9gN5E1F{wk^XR`;Wgf zxR2!`;Ry*J`5Ka=El%*5nor5kVj0n=kY6^`72Jf6Mvn%ldd(&*~Ccx1w?+U{Q7c@ss2vP_; z9qnbl3*H^SJ2Jcot9=WngXIYdiS`Pv>D$3I9m|G9l3}g@F{X-X{2b<_v_y~b=2QSN z>@y0{AkucEyU0fUGY!^lYqIF(w&0(It07X1{bF=DwjN@M=^C^CvV5La1?v zh-EynW&iy0{&8)i-959o>0$j~OGAeQ^GM^$(_9Wr?@Wxmm|jR;Gcoc?$zz+EFSs0V z*eaZI!LgKPMA+5k)ye3~I3A|zPM&UVAcJIhQXGGwS5g~`6@$4bdBGv9RE*2GIs<0_ zk+wJrIQYj-KOK=E{olPwn|KF_;(Xw)MY%G&hmv+H-{WP2-8HXqg545gq_mKZEy$Ri zz#NWiJBkM$>5H$w$4Sw?5BcX`vlf?y=CBlGOqrStC5IW%W0r`pjU{uRYdj(;F zdoowJND@Kh>&{NnxjMJZ!xsVf`0!hI7zmljhpvL`1|LoGm+P&Do2NTx3D#hqAHkGU z#8@)Z;k%UNgzkh7*a4$kCktLE=lsrwLh%N(5=lv=<$)J=F9<)BNx27u$hgKJ1?L`1 z0O8TBQc#TA4KM{ax!C;fdu%USSgdSs7TrR^S9@6qhWdgDbi`%{`$}N%THfkT)FZXe_(s_+u2jh;oF!Moz1uJ8n8{xbXk7r ztXGnTI^1ICwVCzTkX9}W<}B75!O#wr|C=!dl@J&4lfT!syUSWmn0H}#XipP1BRw!E zLI7cVQQWFoV#x*e(glHw%L{eKpN%>LNye$Q??<4fUOzN<{&v^q(EO+)WG$&18giMg zHg>)oIHH<7{9)ibhIdME=~Swjh^G7-oN+Fcjqkq4es@b4(okKSW}T5`8zX74khWBo znA0X4S=Te4G?qk)v8OYMvSJ)|3|vR|3R*`7W?Q%Wf8Kone!ctk!+SQ)n`f$*^9Op< zeg5+4@zedE53hCgfBV_~Z+auZGYg49TK-}ewIHC;rCR`fw*Y*Z1MFl_#qh1YE!^$I zrn8(vt@T&R#fR&Yr=|a8Sb8U6XW`c1M?_^Qn84TidW_B6s16WryvLq6fAu(W)wn_> zox$C-CPUbo#q^JE0TyBqxVP}Q1?$CKE0u^|pE-spBPcJPH<}Cro3@BxW}!iI5E#OC z9njSSOH5}F(qCDCVdV2P{y817RsKCa-x8~h5+SCa-GTEpodj`l8pIl3f(&ZN9y2Q1CY8@Xm z!gcSoNEU=Qj^hCReb;0cXGP!z83W!Fqot}bLkYk&ga$0{7|RIGb?TB-kzoAlhstGk zZA%q3%@{3-`ik81Mj;iZVp_n zgX72?aok5>LOO^dA4rZBo@g?Z*l>Fg9JtSam}e?2aP%+w=Wb58Bd*G5t@P&v!^aD8 zu~M$Mn?X4NpQiZR4&^Z`^X zrA$vS2J8Bg^;pia1%Y=!%1q&%g+Mz@d-ZoQ;J}FnbkH*Zh@hd(0|+30`~fuB=|r3j zp(7-N-uV+1=n?mARs;#Jz7`Ex$9reifE|(#;*f}=%-DBLb{s$i${4964bFPwXR1FA z@60aY3h3nJ;AW0~<497r&`PVT_zzneaF9D|RIodM2n=U1^-<=8XEMO_=5*4yEQ+Ob zyZ|$?rLe4YSOjpW-rjvttu`}&*reA9?*HvxlDP*E2+`O+ttpLnmm?&uem@V z^pH?}{B3XF_yAI)&2U(hCSv648b`x%pBBGYO^6rd9rvoi5gpeWcfA?noZ^tc*;#N? zII0Y0@Y9}avf}`9_?IX`PGYu7QQ?|UePpM6sv!0$P!v#s6oDxJ7dTi!J9ehDUq9-Q~$t@hmQuonPjRUmVzG(LnEh>L_Vxh^>(%e!(|5-=JIAs!bofFxs-JmUX5fDEUO zf2n}}I6jerQKZNc@VPm}I{e&Xc!X-%;tZIqRNeC`HkGoajl_(BoP`R?16S+d14t}1 z_!Mr6cFRP;38`Y{r(~|cS5=qigKGhunHg2F>UUU)uVSlY>f_MbsDQk)$5%j{{}C^N ze2CPNC`fj#fD_1@UeJPb3FlP(Lz01@gaF4Q2y+Pqf@B1}8Hl-MtzP^OiB-3WLA zLpB9p8q$d7w7i|wixfgocy|{9ihDz~)Z9mnJ6P0+s#=ajjq&@HiE~tqGHSic>>mR;25P4Yvz*lAVT10+ z=LjXCYMLPuz5`xh+U^5a>+lK)=U3&@@$1W@`k)pSGAR8B{>mG@0W`uMg(Z?NCZs^w z1akgwfw6bvwQ>LB@Z3LGL&a8PojQTjJrRDFR_xdD*1 zDQY9CZdfApo4l}zkh_1AEE2uy`Kq!!5562c#?%hi=_W;Vy zhu@;7W~QaNfpDl=UC)4AGCT7(xUDJ(!3Akt1G@ytV%c1pRnS+A7=+G^;?9mgu1U); zdAKz3pzmaIm)?@fjt41IPS%%HIDD*RXk#_GT0jp%loG@n^z$q@J8-oQK7cT*fZQNe z=pA-7CJ^3PS&U#GK4ga5g}aGxNyH^X2e&;lca(Ej5xMln0faDgd~=W3Bp;gW7pE?< z9nupmK<+EZIQS)Ac5G==t89av(C&AmLOog~HXZlX`n9$CJRoI88-MxxFP}eO?_UTt z|E){+rLkG{UpQMpdh!~LD#*|T%OP3tfldw7_Nv~h69&or%f54PH{&wSpW^l zP)AI`U}(`!L0wHC=q9C!prqo+r7F?~m~Cm%*5ea8J^T!ZBrU?AqjXc6jTxog!VefS z!Fquia6%@FG^8mgc(w)=Hom!p48!RSB_Cex+h1Lo*<}&M8`xA&SSDn-1qK)q?a3ZU zPVb<^dioWoj^dnElNc3-{~%(VKqTJrsf6%5!%FBvUnT$mn5&BjLJ$xXm-H0uJk%G| z;dBB@Obzak8X7NBwxr9-{LDMlf9H85lEV^tn}-u6{_#^GK^#2Ky=!JIKn9SIGFlZt%q*8fX{1Nt5vZ&!9Fx(t zdd`6kQ}(`ML%-UROf1cg!^!Y+p(c@Ur$2(y5D6mfP}Im>QYvroSRO-g8KTeE0Iw( z8AUd=wS;G5y{M{f22Fw~$0-Y_ItPDrBLYx7vs$7redmygWrE9Mhl<FUA{k5;;J=7WEXPvz;dNLXT%Ryk@F)Xxcm2xF#_S8QasF5;I$O$2XTxPKj@e&H_zRc=?2E(;%bbC1_i-T8ZSggDMcy?lD zqjvI70~2rvV_4i}XaG_As}GTg!eyx0QiU=_vNPm`=1DITW_mgPKCREDJvqj|I_;Jt zngH{`0ir7hfEnL{kaW7-dKQ@p#ex9-5G&>UiM4_Fz-@>=1knUHV5}C_+Z^8xLwXli zC##Zi#aoGTQKOmBfGYA~Hk1Uz*Zs*ADheDNo=1&TqX2nUxN5S!R_?7$Vg$ZlGZ0FL zF^o+xiA@C=ddpvRlsmBK$%r_bM=QvC4VI0_0-Jpn!1wHh>-fmba4pQaJzJ%#P{dU^p5FsMt`gwCmbvi$=L9l2aC z$eDCD+8LFXHY*@oHyX&dR@l?g~qXN4MFldX>oK4G2* z>zcm7cetM5Z$Y*p+0svZLGvDm1TP|Ko{XT{wNHy{>{ z0{%$LnbT@Z0l><8Oh3=3dF&F@E_>?#agsk=7gt(}e*#5D&98 zwjre0NtiQ_!(Czs6a&_T>m#MW7gP@LeVD_vz8s87$HY>tkrcTA5}snwVBlfHC1*E^ zhP$FWR5{z&avKP3GP;q8u4=W3Io$9rL3R!qO~}~Ua24yaU2K9Ja10rR93i=xaex>( zABB!;70LRHYETBlQFD-YTDfBvcW$F(cw)!c{|q`Uj%R>AEEIUlX|iuJ$U@n>KvRRc zIo*s34F1&{yFO)#JA9Ji)v-{o`DwzG?UX!mZloJ#2&36Umq|36ff_xcdPxnTuvpFM zBcY&dxAC@|TFndCy||;|AqA-NVAV7d$GNfthS`@A2ihTmk45GOVN`_bwDg^VY!E}k zL#W{te3VC!9VEmV5=DzioWQt3kXFnynd!#fR^O-IRLW!@1$ct?Z4dJ+RTo{sJkims z9!t_3{zgg3{6rf<&gYCy8ZB)y;0_y26$xk5{AWnx5M`^4P-AQz*9R_hb;G5cX=9*7 z^Bse_c+??MB_mULht6dtjU5*<7l(Rtbw_ycLjymjyn+WT;dwmJe0GRrHC16JtTb?e z!~KK&Vbt1VP&XkWSrWn?o(q){$PgH21xf?v^H!srcvsvFM^hP*u~}eXindCW&)U^9 z;w0F*KV)=aJiuop&R5!hmUC$J8UmD$r1wG8+LoZPxP$Z1hgg1xgw51$r|0797`kyR zhf$@b$i752nvT?caVu8w#B0!xzANsA_kv`q44D%)2z^nzrpk`h72Q5LqPct6BZ({a z;av=%9#Gp;qAxVfWO`V>6N<^CZPi_@C6bmLOJu8{|FM06*l)wTL*3_t320a*-wJp~ z6A>3gJ4&3mAXPP}il}n<~qI6(q zff~S$z&z*Yb-D2G9oLGe!TdT|9d=P+%>n_~bHFoUif$I$7B(Nu&L(EJ5MAolFjQj1 z#G}ndQZMgDgkAyeWEdw^kD-P0YyzbK<0F6?hJsuOnYapobx{GFDT;#_0w!MDy4gkX z6}v1*8`lRJ7L(5u#@@$ZE9NhvVHZp}5gu{)Bhs;DkqxW?3dn125kE&Eok?JQIWQ7N zrQ@x*qZ&n-T5V(W@tVN7!Lm&Ncy2HO2`R(m#UHzkHKm>2HN;rQb8K0TW0!YOe?mMA zLSq^56BA&~5I9;}n5Tyq0YcC+{CI#rWQd z&5EJ6!p-27F2v$tOQds5_;J((%B5sht3koUT*`AcbX&P&mv=G%@TN?Tv7Z&m`~t0@ z4iB3hD+f4|zl|%!)V5milj}XeP1P|eUVC15|S46wr?t- z^0rG!>;Jjh=G?jG&h)>Z&%Ceq{e15C{LXWp^PKgbGhbu0_*MArQz~}J>kkq&+3)?9 zpN*VkRackcBuOk&O= z&2~;3CH5SsKRon`itetkC1T-*Zad#aT

N>#80X+hp4f{P*+{wK|u_Pt$u|^?l$v z*WT#a_hA-oE{821-J)b9N3Y0F)a&h=v1{7~m%TOWct()k;g(-kZSNP611?>@;Qm@RnchQ3o7m807E zX@amdXUq9~c7t8IS5EkmOB%)@TU!pgIBn@=&EWM9eZQ~Sk{-BRFfqQRvC90YinZI9 zKB;(a7IgkxwRVivN`oVXdvmRh+oc}V;jXejk<@x{uT%Rg4Q>B>v%dYufkf5fHg^4A zW{(~eq}P3E&`jeKQ(TH#UDxck`=8>TMJ_~?ciPr;r@z;~mUA>a&um#}JazB#k6pdbc@54po_#6nW*e*513MboAIJ{M3ai`#_DFJ=4|kMH&KwVZsV?yelzl(?mTXDVQNGD;?qS715Jw4hgi37`JrU;?1}CT zt%CQZjV#)CB4^~_;uhz3_?nD!3cY`QX}c>WDIdD(C*KX2id=vPjN5AxwEk7A9D10{9Tjj`DtGKRvVl&>n(a`6P zmK@m)1|XK!8Z;a-=UMyL!c?{D;Y@h(ZO+xCdl zy^k%+EDS#TdgBJ&hoN(mIvzZCY_7{R@k@L4BTjDI*w${D+NJZ;1Kf&c>^KyVH6(5L zG_B<=ZB}16vH$pi2DN-Q8}IYc4g>eRG(>rCvxz;6pK@$KvM-P>EeHsrk)5ea$gpHrRd!48lMrRY~xXtr%OGobaYpTrNu4Vo3{1Y9oxGH3Jh7N z7v$Zq$oSeLN!5;hXSVVBPjB9=Ic5-BFwyxz^683;OO3A?Zd7~Gmp*H+P$fxz&Mli$>?6GL2*2oVXjJg(m>a}(L$2|QDL?Oxjsv`-x?K7YLK-NUX< zV}`4>s@k)sUAq%r=Z8If)TQ3hCrxu#MZIo6*PA_)eN^=vZ37zXbvj;UX?J3aTX~yL zH3-$1R(1+%nbWW>M*gd4z%J+LOU;M?US=-)8;_FTbgqx6F@7xp&lEr%KH0 zRBke0)I1ksuaHwjn^fh!gG!IAX4|$ItyD2hbU5B~{rTQIIvuqCY-8`-d9vO=%O~nB z+*P15iX~{bGlv__|JeG#$Yci}y(0&WcJ?|Mb`j1ZJw9#!nZpf9gF{w6b_?`2o$9h^ zmtiI=YlunKwhQ)mB;MkHL{TR9W6kP_iOD2rzZLzKA2illvT2fIrx80Vcw+Y$x9j7d_+5P5XJ7Kmz(ZN)xw|swnHmf> zVb^7P)#N^jT{?exN~>WO^Y!@|yEm@9JNDst;T+Fa!_r3*ui>7mgDn*Z4<&AMN_Zu^^tYgNbRLhWm z?Do1=ntbZl;LvYXzfLtvf=+z!TeZX3^wq2{ZwMlFO5+Z0!jAE(s`F2;p1))8W8DrL z3%9b8?zHTxV{3Fi%kps3tK(W$jrlUa|u5k+%|dC zvH@zb<+YI5qQ#N@oksQE%U@t>vF2H~^j`X%$LgsT zU&>md8r;LR$?;!YF#$87#r&9HCGa{Ep(t+)4T6x(cD*UQgT zZ$bYaHAiETwOT*8Ks-p)|L@-+1<~GwxxVIc=lz@as}3q_vTa>ca_6l+=jQpKJJmsw ztIsBcDD$lYv~Kkp7pD4sv2v%+MPoyP`>MBt%32e;=}<(*z_U5|8SVYX`;8r@mOQb=L9MA@8m29Q ziQUkl?k$gW>-c)=+zJh~Apx!L9h<5Huog5B|KKYCGFNZZ^^%PqsB4V_4d7M zULVG<7?{vPc~^=-w-bIHO9$$A)y+;Xo%KqX?IjEfAg0xx(&z6>pJS<2KXiA#X63Xi z19!%cn7Jz>ejx8|kDO(9M|^wSW%YNW$Lzb4I4@`S3=S(#eAMpt#mXU(gLC!Nhold^ zT2i#_7BO@}gz2I|X=lFm+vQlx`j);Tz-YB_`C`AuJr7oxigGJ@Xl)8LKGjKOnEI$q zIlA_r99zeHu3LD&bKjF($3%5ro9kUYT;63{dYf=6^f>cNj=bxX`5=7qoE^PiHAl24y>6IMm%2=Ot8RzlmrJ}mX&X%VuhYk^ z^E-F(+Ld)Pd;`*kLUT`l%I6TQNXAZ2#T-K@A#qe53IYB5mI$wL+uXY?n~WKFSQqZ=QnonH*HwE z&zrT=sK6{YqeFE#%j`~xmCbea9SQBs)9vODEcYB?c zqjp;EKUVzg?g*{D>4O9OybIjplMi01S~h2|(%oB;8(1#-A1)N{Xz@?%_HD7pI0m)n z?oJxJS7%7<=)QHQ2i2|}W8T_O!$G;9>I}(yvyyR64V|9c?e*AO=i#ZT|CVsK*>7`R zuF>PY>GpBjXSF(%UPxM=@6HO3Uho#akL)x@*EP|kRB$1GFYiM4$j2p;^9w@qc3g_? z73{G-d4NGe_p06Qp7Rd3-D&&IVDF0#D%MtCHhxN5pK&iGv}91~?JjoF``^rQIvp$#|8%X|OxZ?hkCH8vE6n5LE;;_>Q6Em%8y zK$WWJc>5^g1y9ANtwB-v3d4omlFp+H&-E8HUOSu-mYVhHYgOIr%clxln`VCXbH1nN zSZ~`tMj~m+zKuAq{q4gx|0#*R%WGHPq&zrR>$A5~Ce*fY* zwQ23G%8CoVy==T{IP){%tg9bzs$Jpt{BJYAul`V9b+7dE$2V(DPQL$I{^~#f{C57D zYfe90dSKGG>Qhs55)3+>niJWV-j#0<_*U---quzaA2;Kecf_aX{=wzj`-I zneDEML$8mON1ENb=WS*&>vN@BV-wF+X~?b9H{3p2UsoRN8W}j$$_x5Fv%6$*xvikO9HK`Y^`!MT> zN_f=A+MK4w69b;Ec)X)NqVRQ7;I+s1!V(W1^T@uXIW}bYmd4o7w8-h(i$yQW_DoLH zQ~kI~&CTDJ?a7_!{N7DV*C?*#^b4~d_PIIneBFnA-<^$(w&z{r zuP?6pFsiVaqcOCN=u+}^&ame?4@)Mc#XA)HU+Lbto)bTS)&0h~Et{V3HVPkpa&H_s zx?NP)vdK=Wewt_9SxW;OOLn$@d3vLMZBiSPwQO$Zna%@J0yM%NZf%if{Kj~rzA&aB z-gM=v4xKNb4e`8IqoZV(GRpCeNkh7ab4~B51N(PTZ#@a3jTC?&T;acdVI-0mJ_cZNwR5bU5<%R9pEd$`4_MnE5iguC z7KB8K#C&Sn2F8n~^?+9_;fH3yM+aVw;6;h~Ap%LXINm=*5FHv5=pQW#=SNa8SL=4W zghA?`;b({_I7r3(=Fx&Y3U_Q%1psMQ3j{PukJJ+%74OgE@C0Qkq+w5l|)ZekmWamFh!I(cA|&HNAd(Byt+9`!UOf7?hF_a1xl=#fLMhU zms)Zbwj_upBM71!qHu>b9WYP`g9`_|z`ZZycSeH13NRna6LA=LSbe%C+=56=f|&G* z$}@JRDccDK6534Umdzpiw20^r2KoC&BrJiG+^gN-E z|07!Xa|3yJp>HGmWqX0pWnf4YZ0Ms17^`^y_qw?W5JuK@SE5wGJ>UZ}RnVU&f(SP) zI+iPDFl&o~1AUf(S{*PeTCXGgI?F?Fy;xA{q|J^0hLASSv;SP7c3S{=STVrBBDg%XrUkwuXOUr zjq7_s!3VUUl20vFh>X=7QTBRjpVkC%>N`O=N+~9Y{wozB`-3a+#gSYgUi^Kv?Y#&v zaSeoIG>NO*|1Jq^E{u)7miHptzH(|9G~Z2^L?~Ah%4qOS=_;=2;2JGJw<%(ljH66I ztipf87Wq$r7I)%DC89F|j66;UWD5Ek^PlNzfHyn_aVSgm8is|{<)iIheH`Mc=YDGZ z8+?3;FoMUJ`cCIu#73CR3;SKW5-z^_ea?l4b;s)53)ypz2FKTD5e%sWJQ* zCV9g$t6GA*4q!eM7xEmB!>Vh+df4tCsFP~oBV?EA&$I6(9Zn*M<_E?IgwfE4!P}7C z)mdl*BFBSBlxp`*E)}b_Po=ZUGU#R4Y*C^_&&>CD?%}N~?BZ9crOPR3wr9`zZA#khKu>ZcpFFG zyM9O;#2SHEh$B+ZMG+Gat8jW>OaIzd1d*$$M0A(J5kx0315m5(9ZiPgFG`^q^4=v0ZLwqv(v(J&di$3gqv7Y_q{i$NdSCK{w-SXf=AJ+8lU0dFtZ zqe3Z=jf?15;*col3^TMk>p3Gg><4jVM-wgKw)spztimfBle9$R2x3pVIwiNNC+UDp z1KeZr1KqBGxJVF(TJC^>vaCOogJrC)ZYwL#n5cuF^`{O}PPC_GH4kqUh(p>T;T?)W z;4pxrqaQO9MZj3aBlM$pzJh+)gie4#>WaNN12~W?;rsJr`H|5K?fA`hdv6wj=%FAQ zWrw*b$j0hcH*q+A7}(#S$m|f^aRcPBgJYsqOD`MG}kAAw8kfCMP;!HUAM%H30v zG#j8tcVRuXNy&l=EE7Muk{>?86NyDJ&~IYWz4W!6{S44u4M6~Ht*S>UhQ_KE^uBm1 z8b-3l^(q9+tVlh(#xkJgxrjhu+V>n-*%Mp@#RetH$70ovwM)tS3~f|4SOGPJWDXN{ z8jm0KW9AY&Xnr#-M@$CVbuu{wFDqUdIN#s%c=|w9TxI*X%GenIipO1!30^=q_`C0xk0sPfH zLoOAob+e%!*n+THcBjt7V$(v2J60mG;q@d5WF31fzHwN&ryhs)f`@&V|56s@xqx@ zQ#{FKAU6+K83fN9dd!Zru(phaAAnC|E&LOURr~Dvv$OsXg@rH+i`I!Frx~!~n8-*z z)5PD2lP*Lh$XzQ_*Cv%Iz{Toqm~g*{94-9Ujv!!KU+S5Co`Eajle4QLvD~)$>g|s= z;z0Lv@C%f`2L`!VGgJ9k{Xtv%#CvGNfOId_-r}oF{Ad_TMliTXmoP}K-mPZS1tfO^$!O!G^hz-_R`nH4xrjgMzzUD#n4#Que39#26TY&TBHgk z;&1va2hAYZ*Qeu|5)eEV?18o;9tJ9Sk0xy)!T0br>SjzF0rhZ*G1cF^VNMNOPku~^ zFsMD#;fD~RAjcauTzf=y|| z?LlxL2u8t+%@qM-6~A&99`tEN5Z;TaKHSoh2`uJDO2~x-2F>rk$2e~W&8AR*X!Y0% zu8lXwA2e3=oCW$dt#k=uGC3nA6`f($%#i^d6DgP`H#ScZb*>HoGjD=&MA?r9D9Fa@ z&RsXVZVs$sUze#Riw4oLW1>S97_PF*C2fWQ^%MNix;JKsTq;&;oxa7pDPV(f-3bDk zZK>z!P#l#Df?<{~SmYle2nrJNGBAZ_}bgos2TLcTwl4ZQk&_s0J-5|qz}jV&lRCq@x4R`Ib( z!`;iFWMg5j#!1TZ5GSGgL$4Tiv-nG(D*{__6!Ly6J)Jh~!1RF-3(z#elK(OdY(Aa@ zXg&+ofpJ&~busk%JX{*huH(S^ofkQkz~W(-Q@W6aP5`qKDO0eD=YFp$d&hs=m$nd*Umn*n>4KxNwn#zK?O%>26~u(`|^cT;C;7O)X70^+p?jeQ9Sj|=c=9ZD`f@`60(W$w| zxy)pk@r!~zX-xBHEl#dYd<{)jS4aSbr{pOL$11nj9}}wq?aIw0YG^3QXNJq!#&In$ zNsCksy;@4lX;-QU7^`^n?eRWV;DVvOsB0d>%Wz-`PsF(AWPy24GZA#bHj%9q630$l zl*7d8OnYPeaxO&k!VrRBA$#xmjR#cL0yt>ctC?EXWpGSc(ogcZp!Ko1|A6B&=oO;axET*RabPW&p(GB z5JX-7&XlE_kf(Gwxr_Tg`d>Y~KpWYSGez`GJgX`jI)CpLz$#4o;AbL$9*M+VnUdjZ zXfS1BvAhj&hL6AN>;?OgYgMTI(w^XPSaoG9g1Ch~u$0hAn-WrD>u7Pmx5FvW&0dgE zaOntW9S2&`c$Mthf-dVcaIEtCwgVP50GzK!9g)t3Egv*?{R3xQsa&ewH0uI{tK+~% zN4lro6yRd@e%#n*c{OmSLz{z+lCHVaawB;$Vlniq6)?1tW=Zr8V8?)Fl&w5KK{i&m zS?6o1AE82AfG#82>uT_1X8)Y{S3rLop`c2mo8E0%!V zxj&o2KKam3A%;Td5$)9}CH!3y*jzl^C_WXT4Kqn~)G@63Y{in0w}0oHxw=UZWMVml zaCDShw)n47z~i$_v0A%@?DQb_0Ch8RBN5YWQ^qCi(RM-U-eL+C3+m>UgNIC{*vZJSbLa>LSN%=n)dfx>6^;KitJZg`yDu zV39b28x0%W@p}1>zlK;qOdSl}E_6w#)8o`GI1>Ib5ia+p}1n>6*+UV`cE@MB^B z0CNFY)e5m{wKW5m9~mUzM&f-~e@?6Ab3v{W=?(}sm?a;JRl6kqz}Ir9#aTS+E?*Xh z21{<};t9DD#tA?@11<3(km>059+r$0ikq20Yi`A`n|rh$!@kISP2WI+B4ai8w42&Ut^mv!NA17$ zS%W3>Ldo6U3|*LE#R6R?(8~k8mWans%#cgPYW+v@J!U5q*=zV-0-fx-m5Ha4A8STN zM{py#AxyoP;Vq*@h9J2YNJilovlWG7m2b>kw(1sik9rEIT{_NgESxVEi3Y11 z^903w$$b({) zUR##FbS3n+CfZT=3@tc`g+{<)DEI+Bv}*1wb#numW5EWf=W=h$rDC;OXSi8Ep`Dq0 zat}|1Hb8-37BjNcYbWi@WQPX|vaz~jIkjb}P~*t8Hng9i^@y3x9MAS8z8owDhj;{* zM!65*8(Ep2=a0A^LE594i`5(0k9W=+m@{Ga%K?cyYo9W6BSn!BG4NF)e8<8N*PbM7 z^l}8As*_B4h7W^$KvYdbhjh zj;MszBO-+wEezjdxjz~_d=(tx5UZ03Vr{?>Xb9@_O&%1hwDrlh{kFlkm8LzEDW5}{ zDYc~WxX73YyuyjW=Nrf!$=|`8=(m-s#yA*O*}G!5swWU_wnMlT*qBJze9X8Xoug6h zv>c4t5{eroYQZ)snOG3~n~2r8WQ>|KEI|-Zu#_S5BVQU~2<*s!5e!$XK(H-yQ{i|6 zT9x!qB>RV9(~pc;8Z=h*JY&7BQK0xS^u*A`jiI9%(ZA%wK{bqqzO1$)2-bRt{t#Ty z1hgjoO#;{)Y`$M(co~eNsX=wQY%XmApnMZj3a2h0a%&ILn|C;Om=sEnio z!!bw_1$>$}rt_{rfF+MILa{r+A7y=Ig4oW)oy$@UNB3!@l6 zvA8#>#hE=|;R`Uef})$^6hmWGpD7z+(gzXgD$|hDUx@h;KIx>{-hu zf)^V@0w|n4TTwVx`IWbud_BP4LtwHE9V6bG!wiom7bX}+^qYq#q^$(m9FUEYL*bw# zjDs|nz{VxCa=%)o2pFq)x3O0H z&R`2RjPubR;Moi;_~+nLk;<6ie`Or`6#(ojqVPG}{(_IyAF+4KdYB_3{sqIhBMsPz z?eh77+-P2?KkV;<4O7i~O685JH>Tu%+zwf34P}8QH*4?zpBy%K!mxd}?I3p}`w)a1 zlDjzvbF4TU!VyXmWN%|9^7W+>MxU%Ap)`H+3LE{DYL+>=OCO#d3uL znA}y!A;-*PN{$9@fT zOmf9ok(SPD+k?!d;KLE17xiQF4+?Oxddt=d)z3rzR{#_(vagMJZnJ86J^QiC(ux;A zYepW0grfDoDu%|Yjy zktKYtn5W36ZZp2|SmZXN@9;yxSK2EA#wwow-Sqrv@Z=3<)S$`h$N>H!T%MuTd8H{? z;LDj%jA%rT>Z~XntGq09)xx$=ualvdjGBbojTRot=SM3NG{QFBb2<)oJ_>LYTxg~U z7_0bwpY`T*q4gsN45&GZx-)>`5HS!g&(>S-WV9oX1DF9ztSEfBwW4sW^6agfJm~#8 zjztTH&Jte{TW9pSp-sjPS-&30Rs@VyZ0)nd-w|wG=t?!mT@C|S(kxt_ts6|v@7@7^ z{Q_b%YU@e86oq4zH?aHH(zk^^ed*xMpOPyu1rxWr?ZIVWYVw?5v@JC1rvMkLx5h%H zM<7)25vkM$>%BXk+pJoiosGXsqg9>r-5epbZeh z7RYW$FUHH41}*m}(^tK|*QbGH$?1BOw{08)53BE8+X|#2iowO94_(Q;1wDR_il~%9b~>#l3K(~(X5O5XR{$!z zKue1DG7IL*VPbW5KU6YPmkf`Z)SVs$saU2+6fWS0aT)d|YPl{M@c@iU9?^;RfAlxX z$70pa8d7<)5-d>EhU)yzTWGMq&udh`4Whjody*fW3g(z+)etDYpq!@KZpkM#(m7#K$yUR*;sBA zekOKtUPJ5+2)Lz?01A&ks3;t(oPB(ZpFVg;DflyNxR-i1d;hFj{bEj*t|%z5Bilt3oDQW0E{>wD5`LJlE_6)|sMCo+u0^`eC1=DC| zV^7}mec1N<7AS;n5NXgA%-*m5CIM^?#@gFCMZlbl$|UOOv*%mJ1jyr#8JDb72C2Ed z0{d?Tk4ML~kDC-CV>L(fyHsC+Qmq24vsA2sGf2P5AxlItVmPTv0r&Cs?Geid`MV(@ zG!Zvd4cXq_p9T-Uxu+(A&55z@#)*BvK3CwAGV~k8rk08(LVmF#V%Umo*x|XP1!UwU zxDc8~s?J}gfz3y5`SeT9&}w~5qORdow^lF>#_0KAwN~p@Pz~BZ9?&(YFt~U_rt3{t z7$hI7-(Bna$ESk`;(>2VN`Kj>GaVmJU4kR#@Uc_RR9XEc=sgH}yCEJL8^XZD>I?c* z?~w_Pm=2v!*j6p|Yzw2~G29*_8N9zM`2pWPa0wKZ6h)81s?zdWR6?FXm+e7q(+njv zDDq+?1uEXjjpZj|LEu#oh>i-JM*9F;sLfpM4T4K0gP z`XIF%NQK!uspnR*JSbM_u$zWSu(ghu3Y8Uw+Rnp4`H^yz9^K~t(gBphc}o=NltuEO zSfyECg(fpV;)4oh%GA`2#WYa(0t)VN3Q@oX=I-YOz`1zDGH@<*&THLKMZj3aFLIL2 zv;n%+h`NoWn5R~1SzFHveG+fx460sP00e(l@9mqBXi7wyB~N8}Nc zs2|l@{Y?Vc94tAL6!=yLb_K$XBIrQ1&PIU*qF|d0Uj4*%ojQ@9mAr=>pm2*mio&tV z?aI8?!AF`z5==Fq?)k<|A$Szr3L z)i8(FTdIn|2bqKZDhYX?rTPsw7)_Q1*{MSl8aw>|NeG*xGdi|w`azBg;o~fH2Sm>i z3MV9q;>Jb#M~NhYY53ylm>~|H4OwanSwd4P9s7T!gw2yhto225fBOz-GtiVW#{XqX zae_#;13piQgPUy04kH{ZMM=qJ(*KnbHcwS&!y|<-nttR>jg9qF6iG?mA;fEr?q#)u zoVY+v&{4$x;J@Hw_0Jjo=_PrBd6${gL$eG73h?Ef3Z5S0KOC+!AU1=17E&=?rYerd zsz3Kn&t4WV?^Oxv(GyPYN)*7ud7ZEs6m}%xJt=RN$5ek1PoC+DrZ6h;uTsF~L8F_} z(jibI4y~fj5UM6Ckb(rh^5X~jNAM#=OfzF2&U_k2ey(8;wH%#?%u4&~RIqt@vL(cy z+_-KFn=jD`h>eRCPK77u9K9WNgJ%{(R>;)?G!gdJzfA<2llOO4-`dz$N zmLiFekr(e(`N3W1yFeDm6GTvYkKGE>vD!bu@ra|rqbic9vnL1lDnf^gOhUz?NWpZ5 zP10kg>^-s*a?lS70iB`oJNlPtVDpjx++@rJ=$-dFP3`!MKCVC-o|(54WYxNF^E4Gnx0@t*?vlvj#K5f!UN9t`S`rcvyW4+s;*QY7cikz~?mRkpE;? z8XhSQj=oZ0E@9N32glxnP;x2`1%G!{1dLT2Qyr(D4dWD}a;hWs=|cyWbBSh6pTeQ( zpwj?!qJC*Q43ERA^PJ)>S*{CrCPOFD4Dq4Y!|8Bjzm~bgRg08&la+h`2tEb9V7^|53)SAeF zTDc2`cUFj1&rW3k%lodqW?Iv?Ul;A6*upsZSghKarrVG7g!J>VZI&L}xHUi=& zL$Ehiz9YE^tFYvOp(yoSf?O(AYs_-n*_U8Wq`zDJBx-Y=gL>#%lYQyvY90~ z07|P(!P2nW&TbBF+6gm!g(j+$Df2UFG&H8~p`2JPIB+WoaR7v&v+vvY(4(-bv@37u zJ%ygh^nKJLQU2Xei~6xFg>M=8UzcvO0Bvwv0p*O9raUGfR$-<%=ksxG*b{k|8Zo#h zXn?=&)nWK9i+Fp)nmns!2$;_d@sdg9j6|%)(uxfoE`Y}A#niCtbCHfH2w}YU+WF(o z&g4-r&L9wt;^Xh)aaeWT7ap3?2!7IdR*8c1sHDTmxxROJ`R4>3DBWi=>f%4+aaeVe zvflH1AFMYoRG`c_H7(iDEsTcbp3#Hv-tG9x9?T*h+%+fRENMV<%=M|e}>;Q2OksaUN>tSv*MU>D7InL0DQ z9fm6BBGPGF8uwoToik}AW_FktN<6Q z_fY0Vi(BxSjW5{I9O0VB&~X*GXUjmh(g5x*Ba%TXiga7TjKr!;8a!8$0I|#f?hJ&* zKB;HKayn$Z)Nlm5uVrB9j`DAy^9<-jp>1;IL9t3_6!A7Kfq3NUNiCCIw63Q z@WB!A4P){3&3)E`*!D8zZ&RoM7pwP%_7}4U;PiRBsdonm%-Q(0{*uo|{BI7x8*0-#m4g`7gAS9t{y76k-vdvHPp>J zC%>*3{k#RG7y3n=4n|(lj`zakZs8lMK-zk+oC#vNY`A1lW>E8E;t#9MFSEmx!+ub* zyEG_0i?ajiX^h3H!b!3qzYHcD8MNP%w*`;Gs<#! zKMYnh9R#Y{oM3{67*ak0->$M)-oD$j994H1V-NMcK5ac zSN5yHvT_BuSiLVkPppCCW{A7}s3AB9I>a*m^iyy1P!{i`t;5D!ljoknSvi!r?s-if z6sz=(Rj-6g;PT?7Dir9c8+fRkZqXy37Z$5QsSY>-+5%nqAP82tqFnF3UIM{=ja++*F!)4@EU4x_Ozqp#t4G&BFD7S?n<60 z+Wc7u5(uJu;b2&0kD}YwOo0}6u6qm0Y-oo*G_W{6H(bmQ#*4eo$v(di#F2At-4Jm{ z0~mN%eMb3P+UNjJ59SL{XWz!7;Z2KBALzTYbUT| zFzDvcDpsVQUu*rj;X1$zKsO4XyGl_wR(Y$q+1L9+4Y>qg5}^BSwy%)`S9B&h z`PQ*%#-QB|oCZyz*T%m~0-KAiVXEUQ+QIoZQ>ZH+yEoG$FOOX*=58%Xg@fvH<~~y21GgPANW<5^Q-ASOOwodXs=@2C3%` zg%Tlv8#5zA;HuS_KnWEy=tW7yGI+TVex0Ns&|(;~Q-& z;C)+&2e2X`^*oc|jP!IL;O%J}5kwW!>h9?Av*A?@u(yd60&a1?K@}5g_d^t%fF2zT zCo%A4XiZD&r;>f(J$T`2R~4uqDULK z{*oM^P;+J2a?96UU>5S@Jc~bX!e1fe%i@^e;GYJzw`Doly4bN;4lD-;mNSd(=*qU| zICEHzE>vjP2{;@P5cA=OLdQJ9KskVQb>=v@II$gBj`q$h2P)JlXJdR60Fz{(+Ew&W zwv&^Ki@mFpi=(r%qk}z*#bHySCCgk~MYPZ}7^tHYi(~KP3MO!HWw|&zyRe<8&?)cV zZ6l$xWR%7}rdR50@8SwjR~I(N6}W6y7ketS&AiM!3n-`-P*COo{`2&xriF4K%dRe9 zO9w}e3!CNa%Az9cho`cAf#U-|bjEvS4L#D;!Nrwj4@J*$a&~ZXWwB+L<4s5HA@iOn z!?diWWjZ=IIXJP|4v>8p7blLrv!f$b=e+G&O1}yT)p-xYWV<-qyE;0937j2WI8OG? zG6nz4X5+D`pfMSKs5uuuq1EYZ@8}A4bmTbLJ91o|U1YufqqW8o(m_ciDzpDndL-D; z9&+yN!g8@^xv-oZovB$rD{^b83Z*^+ekk(;J=4X3<>YG5cI7xa0h8n6z?P{-xJR3|CtL@Tm-RDs23xw06y<3OF(PFkyYCNY$RlTx=i*T*3(0s9346KEC*)?2e2%MZ4Uv83cXOe;=f-|wBj#+ zM+;goVGuxT% z#3rpO(+0IKnyjE?-cfT7`bf)k zc5#LF$li&~ae-_@Pe;a;G8Z%&gaVGNO=#9X(IcTk+Cz8-KZ1(Mv3I2j_XCVa-iQF+ zG#TcOCR&+LLeQ<@K$N$K(kE-FOc+sCA(DUf#ZaB&KG8Fs99V2Nv`f(BahxHFxXMJ4 zHp4@_$O#5=z=)1KG(V$ErmntNIWPAxI1agQ6DI7Wo=Bh!Ah#)q$)zl+BX2I3*d)Sx zw(wp{skh*&wQoAau)!Xl!$)~iU%IyHrnUMYf(U*L!vf@`7tj;OjwE{Ib?D^{yCKK* z`!PcL^QPh8U$6NO_Z&wv2#I`SCAtDL0;519tT#(NE2iiYqdYyvj2t|AJk5*8Pq!NN z%es0}ufd^0fw}}YMtXc46;I9R^mNTZHlt!;Tq$QL{3*D>fF+;u6zHI`g8^y{#GC61xe$2tqQam;4hJA`*oN`Tit{22h`7 z_wYFQ4LRh6MOCS1?2ey+AGZj=P*)VmY@bp$RYC|5>!s9j;m|!lSry5p<9Tp9)AJE1U;%L@l$^F0T$rRJPlzfKL@#sSt8$x;5F=jwG7 z_2XhlS;Cj16<+VbX(OP-&>?uMT7&?H7(~H38mvdkLIz#Z4t)sTpAN2%mK5hD0{O87 zDGT7P8|^<3{H9B{=Ibv1WP=#+_9sBXgCm#e5*OONQsIE}FG!%yX&n9Z>s^^^?qwwm zzZ1&}Xe5Zma5@mg0I8>Idp!cqypIgwOZ;I@gva-1IoR7qPm8A3p|{mXztGp^T%Fbg z530E>S9%4-Wx>01T2N{kf&5_JNW>(7_bTRUYDI^GR>Yv5gL7$TwCXL$TXV5@VN zeys!H?m{9Q>N1*q-|;`+ZN7W*_l=pd$zKZ7&$$VR3<%+QkIeseQ&{I zsL&8+DRE^-FqI;)jps%PsdXXDWYX;{$b+RXwJpsL`$_n56{T$AW&6%AItpbDCjk+# z(?sf7nADnpGxedXU_)*j6u~{|@v=#Ot==sr$L9Ot2W_L&Bbwcs_<47XEqV3?RkZQ9 z2XB@^BbW>I4{D**vnl!aoA_uP)vIs6kx=HNJ*clwPJ`F^f&MHG+;$o(;K$L0k7WMw zq47{=tKF%k|6pBfB7_?iC6VyNf~aUaY!9Y-=DJ(H5QOc)f6*9{nu8)ndyX1Cc(@l$ zkA%!Mbhw%yv_;T4g))9GQpuL#ql%AHjG#%0*+HF@^`t<^Z2AAl*U#e?Wocci0#Dk5 zAP6Ruda>mWNcwg83sZE}A%>574qe#G6$Amgq#otF(uiLytd{rFYj^v=>l5II`bc** zeb9p3=OUYxu%&KJmmx6np~XTYYu4}Ar7l>J1?=&v=}mU+$&n2jBeHbJ^x>*ME>HR# zSy{x>$cU5^FmFHo2-Ox}5yUSR))pY)x2vGGcxwe($m=hd%4AdY2lLqwVaD{QW~Ez? zU#~Y`q4Dc76xpmyINNb>PjGlEa1GR1Z@MCgKR1)eLS|;W?3n{PX8c2qc21rM7{DAy8&tDdCq3GEoY5_tnTyt+ z)_Fs{(qU0mT$chMM`p`Hnie-)g|0dwgdbWd#-0Ebl+&wa-(Ov^_+||h==*upN>TOa zed!~gWf3vPt)8nvi59^Rj4Jhfyw-+*Qi!mDE`d1OE)GtDfm26#d>fdzu=61QA8qG* z@0xr^@-mP>@DYePQjgmW1|YUu`Ms-lgb2oBsZjZ?rDzZe?lbVn?dd`;>;eKK*0it z3sJOIj_nqqwjHbz1XhNKDD~W_Q3xNzj}nUF$)Pwc|CMvEBkmymjS6+QPDPzU{Lm;a zEm&~cNsIha`;v^&{hliXhK(UG9YjCyOQ{tlqe`jAQ8tz zkw=FF#sr1HCEIi(%&s%n=)*zTL`N_WbW5Zj?biw=B;mu1COx~G`mD2sKwJ%c)GdSF zD99Ga3V1L)r5PGuH|nRmAK>J@0!~u0;Y!`Ne*@?8c%m4L@09f1c~wgdj`)NcYlfsD zu4hOCxK6_MFgFa-G|Tqsw4Q!+ijC z4&MQGCp90bCmR%RA2WOO5*$=tta;>UpBCW3DRGXOR$Hl2mO?|u^z4!I0~K0De7=~x&LKSswAJ279#=fa$atg+(f z{?q0$yg0^_+O68ff%i#AkEKhu)jF>(@F5TJAN(NWQqN&8^1bG1XF3zdrY^r`(gM~y zU~&&F9p%CBVuT<%lsrnB&i%Cj str: + """ + Performs a search using Google Custom Search JSON API. + + Args: + user_google_email (str): The user's Google email address. Required. + q (str): The search query. Required. + num (int): Number of results to return (1-10). Defaults to 10. + start (int): The index of the first result to return (1-based). Defaults to 1. + safe (Literal["active", "moderate", "off"]): Safe search level. Defaults to "off". + search_type (Optional[Literal["image"]]): Search for images if set to "image". + site_search (Optional[str]): Restrict search to a specific site/domain. + site_search_filter (Optional[Literal["e", "i"]]): Exclude ("e") or include ("i") site_search results. + date_restrict (Optional[str]): Restrict results by date (e.g., "d5" for past 5 days, "m3" for past 3 months). + file_type (Optional[str]): Filter by file type (e.g., "pdf", "doc"). + language (Optional[str]): Language code for results (e.g., "lang_en"). + country (Optional[str]): Country code for results (e.g., "countryUS"). + sites (Optional[List[str]]): List of sites/domains to restrict search to (e.g., ["example.com", "docs.example.com"]). When provided, results are limited to these sites. + + Returns: + str: Formatted search results including title, link, and snippet for each result. + """ + # Get API key and search engine ID from environment + api_key = os.environ.get("GOOGLE_PSE_API_KEY") + if not api_key: + raise ValueError( + "GOOGLE_PSE_API_KEY environment variable not set. Please set it to your Google Custom Search API key." + ) + + cx = os.environ.get("GOOGLE_PSE_ENGINE_ID") + if not cx: + raise ValueError( + "GOOGLE_PSE_ENGINE_ID environment variable not set. Please set it to your Programmable Search Engine ID." + ) + + logger.info( + f"[search_custom] Invoked. Email: '{user_google_email}', Query: '{q}', CX: '{cx}'" + ) + + # Apply site restriction if sites are provided + if sites: + site_query = " OR ".join([f"site:{site}" for site in sites]) + q = f"{q} ({site_query})" + logger.info(f"[search_custom] Applied site restriction: {sites}") + + # Build the request parameters + params = { + "key": api_key, + "cx": cx, + "q": q, + "num": num, + "start": start, + "safe": safe, + } + + # Add optional parameters + if search_type: + params["searchType"] = search_type + if site_search: + params["siteSearch"] = site_search + if site_search_filter: + params["siteSearchFilter"] = site_search_filter + if date_restrict: + params["dateRestrict"] = date_restrict + if file_type: + params["fileType"] = file_type + if language: + params["lr"] = language + if country: + params["cr"] = country + + # Execute the search request + result = await asyncio.to_thread(service.cse().list(**params).execute) + + # Extract search information + search_info = result.get("searchInformation", {}) + total_results = search_info.get("totalResults", "0") + search_time = search_info.get("searchTime", 0) + + # Extract search results + items = result.get("items", []) + + # Format the response + confirmation_message = f"""Search Results for {user_google_email}: +- Query: "{q}" +- Search Engine ID: {cx} +- Total Results: {total_results} +- Search Time: {search_time:.3f} seconds +- Results Returned: {len(items)} (showing {start} to {start + len(items) - 1}) + +""" + + if items: + confirmation_message += "Results:\n" + for i, item in enumerate(items, start): + title = item.get("title", "No title") + link = item.get("link", "No link") + snippet = item.get("snippet", "No description available").replace("\n", " ") + + confirmation_message += f"\n{i}. {title}\n" + confirmation_message += f" URL: {link}\n" + confirmation_message += f" Snippet: {snippet}\n" + + # Add additional metadata if available + if "pagemap" in item: + pagemap = item["pagemap"] + if "metatags" in pagemap and pagemap["metatags"]: + metatag = pagemap["metatags"][0] + if "og:type" in metatag: + confirmation_message += f" Type: {metatag['og:type']}\n" + if "article:published_time" in metatag: + confirmation_message += ( + f" Published: {metatag['article:published_time'][:10]}\n" + ) + else: + confirmation_message += "\nNo results found." + + # Add information about pagination + queries = result.get("queries", {}) + if "nextPage" in queries: + next_start = queries["nextPage"][0].get("startIndex", 0) + confirmation_message += ( + f"\n\nTo see more results, search again with start={next_start}" + ) + + logger.info(f"Search completed successfully for {user_google_email}") + return confirmation_message + + +@server.tool() +@handle_http_errors( + "get_search_engine_info", is_read_only=True, service_type="customsearch" +) +@require_google_service("customsearch", "customsearch") +async def get_search_engine_info(service, user_google_email: str) -> str: + """ + Retrieves metadata about a Programmable Search Engine. + + Args: + user_google_email (str): The user's Google email address. Required. + + Returns: + str: Information about the search engine including its configuration and available refinements. + """ + # Get API key and search engine ID from environment + api_key = os.environ.get("GOOGLE_PSE_API_KEY") + if not api_key: + raise ValueError( + "GOOGLE_PSE_API_KEY environment variable not set. Please set it to your Google Custom Search API key." + ) + + cx = os.environ.get("GOOGLE_PSE_ENGINE_ID") + if not cx: + raise ValueError( + "GOOGLE_PSE_ENGINE_ID environment variable not set. Please set it to your Programmable Search Engine ID." + ) + + logger.info( + f"[get_search_engine_info] Invoked. Email: '{user_google_email}', CX: '{cx}'" + ) + + # Perform a minimal search to get the search engine context + params = { + "key": api_key, + "cx": cx, + "q": "test", # Minimal query to get metadata + "num": 1, + } + + result = await asyncio.to_thread(service.cse().list(**params).execute) + + # Extract context information + context = result.get("context", {}) + title = context.get("title", "Unknown") + + confirmation_message = f"""Search Engine Information for {user_google_email}: +- Search Engine ID: {cx} +- Title: {title} +""" + + # Add facet information if available + if "facets" in context: + confirmation_message += "\nAvailable Refinements:\n" + for facet in context["facets"]: + for item in facet: + label = item.get("label", "Unknown") + anchor = item.get("anchor", "Unknown") + confirmation_message += f" - {label} (anchor: {anchor})\n" + + # Add search information + search_info = result.get("searchInformation", {}) + if search_info: + total_results = search_info.get("totalResults", "Unknown") + confirmation_message += "\nSearch Statistics:\n" + confirmation_message += f" - Total indexed results: {total_results}\n" + + logger.info(f"Search engine info retrieved successfully for {user_google_email}") + return confirmation_message diff --git a/gsheets/__init__.py b/gsheets/__init__.py new file mode 100644 index 0000000..623a8d9 --- /dev/null +++ b/gsheets/__init__.py @@ -0,0 +1,23 @@ +""" +Google Sheets MCP Integration + +This module provides MCP tools for interacting with Google Sheets API. +""" + +from .sheets_tools import ( + list_spreadsheets, + get_spreadsheet_info, + read_sheet_values, + modify_sheet_values, + create_spreadsheet, + create_sheet, +) + +__all__ = [ + "list_spreadsheets", + "get_spreadsheet_info", + "read_sheet_values", + "modify_sheet_values", + "create_spreadsheet", + "create_sheet", +] diff --git a/gsheets/sheets_helpers.py b/gsheets/sheets_helpers.py new file mode 100644 index 0000000..6ac6112 --- /dev/null +++ b/gsheets/sheets_helpers.py @@ -0,0 +1,1050 @@ +""" +Google Sheets Helper Functions + +Shared utilities for Google Sheets operations including A1 parsing and +conditional formatting helpers. +""" + +import asyncio +import json +import logging +import re +from typing import List, Optional, Union + +from core.utils import UserInputError + +logger = logging.getLogger(__name__) + +MAX_GRID_METADATA_CELLS = 5000 + +A1_PART_REGEX = re.compile(r"^([A-Za-z]*)(\d*)$") +SHEET_TITLE_SAFE_RE = re.compile(r"^[A-Za-z0-9_]+$") + + +def _column_to_index(column: str) -> Optional[int]: + """Convert column letters (A, B, AA) to zero-based index.""" + if not column: + return None + result = 0 + for char in column.upper(): + result = result * 26 + (ord(char) - ord("A") + 1) + return result - 1 + + +def _parse_a1_part( + part: str, pattern: re.Pattern[str] = A1_PART_REGEX +) -> tuple[Optional[int], Optional[int]]: + """ + Parse a single A1 part like 'B2' or 'C' into zero-based column/row indexes. + Supports anchors like '$A$1' by stripping the dollar signs. + """ + clean_part = part.replace("$", "") + match = pattern.match(clean_part) + if not match: + raise UserInputError(f"Invalid A1 range part: '{part}'.") + col_letters, row_digits = match.groups() + col_idx = _column_to_index(col_letters) if col_letters else None + row_idx = int(row_digits) - 1 if row_digits else None + return col_idx, row_idx + + +def _split_sheet_and_range(range_name: str) -> tuple[Optional[str], str]: + """ + Split an A1 notation into (sheet_name, range_part), handling quoted sheet names. + + Examples: + - "Sheet1!A1:B2" -> ("Sheet1", "A1:B2") + - "'My Sheet'!$A$1:$B$10" -> ("My Sheet", "$A$1:$B$10") + - "A1:B2" -> (None, "A1:B2") + """ + if "!" not in range_name: + return None, range_name + + if range_name.startswith("'"): + closing = range_name.find("'!") + if closing != -1: + sheet_name = range_name[1:closing].replace("''", "'") + a1_range = range_name[closing + 2 :] + return sheet_name, a1_range + + sheet_name, a1_range = range_name.split("!", 1) + return sheet_name.strip().strip("'"), a1_range + + +def _parse_a1_range(range_name: str, sheets: List[dict]) -> dict: + """ + Convert an A1-style range (with optional sheet name) into a GridRange. + + Falls back to the first sheet if none is provided. + """ + sheet_name, a1_range = _split_sheet_and_range(range_name) + + if not sheets: + raise UserInputError("Spreadsheet has no sheets.") + + target_sheet = None + if sheet_name: + for sheet in sheets: + if sheet.get("properties", {}).get("title") == sheet_name: + target_sheet = sheet + break + if target_sheet is None: + available_titles = [ + sheet.get("properties", {}).get("title", "Untitled") for sheet in sheets + ] + available_list = ", ".join(available_titles) if available_titles else "none" + raise UserInputError( + f"Sheet '{sheet_name}' not found in spreadsheet. Available sheets: {available_list}." + ) + else: + target_sheet = sheets[0] + + props = target_sheet.get("properties", {}) + sheet_id = props.get("sheetId") + + if not a1_range: + raise UserInputError("A1-style range must not be empty (e.g., 'A1', 'A1:B10').") + + if ":" in a1_range: + start, end = a1_range.split(":", 1) + else: + start = end = a1_range + + start_col, start_row = _parse_a1_part(start) + end_col, end_row = _parse_a1_part(end) + + grid_range = {"sheetId": sheet_id} + if start_row is not None: + grid_range["startRowIndex"] = start_row + if start_col is not None: + grid_range["startColumnIndex"] = start_col + if end_row is not None: + grid_range["endRowIndex"] = end_row + 1 + if end_col is not None: + grid_range["endColumnIndex"] = end_col + 1 + + return grid_range + + +def _parse_hex_color(color: Optional[str]) -> Optional[dict]: + """ + Convert a hex color like '#RRGGBB' to Sheets API color (0-1 floats). + """ + if not color: + return None + + trimmed = color.strip() + if trimmed.startswith("#"): + trimmed = trimmed[1:] + + if len(trimmed) != 6: + raise UserInputError(f"Color '{color}' must be in format #RRGGBB or RRGGBB.") + + try: + red = int(trimmed[0:2], 16) / 255 + green = int(trimmed[2:4], 16) / 255 + blue = int(trimmed[4:6], 16) / 255 + except ValueError as exc: + raise UserInputError(f"Color '{color}' is not valid hex.") from exc + + return {"red": red, "green": green, "blue": blue} + + +def _index_to_column(index: int) -> str: + """ + Convert a zero-based column index to column letters (0 -> A, 25 -> Z, 26 -> AA). + """ + if index < 0: + raise UserInputError(f"Column index must be non-negative, got {index}.") + + result = [] + index += 1 # Convert to 1-based for calculation + while index: + index, remainder = divmod(index - 1, 26) + result.append(chr(ord("A") + remainder)) + return "".join(reversed(result)) + + +def _quote_sheet_title_for_a1(sheet_title: str) -> str: + """ + Quote a sheet title for use in A1 notation if necessary. + + If the sheet title contains special characters or spaces, it is wrapped in single quotes. + Any single quotes in the title are escaped by doubling them, as required by Google Sheets. + """ + if SHEET_TITLE_SAFE_RE.match(sheet_title or ""): + return sheet_title + escaped = (sheet_title or "").replace("'", "''") + return f"'{escaped}'" + + +def _format_a1_cell(sheet_title: str, row_index: int, col_index: int) -> str: + """ + Format a cell reference in A1 notation given a sheet title and zero-based row/column indices. + + Args: + sheet_title: The title of the sheet. + row_index: Zero-based row index (0 for first row). + col_index: Zero-based column index (0 for column A). + + Returns: + A string representing the cell reference in A1 notation, e.g., 'Sheet1!B2'. + """ + return f"{_quote_sheet_title_for_a1(sheet_title)}!{_index_to_column(col_index)}{row_index + 1}" + + +def _coerce_int(value: object, default: int = 0) -> int: + """ + Safely convert a value to an integer, returning a default value if conversion fails. + + Args: + value: The value to convert to int. + default: The value to return if conversion fails (default is 0). + + Returns: + The integer value of `value`, or `default` if conversion fails. + """ + try: + return int(value) # type: ignore[arg-type] + except (TypeError, ValueError): + return default + + +def _is_sheets_error_token(value: object) -> bool: + """ + Detect whether a cell value represents a Google Sheets error token (e.g., #ERROR!, #NAME?, #REF!, #N/A). + + Returns True if the value is a string that starts with '#' and ends with '!' or '?', or is exactly '#N/A'. + """ + if not isinstance(value, str): + return False + candidate = value.strip() + if not candidate.startswith("#"): + return False + upper_candidate = candidate.upper() + if upper_candidate == "#N/A": + return True + return upper_candidate.endswith(("!", "?")) + + +def _values_contain_sheets_errors(values: List[List[object]]) -> bool: + """ + Check whether a 2D array of cell values contains any Google Sheets error tokens. + + Args: + values: A 2D list of cell values (as returned from the Sheets API). + + Returns: + True if any cell contains a Google Sheets error token, False otherwise. + """ + for row in values: + for cell in row: + if _is_sheets_error_token(cell): + return True + return False + + +def _a1_range_for_values(a1_range: str, values: List[List[object]]) -> Optional[str]: + """ + Compute a tight A1 range for a returned values matrix. + + This helps keep follow-up includeGridData payloads small vs. using a wide requested range. + Only applies when the A1 range has an explicit starting cell (e.g., 'Sheet1!B2:D10'). + """ + sheet_name, range_part = _split_sheet_and_range(a1_range) + if not range_part: + return None + + start_part = range_part.split(":", 1)[0] + start_col, start_row = _parse_a1_part(start_part) + if start_col is None or start_row is None: + return None + + height = len(values) + width = max((len(row) for row in values), default=0) + if height <= 0 or width <= 0: + return None + + end_row = start_row + height - 1 + end_col = start_col + width - 1 + + start_label = f"{_index_to_column(start_col)}{start_row + 1}" + end_label = f"{_index_to_column(end_col)}{end_row + 1}" + range_ref = ( + start_label if start_label == end_label else f"{start_label}:{end_label}" + ) + + if sheet_name: + return f"{_quote_sheet_title_for_a1(sheet_name)}!{range_ref}" + return range_ref + + +def _a1_range_cell_count(a1_range: str) -> Optional[int]: + """ + Return cell count for an explicit rectangular A1 range (e.g. A1:C10). + + Returns None when the range is open-ended or otherwise does not include + both row and column bounds. + """ + _, range_part = _split_sheet_and_range(a1_range) + if not range_part: + return None + + if ":" in range_part: + start_part, end_part = range_part.split(":", 1) + else: + start_part = end_part = range_part + + try: + start_col, start_row = _parse_a1_part(start_part) + end_col, end_row = _parse_a1_part(end_part) + except UserInputError: + return None + + if None in (start_col, start_row, end_col, end_row): + return None + if end_col < start_col or end_row < start_row: + return None + + return (end_col - start_col + 1) * (end_row - start_row + 1) + + +def _extract_cell_errors_from_grid(spreadsheet: dict) -> list[dict[str, Optional[str]]]: + """ + Extracts error information from spreadsheet grid data. + + Iterates through the sheets and their grid data in the provided spreadsheet dictionary, + collecting all cell errors. Returns a list of dictionaries, each containing: + - "cell": the A1 notation of the cell with the error, + - "type": the error type (e.g., "ERROR", "N/A"), + - "message": the error message, if available. + + Args: + spreadsheet (dict): The spreadsheet data as returned by the Sheets API with grid data included. + + Returns: + list[dict[str, Optional[str]]]: List of error details for each cell with an error. + """ + errors: list[dict[str, Optional[str]]] = [] + for sheet in spreadsheet.get("sheets", []) or []: + sheet_title = sheet.get("properties", {}).get("title") or "Unknown" + for grid in sheet.get("data", []) or []: + start_row = _coerce_int(grid.get("startRow"), default=0) + start_col = _coerce_int(grid.get("startColumn"), default=0) + for row_offset, row_data in enumerate(grid.get("rowData", []) or []): + if not row_data: + continue + for col_offset, cell_data in enumerate( + row_data.get("values", []) or [] + ): + if not cell_data: + continue + error_value = (cell_data.get("effectiveValue") or {}).get( + "errorValue" + ) or None + if not error_value: + continue + errors.append( + { + "cell": _format_a1_cell( + sheet_title, + start_row + row_offset, + start_col + col_offset, + ), + "type": error_value.get("type"), + "message": error_value.get("message"), + } + ) + return errors + + +def _extract_cell_hyperlinks_from_grid(spreadsheet: dict) -> list[dict[str, str]]: + """ + Extract hyperlink URLs from spreadsheet grid data. + + Returns a list of dictionaries with: + - "cell": cell A1 reference + - "url": hyperlink URL + + For rich text cells, this includes URLs from both `CellData.hyperlink` + and `textFormatRuns[].format.link.uri`. + """ + hyperlinks: list[dict[str, str]] = [] + for sheet in spreadsheet.get("sheets", []) or []: + sheet_title = sheet.get("properties", {}).get("title") or "Unknown" + for grid in sheet.get("data", []) or []: + start_row = _coerce_int(grid.get("startRow"), default=0) + start_col = _coerce_int(grid.get("startColumn"), default=0) + for row_offset, row_data in enumerate(grid.get("rowData", []) or []): + if not row_data: + continue + for col_offset, cell_data in enumerate( + row_data.get("values", []) or [] + ): + if not cell_data: + continue + cell_urls: list[str] = [] + seen_urls: set[str] = set() + + hyperlink = cell_data.get("hyperlink") + if ( + isinstance(hyperlink, str) + and hyperlink + and hyperlink not in seen_urls + ): + seen_urls.add(hyperlink) + cell_urls.append(hyperlink) + + for text_run in cell_data.get("textFormatRuns", []) or []: + if not isinstance(text_run, dict): + continue + link_uri = ( + (text_run.get("format") or {}).get("link") or {} + ).get("uri") + if not isinstance(link_uri, str) or not link_uri: + continue + if link_uri in seen_urls: + continue + seen_urls.add(link_uri) + cell_urls.append(link_uri) + + if not cell_urls: + continue + cell_ref = _format_a1_cell( + sheet_title, start_row + row_offset, start_col + col_offset + ) + for url in cell_urls: + hyperlinks.append({"cell": cell_ref, "url": url}) + return hyperlinks + + +async def _fetch_detailed_sheet_errors( + service, spreadsheet_id: str, a1_range: str +) -> list[dict[str, Optional[str]]]: + response = await asyncio.to_thread( + service.spreadsheets() + .get( + spreadsheetId=spreadsheet_id, + ranges=[a1_range], + includeGridData=True, + fields="sheets(properties(title),data(startRow,startColumn,rowData(values(effectiveValue(errorValue(type,message))))))", + ) + .execute + ) + return _extract_cell_errors_from_grid(response) + + +async def _fetch_sheet_hyperlinks( + service, spreadsheet_id: str, a1_range: str +) -> list[dict[str, str]]: + response = await asyncio.to_thread( + service.spreadsheets() + .get( + spreadsheetId=spreadsheet_id, + ranges=[a1_range], + includeGridData=True, + fields="sheets(properties(title),data(startRow,startColumn,rowData(values(hyperlink,textFormatRuns(format(link(uri)))))))", + ) + .execute + ) + return _extract_cell_hyperlinks_from_grid(response) + + +def _format_sheet_error_section( + *, errors: list[dict[str, Optional[str]]], range_label: str, max_details: int = 25 +) -> str: + """ + Format a list of cell error information into a human-readable section. + + Args: + errors: A list of dictionaries, each containing details about a cell error, + including the cell location, error type, and message. + range_label: A string label for the range in which the errors occurred. + max_details: The maximum number of error details to include in the output. + If the number of errors exceeds this value, the output will be truncated + and a summary line will indicate how many additional errors were omitted. + + Returns: + A formatted string listing the cell errors in a human-readable format. + If there are no errors, returns an empty string. + """ + # Limit the number of error details to 25 for performance and readability. + if not errors: + return "" + + lines = [] + for item in errors[:max_details]: + cell = item.get("cell") or "(unknown cell)" + error_type = item.get("type") + message = item.get("message") + if error_type and message: + lines.append(f"- {cell}: {error_type} — {message}") + elif message: + lines.append(f"- {cell}: {message}") + elif error_type: + lines.append(f"- {cell}: {error_type}") + else: + lines.append(f"- {cell}: (unknown error)") + + suffix = ( + f"\n... and {len(errors) - max_details} more errors" + if len(errors) > max_details + else "" + ) + return ( + f"\n\nDetailed cell errors in range '{range_label}':\n" + + "\n".join(lines) + + suffix + ) + + +def _format_sheet_hyperlink_section( + *, hyperlinks: list[dict[str, str]], range_label: str, max_details: int = 25 +) -> str: + """ + Format a list of cell hyperlinks into a human-readable section. + """ + if not hyperlinks: + return "" + + lines = [] + for item in hyperlinks[:max_details]: + cell = item.get("cell") or "(unknown cell)" + url = item.get("url") or "(missing url)" + lines.append(f"- {cell}: {url}") + + suffix = ( + f"\n... and {len(hyperlinks) - max_details} more hyperlinks" + if len(hyperlinks) > max_details + else "" + ) + return f"\n\nHyperlinks in range '{range_label}':\n" + "\n".join(lines) + suffix + + +def _color_to_hex(color: Optional[dict]) -> Optional[str]: + """ + Convert a Sheets color object back to #RRGGBB hex string for display. + """ + if not color: + return None + + def _component(value: Optional[float]) -> int: + try: + # Clamp and round to nearest integer in 0-255 + return max(0, min(255, int(round(float(value or 0) * 255)))) + except (TypeError, ValueError): + return 0 + + red = _component(color.get("red")) + green = _component(color.get("green")) + blue = _component(color.get("blue")) + return f"#{red:02X}{green:02X}{blue:02X}" + + +def _grid_range_to_a1(grid_range: dict, sheet_titles: dict[int, str]) -> str: + """ + Convert a GridRange to an A1-like string using known sheet titles. + Falls back to the sheet ID if the title is unknown. + """ + sheet_id = grid_range.get("sheetId") + sheet_title = sheet_titles.get(sheet_id, f"Sheet {sheet_id}") + + start_row = grid_range.get("startRowIndex") + end_row = grid_range.get("endRowIndex") + start_col = grid_range.get("startColumnIndex") + end_col = grid_range.get("endColumnIndex") + + # If nothing is specified, treat as the whole sheet. + if start_row is None and end_row is None and start_col is None and end_col is None: + return sheet_title + + def row_label(idx: Optional[int]) -> str: + return str(idx + 1) if idx is not None else "" + + def col_label(idx: Optional[int]) -> str: + return _index_to_column(idx) if idx is not None else "" + + start_label = f"{col_label(start_col)}{row_label(start_row)}" + # end indices in GridRange are exclusive; subtract 1 for display + end_label = f"{col_label(end_col - 1 if end_col is not None else None)}{row_label(end_row - 1 if end_row is not None else None)}" + + if start_label and end_label: + range_ref = ( + start_label if start_label == end_label else f"{start_label}:{end_label}" + ) + elif start_label: + range_ref = start_label + elif end_label: + range_ref = end_label + else: + range_ref = "" + + return f"{sheet_title}!{range_ref}" if range_ref else sheet_title + + +def _summarize_conditional_rule( + rule: dict, index: int, sheet_titles: dict[int, str] +) -> str: + """ + Produce a concise human-readable summary of a conditional formatting rule. + """ + ranges = rule.get("ranges", []) + range_labels = [_grid_range_to_a1(rng, sheet_titles) for rng in ranges] or [ + "(no range)" + ] + + if "booleanRule" in rule: + boolean_rule = rule["booleanRule"] + condition = boolean_rule.get("condition", {}) + cond_type = condition.get("type", "UNKNOWN") + cond_values = [ + val.get("userEnteredValue") + for val in condition.get("values", []) + if isinstance(val, dict) and "userEnteredValue" in val + ] + value_desc = f" values={cond_values}" if cond_values else "" + + fmt = boolean_rule.get("format", {}) + fmt_parts = [] + bg_hex = _color_to_hex(fmt.get("backgroundColor")) + if bg_hex: + fmt_parts.append(f"bg {bg_hex}") + fg_hex = _color_to_hex(fmt.get("textFormat", {}).get("foregroundColor")) + if fg_hex: + fmt_parts.append(f"text {fg_hex}") + fmt_desc = ", ".join(fmt_parts) if fmt_parts else "no format" + + return f"[{index}] {cond_type}{value_desc} -> {fmt_desc} on {', '.join(range_labels)}" + + if "gradientRule" in rule: + gradient_rule = rule["gradientRule"] + points = [] + for point_name in ("minpoint", "midpoint", "maxpoint"): + point = gradient_rule.get(point_name) + if not point: + continue + color_hex = _color_to_hex(point.get("color")) + type_desc = point.get("type", point_name) + value_desc = point.get("value") + point_desc = type_desc + if value_desc: + point_desc += f":{value_desc}" + if color_hex: + point_desc += f" {color_hex}" + points.append(point_desc) + gradient_desc = " | ".join(points) if points else "gradient" + return f"[{index}] gradient -> {gradient_desc} on {', '.join(range_labels)}" + + return f"[{index}] (unknown rule) on {', '.join(range_labels)}" + + +def _format_conditional_rules_section( + sheet_title: str, + rules: List[dict], + sheet_titles: dict[int, str], + indent: str = " ", +) -> str: + """ + Build a multi-line string describing conditional formatting rules for a sheet. + """ + if not rules: + return f'{indent}Conditional formats for "{sheet_title}": none.' + + lines = [f'{indent}Conditional formats for "{sheet_title}" ({len(rules)}):'] + for idx, rule in enumerate(rules): + lines.append( + f"{indent} {_summarize_conditional_rule(rule, idx, sheet_titles)}" + ) + return "\n".join(lines) + + +CONDITION_TYPES = { + "NUMBER_GREATER", + "NUMBER_GREATER_THAN_EQ", + "NUMBER_LESS", + "NUMBER_LESS_THAN_EQ", + "NUMBER_EQ", + "NUMBER_NOT_EQ", + "TEXT_CONTAINS", + "TEXT_NOT_CONTAINS", + "TEXT_STARTS_WITH", + "TEXT_ENDS_WITH", + "TEXT_EQ", + "DATE_BEFORE", + "DATE_ON_OR_BEFORE", + "DATE_AFTER", + "DATE_ON_OR_AFTER", + "DATE_EQ", + "DATE_NOT_EQ", + "DATE_BETWEEN", + "DATE_NOT_BETWEEN", + "NOT_BLANK", + "BLANK", + "CUSTOM_FORMULA", + "ONE_OF_RANGE", +} + +GRADIENT_POINT_TYPES = {"MIN", "MAX", "NUMBER", "PERCENT", "PERCENTILE"} + + +async def _fetch_sheets_with_rules( + service, spreadsheet_id: str +) -> tuple[List[dict], dict[int, str]]: + """ + Fetch sheets with titles and conditional format rules in a single request. + """ + response = await asyncio.to_thread( + service.spreadsheets() + .get( + spreadsheetId=spreadsheet_id, + fields="sheets(properties(sheetId,title),conditionalFormats)", + ) + .execute + ) + sheets = response.get("sheets", []) or [] + sheet_titles: dict[int, str] = {} + for sheet in sheets: + props = sheet.get("properties", {}) + sid = props.get("sheetId") + if sid is not None: + sheet_titles[sid] = props.get("title", f"Sheet {sid}") + return sheets, sheet_titles + + +def _select_sheet(sheets: List[dict], sheet_name: Optional[str]) -> dict: + """ + Select a sheet by name, or default to the first sheet if name is not provided. + """ + if not sheets: + raise UserInputError("Spreadsheet has no sheets.") + + if sheet_name is None: + return sheets[0] + + for sheet in sheets: + if sheet.get("properties", {}).get("title") == sheet_name: + return sheet + + available_titles = [ + sheet.get("properties", {}).get("title", "Untitled") for sheet in sheets + ] + raise UserInputError( + f"Sheet '{sheet_name}' not found. Available sheets: {', '.join(available_titles)}." + ) + + +def _parse_condition_values( + condition_values: Optional[Union[str, List[Union[str, int, float]]]], +) -> Optional[List[Union[str, int, float]]]: + """ + Normalize and validate condition_values into a list of strings/numbers. + """ + parsed = condition_values + if isinstance(parsed, str): + try: + parsed = json.loads(parsed) + except json.JSONDecodeError as exc: + raise UserInputError( + "condition_values must be a list or a JSON-encoded list (e.g., '[\"=$B2>1000\"]')." + ) from exc + + if parsed is not None and not isinstance(parsed, list): + parsed = [parsed] + + if parsed: + for idx, val in enumerate(parsed): + if not isinstance(val, (str, int, float)): + raise UserInputError( + f"condition_values[{idx}] must be a string or number, got {type(val).__name__}." + ) + + return parsed + + +def _parse_gradient_points( + gradient_points: Optional[Union[str, List[dict]]], +) -> Optional[List[dict]]: + """ + Normalize gradient points into a list of dicts with type/value/color. + Each point must have a 'type' (MIN, MAX, NUMBER, PERCENT, PERCENTILE) and a color. + """ + if gradient_points is None: + return None + + parsed = gradient_points + if isinstance(parsed, str): + try: + parsed = json.loads(parsed) + except json.JSONDecodeError as exc: + raise UserInputError( + "gradient_points must be a list or JSON-encoded list of points " + '(e.g., \'[{"type":"MIN","color":"#ffffff"}, {"type":"MAX","color":"#ff0000"}]\').' + ) from exc + + if not isinstance(parsed, list): + raise UserInputError("gradient_points must be a list of point objects.") + + if len(parsed) < 2 or len(parsed) > 3: + raise UserInputError("Provide 2 or 3 gradient points (min/max or min/mid/max).") + + normalized_points: List[dict] = [] + for idx, point in enumerate(parsed): + if not isinstance(point, dict): + raise UserInputError( + f"gradient_points[{idx}] must be an object with type/color." + ) + + point_type = point.get("type") + if not point_type or point_type.upper() not in GRADIENT_POINT_TYPES: + raise UserInputError( + f"gradient_points[{idx}].type must be one of {sorted(GRADIENT_POINT_TYPES)}." + ) + color_raw = point.get("color") + color_dict = ( + _parse_hex_color(color_raw) + if not isinstance(color_raw, dict) + else color_raw + ) + if not color_dict: + raise UserInputError(f"gradient_points[{idx}].color is required.") + + normalized = {"type": point_type.upper(), "color": color_dict} + if "value" in point and point["value"] is not None: + normalized["value"] = str(point["value"]) + normalized_points.append(normalized) + + return normalized_points + + +def _build_boolean_rule( + ranges: List[dict], + condition_type: str, + condition_values: Optional[List[Union[str, int, float]]], + background_color: Optional[str], + text_color: Optional[str], +) -> tuple[dict, str]: + """ + Build a Sheets boolean conditional formatting rule payload. + Returns the rule and the normalized condition type. + """ + if not background_color and not text_color: + raise UserInputError( + "Provide at least one of background_color or text_color for the rule format." + ) + + cond_type_normalized = condition_type.upper() + if cond_type_normalized not in CONDITION_TYPES: + raise UserInputError( + f"condition_type must be one of {sorted(CONDITION_TYPES)}." + ) + + condition = {"type": cond_type_normalized} + if condition_values: + condition["values"] = [ + {"userEnteredValue": str(value)} for value in condition_values + ] + + bg_color_parsed = _parse_hex_color(background_color) + text_color_parsed = _parse_hex_color(text_color) + + format_obj = {} + if bg_color_parsed: + format_obj["backgroundColor"] = bg_color_parsed + if text_color_parsed: + format_obj["textFormat"] = {"foregroundColor": text_color_parsed} + + return ( + { + "ranges": ranges, + "booleanRule": { + "condition": condition, + "format": format_obj, + }, + }, + cond_type_normalized, + ) + + +def _build_gradient_rule( + ranges: List[dict], + gradient_points: List[dict], +) -> dict: + """ + Build a Sheets gradient conditional formatting rule payload. + """ + rule_body: dict = {"ranges": ranges, "gradientRule": {}} + if len(gradient_points) == 2: + rule_body["gradientRule"]["minpoint"] = gradient_points[0] + rule_body["gradientRule"]["maxpoint"] = gradient_points[1] + else: + rule_body["gradientRule"]["minpoint"] = gradient_points[0] + rule_body["gradientRule"]["midpoint"] = gradient_points[1] + rule_body["gradientRule"]["maxpoint"] = gradient_points[2] + return rule_body + + +def _extract_cell_notes_from_grid(spreadsheet: dict) -> list[dict[str, str]]: + """ + Extract cell notes from spreadsheet grid data. + + Returns a list of dictionaries with: + - "cell": cell A1 reference + - "note": the note text + """ + notes: list[dict[str, str]] = [] + for sheet in spreadsheet.get("sheets", []) or []: + sheet_title = sheet.get("properties", {}).get("title") or "Unknown" + for grid in sheet.get("data", []) or []: + start_row = _coerce_int(grid.get("startRow"), default=0) + start_col = _coerce_int(grid.get("startColumn"), default=0) + for row_offset, row_data in enumerate(grid.get("rowData", []) or []): + if not row_data: + continue + for col_offset, cell_data in enumerate( + row_data.get("values", []) or [] + ): + if not cell_data: + continue + note = cell_data.get("note") + if not note: + continue + notes.append( + { + "cell": _format_a1_cell( + sheet_title, + start_row + row_offset, + start_col + col_offset, + ), + "note": note, + } + ) + return notes + + +async def _fetch_sheet_notes( + service, spreadsheet_id: str, a1_range: str +) -> list[dict[str, str]]: + """Fetch cell notes for the given range via spreadsheets.get with includeGridData.""" + response = await asyncio.to_thread( + service.spreadsheets() + .get( + spreadsheetId=spreadsheet_id, + ranges=[a1_range], + includeGridData=True, + fields="sheets(properties(title),data(startRow,startColumn,rowData(values(note))))", + ) + .execute + ) + return _extract_cell_notes_from_grid(response) + + +def _format_sheet_notes_section( + *, notes: list[dict[str, str]], range_label: str, max_details: int = 25 +) -> str: + """ + Format a list of cell notes into a human-readable section. + """ + if not notes: + return "" + + lines = [] + for item in notes[:max_details]: + cell = item.get("cell") or "(unknown cell)" + note = item.get("note") or "(empty note)" + lines.append(f"- {cell}: {note}") + + suffix = ( + f"\n... and {len(notes) - max_details} more notes" + if len(notes) > max_details + else "" + ) + return f"\n\nCell notes in range '{range_label}':\n" + "\n".join(lines) + suffix + + +async def _fetch_grid_metadata( + service, + spreadsheet_id: str, + resolved_range: str, + values: List[List[object]], + include_hyperlinks: bool = False, + include_notes: bool = False, +) -> tuple[str, str]: + """Fetch hyperlinks and/or notes for a range via a single spreadsheets.get call. + + Computes tight range bounds, enforces the cell-count cap, builds a combined + ``fields`` selector so only one API round-trip is needed when both flags are + ``True``, then parses the response into formatted output sections. + + Returns: + (hyperlink_section, notes_section) — each is an empty string when the + corresponding flag is ``False`` or no data was found. + """ + if not include_hyperlinks and not include_notes: + return "", "" + + tight_range = _a1_range_for_values(resolved_range, values) + if not tight_range: + logger.info( + "[read_sheet_values] Skipping grid metadata fetch for range '%s': " + "unable to determine tight bounds", + resolved_range, + ) + return "", "" + + cell_count = _a1_range_cell_count(tight_range) or sum(len(row) for row in values) + if cell_count > MAX_GRID_METADATA_CELLS: + logger.info( + "[read_sheet_values] Skipping grid metadata fetch for large range " + "'%s' (%d cells > %d limit)", + tight_range, + cell_count, + MAX_GRID_METADATA_CELLS, + ) + return "", "" + + # Build a combined fields selector so we hit the API at most once. + value_fields: list[str] = [] + if include_hyperlinks: + value_fields.extend(["hyperlink", "textFormatRuns(format(link(uri)))"]) + if include_notes: + value_fields.append("note") + + fields = ( + "sheets(properties(title),data(startRow,startColumn," + f"rowData(values({','.join(value_fields)}))))" + ) + + try: + response = await asyncio.to_thread( + service.spreadsheets() + .get( + spreadsheetId=spreadsheet_id, + ranges=[tight_range], + includeGridData=True, + fields=fields, + ) + .execute + ) + except Exception as exc: + logger.warning( + "[read_sheet_values] Failed fetching grid metadata for range '%s': %s", + tight_range, + exc, + ) + return "", "" + + hyperlink_section = "" + if include_hyperlinks: + hyperlinks = _extract_cell_hyperlinks_from_grid(response) + hyperlink_section = _format_sheet_hyperlink_section( + hyperlinks=hyperlinks, range_label=tight_range + ) + + notes_section = "" + if include_notes: + notes = _extract_cell_notes_from_grid(response) + notes_section = _format_sheet_notes_section( + notes=notes, range_label=tight_range + ) + + return hyperlink_section, notes_section diff --git a/gsheets/sheets_tools.py b/gsheets/sheets_tools.py new file mode 100644 index 0000000..90b54db --- /dev/null +++ b/gsheets/sheets_tools.py @@ -0,0 +1,1205 @@ +""" +Google Sheets MCP Tools + +This module provides MCP tools for interacting with Google Sheets API. +""" + +import logging +import asyncio +import json +import copy +from typing import List, Optional, Union + +from auth.service_decorator import require_google_service +from core.server import server +from core.utils import handle_http_errors, UserInputError +from core.comments import create_comment_tools +from gsheets.sheets_helpers import ( + CONDITION_TYPES, + _a1_range_for_values, + _build_boolean_rule, + _build_gradient_rule, + _fetch_detailed_sheet_errors, + _fetch_grid_metadata, + _fetch_sheets_with_rules, + _format_conditional_rules_section, + _format_sheet_error_section, + _parse_a1_range, + _parse_condition_values, + _parse_gradient_points, + _parse_hex_color, + _select_sheet, + _values_contain_sheets_errors, +) + +# Configure module logger +logger = logging.getLogger(__name__) + + +@server.tool() +@handle_http_errors("list_spreadsheets", is_read_only=True, service_type="sheets") +@require_google_service("drive", "drive_read") +async def list_spreadsheets( + service, + user_google_email: str, + max_results: int = 25, +) -> str: + """ + Lists spreadsheets from Google Drive that the user has access to. + + Args: + user_google_email (str): The user's Google email address. Required. + max_results (int): Maximum number of spreadsheets to return. Defaults to 25. + + Returns: + str: A formatted list of spreadsheet files (name, ID, modified time). + """ + logger.info(f"[list_spreadsheets] Invoked. Email: '{user_google_email}'") + + files_response = await asyncio.to_thread( + service.files() + .list( + q="mimeType='application/vnd.google-apps.spreadsheet'", + pageSize=max_results, + fields="files(id,name,modifiedTime,webViewLink)", + orderBy="modifiedTime desc", + supportsAllDrives=True, + includeItemsFromAllDrives=True, + ) + .execute + ) + + files = files_response.get("files", []) + if not files: + return f"No spreadsheets found for {user_google_email}." + + spreadsheets_list = [ + f'- "{file["name"]}" (ID: {file["id"]}) | Modified: {file.get("modifiedTime", "Unknown")} | Link: {file.get("webViewLink", "No link")}' + for file in files + ] + + text_output = ( + f"Successfully listed {len(files)} spreadsheets for {user_google_email}:\n" + + "\n".join(spreadsheets_list) + ) + + logger.info( + f"Successfully listed {len(files)} spreadsheets for {user_google_email}." + ) + return text_output + + +@server.tool() +@handle_http_errors("get_spreadsheet_info", is_read_only=True, service_type="sheets") +@require_google_service("sheets", "sheets_read") +async def get_spreadsheet_info( + service, + user_google_email: str, + spreadsheet_id: str, +) -> str: + """ + Gets information about a specific spreadsheet including its sheets. + + Args: + user_google_email (str): The user's Google email address. Required. + spreadsheet_id (str): The ID of the spreadsheet to get info for. Required. + + Returns: + str: Formatted spreadsheet information including title, locale, and sheets list. + """ + logger.info( + f"[get_spreadsheet_info] Invoked. Email: '{user_google_email}', Spreadsheet ID: {spreadsheet_id}" + ) + + spreadsheet = await asyncio.to_thread( + service.spreadsheets() + .get( + spreadsheetId=spreadsheet_id, + fields="spreadsheetId,properties(title,locale),sheets(properties(title,sheetId,gridProperties(rowCount,columnCount)),conditionalFormats)", + ) + .execute + ) + + properties = spreadsheet.get("properties", {}) + title = properties.get("title", "Unknown") + locale = properties.get("locale", "Unknown") + sheets = spreadsheet.get("sheets", []) + + sheet_titles = {} + for sheet in sheets: + sheet_props = sheet.get("properties", {}) + sid = sheet_props.get("sheetId") + if sid is not None: + sheet_titles[sid] = sheet_props.get("title", f"Sheet {sid}") + + sheets_info = [] + for sheet in sheets: + sheet_props = sheet.get("properties", {}) + sheet_name = sheet_props.get("title", "Unknown") + sheet_id = sheet_props.get("sheetId", "Unknown") + grid_props = sheet_props.get("gridProperties", {}) + rows = grid_props.get("rowCount", "Unknown") + cols = grid_props.get("columnCount", "Unknown") + rules = sheet.get("conditionalFormats", []) or [] + + sheets_info.append( + f' - "{sheet_name}" (ID: {sheet_id}) | Size: {rows}x{cols} | Conditional formats: {len(rules)}' + ) + if rules: + sheets_info.append( + _format_conditional_rules_section( + sheet_name, rules, sheet_titles, indent=" " + ) + ) + + sheets_section = "\n".join(sheets_info) if sheets_info else " No sheets found" + text_output = "\n".join( + [ + f'Spreadsheet: "{title}" (ID: {spreadsheet_id}) | Locale: {locale}', + f"Sheets ({len(sheets)}):", + sheets_section, + ] + ) + + logger.info( + f"Successfully retrieved info for spreadsheet {spreadsheet_id} for {user_google_email}." + ) + return text_output + + +@server.tool() +@handle_http_errors("read_sheet_values", is_read_only=True, service_type="sheets") +@require_google_service("sheets", "sheets_read") +async def read_sheet_values( + service, + user_google_email: str, + spreadsheet_id: str, + range_name: str = "A1:Z1000", + include_hyperlinks: bool = False, + include_notes: bool = False, +) -> str: + """ + Reads values from a specific range in a Google Sheet. + + Args: + user_google_email (str): The user's Google email address. Required. + spreadsheet_id (str): The ID of the spreadsheet. Required. + range_name (str): The range to read (e.g., "Sheet1!A1:D10", "A1:D10"). Defaults to "A1:Z1000". + include_hyperlinks (bool): If True, also fetch hyperlink metadata for the range. + Defaults to False to avoid expensive includeGridData requests. + include_notes (bool): If True, also fetch cell notes for the range. + Defaults to False to avoid expensive includeGridData requests. + + Returns: + str: The formatted values from the specified range. + """ + logger.info( + f"[read_sheet_values] Invoked. Email: '{user_google_email}', Spreadsheet: {spreadsheet_id}, Range: {range_name}" + ) + + result = await asyncio.to_thread( + service.spreadsheets() + .values() + .get(spreadsheetId=spreadsheet_id, range=range_name) + .execute + ) + + values = result.get("values", []) + if not values: + return f"No data found in range '{range_name}' for {user_google_email}." + + resolved_range = result.get("range", range_name) + detailed_range = _a1_range_for_values(resolved_range, values) or resolved_range + + hyperlink_section, notes_section = await _fetch_grid_metadata( + service, + spreadsheet_id, + resolved_range, + values, + include_hyperlinks=include_hyperlinks, + include_notes=include_notes, + ) + + detailed_errors_section = "" + if _values_contain_sheets_errors(values): + try: + errors = await _fetch_detailed_sheet_errors( + service, spreadsheet_id, detailed_range + ) + detailed_errors_section = _format_sheet_error_section( + errors=errors, range_label=detailed_range + ) + except Exception as exc: + logger.warning( + "[read_sheet_values] Failed fetching detailed error messages for range '%s': %s", + detailed_range, + exc, + ) + + # Format the output as a readable table + formatted_rows = [] + for i, row in enumerate(values, 1): + # Pad row with empty strings to show structure + padded_row = row + [""] * max(0, len(values[0]) - len(row)) if values else row + formatted_rows.append(f"Row {i:2d}: {padded_row}") + + text_output = ( + f"Successfully read {len(values)} rows from range '{range_name}' in spreadsheet {spreadsheet_id} for {user_google_email}:\n" + + "\n".join(formatted_rows[:50]) # Limit to first 50 rows for readability + + (f"\n... and {len(values) - 50} more rows" if len(values) > 50 else "") + ) + + logger.info(f"Successfully read {len(values)} rows for {user_google_email}.") + return text_output + hyperlink_section + notes_section + detailed_errors_section + + +@server.tool() +@handle_http_errors("modify_sheet_values", service_type="sheets") +@require_google_service("sheets", "sheets_write") +async def modify_sheet_values( + service, + user_google_email: str, + spreadsheet_id: str, + range_name: str, + values: Optional[Union[str, List[List[str]]]] = None, + value_input_option: str = "USER_ENTERED", + clear_values: bool = False, +) -> str: + """ + Modifies values in a specific range of a Google Sheet - can write, update, or clear values. + + Args: + user_google_email (str): The user's Google email address. Required. + spreadsheet_id (str): The ID of the spreadsheet. Required. + range_name (str): The range to modify (e.g., "Sheet1!A1:D10", "A1:D10"). Required. + values (Optional[Union[str, List[List[str]]]]): 2D array of values to write/update. Can be a JSON string or Python list. Required unless clear_values=True. + value_input_option (str): How to interpret input values ("RAW" or "USER_ENTERED"). Defaults to "USER_ENTERED". + clear_values (bool): If True, clears the range instead of writing values. Defaults to False. + + Returns: + str: Confirmation message of the successful modification operation. + """ + operation = "clear" if clear_values else "write" + logger.info( + f"[modify_sheet_values] Invoked. Operation: {operation}, Email: '{user_google_email}', Spreadsheet: {spreadsheet_id}, Range: {range_name}" + ) + + # Parse values if it's a JSON string (MCP passes parameters as JSON strings) + if values is not None and isinstance(values, str): + try: + parsed_values = json.loads(values) + if not isinstance(parsed_values, list): + raise ValueError( + f"Values must be a list, got {type(parsed_values).__name__}" + ) + # Validate it's a list of lists + for i, row in enumerate(parsed_values): + if not isinstance(row, list): + raise ValueError( + f"Row {i} must be a list, got {type(row).__name__}" + ) + values = parsed_values + logger.info( + f"[modify_sheet_values] Parsed JSON string to Python list with {len(values)} rows" + ) + except json.JSONDecodeError as e: + raise UserInputError(f"Invalid JSON format for values: {e}") + except ValueError as e: + raise UserInputError(f"Invalid values structure: {e}") + + if not clear_values and not values: + raise UserInputError( + "Either 'values' must be provided or 'clear_values' must be True." + ) + + if clear_values: + result = await asyncio.to_thread( + service.spreadsheets() + .values() + .clear(spreadsheetId=spreadsheet_id, range=range_name) + .execute + ) + + cleared_range = result.get("clearedRange", range_name) + text_output = f"Successfully cleared range '{cleared_range}' in spreadsheet {spreadsheet_id} for {user_google_email}." + logger.info( + f"Successfully cleared range '{cleared_range}' for {user_google_email}." + ) + else: + body = {"values": values} + + result = await asyncio.to_thread( + service.spreadsheets() + .values() + .update( + spreadsheetId=spreadsheet_id, + range=range_name, + valueInputOption=value_input_option, + # NOTE: This increases response payload/shape by including `updatedData`, but lets + # us detect Sheets error tokens (e.g. "#VALUE!", "#REF!") without an extra read. + includeValuesInResponse=True, + responseValueRenderOption="FORMATTED_VALUE", + body=body, + ) + .execute + ) + + updated_cells = result.get("updatedCells", 0) + updated_rows = result.get("updatedRows", 0) + updated_columns = result.get("updatedColumns", 0) + + detailed_errors_section = "" + updated_data = result.get("updatedData") or {} + updated_values = updated_data.get("values", []) or [] + if updated_values and _values_contain_sheets_errors(updated_values): + updated_range = result.get("updatedRange", range_name) + detailed_range = ( + _a1_range_for_values(updated_range, updated_values) or updated_range + ) + try: + errors = await _fetch_detailed_sheet_errors( + service, spreadsheet_id, detailed_range + ) + detailed_errors_section = _format_sheet_error_section( + errors=errors, range_label=detailed_range + ) + except Exception as exc: + logger.warning( + "[modify_sheet_values] Failed fetching detailed error messages for range '%s': %s", + detailed_range, + exc, + ) + + text_output = ( + f"Successfully updated range '{range_name}' in spreadsheet {spreadsheet_id} for {user_google_email}. " + f"Updated: {updated_cells} cells, {updated_rows} rows, {updated_columns} columns." + ) + text_output += detailed_errors_section + logger.info( + f"Successfully updated {updated_cells} cells for {user_google_email}." + ) + + return text_output + + +# Internal implementation function for testing +async def _format_sheet_range_impl( + service, + spreadsheet_id: str, + range_name: str, + background_color: Optional[str] = None, + text_color: Optional[str] = None, + number_format_type: Optional[str] = None, + number_format_pattern: Optional[str] = None, + wrap_strategy: Optional[str] = None, + horizontal_alignment: Optional[str] = None, + vertical_alignment: Optional[str] = None, + bold: Optional[bool] = None, + italic: Optional[bool] = None, + font_size: Optional[int] = None, +) -> str: + """Internal implementation for format_sheet_range. + + Applies formatting to a Google Sheets range including colors, number formats, + text wrapping, alignment, and text styling. + + Args: + service: Google Sheets API service client. + spreadsheet_id: The ID of the spreadsheet. + range_name: A1-style range (optionally with sheet name). + background_color: Hex background color (e.g., "#FFEECC"). + text_color: Hex text color (e.g., "#000000"). + number_format_type: Sheets number format type (e.g., "DATE"). + number_format_pattern: Optional custom pattern for the number format. + wrap_strategy: Text wrap strategy (WRAP, CLIP, OVERFLOW_CELL). + horizontal_alignment: Horizontal alignment (LEFT, CENTER, RIGHT). + vertical_alignment: Vertical alignment (TOP, MIDDLE, BOTTOM). + bold: Whether to apply bold formatting. + italic: Whether to apply italic formatting. + font_size: Font size in points. + + Returns: + Dictionary with keys: range_name, spreadsheet_id, summary. + """ + # Validate at least one formatting option is provided + has_any_format = any( + [ + background_color, + text_color, + number_format_type, + wrap_strategy, + horizontal_alignment, + vertical_alignment, + bold is not None, + italic is not None, + font_size is not None, + ] + ) + if not has_any_format: + raise UserInputError( + "Provide at least one formatting option (background_color, text_color, " + "number_format_type, wrap_strategy, horizontal_alignment, vertical_alignment, " + "bold, italic, or font_size)." + ) + + # Parse colors + bg_color_parsed = _parse_hex_color(background_color) + text_color_parsed = _parse_hex_color(text_color) + + # Validate and normalize number format + number_format = None + if number_format_type: + allowed_number_formats = { + "NUMBER", + "NUMBER_WITH_GROUPING", + "CURRENCY", + "PERCENT", + "SCIENTIFIC", + "DATE", + "TIME", + "DATE_TIME", + "TEXT", + } + normalized_type = number_format_type.upper() + if normalized_type not in allowed_number_formats: + raise UserInputError( + f"number_format_type must be one of {sorted(allowed_number_formats)}." + ) + number_format = {"type": normalized_type} + if number_format_pattern: + number_format["pattern"] = number_format_pattern + + # Validate and normalize wrap_strategy + wrap_strategy_normalized = None + if wrap_strategy: + allowed_wrap_strategies = {"WRAP", "CLIP", "OVERFLOW_CELL"} + wrap_strategy_normalized = wrap_strategy.upper() + if wrap_strategy_normalized not in allowed_wrap_strategies: + raise UserInputError( + f"wrap_strategy must be one of {sorted(allowed_wrap_strategies)}." + ) + + # Validate and normalize horizontal_alignment + h_align_normalized = None + if horizontal_alignment: + allowed_h_alignments = {"LEFT", "CENTER", "RIGHT"} + h_align_normalized = horizontal_alignment.upper() + if h_align_normalized not in allowed_h_alignments: + raise UserInputError( + f"horizontal_alignment must be one of {sorted(allowed_h_alignments)}." + ) + + # Validate and normalize vertical_alignment + v_align_normalized = None + if vertical_alignment: + allowed_v_alignments = {"TOP", "MIDDLE", "BOTTOM"} + v_align_normalized = vertical_alignment.upper() + if v_align_normalized not in allowed_v_alignments: + raise UserInputError( + f"vertical_alignment must be one of {sorted(allowed_v_alignments)}." + ) + + # Get sheet metadata for range parsing + metadata = await asyncio.to_thread( + service.spreadsheets() + .get( + spreadsheetId=spreadsheet_id, + fields="sheets(properties(sheetId,title))", + ) + .execute + ) + sheets = metadata.get("sheets", []) + grid_range = _parse_a1_range(range_name, sheets) + + # Build userEnteredFormat and fields list + user_entered_format = {} + fields = [] + + # Background color + if bg_color_parsed: + user_entered_format["backgroundColor"] = bg_color_parsed + fields.append("userEnteredFormat.backgroundColor") + + # Text format (color, bold, italic, fontSize) + text_format = {} + text_format_fields = [] + + if text_color_parsed: + text_format["foregroundColor"] = text_color_parsed + text_format_fields.append("userEnteredFormat.textFormat.foregroundColor") + + if bold is not None: + text_format["bold"] = bold + text_format_fields.append("userEnteredFormat.textFormat.bold") + + if italic is not None: + text_format["italic"] = italic + text_format_fields.append("userEnteredFormat.textFormat.italic") + + if font_size is not None: + text_format["fontSize"] = font_size + text_format_fields.append("userEnteredFormat.textFormat.fontSize") + + if text_format: + user_entered_format["textFormat"] = text_format + fields.extend(text_format_fields) + + # Number format + if number_format: + user_entered_format["numberFormat"] = number_format + fields.append("userEnteredFormat.numberFormat") + + # Wrap strategy + if wrap_strategy_normalized: + user_entered_format["wrapStrategy"] = wrap_strategy_normalized + fields.append("userEnteredFormat.wrapStrategy") + + # Horizontal alignment + if h_align_normalized: + user_entered_format["horizontalAlignment"] = h_align_normalized + fields.append("userEnteredFormat.horizontalAlignment") + + # Vertical alignment + if v_align_normalized: + user_entered_format["verticalAlignment"] = v_align_normalized + fields.append("userEnteredFormat.verticalAlignment") + + if not user_entered_format: + raise UserInputError( + "No formatting applied. Verify provided formatting options." + ) + + # Build and execute request + request_body = { + "requests": [ + { + "repeatCell": { + "range": grid_range, + "cell": {"userEnteredFormat": user_entered_format}, + "fields": ",".join(fields), + } + } + ] + } + + await asyncio.to_thread( + service.spreadsheets() + .batchUpdate(spreadsheetId=spreadsheet_id, body=request_body) + .execute + ) + + # Build confirmation message + applied_parts = [] + if bg_color_parsed: + applied_parts.append(f"background {background_color}") + if text_color_parsed: + applied_parts.append(f"text color {text_color}") + if number_format: + nf_desc = number_format["type"] + if number_format_pattern: + nf_desc += f" (pattern: {number_format_pattern})" + applied_parts.append(f"number format {nf_desc}") + if wrap_strategy_normalized: + applied_parts.append(f"wrap {wrap_strategy_normalized}") + if h_align_normalized: + applied_parts.append(f"horizontal align {h_align_normalized}") + if v_align_normalized: + applied_parts.append(f"vertical align {v_align_normalized}") + if bold is not None: + applied_parts.append("bold" if bold else "not bold") + if italic is not None: + applied_parts.append("italic" if italic else "not italic") + if font_size is not None: + applied_parts.append(f"font size {font_size}") + + summary = ", ".join(applied_parts) + + # Return structured data for the wrapper to format + return { + "range_name": range_name, + "spreadsheet_id": spreadsheet_id, + "summary": summary, + } + + +@server.tool() +@handle_http_errors("format_sheet_range", service_type="sheets") +@require_google_service("sheets", "sheets_write") +async def format_sheet_range( + service, + user_google_email: str, + spreadsheet_id: str, + range_name: str, + background_color: Optional[str] = None, + text_color: Optional[str] = None, + number_format_type: Optional[str] = None, + number_format_pattern: Optional[str] = None, + wrap_strategy: Optional[str] = None, + horizontal_alignment: Optional[str] = None, + vertical_alignment: Optional[str] = None, + bold: Optional[bool] = None, + italic: Optional[bool] = None, + font_size: Optional[int] = None, +) -> str: + """ + Applies formatting to a range: colors, number formats, text wrapping, + alignment, and text styling. + + Colors accept hex strings (#RRGGBB). Number formats follow Sheets types + (e.g., NUMBER, CURRENCY, DATE, PERCENT). If no sheet name is provided, + the first sheet is used. + + Args: + user_google_email (str): The user's Google email address. Required. + spreadsheet_id (str): The ID of the spreadsheet. Required. + range_name (str): A1-style range (optionally with sheet name). Required. + background_color (Optional[str]): Hex background color (e.g., "#FFEECC"). + text_color (Optional[str]): Hex text color (e.g., "#000000"). + number_format_type (Optional[str]): Sheets number format type (e.g., "DATE"). + number_format_pattern (Optional[str]): Custom pattern for the number format. + wrap_strategy (Optional[str]): Text wrap strategy - WRAP (wrap text within + cell), CLIP (clip text at cell boundary), or OVERFLOW_CELL (allow text + to overflow into adjacent empty cells). + horizontal_alignment (Optional[str]): Horizontal text alignment - LEFT, + CENTER, or RIGHT. + vertical_alignment (Optional[str]): Vertical text alignment - TOP, MIDDLE, + or BOTTOM. + bold (Optional[bool]): Whether to apply bold formatting. + italic (Optional[bool]): Whether to apply italic formatting. + font_size (Optional[int]): Font size in points. + + Returns: + str: Confirmation of the applied formatting. + """ + logger.info( + "[format_sheet_range] Invoked. Email: '%s', Spreadsheet: %s, Range: %s", + user_google_email, + spreadsheet_id, + range_name, + ) + + result = await _format_sheet_range_impl( + service=service, + spreadsheet_id=spreadsheet_id, + range_name=range_name, + background_color=background_color, + text_color=text_color, + number_format_type=number_format_type, + number_format_pattern=number_format_pattern, + wrap_strategy=wrap_strategy, + horizontal_alignment=horizontal_alignment, + vertical_alignment=vertical_alignment, + bold=bold, + italic=italic, + font_size=font_size, + ) + + # Build confirmation message with user email + return ( + f"Applied formatting to range '{result['range_name']}' in spreadsheet " + f"{result['spreadsheet_id']} for {user_google_email}: {result['summary']}." + ) + + +@server.tool() +@handle_http_errors("manage_conditional_formatting", service_type="sheets") +@require_google_service("sheets", "sheets_write") +async def manage_conditional_formatting( + service, + user_google_email: str, + spreadsheet_id: str, + action: str, + range_name: Optional[str] = None, + condition_type: Optional[str] = None, + condition_values: Optional[Union[str, List[Union[str, int, float]]]] = None, + background_color: Optional[str] = None, + text_color: Optional[str] = None, + rule_index: Optional[int] = None, + gradient_points: Optional[Union[str, List[dict]]] = None, + sheet_name: Optional[str] = None, +) -> str: + """ + Manages conditional formatting rules on a Google Sheet. Supports adding, + updating, and deleting conditional formatting rules via a single tool. + + Args: + user_google_email (str): The user's Google email address. Required. + spreadsheet_id (str): The ID of the spreadsheet. Required. + action (str): The operation to perform. Must be one of "add", "update", + or "delete". + range_name (Optional[str]): A1-style range (optionally with sheet name). + Required for "add". Optional for "update" (preserves existing ranges + if omitted). Not used for "delete". + condition_type (Optional[str]): Sheets condition type (e.g., NUMBER_GREATER, + TEXT_CONTAINS, DATE_BEFORE, CUSTOM_FORMULA). Required for "add". + Optional for "update" (preserves existing type if omitted). + condition_values (Optional[Union[str, List[Union[str, int, float]]]]): Values + for the condition; accepts a list or a JSON string representing a list. + Depends on condition_type. Used by "add" and "update". + background_color (Optional[str]): Hex background color to apply when + condition matches. Used by "add" and "update". + text_color (Optional[str]): Hex text color to apply when condition matches. + Used by "add" and "update". + rule_index (Optional[int]): 0-based index of the rule. For "add", optionally + specifies insertion position. Required for "update" and "delete". + gradient_points (Optional[Union[str, List[dict]]]): List (or JSON list) of + gradient points for a color scale. If provided, a gradient rule is created + and boolean parameters are ignored. Used by "add" and "update". + sheet_name (Optional[str]): Sheet name to locate the rule when range_name is + omitted. Defaults to the first sheet. Used by "update" and "delete". + + Returns: + str: Confirmation of the operation and the current rule state. + """ + allowed_actions = {"add", "update", "delete"} + action_normalized = action.strip().lower() + if action_normalized not in allowed_actions: + raise UserInputError( + f"action must be one of {sorted(allowed_actions)}, got '{action}'." + ) + + logger.info( + "[manage_conditional_formatting] Invoked. Action: '%s', Email: '%s', Spreadsheet: %s", + action_normalized, + user_google_email, + spreadsheet_id, + ) + + if action_normalized == "add": + if not range_name: + raise UserInputError("range_name is required for action 'add'.") + if not condition_type and not gradient_points: + raise UserInputError( + "condition_type (or gradient_points) is required for action 'add'." + ) + + if rule_index is not None and ( + not isinstance(rule_index, int) or rule_index < 0 + ): + raise UserInputError( + "rule_index must be a non-negative integer when provided." + ) + + gradient_points_list = _parse_gradient_points(gradient_points) + condition_values_list = ( + None if gradient_points_list else _parse_condition_values(condition_values) + ) + + sheets, sheet_titles = await _fetch_sheets_with_rules(service, spreadsheet_id) + grid_range = _parse_a1_range(range_name, sheets) + + target_sheet = None + for sheet in sheets: + if sheet.get("properties", {}).get("sheetId") == grid_range.get("sheetId"): + target_sheet = sheet + break + if target_sheet is None: + raise UserInputError( + "Target sheet not found while adding conditional formatting." + ) + + current_rules = target_sheet.get("conditionalFormats", []) or [] + + insert_at = rule_index if rule_index is not None else len(current_rules) + if insert_at > len(current_rules): + raise UserInputError( + f"rule_index {insert_at} is out of range for sheet " + f"'{target_sheet.get('properties', {}).get('title', 'Unknown')}' " + f"(current count: {len(current_rules)})." + ) + + if gradient_points_list: + new_rule = _build_gradient_rule([grid_range], gradient_points_list) + rule_desc = "gradient" + values_desc = "" + applied_parts = [f"gradient points {len(gradient_points_list)}"] + else: + rule, cond_type_normalized = _build_boolean_rule( + [grid_range], + condition_type, + condition_values_list, + background_color, + text_color, + ) + new_rule = rule + rule_desc = cond_type_normalized + values_desc = "" + if condition_values_list: + values_desc = f" with values {condition_values_list}" + applied_parts = [] + if background_color: + applied_parts.append(f"background {background_color}") + if text_color: + applied_parts.append(f"text {text_color}") + + new_rules_state = copy.deepcopy(current_rules) + new_rules_state.insert(insert_at, new_rule) + + add_rule_request = {"rule": new_rule} + if rule_index is not None: + add_rule_request["index"] = rule_index + + request_body = {"requests": [{"addConditionalFormatRule": add_rule_request}]} + + await asyncio.to_thread( + service.spreadsheets() + .batchUpdate(spreadsheetId=spreadsheet_id, body=request_body) + .execute + ) + + format_desc = ", ".join(applied_parts) if applied_parts else "format applied" + + sheet_title = target_sheet.get("properties", {}).get("title", "Unknown") + state_text = _format_conditional_rules_section( + sheet_title, new_rules_state, sheet_titles, indent="" + ) + + return "\n".join( + [ + f"Added conditional format on '{range_name}' in spreadsheet " + f"{spreadsheet_id} for {user_google_email}: " + f"{rule_desc}{values_desc}; format: {format_desc}.", + state_text, + ] + ) + + elif action_normalized == "update": + if rule_index is None: + raise UserInputError("rule_index is required for action 'update'.") + if not isinstance(rule_index, int) or rule_index < 0: + raise UserInputError("rule_index must be a non-negative integer.") + + gradient_points_list = _parse_gradient_points(gradient_points) + condition_values_list = ( + None + if gradient_points_list is not None + else _parse_condition_values(condition_values) + ) + + sheets, sheet_titles = await _fetch_sheets_with_rules(service, spreadsheet_id) + + target_sheet = None + grid_range = None + if range_name: + grid_range = _parse_a1_range(range_name, sheets) + for sheet in sheets: + if sheet.get("properties", {}).get("sheetId") == grid_range.get( + "sheetId" + ): + target_sheet = sheet + break + else: + target_sheet = _select_sheet(sheets, sheet_name) + + if target_sheet is None: + raise UserInputError( + "Target sheet not found while updating conditional formatting." + ) + + sheet_props = target_sheet.get("properties", {}) + sheet_id = sheet_props.get("sheetId") + sheet_title = sheet_props.get("title", f"Sheet {sheet_id}") + + rules = target_sheet.get("conditionalFormats", []) or [] + if rule_index >= len(rules): + raise UserInputError( + f"rule_index {rule_index} is out of range for sheet " + f"'{sheet_title}' (current count: {len(rules)})." + ) + + existing_rule = rules[rule_index] + ranges_to_use = existing_rule.get("ranges", []) + if range_name: + ranges_to_use = [grid_range] + if not ranges_to_use: + ranges_to_use = [{"sheetId": sheet_id}] + + new_rule = None + rule_desc = "" + values_desc = "" + format_desc = "" + + if gradient_points_list is not None: + new_rule = _build_gradient_rule(ranges_to_use, gradient_points_list) + rule_desc = "gradient" + format_desc = f"gradient points {len(gradient_points_list)}" + elif "gradientRule" in existing_rule: + if any( + [ + background_color, + text_color, + condition_type, + condition_values_list, + ] + ): + raise UserInputError( + "Existing rule is a gradient rule. Provide gradient_points " + "to update it, or omit formatting/condition parameters to " + "keep it unchanged." + ) + new_rule = { + "ranges": ranges_to_use, + "gradientRule": existing_rule.get("gradientRule", {}), + } + rule_desc = "gradient" + format_desc = "gradient (unchanged)" + else: + existing_boolean = existing_rule.get("booleanRule", {}) + existing_condition = existing_boolean.get("condition", {}) + existing_format = copy.deepcopy(existing_boolean.get("format", {})) + + cond_type = (condition_type or existing_condition.get("type", "")).upper() + if not cond_type: + raise UserInputError("condition_type is required for boolean rules.") + if cond_type not in CONDITION_TYPES: + raise UserInputError( + f"condition_type must be one of {sorted(CONDITION_TYPES)}." + ) + + if condition_values_list is not None: + cond_values = [ + {"userEnteredValue": str(val)} for val in condition_values_list + ] + else: + cond_values = existing_condition.get("values") + + new_format = copy.deepcopy(existing_format) if existing_format else {} + if background_color is not None: + bg_color_parsed = _parse_hex_color(background_color) + if bg_color_parsed: + new_format["backgroundColor"] = bg_color_parsed + elif "backgroundColor" in new_format: + del new_format["backgroundColor"] + if text_color is not None: + text_color_parsed = _parse_hex_color(text_color) + text_format = copy.deepcopy(new_format.get("textFormat", {})) + if text_color_parsed: + text_format["foregroundColor"] = text_color_parsed + elif "foregroundColor" in text_format: + del text_format["foregroundColor"] + if text_format: + new_format["textFormat"] = text_format + elif "textFormat" in new_format: + del new_format["textFormat"] + + if not new_format: + raise UserInputError( + "At least one format option must remain on the rule." + ) + + new_rule = { + "ranges": ranges_to_use, + "booleanRule": { + "condition": {"type": cond_type}, + "format": new_format, + }, + } + if cond_values: + new_rule["booleanRule"]["condition"]["values"] = cond_values + + rule_desc = cond_type + if condition_values_list: + values_desc = f" with values {condition_values_list}" + format_parts = [] + if "backgroundColor" in new_format: + format_parts.append("background updated") + if "textFormat" in new_format and new_format["textFormat"].get( + "foregroundColor" + ): + format_parts.append("text color updated") + format_desc = ( + ", ".join(format_parts) if format_parts else "format preserved" + ) + + new_rules_state = copy.deepcopy(rules) + new_rules_state[rule_index] = new_rule + + request_body = { + "requests": [ + { + "updateConditionalFormatRule": { + "index": rule_index, + "sheetId": sheet_id, + "rule": new_rule, + } + } + ] + } + + await asyncio.to_thread( + service.spreadsheets() + .batchUpdate(spreadsheetId=spreadsheet_id, body=request_body) + .execute + ) + + state_text = _format_conditional_rules_section( + sheet_title, new_rules_state, sheet_titles, indent="" + ) + + return "\n".join( + [ + f"Updated conditional format at index {rule_index} on sheet " + f"'{sheet_title}' in spreadsheet {spreadsheet_id} " + f"for {user_google_email}: " + f"{rule_desc}{values_desc}; format: {format_desc}.", + state_text, + ] + ) + + else: # action_normalized == "delete" + if rule_index is None: + raise UserInputError("rule_index is required for action 'delete'.") + if not isinstance(rule_index, int) or rule_index < 0: + raise UserInputError("rule_index must be a non-negative integer.") + + sheets, sheet_titles = await _fetch_sheets_with_rules(service, spreadsheet_id) + target_sheet = _select_sheet(sheets, sheet_name) + + sheet_props = target_sheet.get("properties", {}) + sheet_id = sheet_props.get("sheetId") + target_sheet_name = sheet_props.get("title", f"Sheet {sheet_id}") + rules = target_sheet.get("conditionalFormats", []) or [] + if rule_index >= len(rules): + raise UserInputError( + f"rule_index {rule_index} is out of range for sheet " + f"'{target_sheet_name}' (current count: {len(rules)})." + ) + + new_rules_state = copy.deepcopy(rules) + del new_rules_state[rule_index] + + request_body = { + "requests": [ + { + "deleteConditionalFormatRule": { + "index": rule_index, + "sheetId": sheet_id, + } + } + ] + } + + await asyncio.to_thread( + service.spreadsheets() + .batchUpdate(spreadsheetId=spreadsheet_id, body=request_body) + .execute + ) + + state_text = _format_conditional_rules_section( + target_sheet_name, new_rules_state, sheet_titles, indent="" + ) + + return "\n".join( + [ + f"Deleted conditional format at index {rule_index} on sheet " + f"'{target_sheet_name}' in spreadsheet {spreadsheet_id} " + f"for {user_google_email}.", + state_text, + ] + ) + + +@server.tool() +@handle_http_errors("create_spreadsheet", service_type="sheets") +@require_google_service("sheets", "sheets_write") +async def create_spreadsheet( + service, + user_google_email: str, + title: str, + sheet_names: Optional[List[str]] = None, +) -> str: + """ + Creates a new Google Spreadsheet. + + Args: + user_google_email (str): The user's Google email address. Required. + title (str): The title of the new spreadsheet. Required. + sheet_names (Optional[List[str]]): List of sheet names to create. If not provided, creates one sheet with default name. + + Returns: + str: Information about the newly created spreadsheet including ID, URL, and locale. + """ + logger.info( + f"[create_spreadsheet] Invoked. Email: '{user_google_email}', Title: {title}" + ) + + spreadsheet_body = {"properties": {"title": title}} + + if sheet_names: + spreadsheet_body["sheets"] = [ + {"properties": {"title": sheet_name}} for sheet_name in sheet_names + ] + + spreadsheet = await asyncio.to_thread( + service.spreadsheets() + .create( + body=spreadsheet_body, + fields="spreadsheetId,spreadsheetUrl,properties(title,locale)", + ) + .execute + ) + + properties = spreadsheet.get("properties", {}) + spreadsheet_id = spreadsheet.get("spreadsheetId") + spreadsheet_url = spreadsheet.get("spreadsheetUrl") + locale = properties.get("locale", "Unknown") + + text_output = ( + f"Successfully created spreadsheet '{title}' for {user_google_email}. " + f"ID: {spreadsheet_id} | URL: {spreadsheet_url} | Locale: {locale}" + ) + + logger.info( + f"Successfully created spreadsheet for {user_google_email}. ID: {spreadsheet_id}" + ) + return text_output + + +@server.tool() +@handle_http_errors("create_sheet", service_type="sheets") +@require_google_service("sheets", "sheets_write") +async def create_sheet( + service, + user_google_email: str, + spreadsheet_id: str, + sheet_name: str, +) -> str: + """ + Creates a new sheet within an existing spreadsheet. + + Args: + user_google_email (str): The user's Google email address. Required. + spreadsheet_id (str): The ID of the spreadsheet. Required. + sheet_name (str): The name of the new sheet. Required. + + Returns: + str: Confirmation message of the successful sheet creation. + """ + logger.info( + f"[create_sheet] Invoked. Email: '{user_google_email}', Spreadsheet: {spreadsheet_id}, Sheet: {sheet_name}" + ) + + request_body = {"requests": [{"addSheet": {"properties": {"title": sheet_name}}}]} + + response = await asyncio.to_thread( + service.spreadsheets() + .batchUpdate(spreadsheetId=spreadsheet_id, body=request_body) + .execute + ) + + sheet_id = response["replies"][0]["addSheet"]["properties"]["sheetId"] + + text_output = f"Successfully created sheet '{sheet_name}' (ID: {sheet_id}) in spreadsheet {spreadsheet_id} for {user_google_email}." + + logger.info( + f"Successfully created sheet for {user_google_email}. Sheet ID: {sheet_id}" + ) + return text_output + + +# Create comment management tools for sheets +_comment_tools = create_comment_tools("spreadsheet", "spreadsheet_id") + +# Extract and register the functions +list_spreadsheet_comments = _comment_tools["list_comments"] +manage_spreadsheet_comment = _comment_tools["manage_comment"] diff --git a/gslides/__init__.py b/gslides/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gslides/slides_tools.py b/gslides/slides_tools.py new file mode 100644 index 0000000..02a5007 --- /dev/null +++ b/gslides/slides_tools.py @@ -0,0 +1,330 @@ +""" +Google Slides MCP Tools + +This module provides MCP tools for interacting with Google Slides API. +""" + +import logging +import asyncio +from typing import List, Dict, Any + + +from auth.service_decorator import require_google_service +from core.server import server +from core.utils import handle_http_errors +from core.comments import create_comment_tools + +logger = logging.getLogger(__name__) + + +@server.tool() +@handle_http_errors("create_presentation", service_type="slides") +@require_google_service("slides", "slides") +async def create_presentation( + service, user_google_email: str, title: str = "Untitled Presentation" +) -> str: + """ + Create a new Google Slides presentation. + + Args: + user_google_email (str): The user's Google email address. Required. + title (str): The title for the new presentation. Defaults to "Untitled Presentation". + + Returns: + str: Details about the created presentation including ID and URL. + """ + logger.info( + f"[create_presentation] Invoked. Email: '{user_google_email}', Title: '{title}'" + ) + + body = {"title": title} + + result = await asyncio.to_thread(service.presentations().create(body=body).execute) + + presentation_id = result.get("presentationId") + presentation_url = f"https://docs.google.com/presentation/d/{presentation_id}/edit" + + confirmation_message = f"""Presentation Created Successfully for {user_google_email}: +- Title: {title} +- Presentation ID: {presentation_id} +- URL: {presentation_url} +- Slides: {len(result.get("slides", []))} slide(s) created""" + + logger.info(f"Presentation created successfully for {user_google_email}") + return confirmation_message + + +@server.tool() +@handle_http_errors("get_presentation", is_read_only=True, service_type="slides") +@require_google_service("slides", "slides_read") +async def get_presentation( + service, user_google_email: str, presentation_id: str +) -> str: + """ + Get details about a Google Slides presentation. + + Args: + user_google_email (str): The user's Google email address. Required. + presentation_id (str): The ID of the presentation to retrieve. + + Returns: + str: Details about the presentation including title, slides count, and metadata. + """ + logger.info( + f"[get_presentation] Invoked. Email: '{user_google_email}', ID: '{presentation_id}'" + ) + + result = await asyncio.to_thread( + service.presentations().get(presentationId=presentation_id).execute + ) + + title = result.get("title", "Untitled") + slides = result.get("slides", []) + page_size = result.get("pageSize", {}) + + slides_info = [] + for i, slide in enumerate(slides, 1): + slide_id = slide.get("objectId", "Unknown") + page_elements = slide.get("pageElements", []) + + # Collect text from the slide whose JSON structure is very complicated + # https://googleapis.github.io/google-api-python-client/docs/dyn/slides_v1.presentations.html#get + slide_text = "" + try: + texts_from_elements = [] + for page_element in slide.get("pageElements", []): + shape = page_element.get("shape", None) + if shape and shape.get("text", None): + text = shape.get("text", None) + if text: + text_elements_in_shape = [] + for text_element in text.get("textElements", []): + text_run = text_element.get("textRun", None) + if text_run: + content = text_run.get("content", None) + if content: + start_index = text_element.get("startIndex", 0) + text_elements_in_shape.append( + (start_index, content) + ) + + if text_elements_in_shape: + # Sort text elements within a single shape + text_elements_in_shape.sort(key=lambda item: item[0]) + full_text_from_shape = "".join( + [item[1] for item in text_elements_in_shape] + ) + texts_from_elements.append(full_text_from_shape) + + # cleanup text we collected + slide_text = "\n".join(texts_from_elements) + slide_text_rows = slide_text.split("\n") + slide_text_rows = [row for row in slide_text_rows if len(row.strip()) > 0] + if slide_text_rows: + slide_text_rows = [" > " + row for row in slide_text_rows] + slide_text = "\n" + "\n".join(slide_text_rows) + else: + slide_text = "" + except Exception as e: + logger.warning(f"Failed to extract text from the slide {slide_id}: {e}") + slide_text = f"" + + slides_info.append( + f" Slide {i}: ID {slide_id}, {len(page_elements)} element(s), text: {slide_text if slide_text else 'empty'}" + ) + + confirmation_message = f"""Presentation Details for {user_google_email}: +- Title: {title} +- Presentation ID: {presentation_id} +- URL: https://docs.google.com/presentation/d/{presentation_id}/edit +- Total Slides: {len(slides)} +- Page Size: {page_size.get("width", {}).get("magnitude", "Unknown")} x {page_size.get("height", {}).get("magnitude", "Unknown")} {page_size.get("width", {}).get("unit", "")} + +Slides Breakdown: +{chr(10).join(slides_info) if slides_info else " No slides found"}""" + + logger.info(f"Presentation retrieved successfully for {user_google_email}") + return confirmation_message + + +@server.tool() +@handle_http_errors("batch_update_presentation", service_type="slides") +@require_google_service("slides", "slides") +async def batch_update_presentation( + service, + user_google_email: str, + presentation_id: str, + requests: List[Dict[str, Any]], +) -> str: + """ + Apply batch updates to a Google Slides presentation. + + Args: + user_google_email (str): The user's Google email address. Required. + presentation_id (str): The ID of the presentation to update. + requests (List[Dict[str, Any]]): List of update requests to apply. + + Returns: + str: Details about the batch update operation results. + """ + logger.info( + f"[batch_update_presentation] Invoked. Email: '{user_google_email}', ID: '{presentation_id}', Requests: {len(requests)}" + ) + + body = {"requests": requests} + + result = await asyncio.to_thread( + service.presentations() + .batchUpdate(presentationId=presentation_id, body=body) + .execute + ) + + replies = result.get("replies", []) + + confirmation_message = f"""Batch Update Completed for {user_google_email}: +- Presentation ID: {presentation_id} +- URL: https://docs.google.com/presentation/d/{presentation_id}/edit +- Requests Applied: {len(requests)} +- Replies Received: {len(replies)}""" + + if replies: + confirmation_message += "\n\nUpdate Results:" + for i, reply in enumerate(replies, 1): + if "createSlide" in reply: + slide_id = reply["createSlide"].get("objectId", "Unknown") + confirmation_message += ( + f"\n Request {i}: Created slide with ID {slide_id}" + ) + elif "createShape" in reply: + shape_id = reply["createShape"].get("objectId", "Unknown") + confirmation_message += ( + f"\n Request {i}: Created shape with ID {shape_id}" + ) + else: + confirmation_message += f"\n Request {i}: Operation completed" + + logger.info(f"Batch update completed successfully for {user_google_email}") + return confirmation_message + + +@server.tool() +@handle_http_errors("get_page", is_read_only=True, service_type="slides") +@require_google_service("slides", "slides_read") +async def get_page( + service, user_google_email: str, presentation_id: str, page_object_id: str +) -> str: + """ + Get details about a specific page (slide) in a presentation. + + Args: + user_google_email (str): The user's Google email address. Required. + presentation_id (str): The ID of the presentation. + page_object_id (str): The object ID of the page/slide to retrieve. + + Returns: + str: Details about the specific page including elements and layout. + """ + logger.info( + f"[get_page] Invoked. Email: '{user_google_email}', Presentation: '{presentation_id}', Page: '{page_object_id}'" + ) + + result = await asyncio.to_thread( + service.presentations() + .pages() + .get(presentationId=presentation_id, pageObjectId=page_object_id) + .execute + ) + + page_type = result.get("pageType", "Unknown") + page_elements = result.get("pageElements", []) + + elements_info = [] + for element in page_elements: + element_id = element.get("objectId", "Unknown") + if "shape" in element: + shape_type = element["shape"].get("shapeType", "Unknown") + elements_info.append(f" Shape: ID {element_id}, Type: {shape_type}") + elif "table" in element: + table = element["table"] + rows = table.get("rows", 0) + cols = table.get("columns", 0) + elements_info.append(f" Table: ID {element_id}, Size: {rows}x{cols}") + elif "line" in element: + line_type = element["line"].get("lineType", "Unknown") + elements_info.append(f" Line: ID {element_id}, Type: {line_type}") + else: + elements_info.append(f" Element: ID {element_id}, Type: Unknown") + + confirmation_message = f"""Page Details for {user_google_email}: +- Presentation ID: {presentation_id} +- Page ID: {page_object_id} +- Page Type: {page_type} +- Total Elements: {len(page_elements)} + +Page Elements: +{chr(10).join(elements_info) if elements_info else " No elements found"}""" + + logger.info(f"Page retrieved successfully for {user_google_email}") + return confirmation_message + + +@server.tool() +@handle_http_errors("get_page_thumbnail", is_read_only=True, service_type="slides") +@require_google_service("slides", "slides_read") +async def get_page_thumbnail( + service, + user_google_email: str, + presentation_id: str, + page_object_id: str, + thumbnail_size: str = "MEDIUM", +) -> str: + """ + Generate a thumbnail URL for a specific page (slide) in a presentation. + + Args: + user_google_email (str): The user's Google email address. Required. + presentation_id (str): The ID of the presentation. + page_object_id (str): The object ID of the page/slide. + thumbnail_size (str): Size of thumbnail ("LARGE", "MEDIUM", "SMALL"). Defaults to "MEDIUM". + + Returns: + str: URL to the generated thumbnail image. + """ + logger.info( + f"[get_page_thumbnail] Invoked. Email: '{user_google_email}', Presentation: '{presentation_id}', Page: '{page_object_id}', Size: '{thumbnail_size}'" + ) + + result = await asyncio.to_thread( + service.presentations() + .pages() + .getThumbnail( + presentationId=presentation_id, + pageObjectId=page_object_id, + thumbnailProperties_thumbnailSize=thumbnail_size, + thumbnailProperties_mimeType="PNG", + ) + .execute + ) + + thumbnail_url = result.get("contentUrl", "") + + confirmation_message = f"""Thumbnail Generated for {user_google_email}: +- Presentation ID: {presentation_id} +- Page ID: {page_object_id} +- Thumbnail Size: {thumbnail_size} +- Thumbnail URL: {thumbnail_url} + +You can view or download the thumbnail using the provided URL.""" + + logger.info(f"Thumbnail generated successfully for {user_google_email}") + return confirmation_message + + +# Create comment management tools for slides +_comment_tools = create_comment_tools("presentation", "presentation_id") +list_presentation_comments = _comment_tools["list_comments"] +manage_presentation_comment = _comment_tools["manage_comment"] + +# Aliases for backwards compatibility and intuitive naming +list_slide_comments = list_presentation_comments +manage_slide_comment = manage_presentation_comment diff --git a/gtasks/__init__.py b/gtasks/__init__.py new file mode 100644 index 0000000..6c9dd88 --- /dev/null +++ b/gtasks/__init__.py @@ -0,0 +1,5 @@ +""" +Google Tasks MCP Integration + +This module provides MCP tools for interacting with Google Tasks API. +""" diff --git a/gtasks/tasks_tools.py b/gtasks/tasks_tools.py new file mode 100644 index 0000000..0e9cff3 --- /dev/null +++ b/gtasks/tasks_tools.py @@ -0,0 +1,951 @@ +""" +Google Tasks MCP Tools + +This module provides MCP tools for interacting with Google Tasks API. +""" + +import asyncio +import logging +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional + +from googleapiclient.errors import HttpError # type: ignore +from mcp import Resource + +from auth.oauth_config import is_oauth21_enabled, is_external_oauth21_provider +from auth.permissions import is_action_denied +from auth.service_decorator import require_google_service +from core.server import server +from core.utils import UserInputError, handle_http_errors + +logger = logging.getLogger(__name__) + +LIST_TASKS_MAX_RESULTS_DEFAULT = 20 +LIST_TASKS_MAX_RESULTS_MAX = 10_000 +LIST_TASKS_MAX_POSITION = "99999999999999999999" + + +def _format_reauth_message(error: Exception, user_google_email: str) -> str: + base = f"API error: {error}" + + # Only suggest re-authentication for auth-related errors (401, 403) + if isinstance(error, HttpError) and error.resp.status in (401, 403): + base += ". You might need to re-authenticate." + if is_oauth21_enabled(): + if is_external_oauth21_provider(): + hint = ( + "LLM: Ask the user to provide a valid OAuth 2.1 bearer token in the " + "Authorization header and retry." + ) + else: + hint = ( + "LLM: Ask the user to authenticate via their MCP client's OAuth 2.1 " + "flow and retry." + ) + else: + hint = ( + "LLM: Try 'start_google_auth' with the user's email " + f"({user_google_email}) and service_name='Google Tasks'." + ) + return f"{base} {hint}" + + return base + + +class StructuredTask: + def __init__(self, task: Dict[str, str], is_placeholder_parent: bool) -> None: + self.id = task["id"] + self.title = task.get("title", None) + self.status = task.get("status", None) + self.due = task.get("due", None) + self.notes = task.get("notes", None) + self.updated = task.get("updated", None) + self.completed = task.get("completed", None) + self.is_placeholder_parent = is_placeholder_parent + self.subtasks: List["StructuredTask"] = [] + + def add_subtask(self, subtask: "StructuredTask") -> None: + self.subtasks.append(subtask) + + def __repr__(self) -> str: + return f"StructuredTask(title={self.title}, {len(self.subtasks)} subtasks)" + + +def _adjust_due_max_for_tasks_api(due_max: str) -> str: + """ + Compensate for the Google Tasks API treating dueMax as an exclusive bound. + + The API stores due dates at day resolution and compares using < dueMax, so to + include tasks due on the requested date we bump the bound by one day. + """ + try: + parsed = datetime.fromisoformat(due_max.replace("Z", "+00:00")) + except ValueError: + logger.warning( + "[list_tasks] Unable to parse due_max '%s'; sending unmodified value", + due_max, + ) + return due_max + + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + + adjusted = parsed + timedelta(days=1) + if adjusted.tzinfo == timezone.utc: + return adjusted.isoformat().replace("+00:00", "Z") + return adjusted.isoformat() + + +@server.tool() # type: ignore +@require_google_service("tasks", "tasks_read") # type: ignore +@handle_http_errors("list_task_lists", service_type="tasks") # type: ignore +async def list_task_lists( + service: Resource, + user_google_email: str, + max_results: int = 1000, + page_token: Optional[str] = None, +) -> str: + """ + List all task lists for the user. + + Args: + user_google_email (str): The user's Google email address. Required. + max_results (int): Maximum number of task lists to return (default: 1000, max: 1000). + page_token (Optional[str]): Token for pagination. + + Returns: + str: List of task lists with their IDs, titles, and details. + """ + logger.info(f"[list_task_lists] Invoked. Email: '{user_google_email}'") + + try: + params: Dict[str, Any] = {} + if max_results is not None: + params["maxResults"] = max_results + if page_token: + params["pageToken"] = page_token + + result = await asyncio.to_thread(service.tasklists().list(**params).execute) + + task_lists = result.get("items", []) + next_page_token = result.get("nextPageToken") + + if not task_lists: + return f"No task lists found for {user_google_email}." + + response = f"Task Lists for {user_google_email}:\n" + for task_list in task_lists: + response += f"- {task_list['title']} (ID: {task_list['id']})\n" + response += f" Updated: {task_list.get('updated', 'N/A')}\n" + + if next_page_token: + response += f"\nNext page token: {next_page_token}" + + logger.info(f"Found {len(task_lists)} task lists for {user_google_email}") + return response + + except HttpError as error: + message = _format_reauth_message(error, user_google_email) + logger.error(message, exc_info=True) + raise Exception(message) + except Exception as e: + message = f"Unexpected error: {e}." + logger.exception(message) + raise Exception(message) + + +@server.tool() # type: ignore +@require_google_service("tasks", "tasks_read") # type: ignore +@handle_http_errors("get_task_list", service_type="tasks") # type: ignore +async def get_task_list( + service: Resource, user_google_email: str, task_list_id: str +) -> str: + """ + Get details of a specific task list. + + Args: + user_google_email (str): The user's Google email address. Required. + task_list_id (str): The ID of the task list to retrieve. + + Returns: + str: Task list details including title, ID, and last updated time. + """ + logger.info( + f"[get_task_list] Invoked. Email: '{user_google_email}', Task List ID: {task_list_id}" + ) + + try: + task_list = await asyncio.to_thread( + service.tasklists().get(tasklist=task_list_id).execute + ) + + response = f"""Task List Details for {user_google_email}: +- Title: {task_list["title"]} +- ID: {task_list["id"]} +- Updated: {task_list.get("updated", "N/A")} +- Self Link: {task_list.get("selfLink", "N/A")}""" + + logger.info( + f"Retrieved task list '{task_list['title']}' for {user_google_email}" + ) + return response + + except HttpError as error: + message = _format_reauth_message(error, user_google_email) + logger.error(message, exc_info=True) + raise Exception(message) + except Exception as e: + message = f"Unexpected error: {e}." + logger.exception(message) + raise Exception(message) + + +# --- Task list _impl functions --- + + +async def _create_task_list_impl( + service: Resource, user_google_email: str, title: str +) -> str: + """Implementation for creating a new task list.""" + logger.info( + f"[create_task_list] Invoked. Email: '{user_google_email}', Title: '{title}'" + ) + + body = {"title": title} + + result = await asyncio.to_thread(service.tasklists().insert(body=body).execute) + + response = f"""Task List Created for {user_google_email}: +- Title: {result["title"]} +- ID: {result["id"]} +- Created: {result.get("updated", "N/A")} +- Self Link: {result.get("selfLink", "N/A")}""" + + logger.info( + f"Created task list '{title}' with ID {result['id']} for {user_google_email}" + ) + return response + + +async def _update_task_list_impl( + service: Resource, user_google_email: str, task_list_id: str, title: str +) -> str: + """Implementation for updating an existing task list.""" + logger.info( + f"[update_task_list] Invoked. Email: '{user_google_email}', Task List ID: {task_list_id}, New Title: '{title}'" + ) + + body = {"id": task_list_id, "title": title} + + result = await asyncio.to_thread( + service.tasklists().update(tasklist=task_list_id, body=body).execute + ) + + response = f"""Task List Updated for {user_google_email}: +- Title: {result["title"]} +- ID: {result["id"]} +- Updated: {result.get("updated", "N/A")}""" + + logger.info( + f"Updated task list {task_list_id} with new title '{title}' for {user_google_email}" + ) + return response + + +async def _delete_task_list_impl( + service: Resource, user_google_email: str, task_list_id: str +) -> str: + """Implementation for deleting a task list.""" + logger.info( + f"[delete_task_list] Invoked. Email: '{user_google_email}', Task List ID: {task_list_id}" + ) + + await asyncio.to_thread(service.tasklists().delete(tasklist=task_list_id).execute) + + response = f"Task list {task_list_id} has been deleted for {user_google_email}. All tasks in this list have also been deleted." + + logger.info(f"Deleted task list {task_list_id} for {user_google_email}") + return response + + +async def _clear_completed_tasks_impl( + service: Resource, user_google_email: str, task_list_id: str +) -> str: + """Implementation for clearing completed tasks from a task list.""" + logger.info( + f"[clear_completed_tasks] Invoked. Email: '{user_google_email}', Task List ID: {task_list_id}" + ) + + await asyncio.to_thread(service.tasks().clear(tasklist=task_list_id).execute) + + response = f"All completed tasks have been cleared from task list {task_list_id} for {user_google_email}. The tasks are now hidden and won't appear in default task list views." + + logger.info( + f"Cleared completed tasks from list {task_list_id} for {user_google_email}" + ) + return response + + +# --- Consolidated manage_task_list tool --- + + +@server.tool() # type: ignore +@require_google_service("tasks", "tasks") # type: ignore +@handle_http_errors("manage_task_list", service_type="tasks") # type: ignore +async def manage_task_list( + service: Resource, + user_google_email: str, + action: str, + task_list_id: Optional[str] = None, + title: Optional[str] = None, +) -> str: + """ + Manage task lists: create, update, delete, or clear completed tasks. + + Args: + user_google_email (str): The user's Google email address. Required. + action (str): The action to perform. Must be one of: "create", "update", "delete", "clear_completed". + task_list_id (Optional[str]): The ID of the task list. Required for "update", "delete", and "clear_completed" actions. + title (Optional[str]): The title for the task list. Required for "create" and "update" actions. + + Returns: + str: Result of the requested action. + """ + logger.info( + f"[manage_task_list] Invoked. Email: '{user_google_email}', Action: '{action}'" + ) + + valid_actions = ("create", "update", "delete", "clear_completed") + if action not in valid_actions: + raise UserInputError( + f"Invalid action '{action}'. Must be one of: {', '.join(valid_actions)}" + ) + + if is_action_denied("tasks", action): + raise UserInputError( + f"The '{action}' action is not allowed under the current permission level." + ) + + if action == "create": + if not title: + raise UserInputError("'title' is required for the 'create' action.") + return await _create_task_list_impl(service, user_google_email, title) + + if action == "update": + if not task_list_id: + raise UserInputError("'task_list_id' is required for the 'update' action.") + if not title: + raise UserInputError("'title' is required for the 'update' action.") + return await _update_task_list_impl( + service, user_google_email, task_list_id, title + ) + + if action == "delete": + if not task_list_id: + raise UserInputError("'task_list_id' is required for the 'delete' action.") + return await _delete_task_list_impl(service, user_google_email, task_list_id) + + # action == "clear_completed" + if not task_list_id: + raise UserInputError( + "'task_list_id' is required for the 'clear_completed' action." + ) + return await _clear_completed_tasks_impl(service, user_google_email, task_list_id) + + +# --- Task tools --- + + +@server.tool() # type: ignore +@require_google_service("tasks", "tasks_read") # type: ignore +@handle_http_errors("list_tasks", service_type="tasks") # type: ignore +async def list_tasks( + service: Resource, + user_google_email: str, + task_list_id: str, + max_results: int = LIST_TASKS_MAX_RESULTS_DEFAULT, + page_token: Optional[str] = None, + show_completed: bool = True, + show_deleted: bool = False, + show_hidden: bool = False, + show_assigned: bool = False, + completed_max: Optional[str] = None, + completed_min: Optional[str] = None, + due_max: Optional[str] = None, + due_min: Optional[str] = None, + updated_min: Optional[str] = None, +) -> str: + """ + List all tasks in a specific task list. + + Args: + user_google_email (str): The user's Google email address. Required. + task_list_id (str): The ID of the task list to retrieve tasks from. + max_results (int): Maximum number of tasks to return. (default: 20, max: 10000). + page_token (Optional[str]): Token for pagination. + show_completed (bool): Whether to include completed tasks (default: True). Note that show_hidden must also be true to show tasks completed in first party clients, such as the web UI and Google's mobile apps. + show_deleted (bool): Whether to include deleted tasks (default: False). + show_hidden (bool): Whether to include hidden tasks (default: False). + show_assigned (bool): Whether to include assigned tasks (default: False). + completed_max (Optional[str]): Upper bound for completion date (RFC 3339 timestamp). + completed_min (Optional[str]): Lower bound for completion date (RFC 3339 timestamp). + due_max (Optional[str]): Upper bound for due date (RFC 3339 timestamp). + due_min (Optional[str]): Lower bound for due date (RFC 3339 timestamp). + updated_min (Optional[str]): Lower bound for last modification time (RFC 3339 timestamp). + + Returns: + str: List of tasks with their details. + """ + logger.info( + f"[list_tasks] Invoked. Email: '{user_google_email}', Task List ID: {task_list_id}" + ) + + try: + params: Dict[str, Any] = {"tasklist": task_list_id} + if max_results is not None: + params["maxResults"] = max_results + if page_token: + params["pageToken"] = page_token + if show_completed is not None: + params["showCompleted"] = show_completed + if show_deleted is not None: + params["showDeleted"] = show_deleted + if show_hidden is not None: + params["showHidden"] = show_hidden + if show_assigned is not None: + params["showAssigned"] = show_assigned + if completed_max: + params["completedMax"] = completed_max + if completed_min: + params["completedMin"] = completed_min + if due_max: + adjusted_due_max = _adjust_due_max_for_tasks_api(due_max) + if adjusted_due_max != due_max: + logger.info( + "[list_tasks] Adjusted due_max from '%s' to '%s' to include due date boundary", + due_max, + adjusted_due_max, + ) + params["dueMax"] = adjusted_due_max + if due_min: + params["dueMin"] = due_min + if updated_min: + params["updatedMin"] = updated_min + + result = await asyncio.to_thread(service.tasks().list(**params).execute) + + tasks = result.get("items", []) + next_page_token = result.get("nextPageToken") + + # In order to return a sorted and organized list of tasks all at once, we support retrieving more than a single + # page from the Google tasks API. + results_remaining = ( + min(max_results, LIST_TASKS_MAX_RESULTS_MAX) + if max_results + else LIST_TASKS_MAX_RESULTS_DEFAULT + ) + results_remaining -= len(tasks) + while results_remaining > 0 and next_page_token: + params["pageToken"] = next_page_token + params["maxResults"] = str(results_remaining) + result = await asyncio.to_thread(service.tasks().list(**params).execute) + more_tasks = result.get("items", []) + next_page_token = result.get("nextPageToken") + if len(more_tasks) == 0: + # For some unexpected reason, no more tasks were returned. Break to avoid an infinite loop. + break + tasks.extend(more_tasks) + results_remaining -= len(more_tasks) + + if not tasks: + return ( + f"No tasks found in task list {task_list_id} for {user_google_email}." + ) + + structured_tasks = get_structured_tasks(tasks) + + response = f"Tasks in list {task_list_id} for {user_google_email}:\n" + response += serialize_tasks(structured_tasks, 0) + + if next_page_token: + response += f"Next page token: {next_page_token}\n" + + logger.info( + f"Found {len(tasks)} tasks in list {task_list_id} for {user_google_email}" + ) + return response + + except HttpError as error: + message = _format_reauth_message(error, user_google_email) + logger.error(message, exc_info=True) + raise Exception(message) + except Exception as e: + message = f"Unexpected error: {e}." + logger.exception(message) + raise Exception(message) + + +def get_structured_tasks(tasks: List[Dict[str, str]]) -> List[StructuredTask]: + """ + Convert a flat list of task dictionaries into StructuredTask objects based on parent-child relationships sorted by position. + + Args: + tasks: List of task dictionaries. + + Returns: + list: Sorted list of top-level StructuredTask objects with nested subtasks. + """ + tasks_by_id = { + task["id"]: StructuredTask(task, is_placeholder_parent=False) for task in tasks + } + positions_by_id = { + task["id"]: int(task["position"]) for task in tasks if "position" in task + } + + # Placeholder virtual root as parent for top-level tasks + root_task = StructuredTask( + {"id": "root", "title": "Root"}, is_placeholder_parent=False + ) + + for task in tasks: + structured_task = tasks_by_id[task["id"]] + parent_id = task.get("parent") + parent = None + + if not parent_id: + # Task without parent: parent to the virtual root + parent = root_task + elif parent_id in tasks_by_id: + # Subtask: parent to its actual parent + parent = tasks_by_id[parent_id] + else: + # Orphaned subtask: create placeholder parent + # Due to paging or filtering, a subtask may have a parent that is not present in the list of tasks. + # We will create placeholder StructuredTask objects for these missing parents to maintain the hierarchy. + parent = StructuredTask({"id": parent_id}, is_placeholder_parent=True) + tasks_by_id[parent_id] = parent + root_task.add_subtask(parent) + + parent.add_subtask(structured_task) + + sort_structured_tasks(root_task, positions_by_id) + return root_task.subtasks + + +def sort_structured_tasks( + root_task: StructuredTask, positions_by_id: Dict[str, int] +) -> None: + """ + Recursively sort--in place--StructuredTask objects and their subtasks based on position. + + Args: + root_task: The root StructuredTask object. + positions_by_id: Dictionary mapping task IDs to their positions. + """ + + def get_position(task: StructuredTask) -> int | float: + # Tasks without position go to the end (infinity) + result = positions_by_id.get(task.id, float("inf")) + return result + + root_task.subtasks.sort(key=get_position) + for subtask in root_task.subtasks: + sort_structured_tasks(subtask, positions_by_id) + + +def serialize_tasks(structured_tasks: List[StructuredTask], subtask_level: int) -> str: + """ + Serialize a list of StructuredTask objects into a formatted string with indentation for subtasks. + Args: + structured_tasks (list): List of StructuredTask objects. + subtask_level (int): Current level of indentation for subtasks. + + Returns: + str: Formatted string representation of the tasks. + """ + response = "" + placeholder_parent_count = 0 + placeholder_parent_title = "Unknown parent" + for task in structured_tasks: + indent = " " * subtask_level + bullet = "-" if subtask_level == 0 else "*" + if task.title is not None: + title = task.title + elif task.is_placeholder_parent: + title = placeholder_parent_title + placeholder_parent_count += 1 + else: + title = "Untitled" + response += f"{indent}{bullet} {title} (ID: {task.id})\n" + response += f"{indent} Status: {task.status or 'N/A'}\n" + response += f"{indent} Due: {task.due}\n" if task.due else "" + if task.notes: + response += f"{indent} Notes: {task.notes[:100]}{'...' if len(task.notes) > 100 else ''}\n" + response += f"{indent} Completed: {task.completed}\n" if task.completed else "" + response += f"{indent} Updated: {task.updated or 'N/A'}\n" + response += "\n" + + response += serialize_tasks(task.subtasks, subtask_level + 1) + + if placeholder_parent_count > 0: + # Placeholder parents should only appear at the top level + assert subtask_level == 0 + response += f""" +{placeholder_parent_count} tasks with title {placeholder_parent_title} are included as placeholders. +These placeholders contain subtasks whose parents were not present in the task list. +This can occur due to pagination. Callers can often avoid this problem if max_results is large enough to contain all tasks (subtasks and their parents) without paging. +This can also occur due to filtering that excludes parent tasks while including their subtasks or due to deleted or hidden parent tasks. +""" + + return response + + +@server.tool() # type: ignore +@require_google_service("tasks", "tasks_read") # type: ignore +@handle_http_errors("get_task", service_type="tasks") # type: ignore +async def get_task( + service: Resource, user_google_email: str, task_list_id: str, task_id: str +) -> str: + """ + Get details of a specific task. + + Args: + user_google_email (str): The user's Google email address. Required. + task_list_id (str): The ID of the task list containing the task. + task_id (str): The ID of the task to retrieve. + + Returns: + str: Task details including title, notes, status, due date, etc. + """ + logger.info( + f"[get_task] Invoked. Email: '{user_google_email}', Task List ID: {task_list_id}, Task ID: {task_id}" + ) + + try: + task = await asyncio.to_thread( + service.tasks().get(tasklist=task_list_id, task=task_id).execute + ) + + response = f"""Task Details for {user_google_email}: +- Title: {task.get("title", "Untitled")} +- ID: {task["id"]} +- Status: {task.get("status", "N/A")} +- Updated: {task.get("updated", "N/A")}""" + + if task.get("due"): + response += f"\n- Due Date: {task['due']}" + if task.get("completed"): + response += f"\n- Completed: {task['completed']}" + if task.get("notes"): + response += f"\n- Notes: {task['notes']}" + if task.get("parent"): + response += f"\n- Parent Task ID: {task['parent']}" + if task.get("position"): + response += f"\n- Position: {task['position']}" + if task.get("selfLink"): + response += f"\n- Self Link: {task['selfLink']}" + if task.get("webViewLink"): + response += f"\n- Web View Link: {task['webViewLink']}" + + logger.info( + f"Retrieved task '{task.get('title', 'Untitled')}' for {user_google_email}" + ) + return response + + except HttpError as error: + message = _format_reauth_message(error, user_google_email) + logger.error(message, exc_info=True) + raise Exception(message) + except Exception as e: + message = f"Unexpected error: {e}." + logger.exception(message) + raise Exception(message) + + +# --- Task _impl functions --- + + +async def _create_task_impl( + service: Resource, + user_google_email: str, + task_list_id: str, + title: str, + notes: Optional[str] = None, + due: Optional[str] = None, + parent: Optional[str] = None, + previous: Optional[str] = None, +) -> str: + """Implementation for creating a new task in a task list.""" + logger.info( + f"[create_task] Invoked. Email: '{user_google_email}', Task List ID: {task_list_id}, Title: '{title}'" + ) + + body = {"title": title} + if notes: + body["notes"] = notes + if due: + body["due"] = due + + params = {"tasklist": task_list_id, "body": body} + if parent: + params["parent"] = parent + if previous: + params["previous"] = previous + + result = await asyncio.to_thread(service.tasks().insert(**params).execute) + + response = f"""Task Created for {user_google_email}: +- Title: {result["title"]} +- ID: {result["id"]} +- Status: {result.get("status", "N/A")} +- Updated: {result.get("updated", "N/A")}""" + + if result.get("due"): + response += f"\n- Due Date: {result['due']}" + if result.get("notes"): + response += f"\n- Notes: {result['notes']}" + if result.get("webViewLink"): + response += f"\n- Web View Link: {result['webViewLink']}" + + logger.info( + f"Created task '{title}' with ID {result['id']} for {user_google_email}" + ) + return response + + +async def _update_task_impl( + service: Resource, + user_google_email: str, + task_list_id: str, + task_id: str, + title: Optional[str] = None, + notes: Optional[str] = None, + status: Optional[str] = None, + due: Optional[str] = None, +) -> str: + """Implementation for updating an existing task.""" + logger.info( + f"[update_task] Invoked. Email: '{user_google_email}', Task List ID: {task_list_id}, Task ID: {task_id}" + ) + + # First get the current task to build the update body + current_task = await asyncio.to_thread( + service.tasks().get(tasklist=task_list_id, task=task_id).execute + ) + + body = { + "id": task_id, + "title": title if title is not None else current_task.get("title", ""), + "status": status + if status is not None + else current_task.get("status", "needsAction"), + } + + if notes is not None: + body["notes"] = notes + elif current_task.get("notes"): + body["notes"] = current_task["notes"] + + if due is not None: + body["due"] = due + elif current_task.get("due"): + body["due"] = current_task["due"] + + result = await asyncio.to_thread( + service.tasks().update(tasklist=task_list_id, task=task_id, body=body).execute + ) + + response = f"""Task Updated for {user_google_email}: +- Title: {result["title"]} +- ID: {result["id"]} +- Status: {result.get("status", "N/A")} +- Updated: {result.get("updated", "N/A")}""" + + if result.get("due"): + response += f"\n- Due Date: {result['due']}" + if result.get("notes"): + response += f"\n- Notes: {result['notes']}" + if result.get("completed"): + response += f"\n- Completed: {result['completed']}" + + logger.info(f"Updated task {task_id} for {user_google_email}") + return response + + +async def _delete_task_impl( + service: Resource, user_google_email: str, task_list_id: str, task_id: str +) -> str: + """Implementation for deleting a task from a task list.""" + logger.info( + f"[delete_task] Invoked. Email: '{user_google_email}', Task List ID: {task_list_id}, Task ID: {task_id}" + ) + + await asyncio.to_thread( + service.tasks().delete(tasklist=task_list_id, task=task_id).execute + ) + + response = f"Task {task_id} has been deleted from task list {task_list_id} for {user_google_email}." + + logger.info(f"Deleted task {task_id} for {user_google_email}") + return response + + +async def _move_task_impl( + service: Resource, + user_google_email: str, + task_list_id: str, + task_id: str, + parent: Optional[str] = None, + previous: Optional[str] = None, + destination_task_list: Optional[str] = None, +) -> str: + """Implementation for moving a task to a different position, parent, or list.""" + logger.info( + f"[move_task] Invoked. Email: '{user_google_email}', Task List ID: {task_list_id}, Task ID: {task_id}" + ) + + params = {"tasklist": task_list_id, "task": task_id} + if parent: + params["parent"] = parent + if previous: + params["previous"] = previous + if destination_task_list: + params["destinationTasklist"] = destination_task_list + + result = await asyncio.to_thread(service.tasks().move(**params).execute) + + response = f"""Task Moved for {user_google_email}: +- Title: {result["title"]} +- ID: {result["id"]} +- Status: {result.get("status", "N/A")} +- Updated: {result.get("updated", "N/A")}""" + + if result.get("parent"): + response += f"\n- Parent Task ID: {result['parent']}" + if result.get("position"): + response += f"\n- Position: {result['position']}" + + move_details = [] + if destination_task_list: + move_details.append(f"moved to task list {destination_task_list}") + if parent: + move_details.append(f"made a subtask of {parent}") + if previous: + move_details.append(f"positioned after {previous}") + + if move_details: + response += f"\n- Move Details: {', '.join(move_details)}" + + logger.info(f"Moved task {task_id} for {user_google_email}") + return response + + +# --- Consolidated manage_task tool --- + + +@server.tool() # type: ignore +@require_google_service("tasks", "tasks") # type: ignore +@handle_http_errors("manage_task", service_type="tasks") # type: ignore +async def manage_task( + service: Resource, + user_google_email: str, + action: str, + task_list_id: str, + task_id: Optional[str] = None, + title: Optional[str] = None, + notes: Optional[str] = None, + status: Optional[str] = None, + due: Optional[str] = None, + parent: Optional[str] = None, + previous: Optional[str] = None, + destination_task_list: Optional[str] = None, +) -> str: + """ + Manage tasks: create, update, delete, or move tasks within task lists. + + Args: + user_google_email (str): The user's Google email address. Required. + action (str): The action to perform. Must be one of: "create", "update", "delete", "move". + task_list_id (str): The ID of the task list. Required for all actions. + task_id (Optional[str]): The ID of the task. Required for "update", "delete", and "move" actions. + title (Optional[str]): The title of the task. Required for "create", optional for "update". + notes (Optional[str]): Notes/description for the task. Used by "create" and "update" actions. + status (Optional[str]): Task status ("needsAction" or "completed"). Used by "update" action. + due (Optional[str]): Due date in RFC 3339 format (e.g., "2024-12-31T23:59:59Z"). Used by "create" and "update" actions. + parent (Optional[str]): Parent task ID (for subtasks). Used by "create" and "move" actions. + previous (Optional[str]): Previous sibling task ID (for positioning). Used by "create" and "move" actions. + destination_task_list (Optional[str]): Destination task list ID (for moving between lists). Used by "move" action. + + Returns: + str: Result of the requested action. + """ + logger.info( + f"[manage_task] Invoked. Email: '{user_google_email}', Action: '{action}', Task List ID: {task_list_id}" + ) + + allowed_statuses = {"needsAction", "completed"} + if status is not None and status not in allowed_statuses: + raise UserInputError("invalid status: must be 'needsAction' or 'completed'") + + valid_actions = ("create", "update", "delete", "move") + if action not in valid_actions: + raise UserInputError( + f"Invalid action '{action}'. Must be one of: {', '.join(valid_actions)}" + ) + + if is_action_denied("tasks", action): + raise UserInputError( + f"The '{action}' action is not allowed under the current permission level." + ) + + if action == "create": + if status is not None: + raise UserInputError("'status' is only supported for the 'update' action.") + if not title: + raise UserInputError("'title' is required for the 'create' action.") + return await _create_task_impl( + service, + user_google_email, + task_list_id, + title, + notes=notes, + due=due, + parent=parent, + previous=previous, + ) + + if action == "update": + if status is not None and status not in allowed_statuses: + raise UserInputError("invalid status: must be 'needsAction' or 'completed'") + if not task_id: + raise UserInputError("'task_id' is required for the 'update' action.") + return await _update_task_impl( + service, + user_google_email, + task_list_id, + task_id, + title=title, + notes=notes, + status=status, + due=due, + ) + + if action == "delete": + if not task_id: + raise UserInputError("'task_id' is required for the 'delete' action.") + return await _delete_task_impl( + service, user_google_email, task_list_id, task_id + ) + + # action == "move" + if not task_id: + raise UserInputError("'task_id' is required for the 'move' action.") + return await _move_task_impl( + service, + user_google_email, + task_list_id, + task_id, + parent=parent, + previous=previous, + destination_task_list=destination_task_list, + ) diff --git a/helm-chart/workspace-mcp/Chart.yaml b/helm-chart/workspace-mcp/Chart.yaml new file mode 100644 index 0000000..dcd7ca8 --- /dev/null +++ b/helm-chart/workspace-mcp/Chart.yaml @@ -0,0 +1,19 @@ +apiVersion: v2 +name: workspace-mcp +description: A Helm chart for Google Workspace MCP Server - Comprehensive Google Workspace integration for AI assistants +type: application +version: 0.1.0 +appVersion: "1.2.0" +keywords: + - mcp + - google + - workspace + - ai + - llm + - claude +home: https://workspacemcp.com +sources: + - https://github.com/taylorwilsdon/google_workspace_mcp +maintainers: + - name: Taylor Wilsdon + email: taylor@taylorwilsdon.com \ No newline at end of file diff --git a/helm-chart/workspace-mcp/README.md b/helm-chart/workspace-mcp/README.md new file mode 100644 index 0000000..cbb8b64 --- /dev/null +++ b/helm-chart/workspace-mcp/README.md @@ -0,0 +1,143 @@ +# Google Workspace MCP Server Helm Chart + +This Helm chart deploys the Google Workspace MCP Server on a Kubernetes cluster. The Google Workspace MCP Server provides comprehensive integration with Google Workspace services including Gmail, Calendar, Drive, Docs, Sheets, Slides, Forms, Tasks, and Chat. + +Disclaimer - this is a user submitted feature and not one that the maintainer uses personally. It may be out of date - use at your own risk! + +## Prerequisites + +- Kubernetes 1.19+ +- Helm 3.2.0+ +- Google Cloud Project with OAuth 2.0 credentials +- Enabled Google Workspace APIs + +## Installing the Chart + +To install the chart with the release name `workspace-mcp`: + +```bash +# First, set your Google OAuth credentials +helm install workspace-mcp ./helm-chart/workspace-mcp \ + --set secrets.googleOAuth.clientId="your-client-id.apps.googleusercontent.com" \ + --set secrets.googleOAuth.clientSecret="your-client-secret" +``` + +## Configuration + +The following table lists the configurable parameters and their default values: + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `replicaCount` | Number of replicas | `1` | +| `image.repository` | Container image repository | `workspace-mcp` | +| `image.tag` | Container image tag | `""` (uses Chart.AppVersion) | +| `image.pullPolicy` | Image pull policy | `IfNotPresent` | +| `secrets.googleOAuth.clientId` | Google OAuth Client ID | `""` (required) | +| `secrets.googleOAuth.clientSecret` | Google OAuth Client Secret | `""` (required) | +| `secrets.googleOAuth.userEmail` | Default user email for single-user mode | `""` | +| `singleUserMode` | Enable single-user mode | `false` | +| `tools.enabled` | List of tools to enable | `[]` (all tools enabled) | +| `env.MCP_ENABLE_OAUTH21` | Enable OAuth 2.1 support | `"false"` | +| `service.type` | Kubernetes service type | `ClusterIP` | +| `service.port` | Service port | `8000` | +| `ingress.enabled` | Enable ingress | `false` | +| `resources.limits.cpu` | CPU limit | `500m` | +| `resources.limits.memory` | Memory limit | `512Mi` | +| `autoscaling.enabled` | Enable HPA | `false` | + +## Google OAuth Setup + +Before deploying, you need to set up Google OAuth credentials: + +1. Create a project in [Google Cloud Console](https://console.cloud.google.com/) +2. Enable the required Google Workspace APIs +3. Create OAuth 2.0 credentials (Web application) +4. Set authorized redirect URI: `http://your-domain:8000/oauth2callback` + +## Examples + +### Basic deployment with specific tools: + +```bash +helm install workspace-mcp ./helm-chart/workspace-mcp \ + --set secrets.googleOAuth.clientId="your-client-id" \ + --set secrets.googleOAuth.clientSecret="your-secret" \ + --set tools.enabled="{gmail,calendar,drive}" +``` + +### Production deployment with ingress: + +```bash +helm install workspace-mcp ./helm-chart/workspace-mcp \ + --set secrets.googleOAuth.clientId="your-client-id" \ + --set secrets.googleOAuth.clientSecret="your-secret" \ + --set ingress.enabled=true \ + --set ingress.hosts[0].host="workspace-mcp.yourdomain.com" \ + --set ingress.hosts[0].paths[0].path="/" \ + --set ingress.hosts[0].paths[0].pathType="Prefix" +``` + +### Single-user mode deployment: + +```bash +helm install workspace-mcp ./helm-chart/workspace-mcp \ + --set secrets.googleOAuth.clientId="your-client-id" \ + --set secrets.googleOAuth.clientSecret="your-secret" \ + --set singleUserMode=true \ + --set secrets.googleOAuth.userEmail="user@yourdomain.com" +``` + +### Enable OAuth 2.1 for multi-user environments: + +```bash +helm install workspace-mcp ./helm-chart/workspace-mcp \ + --set secrets.googleOAuth.clientId="your-client-id" \ + --set secrets.googleOAuth.clientSecret="your-secret" \ + --set env.MCP_ENABLE_OAUTH21="true" +``` + +## Uninstalling the Chart + +To uninstall/delete the `workspace-mcp` deployment: + +```bash +helm delete workspace-mcp +``` + +## Available Tools + +You can selectively enable tools using the `tools.enabled` parameter: + +- `gmail` - Gmail integration +- `drive` - Google Drive integration +- `calendar` - Google Calendar integration +- `docs` - Google Docs integration +- `sheets` - Google Sheets integration +- `slides` - Google Slides integration +- `forms` - Google Forms integration +- `tasks` - Google Tasks integration +- `chat` - Google Chat integration +- `search` - Google Custom Search integration + +If `tools.enabled` is empty or not set, all tools will be enabled. + +## Health Checks + +The chart includes health checks that verify the application is running correctly: + +- Liveness probe checks `/health` endpoint +- Readiness probe ensures the service is ready to accept traffic +- Configurable timing and thresholds via `healthCheck` values + +## Security + +- Runs as non-root user (UID 1000) +- Uses read-only root filesystem where possible +- Drops all Linux capabilities +- Secrets are stored securely in Kubernetes secrets + +## Support + +For issues and questions: +- GitHub: https://github.com/taylorwilsdon/google_workspace_mcp +- Documentation: https://workspacemcp.com diff --git a/helm-chart/workspace-mcp/templates/NOTES.txt b/helm-chart/workspace-mcp/templates/NOTES.txt new file mode 100644 index 0000000..32d8be3 --- /dev/null +++ b/helm-chart/workspace-mcp/templates/NOTES.txt @@ -0,0 +1,55 @@ +1. Get the application URL by running these commands: +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "workspace-mcp.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "workspace-mcp.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "workspace-mcp.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "workspace-mcp.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT +{{- end }} + +2. Check the health of your Google Workspace MCP Server: +{{- if .Values.healthCheck.enabled }} + kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "workspace-mcp.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" + + # View application logs + kubectl logs --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "workspace-mcp.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" +{{- end }} + +3. Configuration Summary: +{{- if .Values.singleUserMode }} + - Mode: Single-user +{{- else }} + - Mode: Multi-user +{{- end }} +{{- if .Values.tools.enabled }} + - Enabled Tools: {{ join ", " .Values.tools.enabled }} +{{- else }} + - Enabled Tools: All Google Workspace tools +{{- end }} +{{- if eq .Values.env.MCP_ENABLE_OAUTH21 "true" }} + - OAuth 2.1: Enabled +{{- else }} + - OAuth 2.1: Disabled (using legacy OAuth 2.0) +{{- end }} + +4. Important Notes: + - Make sure you have configured your Google OAuth credentials in the secret + - The application requires internet access to reach Google APIs + - OAuth callback URL: {{ if .Values.env.WORKSPACE_EXTERNAL_URL }}{{ .Values.env.WORKSPACE_EXTERNAL_URL }}{{ else }}{{ default "http://localhost" .Values.env.WORKSPACE_MCP_BASE_URI }}:{{ .Values.env.WORKSPACE_MCP_PORT }}{{ end }}/oauth2callback + +For more information about the Google Workspace MCP Server, visit: +https://github.com/taylorwilsdon/google_workspace_mcp \ No newline at end of file diff --git a/helm-chart/workspace-mcp/templates/_helpers.tpl b/helm-chart/workspace-mcp/templates/_helpers.tpl new file mode 100644 index 0000000..6f0063f --- /dev/null +++ b/helm-chart/workspace-mcp/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "workspace-mcp.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "workspace-mcp.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "workspace-mcp.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "workspace-mcp.labels" -}} +helm.sh/chart: {{ include "workspace-mcp.chart" . }} +{{ include "workspace-mcp.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "workspace-mcp.selectorLabels" -}} +app.kubernetes.io/name: {{ include "workspace-mcp.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "workspace-mcp.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "workspace-mcp.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} \ No newline at end of file diff --git a/helm-chart/workspace-mcp/templates/configmap.yaml b/helm-chart/workspace-mcp/templates/configmap.yaml new file mode 100644 index 0000000..2be9648 --- /dev/null +++ b/helm-chart/workspace-mcp/templates/configmap.yaml @@ -0,0 +1,12 @@ +{{- if .Values.env }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "workspace-mcp.fullname" . }}-config + labels: + {{- include "workspace-mcp.labels" . | nindent 4 }} +data: + {{- range $key, $value := .Values.env }} + {{ $key }}: {{ $value | quote }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/helm-chart/workspace-mcp/templates/deployment.yaml b/helm-chart/workspace-mcp/templates/deployment.yaml new file mode 100644 index 0000000..3de5cfd --- /dev/null +++ b/helm-chart/workspace-mcp/templates/deployment.yaml @@ -0,0 +1,132 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "workspace-mcp.fullname" . }} + labels: + {{- include "workspace-mcp.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "workspace-mcp.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "workspace-mcp.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "workspace-mcp.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.targetPort }} + protocol: TCP + {{- if .Values.healthCheck.enabled }} + livenessProbe: + httpGet: + path: {{ .Values.healthCheck.path }} + port: http + initialDelaySeconds: {{ .Values.healthCheck.initialDelaySeconds }} + periodSeconds: {{ .Values.healthCheck.periodSeconds }} + timeoutSeconds: {{ .Values.healthCheck.timeoutSeconds }} + successThreshold: {{ .Values.healthCheck.successThreshold }} + failureThreshold: {{ .Values.healthCheck.failureThreshold }} + readinessProbe: + httpGet: + path: {{ .Values.healthCheck.path }} + port: http + initialDelaySeconds: {{ .Values.healthCheck.initialDelaySeconds }} + periodSeconds: {{ .Values.healthCheck.periodSeconds }} + timeoutSeconds: {{ .Values.healthCheck.timeoutSeconds }} + successThreshold: {{ .Values.healthCheck.successThreshold }} + failureThreshold: {{ .Values.healthCheck.failureThreshold }} + {{- end }} + env: + # Google OAuth credentials from secret + - name: GOOGLE_OAUTH_CLIENT_ID + valueFrom: + secretKeyRef: + name: {{ include "workspace-mcp.fullname" . }}-oauth + key: client-id + - name: GOOGLE_OAUTH_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ include "workspace-mcp.fullname" . }}-oauth + key: client-secret + {{- if .Values.secrets.googleOAuth.userEmail }} + - name: USER_GOOGLE_EMAIL + valueFrom: + secretKeyRef: + name: {{ include "workspace-mcp.fullname" . }}-oauth + key: user-email + {{- end }} + # Single-user mode + {{- if .Values.singleUserMode }} + - name: MCP_SINGLE_USER_MODE + value: "1" + {{- end }} + # Tool configuration + {{- if .Values.toolTier }} + - name: TOOL_TIER + value: {{ .Values.toolTier | quote }} + {{- end }} + {{- if .Values.tools.enabled }} + - name: TOOLS + value: {{ .Values.tools.enabled | join " " | quote }} + {{- end }} + # Environment variables from values + {{- range $key, $value := .Values.env }} + - name: {{ $key }} + value: {{ $value | quote }} + {{- end }} + {{- if .Values.command.override }} + command: {{ .Values.command.override }} + {{- end }} + {{- if not .Values.command.disableArgs }} + args: + - "uv run main.py --transport streamable-http {{- if .Values.singleUserMode }} --single-user{{- end }} ${TOOL_TIER:+--tool-tier \"$TOOL_TIER\"} ${TOOLS:+--tools $TOOLS}" + {{- end }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + - name: credentials + mountPath: /app/.credentials + readOnly: false + - name: tmp + mountPath: /tmp + readOnly: false + volumes: + - name: credentials + emptyDir: + sizeLimit: 100Mi + - name: tmp + emptyDir: + sizeLimit: 100Mi + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} \ No newline at end of file diff --git a/helm-chart/workspace-mcp/templates/hpa.yaml b/helm-chart/workspace-mcp/templates/hpa.yaml new file mode 100644 index 0000000..ebaf476 --- /dev/null +++ b/helm-chart/workspace-mcp/templates/hpa.yaml @@ -0,0 +1,32 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "workspace-mcp.fullname" . }} + labels: + {{- include "workspace-mcp.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "workspace-mcp.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/helm-chart/workspace-mcp/templates/ingress.yaml b/helm-chart/workspace-mcp/templates/ingress.yaml new file mode 100644 index 0000000..785bc5c --- /dev/null +++ b/helm-chart/workspace-mcp/templates/ingress.yaml @@ -0,0 +1,59 @@ +{{- if .Values.ingress.enabled -}} +{{- $fullName := include "workspace-mcp.fullname" . -}} +{{- $svcPort := .Values.service.port -}} +{{- if and .Values.ingress.className (not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class")) }} + {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} +{{- end }} +{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1 +{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + {{- include "workspace-mcp.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} + pathType: {{ .pathType }} + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ $fullName }} + port: + number: {{ $svcPort }} + {{- else }} + serviceName: {{ $fullName }} + servicePort: {{ $svcPort }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/helm-chart/workspace-mcp/templates/poddisruptionbudget.yaml b/helm-chart/workspace-mcp/templates/poddisruptionbudget.yaml new file mode 100644 index 0000000..c37070f --- /dev/null +++ b/helm-chart/workspace-mcp/templates/poddisruptionbudget.yaml @@ -0,0 +1,18 @@ +{{- if .Values.podDisruptionBudget.enabled }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "workspace-mcp.fullname" . }} + labels: + {{- include "workspace-mcp.labels" . | nindent 4 }} +spec: + selector: + matchLabels: + {{- include "workspace-mcp.selectorLabels" . | nindent 6 }} + {{- if .Values.podDisruptionBudget.minAvailable }} + minAvailable: {{ .Values.podDisruptionBudget.minAvailable }} + {{- end }} + {{- if .Values.podDisruptionBudget.maxUnavailable }} + maxUnavailable: {{ .Values.podDisruptionBudget.maxUnavailable }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/helm-chart/workspace-mcp/templates/secret.yaml b/helm-chart/workspace-mcp/templates/secret.yaml new file mode 100644 index 0000000..a5f330b --- /dev/null +++ b/helm-chart/workspace-mcp/templates/secret.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "workspace-mcp.fullname" . }}-oauth + labels: + {{- include "workspace-mcp.labels" . | nindent 4 }} +type: Opaque +data: + {{- if .Values.secrets.googleOAuth.clientId }} + client-id: {{ .Values.secrets.googleOAuth.clientId | b64enc }} + {{- else }} + {{- fail "Google OAuth Client ID is required. Set values.secrets.googleOAuth.clientId" }} + {{- end }} + {{- if .Values.secrets.googleOAuth.clientSecret }} + client-secret: {{ .Values.secrets.googleOAuth.clientSecret | b64enc }} + {{- else }} + {{- fail "Google OAuth Client Secret is required. Set values.secrets.googleOAuth.clientSecret" }} + {{- end }} + {{- if .Values.secrets.googleOAuth.userEmail }} + user-email: {{ .Values.secrets.googleOAuth.userEmail | b64enc }} + {{- end }} \ No newline at end of file diff --git a/helm-chart/workspace-mcp/templates/service.yaml b/helm-chart/workspace-mcp/templates/service.yaml new file mode 100644 index 0000000..3bde636 --- /dev/null +++ b/helm-chart/workspace-mcp/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "workspace-mcp.fullname" . }} + labels: + {{- include "workspace-mcp.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: {{ .Values.service.targetPort }} + protocol: TCP + name: http + selector: + {{- include "workspace-mcp.selectorLabels" . | nindent 4 }} \ No newline at end of file diff --git a/helm-chart/workspace-mcp/templates/serviceaccount.yaml b/helm-chart/workspace-mcp/templates/serviceaccount.yaml new file mode 100644 index 0000000..b62c090 --- /dev/null +++ b/helm-chart/workspace-mcp/templates/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "workspace-mcp.serviceAccountName" . }} + labels: + {{- include "workspace-mcp.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/helm-chart/workspace-mcp/values.yaml b/helm-chart/workspace-mcp/values.yaml new file mode 100644 index 0000000..0054f7c --- /dev/null +++ b/helm-chart/workspace-mcp/values.yaml @@ -0,0 +1,149 @@ +# Default values for workspace-mcp +replicaCount: 1 + +image: + repository: workspace-mcp + pullPolicy: IfNotPresent + # Uses Chart.AppVersion if not specified + tag: "" + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + create: true + annotations: {} + name: "" + +podAnnotations: {} + +podSecurityContext: + fsGroup: 1000 + +securityContext: + capabilities: + drop: + - ALL + readOnlyRootFilesystem: false + runAsNonRoot: true + runAsUser: 1000 + +service: + type: ClusterIP + port: 8000 + targetPort: 8000 + +ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: workspace-mcp.local + paths: + - path: / + pathType: Prefix + tls: [] + # - secretName: workspace-mcp-tls + # hosts: + # - workspace-mcp.local + +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 250m + memory: 256Mi + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +nodeSelector: {} + +tolerations: [] + +affinity: {} + +# Environment variables for the application +env: + # Server configuration + WORKSPACE_MCP_PORT: "8000" + # Set the base URI for your Kubernetes environment + # For internal cluster access: "http://{{ include "workspace-mcp.fullname" . }}.{{ .Release.Namespace }}.svc.cluster.local" + # For external access: "https://your-domain.com" or "http://your-ingress-host" + WORKSPACE_MCP_BASE_URI: "" + + # External URL for reverse proxy setups (e.g., "https://your-domain.com") + # If set, this overrides the base_uri:port combination for OAuth endpoints + WORKSPACE_EXTERNAL_URL: "" + + # OAuth 2.1 support + MCP_ENABLE_OAUTH21: "false" + + # Development only - set to "1" for local development + OAUTHLIB_INSECURE_TRANSPORT: "0" + + # Optional: Google Custom Search + # GOOGLE_PSE_API_KEY: "" + # GOOGLE_PSE_ENGINE_ID: "" + +# Secret configuration for sensitive data +secrets: + # Google OAuth credentials (required) + googleOAuth: + # Set these values or create secret manually + clientId: "" + clientSecret: "" + # Optional: default user email for single-user setups + userEmail: "" + +# Tool selection - specify which Google Workspace tools to enable +tools: + enabled: [] + # Available tools: gmail, drive, calendar, docs, sheets, chat, forms, slides, tasks, search + # Example: ["gmail", "drive", "calendar"] + # Leave empty to enable all tools + +# Tool tier selection - alternative to specifying individual tools +# Options: core, extended, complete +# Leave empty to use default or tools.enabled configuration +toolTier: "" + +# Single-user mode configuration +singleUserMode: false + +# Command configuration +command: + # Set disableArgs to true to remove the args section entirely (uses Dockerfile defaults) + disableArgs: false + # Override the entire command if needed + override: [] + +# Health check configuration +healthCheck: + enabled: true + path: /health + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 10 + successThreshold: 1 + failureThreshold: 3 + +# Pod disruption budget +podDisruptionBudget: + enabled: false + minAvailable: 1 + # maxUnavailable: 1 + +# Network policies +networkPolicy: + enabled: false + ingress: [] + egress: [] \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..401a270 --- /dev/null +++ b/main.py @@ -0,0 +1,579 @@ +import io +import argparse +import logging +import os +import socket +import sys +from importlib import metadata, import_module +from dotenv import load_dotenv + +# Prevent any stray startup output on macOS (e.g. platform identifiers) from +# corrupting the MCP JSON-RPC handshake on stdout. We capture anything written +# to stdout during module-level initialisation and replay it to stderr so that +# diagnostic information is not lost. +_original_stdout = sys.stdout +if sys.platform == "darwin": + sys.stdout = io.StringIO() + +# Check for CLI mode early - before loading oauth_config +# CLI mode requires OAuth 2.0 since there's no MCP session context +_CLI_MODE = "--cli" in sys.argv +if _CLI_MODE: + os.environ["MCP_ENABLE_OAUTH21"] = "false" + os.environ["WORKSPACE_MCP_STATELESS_MODE"] = "false" + +from auth.oauth_config import reload_oauth_config, is_stateless_mode # noqa: E402 +from core.log_formatter import EnhancedLogFormatter, configure_file_logging # noqa: E402 +from core.utils import check_credentials_directory_permissions # noqa: E402 +from core.server import server, set_transport_mode, configure_server_for_http # noqa: E402 +from core.tool_tier_loader import resolve_tools_from_tier # noqa: E402 +from core.tool_registry import ( # noqa: E402 + set_enabled_tools as set_enabled_tool_names, + wrap_server_tool_method, + filter_server_tools, +) + +dotenv_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env") +load_dotenv(dotenv_path=dotenv_path) + +# Suppress googleapiclient discovery cache warning +logging.getLogger("googleapiclient.discovery_cache").setLevel(logging.ERROR) + +# Suppress httpx/httpcore INFO logs that leak access tokens in URLs +# (e.g. tokeninfo?access_token=ya29.xxx) +logging.getLogger("httpx").setLevel(logging.WARNING) +logging.getLogger("httpcore").setLevel(logging.WARNING) + +reload_oauth_config() + +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + +configure_file_logging() + + +def safe_print(text): + # Don't print in CLI mode - we want clean output + if _CLI_MODE: + return + + # Don't print to stderr when running as MCP server via uvx to avoid JSON parsing errors + # Check if we're running as MCP server (no TTY and uvx in process name) + if not sys.stderr.isatty(): + # Running as MCP server, suppress output to avoid JSON parsing errors + logger.debug(f"[MCP Server] {text}") + return + + try: + print(text, file=sys.stderr) + except UnicodeEncodeError: + print(text.encode("ascii", errors="replace").decode(), file=sys.stderr) + + +def configure_safe_logging(): + class SafeEnhancedFormatter(EnhancedLogFormatter): + """Enhanced ASCII formatter with additional Windows safety.""" + + def format(self, record): + try: + return super().format(record) + except UnicodeEncodeError: + # Fallback to ASCII-safe formatting + service_prefix = self._get_ascii_prefix(record.name, record.levelname) + safe_msg = ( + str(record.getMessage()) + .encode("ascii", errors="replace") + .decode("ascii") + ) + return f"{service_prefix} {safe_msg}" + + # Replace all console handlers' formatters with safe enhanced ones + for handler in logging.root.handlers: + # Only apply to console/stream handlers, keep file handlers as-is + if isinstance(handler, logging.StreamHandler) and handler.stream.name in [ + "", + "", + ]: + safe_formatter = SafeEnhancedFormatter(use_colors=True) + handler.setFormatter(safe_formatter) + + +def resolve_permissions_mode_selection( + permission_services: list[str], tool_tier: str | None +) -> tuple[list[str], set[str] | None]: + """ + Resolve service imports and optional tool-name filtering for --permissions mode. + + When a tier is specified, both: + - imported services are narrowed to services with tier-matched tools + - registered tools are narrowed to the resolved tool names + """ + if tool_tier is None: + return permission_services, None + + tier_tools, tier_services = resolve_tools_from_tier(tool_tier, permission_services) + return tier_services, set(tier_tools) + + +def narrow_permissions_to_services( + permissions: dict[str, str], services: list[str] +) -> dict[str, str]: + """Restrict permission entries to the provided service list order.""" + return { + service: permissions[service] for service in services if service in permissions + } + + +def _restore_stdout() -> None: + """Restore the real stdout and replay any captured output to stderr.""" + captured_stdout = sys.stdout + + # Idempotent: if already restored, nothing to do. + if captured_stdout is _original_stdout: + return + + captured = "" + required_stringio_methods = ("getvalue", "write", "flush") + try: + if all( + callable(getattr(captured_stdout, method_name, None)) + for method_name in required_stringio_methods + ): + captured = captured_stdout.getvalue() + finally: + sys.stdout = _original_stdout + + if captured: + print(captured, end="", file=sys.stderr) + + +def main(): + """ + Main entry point for the Google Workspace MCP server. + Uses FastMCP's native streamable-http transport. + Supports CLI mode for direct tool invocation without running the server. + """ + _restore_stdout() + # Check if CLI mode is enabled - suppress startup messages + if _CLI_MODE: + # Suppress logging output in CLI mode for clean output + logging.getLogger().setLevel(logging.ERROR) + logging.getLogger("auth").setLevel(logging.ERROR) + logging.getLogger("core").setLevel(logging.ERROR) + + # Configure safe logging for Windows Unicode handling + configure_safe_logging() + + # Parse command line arguments + parser = argparse.ArgumentParser(description="Google Workspace MCP Server") + parser.add_argument( + "--single-user", + action="store_true", + help="Run in single-user mode - bypass session mapping and use any credentials from the credentials directory", + ) + parser.add_argument( + "--tools", + nargs="*", + choices=[ + "gmail", + "drive", + "calendar", + "docs", + "sheets", + "chat", + "forms", + "slides", + "tasks", + "contacts", + "search", + "appscript", + ], + help="Specify which tools to register. If not provided, all tools are registered.", + ) + parser.add_argument( + "--tool-tier", + choices=["core", "extended", "complete"], + help="Load tools based on tier level. Can be combined with --tools to filter services.", + ) + parser.add_argument( + "--transport", + choices=["stdio", "streamable-http"], + default="stdio", + help="Transport mode: stdio (default) or streamable-http", + ) + parser.add_argument( + "--cli", + nargs=argparse.REMAINDER, + metavar="COMMAND", + help="Run in CLI mode for direct tool invocation. Use --cli to list tools, --cli to run a tool.", + ) + parser.add_argument( + "--read-only", + action="store_true", + help="Run in read-only mode - requests only read-only scopes and disables tools requiring write permissions", + ) + parser.add_argument( + "--permissions", + nargs="+", + metavar="SERVICE:LEVEL", + help=( + "Granular per-service permission levels. Format: service:level. " + "Example: --permissions gmail:organize drive:readonly. " + "Gmail levels: readonly, organize, drafts, send, full (cumulative). " + "Other services: readonly, full. " + "Mutually exclusive with --read-only and --tools." + ), + ) + args = parser.parse_args() + + # Clean up CLI args - argparse.REMAINDER may include leading dashes from first arg + if args.cli is not None: + # Filter out empty strings that might appear + args.cli = [a for a in args.cli if a] + + # Validate mutually exclusive flags + if args.permissions and args.read_only: + print( + "Error: --permissions and --read-only are mutually exclusive. " + "Use service:readonly within --permissions instead.", + file=sys.stderr, + ) + sys.exit(1) + if args.permissions and args.tools is not None: + print( + "Error: --permissions and --tools cannot be combined. " + "Select services via --permissions (optionally with --tool-tier).", + file=sys.stderr, + ) + sys.exit(1) + + # Set port and base URI once for reuse throughout the function + port = int(os.getenv("PORT", os.getenv("WORKSPACE_MCP_PORT", 8000))) + base_uri = os.getenv("WORKSPACE_MCP_BASE_URI", "http://localhost") + host = os.getenv("WORKSPACE_MCP_HOST", "0.0.0.0") + external_url = os.getenv("WORKSPACE_EXTERNAL_URL") + display_url = external_url if external_url else f"{base_uri}:{port}" + + safe_print("🔧 Google Workspace MCP Server") + safe_print("=" * 35) + safe_print("📋 Server Information:") + try: + version = metadata.version("workspace-mcp") + except metadata.PackageNotFoundError: + version = "dev" + safe_print(f" 📦 Version: {version}") + safe_print(f" 🌐 Transport: {args.transport}") + if args.transport == "streamable-http": + safe_print(f" 🔗 URL: {display_url}") + safe_print(f" 🔐 OAuth Callback: {display_url}/oauth2callback") + safe_print(f" 👤 Mode: {'Single-user' if args.single_user else 'Multi-user'}") + if args.read_only: + safe_print(" 🔒 Read-Only: Enabled") + if args.permissions: + safe_print(" 🔒 Permissions: Granular mode") + safe_print(f" 🐍 Python: {sys.version.split()[0]}") + safe_print("") + + # Active Configuration + safe_print("⚙️ Active Configuration:") + + # Redact client secret for security + client_secret = os.getenv("GOOGLE_OAUTH_CLIENT_SECRET", "Not Set") + redacted_secret = ( + f"{client_secret[:4]}...{client_secret[-4:]}" + if len(client_secret) > 8 + else "Invalid or too short" + ) + + # Determine credentials directory (same logic as credential_store.py) + workspace_creds_dir = os.getenv("WORKSPACE_MCP_CREDENTIALS_DIR") + google_creds_dir = os.getenv("GOOGLE_MCP_CREDENTIALS_DIR") + if workspace_creds_dir: + creds_dir_display = os.path.expanduser(workspace_creds_dir) + creds_dir_source = "WORKSPACE_MCP_CREDENTIALS_DIR" + elif google_creds_dir: + creds_dir_display = os.path.expanduser(google_creds_dir) + creds_dir_source = "GOOGLE_MCP_CREDENTIALS_DIR" + else: + creds_dir_display = os.path.join( + os.path.expanduser("~"), ".google_workspace_mcp", "credentials" + ) + creds_dir_source = "default" + + config_vars = { + "GOOGLE_OAUTH_CLIENT_ID": os.getenv("GOOGLE_OAUTH_CLIENT_ID", "Not Set"), + "GOOGLE_OAUTH_CLIENT_SECRET": redacted_secret, + "USER_GOOGLE_EMAIL": os.getenv("USER_GOOGLE_EMAIL", "Not Set"), + "CREDENTIALS_DIR": f"{creds_dir_display} ({creds_dir_source})", + "MCP_SINGLE_USER_MODE": os.getenv("MCP_SINGLE_USER_MODE", "false"), + "MCP_ENABLE_OAUTH21": os.getenv("MCP_ENABLE_OAUTH21", "false"), + "WORKSPACE_MCP_STATELESS_MODE": os.getenv( + "WORKSPACE_MCP_STATELESS_MODE", "false" + ), + "OAUTHLIB_INSECURE_TRANSPORT": os.getenv( + "OAUTHLIB_INSECURE_TRANSPORT", "false" + ), + "GOOGLE_CLIENT_SECRET_PATH": os.getenv("GOOGLE_CLIENT_SECRET_PATH", "Not Set"), + } + + for key, value in config_vars.items(): + safe_print(f" - {key}: {value}") + safe_print("") + + # Import tool modules to register them with the MCP server via decorators + tool_imports = { + "gmail": lambda: import_module("gmail.gmail_tools"), + "drive": lambda: import_module("gdrive.drive_tools"), + "calendar": lambda: import_module("gcalendar.calendar_tools"), + "docs": lambda: import_module("gdocs.docs_tools"), + "sheets": lambda: import_module("gsheets.sheets_tools"), + "chat": lambda: import_module("gchat.chat_tools"), + "forms": lambda: import_module("gforms.forms_tools"), + "slides": lambda: import_module("gslides.slides_tools"), + "tasks": lambda: import_module("gtasks.tasks_tools"), + "contacts": lambda: import_module("gcontacts.contacts_tools"), + "search": lambda: import_module("gsearch.search_tools"), + "appscript": lambda: import_module("gappsscript.apps_script_tools"), + } + + tool_icons = { + "gmail": "📧", + "drive": "📁", + "calendar": "📅", + "docs": "📄", + "sheets": "📊", + "chat": "💬", + "forms": "📝", + "slides": "🖼️", + "tasks": "✓", + "contacts": "👤", + "search": "🔍", + "appscript": "📜", + } + + # Determine which tools to import based on arguments + perms = None + if args.permissions: + # Granular permissions mode — parse and activate before tool selection + from auth.permissions import parse_permissions_arg, set_permissions + + try: + perms = parse_permissions_arg(args.permissions) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + # Permissions implicitly defines which services to load + tools_to_import = list(perms.keys()) + set_enabled_tool_names(None) + + if args.tool_tier is not None: + # Combine with tier filtering within the permission-selected services + try: + tools_to_import, tier_tool_filter = resolve_permissions_mode_selection( + tools_to_import, args.tool_tier + ) + set_enabled_tool_names(tier_tool_filter) + perms = narrow_permissions_to_services(perms, tools_to_import) + except Exception as e: + print( + f"Error loading tools for tier '{args.tool_tier}': {e}", + file=sys.stderr, + ) + sys.exit(1) + set_permissions(perms) + elif args.tool_tier is not None: + # Use tier-based tool selection, optionally filtered by services + try: + tier_tools, suggested_services = resolve_tools_from_tier( + args.tool_tier, args.tools + ) + + # If --tools specified, use those services; otherwise use all services that have tier tools + if args.tools is not None: + tools_to_import = args.tools + else: + tools_to_import = suggested_services + + # Set the specific tools that should be registered + set_enabled_tool_names(set(tier_tools)) + except Exception as e: + safe_print(f"❌ Error loading tools for tier '{args.tool_tier}': {e}") + sys.exit(1) + elif args.tools is not None: + # Use explicit tool list without tier filtering + tools_to_import = args.tools + # Don't filter individual tools when using explicit service list only + set_enabled_tool_names(None) + else: + # Default: import all tools + tools_to_import = tool_imports.keys() + # Don't filter individual tools when importing all + set_enabled_tool_names(None) + + wrap_server_tool_method(server) + + from auth.scopes import set_enabled_tools, set_read_only + + set_enabled_tools(list(tools_to_import)) + if args.read_only: + set_read_only(True) + + safe_print( + f"🛠️ Loading {len(tools_to_import)} tool module{'s' if len(tools_to_import) != 1 else ''}:" + ) + for tool in tools_to_import: + try: + tool_imports[tool]() + safe_print( + f" {tool_icons[tool]} {tool.title()} - Google {tool.title()} API integration" + ) + except ModuleNotFoundError as exc: + logger.error("Failed to import tool '%s': %s", tool, exc, exc_info=True) + safe_print(f" ⚠️ Failed to load {tool.title()} tool module ({exc}).") + + if perms: + safe_print("🔒 Permission Levels:") + for svc, lvl in sorted(perms.items()): + safe_print(f" {tool_icons.get(svc, ' ')} {svc}: {lvl}") + safe_print("") + + # Filter tools based on tier configuration (if tier-based loading is enabled) + filter_server_tools(server) + + # Handle CLI mode - execute tool and exit + if args.cli is not None: + import asyncio + from core.cli_handler import handle_cli_mode + + # CLI mode - run tool directly and exit + exit_code = asyncio.run(handle_cli_mode(server, args.cli)) + sys.exit(exit_code) + + safe_print("📊 Configuration Summary:") + safe_print(f" 🔧 Services Loaded: {len(tools_to_import)}/{len(tool_imports)}") + if args.tool_tier is not None: + if args.tools is not None: + safe_print( + f" 📊 Tool Tier: {args.tool_tier} (filtered to {', '.join(args.tools)})" + ) + else: + safe_print(f" 📊 Tool Tier: {args.tool_tier}") + safe_print(f" 📝 Log Level: {logging.getLogger().getEffectiveLevel()}") + safe_print("") + + # Set global single-user mode flag + if args.single_user: + # Check for incompatible OAuth 2.1 mode + if os.getenv("MCP_ENABLE_OAUTH21", "false").lower() == "true": + safe_print("❌ Single-user mode is incompatible with OAuth 2.1 mode") + safe_print( + " Single-user mode is for legacy clients that pass user emails" + ) + safe_print( + " OAuth 2.1 mode is for multi-user scenarios with bearer tokens" + ) + safe_print( + " Please choose one mode: either --single-user OR MCP_ENABLE_OAUTH21=true" + ) + sys.exit(1) + + if is_stateless_mode(): + safe_print("❌ Single-user mode is incompatible with stateless mode") + safe_print(" Stateless mode requires OAuth 2.1 which is multi-user") + sys.exit(1) + os.environ["MCP_SINGLE_USER_MODE"] = "1" + safe_print("🔐 Single-user mode enabled") + safe_print("") + + # Check credentials directory permissions before starting (skip in stateless mode) + if not is_stateless_mode(): + try: + safe_print("🔍 Checking credentials directory permissions...") + check_credentials_directory_permissions() + safe_print("✅ Credentials directory permissions verified") + safe_print("") + except (PermissionError, OSError) as e: + safe_print(f"❌ Credentials directory permission check failed: {e}") + safe_print( + " Please ensure the service has write permissions to create/access the credentials directory" + ) + logger.error(f"Failed credentials directory permission check: {e}") + sys.exit(1) + else: + safe_print("🔍 Skipping credentials directory check (stateless mode)") + safe_print("") + + try: + # Set transport mode for OAuth callback handling + set_transport_mode(args.transport) + + # Configure auth initialization for FastMCP lifecycle events + if args.transport == "streamable-http": + configure_server_for_http() + safe_print("") + safe_print(f"🚀 Starting HTTP server on {base_uri}:{port}") + if external_url: + safe_print(f" External URL: {external_url}") + else: + safe_print("") + safe_print("🚀 Starting STDIO server") + # Start minimal OAuth callback server for stdio mode + from auth.oauth_callback_server import ensure_oauth_callback_available + + success, error_msg = ensure_oauth_callback_available( + "stdio", port, base_uri + ) + if success: + safe_print( + f" OAuth callback server started on {display_url}/oauth2callback" + ) + else: + warning_msg = " ⚠️ Warning: Failed to start OAuth callback server" + if error_msg: + warning_msg += f": {error_msg}" + safe_print(warning_msg) + + safe_print("✅ Ready for MCP connections") + safe_print("") + + if args.transport == "streamable-http": + # Check port availability before starting HTTP server + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind((host, port)) + except OSError as e: + safe_print(f"Socket error: {e}") + safe_print( + f"❌ Port {port} is already in use. Cannot start HTTP server." + ) + sys.exit(1) + + server.run( + transport="streamable-http", + host=host, + port=port, + stateless_http=is_stateless_mode(), + ) + else: + server.run() + except KeyboardInterrupt: + safe_print("\n👋 Server shutdown requested") + # Clean up OAuth callback server if running + from auth.oauth_callback_server import cleanup_oauth_callback_server + + cleanup_oauth_callback_server() + sys.exit(0) + except Exception as e: + safe_print(f"\n❌ Server error: {e}") + logger.error(f"Unexpected error running server: {e}", exc_info=True) + # Clean up OAuth callback server if running + from auth.oauth_callback_server import cleanup_oauth_callback_server + + cleanup_oauth_callback_server() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..2af8f66 --- /dev/null +++ b/manifest.json @@ -0,0 +1,191 @@ +{ + "dxt_version": "0.1", + "name": "workspace-mcp", + "display_name": "Google Workspace MCP", + "version": "1.14.3", + "description": "Full natural language control over Google Calendar, Drive, Gmail, Docs, Sheets, Slides, Forms, Tasks, Chat and Custom Search through all MCP clients, AI assistants and developer tools", + "long_description": "A production-ready MCP server that integrates all major Google Workspace services with AI assistants. Includes Google PSE integration for custom web searches.", + "author": { + "name": "Taylor Wilsdon", + "email": "taylor@taylorwilsdon.com", + "url": "https://taylorwilsdon.com" + }, + "homepage": "https://workspacemcp.com/", + "documentation": "https://github.com/taylorwilsdon/google_workspace_mcp", + "support": "https://github.com/taylorwilsdon/google_workspace_mcp/issues", + "server": { + "type": "python", + "entry_point": "main.py", + "mcp_config": { + "command": "uv", + "args": [ + "run", + "--directory", + "${__dirname}", + "python", + "${__dirname}/main.py" + ], + "env": { + "GOOGLE_OAUTH_CLIENT_ID": "${user_config.GOOGLE_OAUTH_CLIENT_ID}", + "GOOGLE_OAUTH_CLIENT_SECRET": "${user_config.GOOGLE_OAUTH_CLIENT_SECRET}", + "USER_GOOGLE_EMAIL": "${user_config.USER_GOOGLE_EMAIL}", + "GOOGLE_OAUTH_REDIRECT_URI": "${user_config.GOOGLE_OAUTH_REDIRECT_URI}", + "GOOGLE_CLIENT_SECRET_PATH": "${user_config.GOOGLE_CLIENT_SECRET_PATH}", + "GOOGLE_CLIENT_SECRETS": "${user_config.GOOGLE_CLIENT_SECRETS}", + "WORKSPACE_MCP_BASE_URI": "${user_config.WORKSPACE_MCP_BASE_URI}", + "WORKSPACE_MCP_PORT": "${user_config.WORKSPACE_MCP_PORT}", + "WORKSPACE_EXTERNAL_URL": "${user_config.WORKSPACE_EXTERNAL_URL}", + "OAUTHLIB_INSECURE_TRANSPORT": "${user_config.OAUTHLIB_INSECURE_TRANSPORT}", + "GOOGLE_PSE_API_KEY": "${user_config.GOOGLE_PSE_API_KEY}", + "GOOGLE_PSE_ENGINE_ID": "${user_config.GOOGLE_PSE_ENGINE_ID}" + } + } + }, + "tools": [ + { + "name": "google_calendar", + "description": "Manage Google Calendar through AI with full calendar and event capability" + }, + { + "name": "google_drive", + "description": "Manage Google Drive through AI with full search, list and create capability" + }, + { + "name": "gmail", + "description": "Manage Gmail through AI with support for search, draft, send, respond and more" + }, + { + "name": "google_docs", + "description": "Manage Google Docs through AI with capability to search, extract, list, create and copy - including templated variable replacement support" + }, + { + "name": "google_sheets", + "description": "Manage Google Sheets through AI with support for read, write, modify and create" + }, + { + "name": "google_slides", + "description": "Manage Google Slides through AI with support for creation, modification and presentation management" + }, + { + "name": "google_forms", + "description": "Manage Google Forms through AI with support for creation, retrieval, publishing, response management and more" + }, + { + "name": "google_chat", + "description": "Manage Google Chat through AI with support for space and DM list, get, send and search messages" + }, + { + "name": "google_tasks", + "description": "Manage Google Tasks through AI with support for task creation, management, and organization" + }, + { + "name": "google_custom_search", + "description": "Perform custom web searches through AI using Google Programmable Search Engine with site-specific and filtered search capabilities" + } + ], + "user_config": { + "GOOGLE_OAUTH_CLIENT_ID": { + "type": "string", + "title": "Google OAuth Client ID", + "description": "OAuth 2.0 client ID from Google Cloud Console (e.g., your-client-id.apps.googleusercontent.com)", + "required": false, + "default": "your-client-id.apps.googleusercontent.com" + }, + "GOOGLE_OAUTH_CLIENT_SECRET": { + "type": "string", + "title": "Google OAuth Client Secret", + "description": "OAuth 2.0 client secret from Google Cloud Console", + "required": false + }, + "USER_GOOGLE_EMAIL": { + "type": "string", + "title": "User Google Email", + "description": "Optional default email for legacy OAuth 2.0 authentication flows. If set, the LLM won't need to specify your email when calling start_google_auth. Ignored when OAuth 2.1 is enabled.", + "required": false + }, + "GOOGLE_OAUTH_REDIRECT_URI": { + "type": "string", + "title": "Google OAuth Redirect URI", + "description": "OAuth 2.0 redirect URI for authentication callback", + "required": false, + "default": "http://localhost:8000/oauth2callback" + }, + "GOOGLE_CLIENT_SECRET_PATH": { + "type": "file", + "title": "Google Client Secret File Path", + "description": "Path to the client_secret.json file containing OAuth credentials", + "required": false + }, + "GOOGLE_CLIENT_SECRETS": { + "type": "string", + "title": "Google Client Secrets (Legacy)", + "description": "Legacy environment variable for client secret file path (use GOOGLE_CLIENT_SECRET_PATH instead)", + "required": false + }, + "WORKSPACE_MCP_BASE_URI": { + "type": "string", + "title": "Workspace MCP Base URI", + "description": "Base URI for the MCP server, affects OAuth redirect URI and Gemini function calling", + "required": false, + "default": "http://localhost" + }, + "WORKSPACE_MCP_PORT": { + "type": "number", + "title": "Workspace MCP Port", + "description": "Port number for the MCP server to listen on", + "required": false, + "default": 8000 + }, + "WORKSPACE_EXTERNAL_URL": { + "type": "string", + "title": "External URL", + "description": "External URL for reverse proxy setups (e.g., https://your-domain.com). Overrides base_uri:port for OAuth endpoints", + "required": false + }, + "OAUTHLIB_INSECURE_TRANSPORT": { + "type": "boolean", + "title": "OAuth Insecure Transport", + "description": "Allow OAuth over HTTP for development (enable for development only)", + "required": false, + "default": true + }, + "GOOGLE_PSE_API_KEY": { + "type": "string", + "title": "Google Custom Search API Key", + "description": "API key for Google Programmable Search Engine (Custom Search JSON API)", + "required": false + }, + "GOOGLE_PSE_ENGINE_ID": { + "type": "string", + "title": "Google Custom Search Engine ID", + "description": "Programmable Search Engine ID (cx parameter) from Google Custom Search Engine Control Panel", + "required": false + } + }, + "keywords": [ + "google", + "workspace", + "mcp", + "server", + "calendar", + "drive", + "docs", + "forms", + "gmail", + "slides", + "sheets", + "chat", + "tasks", + "search", + "custom-search", + "programmable-search", + "oauth", + "productivity", + "ai-assistant" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/taylorwilsdon/google_workspace_mcp" + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..28a4915 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,121 @@ +[build-system] +requires = [ "setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "workspace-mcp" +version = "1.14.3" +description = "Comprehensive, highly performant Google Workspace Streamable HTTP & SSE MCP Server for Calendar, Gmail, Docs, Sheets, Slides & Drive" +readme = "README.md" +keywords = [ "mcp", "google", "workspace", "llm", "ai", "claude", "model", "context", "protocol", "server"] +license = "MIT" +requires-python = ">=3.10" +dependencies = [ + "fastapi>=0.115.12", + "fastmcp>=3.1.1", + "google-api-python-client>=2.168.0", + "google-auth-httplib2>=0.2.0", + "google-auth-oauthlib>=1.2.2", + "httpx>=0.28.1", + "py-key-value-aio>=0.3.0", + "pyjwt>=2.12.0", + "python-dotenv>=1.1.0", + "pyyaml>=6.0.2", + "cryptography>=45.0.0", + "defusedxml>=0.7.1", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Internet :: WWW/HTTP :: Dynamic Content", + "Topic :: Internet :: WWW/HTTP :: HTTP Servers", + "Topic :: Software Development :: Libraries :: Application Frameworks", + "Topic :: Communications :: Chat", + "Topic :: Office/Business", + "Topic :: Scientific/Engineering :: Artificial Intelligence", + "Typing :: Typed" +] + +[[project.authors]] +name = "Taylor Wilsdon" +email = "taylor@taylorwilsdon.com" + +[project.urls] +Homepage = "https://workspacemcp.com" +Repository = "https://git.alwisp.com/jason/google-mcp.git" +Documentation = "https://git.alwisp.com/jason/google-mcp#readme" +Issues = "https://git.alwisp.com/jason/google-mcp/issues" +Changelog = "https://git.alwisp.com/jason/google-mcp/releases" + +[project.scripts] +workspace-mcp = "main:main" + +[project.optional-dependencies] +disk = [ + "py-key-value-aio[filetree]>=0.3.0", +] +valkey = [ + "py-key-value-aio[valkey]>=0.3.0", +] +test = [ + "pytest>=8.3.0", + "pytest-asyncio>=0.23.0", + "requests>=2.32.3", +] +release = [ + "tomlkit>=0.13.3", + "twine>=5.0.0", +] +dev = [ + "pytest>=8.3.0", + "pytest-asyncio>=0.23.0", + "requests>=2.32.3", + "ruff>=0.12.4", + "tomlkit>=0.13.3", + "twine>=5.0.0", +] + +[dependency-groups] +disk = [ + "py-key-value-aio[filetree]>=0.3.0", +] +valkey = [ + "py-key-value-aio[valkey]>=0.3.0", +] +test = [ + "pytest>=8.3.0", + "pytest-asyncio>=0.23.0", + "requests>=2.32.3", +] +release = [ + "tomlkit>=0.13.3", + "twine>=5.0.0", +] +dev = [ + "pytest>=8.3.0", + "pytest-asyncio>=0.23.0", + "requests>=2.32.3", + "ruff>=0.12.4", + "tomlkit>=0.13.3", + "twine>=5.0.0", +] + +[tool.setuptools] +py-modules = [ "fastmcp_server", "main"] + +[tool.setuptools.packages.find] +where = ["."] +exclude = ["tests*", "docs*", "build", "dist"] + +[tool.pytest.ini_options] +addopts = "--ignore=tests/gappsscript/manual_test.py" + +[tool.setuptools.package-data] +core = ["tool_tiers.yaml"] diff --git a/server.json b/server.json new file mode 100644 index 0000000..a753a5a --- /dev/null +++ b/server.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.taylorwilsdon/workspace-mcp", + "description": "Google Workspace MCP server for Gmail, Drive, Calendar, Docs, Sheets, Slides, Forms, Tasks, Chat.", + "status": "active", + "version": "1.14.3", + "packages": [ + { + "registryType": "pypi", + "identifier": "workspace-mcp", + "transport": { + "type": "stdio" + }, + "version": "1.14.3" + } + ] +} diff --git a/smithery.yaml b/smithery.yaml new file mode 100644 index 0000000..5f42baf --- /dev/null +++ b/smithery.yaml @@ -0,0 +1,101 @@ +runtime: "container" +startCommand: + type: "http" + configSchema: + type: object + required: + - googleOauthClientId + - googleOauthClientSecret + properties: + googleOauthClientId: + type: string + description: "Your Google OAuth 2.0 Client ID from Google Cloud Console" + googleOauthClientSecret: + type: string + description: "Your Google OAuth 2.0 Client Secret from Google Cloud Console" + userGoogleEmail: + type: string + default: "" + description: "Default email for single-user auth - avoids needing to specify email in system prompts" + googleOauthRedirectUri: + type: string + default: "" + description: "OAuth redirect URI - uses default http://localhost:8000/oauth2callback if not set" + googleClientSecretPath: + type: string + default: "" + description: "Path to client_secret.json file (alternative to environment variables)" + workspaceMcpBaseUri: + type: string + default: "http://localhost" + description: "Base URI for the server (do not include port)" + workspaceMcpPort: + type: number + default: 8000 + description: "Port the server listens on" + mcpSingleUserMode: + type: boolean + default: false + description: "Enable single-user mode - bypasses session mapping" + mcpEnableOauth21: + type: boolean + default: true + description: "Enable OAuth 2.1 multi-user support (requires streamable-http)" + oauthlibInsecureTransport: + type: boolean + default: false + description: "Enable insecure transport for development environments" + googlePseApiKey: + type: string + default: "" + description: "API key for Google Custom Search - required for search tools" + googlePseEngineId: + type: string + default: "" + description: "Programmable Search Engine ID for Custom Search - required for search tools" + tools: + type: string + default: "" + description: "Comma-separated list of tools to enable (gmail,drive,calendar,docs,sheets,chat,forms,slides,tasks,search). Leave empty for all tools." + workspaceMcpStatelessMode: + type: boolean + default: true + description: "Enable stateless mode - no session persistence" + commandFunction: + |- + (config) => { + const args = ['run', 'main.py', '--transport', 'streamable-http']; + + // Add single-user flag if enabled + if (config.mcpSingleUserMode) { + args.push('--single-user'); + } + + // Add tools selection if specified + if (config.tools && config.tools.trim() !== '') { + args.push('--tools'); + args.push(...config.tools.split(',').map(t => t.trim()).filter(t => t)); + } + + return { + command: 'uv', + args: ['run', 'python', 'main.py'], + env: { + GOOGLE_OAUTH_CLIENT_ID: config.googleOauthClientId, + GOOGLE_OAUTH_CLIENT_SECRET: config.googleOauthClientSecret, + ...(config.userGoogleEmail && { USER_GOOGLE_EMAIL: config.userGoogleEmail }), + ...(config.googleOauthRedirectUri && { GOOGLE_OAUTH_REDIRECT_URI: config.googleOauthRedirectUri }), + ...(config.googleClientSecretPath && { GOOGLE_CLIENT_SECRET_PATH: config.googleClientSecretPath }), + WORKSPACE_MCP_BASE_URI: config.workspaceMcpBaseUri, + WORKSPACE_MCP_PORT: String(config.workspaceMcpPort), + PORT: String(config.workspaceMcpPort), + ...(config.workspaceExternalUrl && { WORKSPACE_EXTERNAL_URL: config.workspaceExternalUrl }), + ...(config.mcpSingleUserMode && { MCP_SINGLE_USER_MODE: '1' }), + ...(config.mcpEnableOauth21 && { MCP_ENABLE_OAUTH21: 'true' }), + ...(config.workspaceMcpStatelessMode && { WORKSPACE_MCP_STATELESS_MODE: 'true' }), + ...(config.oauthlibInsecureTransport && { OAUTHLIB_INSECURE_TRANSPORT: '1' }), + ...(config.googlePseApiKey && { GOOGLE_PSE_API_KEY: config.googlePseApiKey }), + ...(config.googlePseEngineId && { GOOGLE_PSE_ENGINE_ID: config.googlePseEngineId }) + } + }; + } diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/auth/test_google_auth_callback_refresh_token.py b/tests/auth/test_google_auth_callback_refresh_token.py new file mode 100644 index 0000000..6734942 --- /dev/null +++ b/tests/auth/test_google_auth_callback_refresh_token.py @@ -0,0 +1,128 @@ +from google.oauth2.credentials import Credentials + +from auth.google_auth import handle_auth_callback + + +class _DummyFlow: + def __init__(self, credentials): + self.credentials = credentials + + def fetch_token(self, authorization_response): # noqa: ARG002 + return None + + +class _DummyOAuthStore: + def __init__(self, session_credentials=None): + self._session_credentials = session_credentials + self.stored_refresh_token = None + + def validate_and_consume_oauth_state(self, state, session_id=None): # noqa: ARG002 + return {"session_id": session_id, "code_verifier": "verifier"} + + def get_credentials_by_mcp_session(self, mcp_session_id): # noqa: ARG002 + return self._session_credentials + + def store_session(self, **kwargs): + self.stored_refresh_token = kwargs.get("refresh_token") + + +class _DummyCredentialStore: + def __init__(self, existing_credentials=None): + self._existing_credentials = existing_credentials + self.saved_credentials = None + + def get_credential(self, user_email): # noqa: ARG002 + return self._existing_credentials + + def store_credential(self, user_email, credentials): # noqa: ARG002 + self.saved_credentials = credentials + return True + + +def _make_credentials(refresh_token): + return Credentials( + token="access-token", + refresh_token=refresh_token, + token_uri="https://oauth2.googleapis.com/token", + client_id="client-id", + client_secret="client-secret", + scopes=["scope.a"], + ) + + +def test_callback_preserves_refresh_token_from_credential_store(monkeypatch): + callback_credentials = _make_credentials(refresh_token=None) + oauth_store = _DummyOAuthStore(session_credentials=None) + credential_store = _DummyCredentialStore( + existing_credentials=_make_credentials(refresh_token="file-refresh-token") + ) + + monkeypatch.setattr( + "auth.google_auth.create_oauth_flow", + lambda **kwargs: _DummyFlow(callback_credentials), # noqa: ARG005 + ) + monkeypatch.setattr( + "auth.google_auth.get_oauth21_session_store", lambda: oauth_store + ) + monkeypatch.setattr( + "auth.google_auth.get_credential_store", lambda: credential_store + ) + monkeypatch.setattr( + "auth.google_auth.get_user_info", + lambda credentials: {"email": "user@gmail.com"}, # noqa: ARG005 + ) + monkeypatch.setattr( + "auth.google_auth.save_credentials_to_session", lambda *args: None + ) + monkeypatch.setattr("auth.google_auth.is_stateless_mode", lambda: False) + + _email, credentials = handle_auth_callback( + scopes=["scope.a"], + authorization_response="http://localhost/callback?state=abc123&code=code123", + redirect_uri="http://localhost/callback", + session_id="session-1", + ) + + assert credentials.refresh_token == "file-refresh-token" + assert credential_store.saved_credentials.refresh_token == "file-refresh-token" + assert oauth_store.stored_refresh_token == "file-refresh-token" + + +def test_callback_prefers_session_refresh_token_over_credential_store(monkeypatch): + callback_credentials = _make_credentials(refresh_token=None) + oauth_store = _DummyOAuthStore( + session_credentials=_make_credentials(refresh_token="session-refresh-token") + ) + credential_store = _DummyCredentialStore( + existing_credentials=_make_credentials(refresh_token="file-refresh-token") + ) + + monkeypatch.setattr( + "auth.google_auth.create_oauth_flow", + lambda **kwargs: _DummyFlow(callback_credentials), # noqa: ARG005 + ) + monkeypatch.setattr( + "auth.google_auth.get_oauth21_session_store", lambda: oauth_store + ) + monkeypatch.setattr( + "auth.google_auth.get_credential_store", lambda: credential_store + ) + monkeypatch.setattr( + "auth.google_auth.get_user_info", + lambda credentials: {"email": "user@gmail.com"}, # noqa: ARG005 + ) + monkeypatch.setattr( + "auth.google_auth.save_credentials_to_session", lambda *args: None + ) + monkeypatch.setattr("auth.google_auth.is_stateless_mode", lambda: False) + + _email, credentials = handle_auth_callback( + scopes=["scope.a"], + authorization_response="http://localhost/callback?state=abc123&code=code123", + redirect_uri="http://localhost/callback", + session_id="session-1", + ) + + assert credentials.refresh_token == "session-refresh-token" + assert credential_store.saved_credentials.refresh_token == "session-refresh-token" + assert oauth_store.stored_refresh_token == "session-refresh-token" diff --git a/tests/auth/test_google_auth_pkce.py b/tests/auth/test_google_auth_pkce.py new file mode 100644 index 0000000..57e775e --- /dev/null +++ b/tests/auth/test_google_auth_pkce.py @@ -0,0 +1,118 @@ +"""Regression tests for OAuth PKCE flow wiring.""" + +import os +import sys +from unittest.mock import patch + + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +from auth.google_auth import create_oauth_flow # noqa: E402 + + +DUMMY_CLIENT_CONFIG = { + "web": { + "client_id": "dummy-client-id.apps.googleusercontent.com", + "client_secret": "dummy-secret", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + } +} + + +def test_create_oauth_flow_autogenerates_verifier_when_missing(): + expected_flow = object() + with ( + patch( + "auth.google_auth.load_client_secrets_from_env", + return_value=DUMMY_CLIENT_CONFIG, + ), + patch( + "auth.google_auth.Flow.from_client_config", + return_value=expected_flow, + ) as mock_from_client_config, + ): + flow = create_oauth_flow( + scopes=["openid"], + redirect_uri="http://localhost/callback", + state="oauth-state-1", + ) + + assert flow is expected_flow + args, kwargs = mock_from_client_config.call_args + assert args[0] == DUMMY_CLIENT_CONFIG + assert kwargs["autogenerate_code_verifier"] is True + assert "code_verifier" not in kwargs + + +def test_create_oauth_flow_preserves_callback_verifier(): + expected_flow = object() + with ( + patch( + "auth.google_auth.load_client_secrets_from_env", + return_value=DUMMY_CLIENT_CONFIG, + ), + patch( + "auth.google_auth.Flow.from_client_config", + return_value=expected_flow, + ) as mock_from_client_config, + ): + flow = create_oauth_flow( + scopes=["openid"], + redirect_uri="http://localhost/callback", + state="oauth-state-2", + code_verifier="saved-verifier", + ) + + assert flow is expected_flow + args, kwargs = mock_from_client_config.call_args + assert args[0] == DUMMY_CLIENT_CONFIG + assert kwargs["code_verifier"] == "saved-verifier" + assert kwargs["autogenerate_code_verifier"] is False + + +def test_create_oauth_flow_file_config_still_enables_pkce(): + expected_flow = object() + with ( + patch("auth.google_auth.load_client_secrets_from_env", return_value=None), + patch("auth.google_auth.os.path.exists", return_value=True), + patch( + "auth.google_auth.Flow.from_client_secrets_file", + return_value=expected_flow, + ) as mock_from_file, + ): + flow = create_oauth_flow( + scopes=["openid"], + redirect_uri="http://localhost/callback", + state="oauth-state-3", + ) + + assert flow is expected_flow + _args, kwargs = mock_from_file.call_args + assert kwargs["autogenerate_code_verifier"] is True + assert "code_verifier" not in kwargs + + +def test_create_oauth_flow_allows_disabling_autogenerate_without_verifier(): + expected_flow = object() + with ( + patch( + "auth.google_auth.load_client_secrets_from_env", + return_value=DUMMY_CLIENT_CONFIG, + ), + patch( + "auth.google_auth.Flow.from_client_config", + return_value=expected_flow, + ) as mock_from_client_config, + ): + flow = create_oauth_flow( + scopes=["openid"], + redirect_uri="http://localhost/callback", + state="oauth-state-4", + autogenerate_code_verifier=False, + ) + + assert flow is expected_flow + _args, kwargs = mock_from_client_config.call_args + assert kwargs["autogenerate_code_verifier"] is False + assert "code_verifier" not in kwargs diff --git a/tests/auth/test_google_auth_prompt_selection.py b/tests/auth/test_google_auth_prompt_selection.py new file mode 100644 index 0000000..d5fb254 --- /dev/null +++ b/tests/auth/test_google_auth_prompt_selection.py @@ -0,0 +1,119 @@ +from types import SimpleNamespace + +from auth.google_auth import _determine_oauth_prompt + + +class _DummyCredentialStore: + def __init__(self, credentials_by_email=None): + self._credentials_by_email = credentials_by_email or {} + + def get_credential(self, user_email): + return self._credentials_by_email.get(user_email) + + +class _DummySessionStore: + def __init__(self, user_by_session=None, credentials_by_session=None): + self._user_by_session = user_by_session or {} + self._credentials_by_session = credentials_by_session or {} + + def get_user_by_mcp_session(self, mcp_session_id): + return self._user_by_session.get(mcp_session_id) + + def get_credentials_by_mcp_session(self, mcp_session_id): + return self._credentials_by_session.get(mcp_session_id) + + +def _credentials_with_scopes(scopes): + return SimpleNamespace(scopes=scopes) + + +def test_prompt_select_account_when_existing_credentials_cover_scopes(monkeypatch): + required_scopes = ["scope.a", "scope.b"] + monkeypatch.setattr( + "auth.google_auth.get_oauth21_session_store", + lambda: _DummySessionStore(), + ) + monkeypatch.setattr( + "auth.google_auth.get_credential_store", + lambda: _DummyCredentialStore( + {"user@gmail.com": _credentials_with_scopes(required_scopes)} + ), + ) + monkeypatch.setattr("auth.google_auth.is_stateless_mode", lambda: False) + + prompt = _determine_oauth_prompt( + user_google_email="user@gmail.com", + required_scopes=required_scopes, + session_id=None, + ) + + assert prompt == "select_account" + + +def test_prompt_consent_when_existing_credentials_missing_scopes(monkeypatch): + monkeypatch.setattr( + "auth.google_auth.get_oauth21_session_store", + lambda: _DummySessionStore(), + ) + monkeypatch.setattr( + "auth.google_auth.get_credential_store", + lambda: _DummyCredentialStore( + {"user@gmail.com": _credentials_with_scopes(["scope.a"])} + ), + ) + monkeypatch.setattr("auth.google_auth.is_stateless_mode", lambda: False) + + prompt = _determine_oauth_prompt( + user_google_email="user@gmail.com", + required_scopes=["scope.a", "scope.b"], + session_id=None, + ) + + assert prompt == "consent" + + +def test_prompt_consent_when_no_existing_credentials(monkeypatch): + monkeypatch.setattr( + "auth.google_auth.get_oauth21_session_store", + lambda: _DummySessionStore(), + ) + monkeypatch.setattr( + "auth.google_auth.get_credential_store", + lambda: _DummyCredentialStore(), + ) + monkeypatch.setattr("auth.google_auth.is_stateless_mode", lambda: False) + + prompt = _determine_oauth_prompt( + user_google_email="new_user@gmail.com", + required_scopes=["scope.a"], + session_id=None, + ) + + assert prompt == "consent" + + +def test_prompt_uses_session_mapping_when_email_not_provided(monkeypatch): + session_id = "session-123" + required_scopes = ["scope.a"] + monkeypatch.setattr( + "auth.google_auth.get_oauth21_session_store", + lambda: _DummySessionStore( + user_by_session={session_id: "mapped@gmail.com"}, + credentials_by_session={ + session_id: _credentials_with_scopes(required_scopes) + }, + ), + ) + monkeypatch.setattr( + "auth.google_auth.get_credential_store", + lambda: _DummyCredentialStore(), + ) + monkeypatch.setattr("auth.google_auth.is_stateless_mode", lambda: False) + + prompt = _determine_oauth_prompt( + user_google_email=None, + required_scopes=required_scopes, + session_id=session_id, + ) + + assert prompt == "select_account" diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/core/test_attachment_route.py b/tests/core/test_attachment_route.py new file mode 100644 index 0000000..22ee04f --- /dev/null +++ b/tests/core/test_attachment_route.py @@ -0,0 +1,69 @@ +import pytest +from starlette.requests import Request +from starlette.responses import FileResponse, JSONResponse + +from core.server import serve_attachment + + +def _build_request(file_id: str) -> Request: + scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": "GET", + "scheme": "http", + "path": f"/attachments/{file_id}", + "raw_path": f"/attachments/{file_id}".encode(), + "query_string": b"", + "headers": [], + "client": ("127.0.0.1", 12345), + "server": ("localhost", 8000), + "path_params": {"file_id": file_id}, + } + + async def receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + return Request(scope, receive) + + +@pytest.mark.asyncio +async def test_serve_attachment_uses_path_param_file_id(monkeypatch, tmp_path): + file_path = tmp_path / "sample.pdf" + file_path.write_bytes(b"%PDF-1.3\n") + captured = {} + + class DummyStorage: + def get_attachment_metadata(self, file_id): + captured["file_id"] = file_id + return {"filename": "sample.pdf", "mime_type": "application/pdf"} + + def get_attachment_path(self, _file_id): + return file_path + + monkeypatch.setattr( + "core.attachment_storage.get_attachment_storage", lambda: DummyStorage() + ) + + response = await serve_attachment(_build_request("abc123")) + + assert captured["file_id"] == "abc123" + assert isinstance(response, FileResponse) + assert response.status_code == 200 + + +@pytest.mark.asyncio +async def test_serve_attachment_404_when_metadata_missing(monkeypatch): + class DummyStorage: + def get_attachment_metadata(self, _file_id): + return None + + monkeypatch.setattr( + "core.attachment_storage.get_attachment_storage", lambda: DummyStorage() + ) + + response = await serve_attachment(_build_request("missing")) + + assert isinstance(response, JSONResponse) + assert response.status_code == 404 + assert b"Attachment not found or expired" in response.body diff --git a/tests/core/test_comments.py b/tests/core/test_comments.py new file mode 100644 index 0000000..d5f75cc --- /dev/null +++ b/tests/core/test_comments.py @@ -0,0 +1,112 @@ +"""Tests for core comments module.""" + +import sys +import os +import pytest +from unittest.mock import Mock + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +from core.comments import _read_comments_impl + + +@pytest.mark.asyncio +async def test_read_comments_includes_quoted_text(): + """Verify that quotedFileContent.value is surfaced in the output.""" + mock_service = Mock() + mock_service.comments.return_value.list.return_value.execute = Mock( + return_value={ + "comments": [ + { + "id": "c1", + "content": "Needs a citation here.", + "author": {"displayName": "Alice"}, + "createdTime": "2025-01-15T10:00:00Z", + "modifiedTime": "2025-01-15T10:00:00Z", + "resolved": False, + "quotedFileContent": { + "mimeType": "text/html", + "value": "the specific text that was highlighted", + }, + "replies": [], + }, + { + "id": "c2", + "content": "General comment without anchor.", + "author": {"displayName": "Bob"}, + "createdTime": "2025-01-16T09:00:00Z", + "modifiedTime": "2025-01-16T09:00:00Z", + "resolved": False, + "replies": [], + }, + ] + } + ) + + result = await _read_comments_impl(mock_service, "document", "doc123") + + # Comment with anchor text should show the quoted text + assert "Quoted text: the specific text that was highlighted" in result + assert "Needs a citation here." in result + + # Comment without anchor text should not have a "Quoted text" line between Bob's author and content + # The output uses literal \n joins, so split on that + parts = result.split("\\n") + bob_section_started = False + for part in parts: + if "Author: Bob" in part: + bob_section_started = True + if bob_section_started and "Quoted text:" in part: + pytest.fail( + "Comment without quotedFileContent should not show 'Quoted text'" + ) + if bob_section_started and "Content: General comment" in part: + break + + +@pytest.mark.asyncio +async def test_read_comments_empty(): + """Verify empty comments returns appropriate message.""" + mock_service = Mock() + mock_service.comments.return_value.list.return_value.execute = Mock( + return_value={"comments": []} + ) + + result = await _read_comments_impl(mock_service, "document", "doc123") + assert "No comments found" in result + + +@pytest.mark.asyncio +async def test_read_comments_with_replies(): + """Verify replies are included in output.""" + mock_service = Mock() + mock_service.comments.return_value.list.return_value.execute = Mock( + return_value={ + "comments": [ + { + "id": "c1", + "content": "Question?", + "author": {"displayName": "Alice"}, + "createdTime": "2025-01-15T10:00:00Z", + "modifiedTime": "2025-01-15T10:00:00Z", + "resolved": False, + "quotedFileContent": {"value": "some text"}, + "replies": [ + { + "id": "r1", + "content": "Answer.", + "author": {"displayName": "Bob"}, + "createdTime": "2025-01-15T11:00:00Z", + "modifiedTime": "2025-01-15T11:00:00Z", + } + ], + } + ] + } + ) + + result = await _read_comments_impl(mock_service, "document", "doc123") + assert "Question?" in result + assert "Answer." in result + assert "Bob" in result + assert "Quoted text: some text" in result diff --git a/tests/core/test_well_known_cache_control_middleware.py b/tests/core/test_well_known_cache_control_middleware.py new file mode 100644 index 0000000..cf3705d --- /dev/null +++ b/tests/core/test_well_known_cache_control_middleware.py @@ -0,0 +1,90 @@ +import importlib + +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.responses import Response +from starlette.routing import Route +from starlette.testclient import TestClient + + +def test_well_known_cache_control_middleware_rewrites_headers(): + from core.server import WellKnownCacheControlMiddleware, _compute_scope_fingerprint + + async def well_known_endpoint(request): + response = Response("ok") + response.headers["Cache-Control"] = "public, max-age=3600" + response.set_cookie("a", "1") + response.set_cookie("b", "2") + return response + + async def regular_endpoint(request): + response = Response("ok") + response.headers["Cache-Control"] = "public, max-age=3600" + return response + + app = Starlette( + routes=[ + Route("/.well-known/oauth-authorization-server", well_known_endpoint), + Route("/.well-known/oauth-authorization-server-extra", regular_endpoint), + Route("/health", regular_endpoint), + ], + middleware=[Middleware(WellKnownCacheControlMiddleware)], + ) + client = TestClient(app) + + well_known = client.get("/.well-known/oauth-authorization-server") + assert well_known.status_code == 200 + assert well_known.headers["cache-control"] == "no-store, must-revalidate" + assert well_known.headers["etag"] == f'"{_compute_scope_fingerprint()}"' + assert sorted(well_known.headers.get_list("set-cookie")) == sorted( + ["a=1; Path=/; SameSite=lax", "b=2; Path=/; SameSite=lax"] + ) + + regular = client.get("/health") + assert regular.status_code == 200 + assert regular.headers["cache-control"] == "public, max-age=3600" + assert "etag" not in regular.headers + + extra = client.get("/.well-known/oauth-authorization-server-extra") + assert extra.status_code == 200 + assert extra.headers["cache-control"] == "public, max-age=3600" + assert "etag" not in extra.headers + + +def test_configured_server_applies_no_cache_to_served_oauth_discovery_routes( + monkeypatch, +): + monkeypatch.setenv("MCP_ENABLE_OAUTH21", "true") + monkeypatch.setenv("GOOGLE_OAUTH_CLIENT_ID", "dummy-client") + monkeypatch.setenv("GOOGLE_OAUTH_CLIENT_SECRET", "dummy-secret") + monkeypatch.setenv("WORKSPACE_MCP_BASE_URI", "http://localhost") + monkeypatch.setenv("WORKSPACE_MCP_PORT", "8000") + monkeypatch.delenv("WORKSPACE_EXTERNAL_URL", raising=False) + monkeypatch.setenv("EXTERNAL_OAUTH21_PROVIDER", "false") + + import core.server as core_server + from auth.oauth_config import reload_oauth_config + + reload_oauth_config() + core_server = importlib.reload(core_server) + core_server.set_transport_mode("streamable-http") + core_server.configure_server_for_http() + + app = core_server.server.http_app(transport="streamable-http", path="/mcp") + client = TestClient(app) + + authorization_server = client.get("/.well-known/oauth-authorization-server") + assert authorization_server.status_code == 200 + assert authorization_server.headers["cache-control"] == "no-store, must-revalidate" + assert authorization_server.headers["etag"].startswith('"') + assert authorization_server.headers["etag"].endswith('"') + + protected_resource = client.get("/.well-known/oauth-protected-resource/mcp") + assert protected_resource.status_code == 200 + assert protected_resource.headers["cache-control"] == "no-store, must-revalidate" + assert protected_resource.headers["etag"].startswith('"') + assert protected_resource.headers["etag"].endswith('"') + + # Ensure we did not create a shadow route at the wrong path. + wrong_path = client.get("/.well-known/oauth-protected-resource") + assert wrong_path.status_code == 404 diff --git a/tests/gappsscript/__init__.py b/tests/gappsscript/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/gappsscript/manual_test.py b/tests/gappsscript/manual_test.py new file mode 100644 index 0000000..f91f584 --- /dev/null +++ b/tests/gappsscript/manual_test.py @@ -0,0 +1,373 @@ +""" +Manual E2E test script for Apps Script integration. + +This script tests Apps Script tools against the real Google API. +Requires valid OAuth credentials and enabled Apps Script API. + +Usage: + python tests/gappsscript/manual_test.py + +Environment Variables: + GOOGLE_CLIENT_SECRET_PATH: Path to client_secret.json (default: ./client_secret.json) + GOOGLE_TOKEN_PATH: Path to store OAuth token (default: ./test_token.pickle) + +Note: This will create real Apps Script projects in your account. + Delete test projects manually after running. +""" + +import asyncio +import sys +import os + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +from googleapiclient.discovery import build +from google_auth_oauthlib.flow import InstalledAppFlow +from google.auth.transport.requests import Request +import pickle + + +SCOPES = [ + "https://www.googleapis.com/auth/script.projects", + "https://www.googleapis.com/auth/script.deployments", + "https://www.googleapis.com/auth/script.processes", + "https://www.googleapis.com/auth/drive.readonly", # For listing script projects + "https://www.googleapis.com/auth/userinfo.email", # Basic user info + "openid", # Required by Google OAuth +] + +# Allow http://localhost for OAuth (required for headless auth) +os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" + +# Default paths (can be overridden via environment variables) +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")) +DEFAULT_CLIENT_SECRET = os.path.join(PROJECT_ROOT, "client_secret.json") +DEFAULT_TOKEN_PATH = os.path.join(PROJECT_ROOT, "test_token.pickle") + + +def get_credentials(): + """ + Get OAuth credentials for Apps Script API. + + Credential paths can be configured via environment variables: + - GOOGLE_CLIENT_SECRET_PATH: Path to client_secret.json + - GOOGLE_TOKEN_PATH: Path to store/load OAuth token + + Returns: + Credentials object + """ + creds = None + token_path = os.environ.get("GOOGLE_TOKEN_PATH", DEFAULT_TOKEN_PATH) + client_secret_path = os.environ.get( + "GOOGLE_CLIENT_SECRET_PATH", DEFAULT_CLIENT_SECRET + ) + + if os.path.exists(token_path): + with open(token_path, "rb") as token: + creds = pickle.load(token) + + if not creds or not creds.valid: + if creds and creds.expired and creds.refresh_token: + creds.refresh(Request()) + else: + if not os.path.exists(client_secret_path): + print(f"Error: {client_secret_path} not found") + print("\nTo fix this:") + print("1. Go to Google Cloud Console > APIs & Services > Credentials") + print("2. Create an OAuth 2.0 Client ID (Desktop application type)") + print("3. Download the JSON and save as client_secret.json") + print(f"\nExpected path: {client_secret_path}") + print("\nOr set GOOGLE_CLIENT_SECRET_PATH environment variable") + sys.exit(1) + + flow = InstalledAppFlow.from_client_secrets_file(client_secret_path, SCOPES) + # Set redirect URI to match client_secret.json + flow.redirect_uri = "http://localhost" + # Headless flow: user copies redirect URL after auth + auth_url, _ = flow.authorization_url(prompt="consent") + print("\n" + "=" * 60) + print("HEADLESS AUTH") + print("=" * 60) + print("\n1. Open this URL in any browser:\n") + print(auth_url) + print("\n2. Sign in and authorize the app") + print("3. You'll be redirected to http://localhost (won't load)") + print("4. Copy the FULL URL from browser address bar") + print(" (looks like: http://localhost/?code=4/0A...&scope=...)") + print("5. Paste it below:\n") + redirect_response = input("Paste full redirect URL: ").strip() + flow.fetch_token(authorization_response=redirect_response) + creds = flow.credentials + + with open(token_path, "wb") as token: + pickle.dump(creds, token) + + return creds + + +async def test_list_projects(drive_service): + """Test listing Apps Script projects using Drive API""" + print("\n=== Test: List Projects ===") + + from gappsscript.apps_script_tools import _list_script_projects_impl + + try: + result = await _list_script_projects_impl( + service=drive_service, user_google_email="test@example.com", page_size=10 + ) + print(result) + return True + except Exception as e: + print(f"Error: {e}") + return False + + +async def test_create_project(service): + """Test creating a new Apps Script project""" + print("\n=== Test: Create Project ===") + + from gappsscript.apps_script_tools import _create_script_project_impl + + try: + result = await _create_script_project_impl( + service=service, + user_google_email="test@example.com", + title="MCP Test Project", + ) + print(result) + + if "Script ID:" in result: + script_id = result.split("Script ID: ")[1].split("\n")[0] + return script_id + return None + except Exception as e: + print(f"Error: {e}") + return None + + +async def test_get_project(service, script_id): + """Test retrieving project details""" + print(f"\n=== Test: Get Project {script_id} ===") + + from gappsscript.apps_script_tools import _get_script_project_impl + + try: + result = await _get_script_project_impl( + service=service, user_google_email="test@example.com", script_id=script_id + ) + print(result) + return True + except Exception as e: + print(f"Error: {e}") + return False + + +async def test_update_content(service, script_id): + """Test updating script content""" + print(f"\n=== Test: Update Content {script_id} ===") + + from gappsscript.apps_script_tools import _update_script_content_impl + + files = [ + { + "name": "appsscript", + "type": "JSON", + "source": """{ + "timeZone": "America/New_York", + "dependencies": {}, + "exceptionLogging": "STACKDRIVER", + "runtimeVersion": "V8" +}""", + }, + { + "name": "Code", + "type": "SERVER_JS", + "source": """function testFunction() { + Logger.log('Hello from MCP test!'); + return 'Test successful'; +}""", + }, + ] + + try: + result = await _update_script_content_impl( + service=service, + user_google_email="test@example.com", + script_id=script_id, + files=files, + ) + print(result) + return True + except Exception as e: + print(f"Error: {e}") + return False + + +async def test_run_function(service, script_id): + """Test running a script function""" + print(f"\n=== Test: Run Function {script_id} ===") + + from gappsscript.apps_script_tools import _run_script_function_impl + + try: + result = await _run_script_function_impl( + service=service, + user_google_email="test@example.com", + script_id=script_id, + function_name="testFunction", + dev_mode=True, + ) + print(result) + return True + except Exception as e: + print(f"Error: {e}") + return False + + +async def test_create_deployment(service, script_id): + """Test creating a deployment""" + print(f"\n=== Test: Create Deployment {script_id} ===") + + from gappsscript.apps_script_tools import _create_deployment_impl + + try: + result = await _create_deployment_impl( + service=service, + user_google_email="test@example.com", + script_id=script_id, + description="MCP Test Deployment", + ) + print(result) + + if "Deployment ID:" in result: + deployment_id = result.split("Deployment ID: ")[1].split("\n")[0] + return deployment_id + return None + except Exception as e: + print(f"Error: {e}") + return None + + +async def test_list_deployments(service, script_id): + """Test listing deployments""" + print(f"\n=== Test: List Deployments {script_id} ===") + + from gappsscript.apps_script_tools import _list_deployments_impl + + try: + result = await _list_deployments_impl( + service=service, user_google_email="test@example.com", script_id=script_id + ) + print(result) + return True + except Exception as e: + print(f"Error: {e}") + return False + + +async def test_list_processes(service): + """Test listing script processes""" + print("\n=== Test: List Processes ===") + + from gappsscript.apps_script_tools import _list_script_processes_impl + + try: + result = await _list_script_processes_impl( + service=service, user_google_email="test@example.com", page_size=10 + ) + print(result) + return True + except Exception as e: + print(f"Error: {e}") + return False + + +async def cleanup_test_project(service, script_id): + """ + Cleanup test project (requires Drive API). + Note: Apps Script API does not have a delete endpoint. + Projects must be deleted via Drive API by moving to trash. + """ + print(f"\n=== Cleanup: Delete Project {script_id} ===") + print("Note: Apps Script projects must be deleted via Drive API") + print(f"Please manually delete: https://script.google.com/d/{script_id}/edit") + + +async def run_all_tests(): + """Run all manual tests""" + print("=" * 60) + print("Apps Script MCP Manual Test Suite") + print("=" * 60) + + print("\nGetting OAuth credentials...") + creds = get_credentials() + + print("Building API services...") + script_service = build("script", "v1", credentials=creds) + drive_service = build("drive", "v3", credentials=creds) + + test_script_id = None + deployment_id = None + + try: + success = await test_list_projects(drive_service) + if not success: + print("\nWarning: List projects failed") + + test_script_id = await test_create_project(script_service) + if test_script_id: + print(f"\nCreated test project: {test_script_id}") + + await test_get_project(script_service, test_script_id) + await test_update_content(script_service, test_script_id) + + await asyncio.sleep(2) + + await test_run_function(script_service, test_script_id) + + deployment_id = await test_create_deployment(script_service, test_script_id) + if deployment_id: + print(f"\nCreated deployment: {deployment_id}") + + await test_list_deployments(script_service, test_script_id) + else: + print("\nSkipping tests that require a project (creation failed)") + + await test_list_processes(script_service) + + finally: + if test_script_id: + await cleanup_test_project(script_service, test_script_id) + + print("\n" + "=" * 60) + print("Manual Test Suite Complete") + print("=" * 60) + + +def main(): + """Main entry point""" + import argparse + + parser = argparse.ArgumentParser(description="Manual E2E test for Apps Script") + parser.add_argument( + "--yes", "-y", action="store_true", help="Skip confirmation prompt" + ) + args = parser.parse_args() + + print("\nIMPORTANT: This script will:") + print("1. Create a test Apps Script project in your account") + print("2. Run various operations on it") + print("3. Leave the project for manual cleanup") + print("\nYou must manually delete the test project after running this.") + + if not args.yes: + response = input("\nContinue? (yes/no): ") + if response.lower() not in ["yes", "y"]: + print("Aborted") + return + + asyncio.run(run_all_tests()) + + +if __name__ == "__main__": + main() diff --git a/tests/gappsscript/test_apps_script_tools.py b/tests/gappsscript/test_apps_script_tools.py new file mode 100644 index 0000000..461a322 --- /dev/null +++ b/tests/gappsscript/test_apps_script_tools.py @@ -0,0 +1,432 @@ +""" +Unit tests for Google Apps Script MCP tools + +Tests all Apps Script tools with mocked API responses +""" + +import pytest +from unittest.mock import Mock +import sys +import os + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +# Import the internal implementation functions (not the decorated ones) +from gappsscript.apps_script_tools import ( + _list_script_projects_impl, + _get_script_project_impl, + _create_script_project_impl, + _update_script_content_impl, + _run_script_function_impl, + _create_deployment_impl, + _list_deployments_impl, + _update_deployment_impl, + _delete_deployment_impl, + _list_script_processes_impl, + _delete_script_project_impl, + _list_versions_impl, + _create_version_impl, + _get_version_impl, + _get_script_metrics_impl, + _generate_trigger_code_impl, +) + + +@pytest.mark.asyncio +async def test_list_script_projects(): + """Test listing Apps Script projects via Drive API""" + mock_service = Mock() + mock_response = { + "files": [ + { + "id": "test123", + "name": "Test Project", + "createdTime": "2025-01-10T10:00:00Z", + "modifiedTime": "2026-01-12T15:30:00Z", + }, + ] + } + + mock_service.files().list().execute.return_value = mock_response + + result = await _list_script_projects_impl( + service=mock_service, user_google_email="test@example.com", page_size=50 + ) + + assert "Found 1 Apps Script projects" in result + assert "Test Project" in result + assert "test123" in result + + +@pytest.mark.asyncio +async def test_get_script_project(): + """Test retrieving complete project details""" + mock_service = Mock() + + # projects().get() returns metadata only (no files) + mock_metadata_response = { + "scriptId": "test123", + "title": "Test Project", + "creator": {"email": "creator@example.com"}, + "createTime": "2025-01-10T10:00:00Z", + "updateTime": "2026-01-12T15:30:00Z", + } + + # projects().getContent() returns files with source code + mock_content_response = { + "scriptId": "test123", + "files": [ + { + "name": "Code", + "type": "SERVER_JS", + "source": "function test() { return 'hello'; }", + } + ], + } + + mock_service.projects().get().execute.return_value = mock_metadata_response + mock_service.projects().getContent().execute.return_value = mock_content_response + + result = await _get_script_project_impl( + service=mock_service, user_google_email="test@example.com", script_id="test123" + ) + + assert "Test Project" in result + assert "creator@example.com" in result + assert "Code" in result + + +@pytest.mark.asyncio +async def test_create_script_project(): + """Test creating new Apps Script project""" + mock_service = Mock() + mock_response = {"scriptId": "new123", "title": "New Project"} + + mock_service.projects().create().execute.return_value = mock_response + + result = await _create_script_project_impl( + service=mock_service, user_google_email="test@example.com", title="New Project" + ) + + assert "Script ID: new123" in result + assert "New Project" in result + + +@pytest.mark.asyncio +async def test_update_script_content(): + """Test updating script project files""" + mock_service = Mock() + files_to_update = [ + {"name": "Code", "type": "SERVER_JS", "source": "function main() {}"} + ] + mock_response = {"files": files_to_update} + + mock_service.projects().updateContent().execute.return_value = mock_response + + result = await _update_script_content_impl( + service=mock_service, + user_google_email="test@example.com", + script_id="test123", + files=files_to_update, + ) + + assert "Updated script project: test123" in result + assert "Code" in result + + +@pytest.mark.asyncio +async def test_run_script_function(): + """Test executing script function""" + mock_service = Mock() + mock_response = {"response": {"result": "Success"}} + + mock_service.scripts().run().execute.return_value = mock_response + + result = await _run_script_function_impl( + service=mock_service, + user_google_email="test@example.com", + script_id="test123", + function_name="myFunction", + dev_mode=True, + ) + + assert "Execution successful" in result + assert "myFunction" in result + + +@pytest.mark.asyncio +async def test_create_deployment(): + """Test creating deployment""" + mock_service = Mock() + + # Mock version creation (called first) + mock_version_response = {"versionNumber": 1} + mock_service.projects().versions().create().execute.return_value = ( + mock_version_response + ) + + # Mock deployment creation (called second) + mock_deploy_response = { + "deploymentId": "deploy123", + "deploymentConfig": {}, + } + mock_service.projects().deployments().create().execute.return_value = ( + mock_deploy_response + ) + + result = await _create_deployment_impl( + service=mock_service, + user_google_email="test@example.com", + script_id="test123", + description="Test deployment", + ) + + assert "Deployment ID: deploy123" in result + assert "Test deployment" in result + assert "Version: 1" in result + + +@pytest.mark.asyncio +async def test_list_deployments(): + """Test listing deployments""" + mock_service = Mock() + mock_response = { + "deployments": [ + { + "deploymentId": "deploy123", + "description": "Production", + "updateTime": "2026-01-12T15:30:00Z", + } + ] + } + + mock_service.projects().deployments().list().execute.return_value = mock_response + + result = await _list_deployments_impl( + service=mock_service, user_google_email="test@example.com", script_id="test123" + ) + + assert "Production" in result + assert "deploy123" in result + + +@pytest.mark.asyncio +async def test_update_deployment(): + """Test updating deployment""" + mock_service = Mock() + mock_response = { + "deploymentId": "deploy123", + "description": "Updated description", + } + + mock_service.projects().deployments().update().execute.return_value = mock_response + + result = await _update_deployment_impl( + service=mock_service, + user_google_email="test@example.com", + script_id="test123", + deployment_id="deploy123", + description="Updated description", + ) + + assert "Updated deployment: deploy123" in result + + +@pytest.mark.asyncio +async def test_delete_deployment(): + """Test deleting deployment""" + mock_service = Mock() + mock_service.projects().deployments().delete().execute.return_value = {} + + result = await _delete_deployment_impl( + service=mock_service, + user_google_email="test@example.com", + script_id="test123", + deployment_id="deploy123", + ) + + assert "Deleted deployment: deploy123 from script: test123" in result + + +@pytest.mark.asyncio +async def test_list_script_processes(): + """Test listing script processes""" + mock_service = Mock() + mock_response = { + "processes": [ + { + "functionName": "myFunction", + "processStatus": "COMPLETED", + "startTime": "2026-01-12T15:30:00Z", + "duration": "5s", + } + ] + } + + mock_service.processes().list().execute.return_value = mock_response + + result = await _list_script_processes_impl( + service=mock_service, user_google_email="test@example.com", page_size=50 + ) + + assert "myFunction" in result + assert "COMPLETED" in result + + +@pytest.mark.asyncio +async def test_delete_script_project(): + """Test deleting a script project""" + mock_service = Mock() + mock_service.files().delete().execute.return_value = {} + + result = await _delete_script_project_impl( + service=mock_service, user_google_email="test@example.com", script_id="test123" + ) + + assert "Deleted Apps Script project: test123" in result + + +@pytest.mark.asyncio +async def test_list_versions(): + """Test listing script versions""" + mock_service = Mock() + mock_response = { + "versions": [ + { + "versionNumber": 1, + "description": "Initial version", + "createTime": "2025-01-10T10:00:00Z", + }, + { + "versionNumber": 2, + "description": "Bug fix", + "createTime": "2026-01-12T15:30:00Z", + }, + ] + } + + mock_service.projects().versions().list().execute.return_value = mock_response + + result = await _list_versions_impl( + service=mock_service, user_google_email="test@example.com", script_id="test123" + ) + + assert "Version 1" in result + assert "Initial version" in result + assert "Version 2" in result + assert "Bug fix" in result + + +@pytest.mark.asyncio +async def test_create_version(): + """Test creating a new version""" + mock_service = Mock() + mock_response = { + "versionNumber": 3, + "createTime": "2026-01-13T10:00:00Z", + } + + mock_service.projects().versions().create().execute.return_value = mock_response + + result = await _create_version_impl( + service=mock_service, + user_google_email="test@example.com", + script_id="test123", + description="New feature", + ) + + assert "Created version 3" in result + assert "New feature" in result + + +@pytest.mark.asyncio +async def test_get_version(): + """Test getting a specific version""" + mock_service = Mock() + mock_response = { + "versionNumber": 2, + "description": "Bug fix", + "createTime": "2026-01-12T15:30:00Z", + } + + mock_service.projects().versions().get().execute.return_value = mock_response + + result = await _get_version_impl( + service=mock_service, + user_google_email="test@example.com", + script_id="test123", + version_number=2, + ) + + assert "Version 2" in result + assert "Bug fix" in result + + +@pytest.mark.asyncio +async def test_get_script_metrics(): + """Test getting script metrics""" + mock_service = Mock() + mock_response = { + "activeUsers": [ + {"startTime": "2026-01-01", "endTime": "2026-01-02", "value": "10"} + ], + "totalExecutions": [ + {"startTime": "2026-01-01", "endTime": "2026-01-02", "value": "100"} + ], + "failedExecutions": [ + {"startTime": "2026-01-01", "endTime": "2026-01-02", "value": "5"} + ], + } + + mock_service.projects().getMetrics().execute.return_value = mock_response + + result = await _get_script_metrics_impl( + service=mock_service, + user_google_email="test@example.com", + script_id="test123", + metrics_granularity="DAILY", + ) + + assert "Active Users" in result + assert "10 users" in result + assert "Total Executions" in result + assert "100 executions" in result + assert "Failed Executions" in result + assert "5 failures" in result + + +def test_generate_trigger_code_daily(): + """Test generating daily trigger code""" + result = _generate_trigger_code_impl( + trigger_type="time_daily", + function_name="sendReport", + schedule="9", + ) + + assert "INSTALLABLE TRIGGER" in result + assert "createDailyTrigger_sendReport" in result + assert "everyDays(1)" in result + assert "atHour(9)" in result + + +def test_generate_trigger_code_on_edit(): + """Test generating onEdit trigger code""" + result = _generate_trigger_code_impl( + trigger_type="on_edit", + function_name="processEdit", + ) + + assert "SIMPLE TRIGGER" in result + assert "function onEdit" in result + assert "processEdit()" in result + + +def test_generate_trigger_code_invalid(): + """Test generating trigger code with invalid type""" + result = _generate_trigger_code_impl( + trigger_type="invalid_type", + function_name="test", + ) + + assert "Unknown trigger type" in result + assert "Valid types:" in result diff --git a/tests/gchat/__init__.py b/tests/gchat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/gchat/test_chat_tools.py b/tests/gchat/test_chat_tools.py new file mode 100644 index 0000000..232b146 --- /dev/null +++ b/tests/gchat/test_chat_tools.py @@ -0,0 +1,419 @@ +""" +Unit tests for Google Chat MCP tools — attachment support +""" + +import base64 +from urllib.parse import urlparse + +import pytest +from unittest.mock import AsyncMock, Mock, patch +import sys +import os + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + + +def _make_message(text="Hello", attachments=None, msg_name="spaces/S/messages/M"): + """Build a minimal Chat API message dict for testing.""" + msg = { + "name": msg_name, + "text": text, + "createTime": "2025-01-01T00:00:00Z", + "sender": {"name": "users/123", "displayName": "Test User"}, + } + if attachments is not None: + msg["attachment"] = attachments + return msg + + +def _make_attachment( + name="spaces/S/messages/M/attachments/A", + content_name="image.png", + content_type="image/png", + resource_name="spaces/S/attachments/A", +): + att = { + "name": name, + "contentName": content_name, + "contentType": content_type, + "source": "UPLOADED_CONTENT", + } + if resource_name: + att["attachmentDataRef"] = {"resourceName": resource_name} + return att + + +def _unwrap(tool): + """Unwrap a FunctionTool + decorator chain to the original async function.""" + fn = getattr(tool, "fn", tool) + while hasattr(fn, "__wrapped__"): + fn = fn.__wrapped__ + return fn + + +# --------------------------------------------------------------------------- +# get_messages: attachment metadata appears in output +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@patch("gchat.chat_tools._resolve_sender", new_callable=AsyncMock) +async def test_get_messages_shows_attachment_metadata(mock_resolve): + """When a message has attachments, get_messages should surface their metadata.""" + mock_resolve.return_value = "Test User" + + att = _make_attachment() + msg = _make_message(attachments=[att]) + + chat_service = Mock() + chat_service.spaces().get().execute.return_value = {"displayName": "Test Space"} + chat_service.spaces().messages().list().execute.return_value = {"messages": [msg]} + + people_service = Mock() + + from gchat.chat_tools import get_messages + + result = await _unwrap(get_messages)( + chat_service=chat_service, + people_service=people_service, + user_google_email="test@example.com", + space_id="spaces/S", + ) + + assert "[attachment 0: image.png (image/png)]" in result + assert "download_chat_attachment" in result + + +@pytest.mark.asyncio +@patch("gchat.chat_tools._resolve_sender", new_callable=AsyncMock) +async def test_get_messages_no_attachments_unchanged(mock_resolve): + """Messages without attachments should not include attachment lines.""" + mock_resolve.return_value = "Test User" + + msg = _make_message(text="Plain text message") + + chat_service = Mock() + chat_service.spaces().get().execute.return_value = {"displayName": "Test Space"} + chat_service.spaces().messages().list().execute.return_value = {"messages": [msg]} + + people_service = Mock() + + from gchat.chat_tools import get_messages + + result = await _unwrap(get_messages)( + chat_service=chat_service, + people_service=people_service, + user_google_email="test@example.com", + space_id="spaces/S", + ) + + assert "Plain text message" in result + assert "[attachment" not in result + + +@pytest.mark.asyncio +@patch("gchat.chat_tools._resolve_sender", new_callable=AsyncMock) +async def test_get_messages_multiple_attachments(mock_resolve): + """Multiple attachments should each appear with their index.""" + mock_resolve.return_value = "Test User" + + attachments = [ + _make_attachment(content_name="photo.jpg", content_type="image/jpeg"), + _make_attachment( + name="spaces/S/messages/M/attachments/B", + content_name="doc.pdf", + content_type="application/pdf", + ), + ] + msg = _make_message(attachments=attachments) + + chat_service = Mock() + chat_service.spaces().get().execute.return_value = {"displayName": "Test Space"} + chat_service.spaces().messages().list().execute.return_value = {"messages": [msg]} + + people_service = Mock() + + from gchat.chat_tools import get_messages + + result = await _unwrap(get_messages)( + chat_service=chat_service, + people_service=people_service, + user_google_email="test@example.com", + space_id="spaces/S", + ) + + assert "[attachment 0: photo.jpg (image/jpeg)]" in result + assert "[attachment 1: doc.pdf (application/pdf)]" in result + + +# --------------------------------------------------------------------------- +# search_messages: attachment indicator +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@patch("gchat.chat_tools._resolve_sender", new_callable=AsyncMock) +async def test_search_messages_shows_attachment_indicator(mock_resolve): + """search_messages should show [attachment: filename] for messages with attachments.""" + mock_resolve.return_value = "Test User" + + att = _make_attachment(content_name="report.pdf", content_type="application/pdf") + msg = _make_message(text="Here is the report", attachments=[att]) + msg["_space_name"] = "General" + + chat_service = Mock() + chat_service.spaces().list().execute.return_value = { + "spaces": [{"name": "spaces/S", "displayName": "General"}] + } + chat_service.spaces().messages().list().execute.return_value = {"messages": [msg]} + + people_service = Mock() + + from gchat.chat_tools import search_messages + + result = await _unwrap(search_messages)( + chat_service=chat_service, + people_service=people_service, + user_google_email="test@example.com", + query="report", + ) + + assert "[attachment: report.pdf (application/pdf)]" in result + + +# --------------------------------------------------------------------------- +# download_chat_attachment: edge cases +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_download_no_attachments(): + """Should return a clear message when the message has no attachments.""" + service = Mock() + service.spaces().messages().get().execute.return_value = _make_message() + + from gchat.chat_tools import download_chat_attachment + + result = await _unwrap(download_chat_attachment)( + service=service, + user_google_email="test@example.com", + message_id="spaces/S/messages/M", + ) + + assert "No attachments found" in result + + +@pytest.mark.asyncio +async def test_download_invalid_index(): + """Should return an error for out-of-range attachment_index.""" + msg = _make_message(attachments=[_make_attachment()]) + service = Mock() + service.spaces().messages().get().execute.return_value = msg + + from gchat.chat_tools import download_chat_attachment + + result = await _unwrap(download_chat_attachment)( + service=service, + user_google_email="test@example.com", + message_id="spaces/S/messages/M", + attachment_index=5, + ) + + assert "Invalid attachment_index" in result + assert "1 attachment(s)" in result + + +@pytest.mark.asyncio +async def test_download_uses_api_media_endpoint(): + """Should always use chat.googleapis.com media endpoint, not downloadUri.""" + fake_bytes = b"fake image content" + att = _make_attachment() + # Even with a downloadUri present, we should use the API endpoint + att["downloadUri"] = "https://chat.google.com/api/get_attachment_url?bad=url" + msg = _make_message(attachments=[att]) + + service = Mock() + service.spaces().messages().get().execute.return_value = msg + service._http.credentials.token = "fake-access-token" + + from gchat.chat_tools import download_chat_attachment + + saved = Mock() + saved.path = "/tmp/image_abc.png" + saved.file_id = "abc" + + mock_response = Mock() + mock_response.content = fake_bytes + mock_response.status_code = 200 + + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + + with ( + patch("gchat.chat_tools.httpx.AsyncClient", return_value=mock_client), + patch("auth.oauth_config.is_stateless_mode", return_value=False), + patch("core.config.get_transport_mode", return_value="stdio"), + patch("core.attachment_storage.get_attachment_storage") as mock_get_storage, + ): + mock_get_storage.return_value.save_attachment.return_value = saved + + result = await _unwrap(download_chat_attachment)( + service=service, + user_google_email="test@example.com", + message_id="spaces/S/messages/M", + attachment_index=0, + ) + + assert "image.png" in result + assert "/tmp/image_abc.png" in result + assert "Saved to:" in result + + # Verify we used the API endpoint with attachmentDataRef.resourceName + call_args = mock_client.get.call_args + url_used = call_args.args[0] + parsed = urlparse(url_used) + assert parsed.scheme == "https" + assert parsed.hostname == "chat.googleapis.com" + assert "alt=media" in url_used + assert "spaces/S/attachments/A" in parsed.path + assert "/messages/" not in parsed.path + + # Verify Bearer token + assert call_args.kwargs["headers"]["Authorization"] == "Bearer fake-access-token" + + # Verify save_attachment was called with correct base64 data + save_args = mock_get_storage.return_value.save_attachment.call_args + assert save_args.kwargs["filename"] == "image.png" + assert save_args.kwargs["mime_type"] == "image/png" + decoded = base64.urlsafe_b64decode(save_args.kwargs["base64_data"]) + assert decoded == fake_bytes + + +@pytest.mark.asyncio +async def test_download_falls_back_to_att_name(): + """When attachmentDataRef is missing, should fall back to attachment name.""" + fake_bytes = b"fetched content" + att = _make_attachment(name="spaces/S/messages/M/attachments/A", resource_name=None) + msg = _make_message(attachments=[att]) + + service = Mock() + service.spaces().messages().get().execute.return_value = msg + service._http.credentials.token = "fake-access-token" + + saved = Mock() + saved.path = "/tmp/image_fetched.png" + saved.file_id = "f1" + + mock_response = Mock() + mock_response.content = fake_bytes + mock_response.status_code = 200 + + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + + from gchat.chat_tools import download_chat_attachment + + with ( + patch("gchat.chat_tools.httpx.AsyncClient", return_value=mock_client), + patch("auth.oauth_config.is_stateless_mode", return_value=False), + patch("core.config.get_transport_mode", return_value="stdio"), + patch("core.attachment_storage.get_attachment_storage") as mock_get_storage, + ): + mock_get_storage.return_value.save_attachment.return_value = saved + + result = await _unwrap(download_chat_attachment)( + service=service, + user_google_email="test@example.com", + message_id="spaces/S/messages/M", + attachment_index=0, + ) + + assert "image.png" in result + assert "/tmp/image_fetched.png" in result + + # Falls back to attachment name when no attachmentDataRef + call_args = mock_client.get.call_args + assert "spaces/S/messages/M/attachments/A" in call_args.args[0] + + +@pytest.mark.asyncio +async def test_download_http_mode_returns_url(): + """In HTTP mode, should return a download URL instead of file path.""" + fake_bytes = b"image data" + att = _make_attachment() + msg = _make_message(attachments=[att]) + + service = Mock() + service.spaces().messages().get().execute.return_value = msg + service._http.credentials.token = "fake-token" + + mock_response = Mock() + mock_response.content = fake_bytes + mock_response.status_code = 200 + + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + + saved = Mock() + saved.path = "/tmp/image_alt.png" + saved.file_id = "alt1" + + from gchat.chat_tools import download_chat_attachment + + with ( + patch("gchat.chat_tools.httpx.AsyncClient", return_value=mock_client), + patch("auth.oauth_config.is_stateless_mode", return_value=False), + patch("core.config.get_transport_mode", return_value="http"), + patch("core.attachment_storage.get_attachment_storage") as mock_get_storage, + patch( + "core.attachment_storage.get_attachment_url", + return_value="http://localhost:8005/attachments/alt1", + ), + ): + mock_get_storage.return_value.save_attachment.return_value = saved + + result = await _unwrap(download_chat_attachment)( + service=service, + user_google_email="test@example.com", + message_id="spaces/S/messages/M", + attachment_index=0, + ) + + assert "Download URL:" in result + assert "expire after 1 hour" in result + + +@pytest.mark.asyncio +async def test_download_returns_error_on_failure(): + """When download fails, should return a clear error message.""" + att = _make_attachment() + att["downloadUri"] = "https://storage.googleapis.com/fake?alt=media" + msg = _make_message(attachments=[att]) + + service = Mock() + service.spaces().messages().get().execute.return_value = msg + service._http.credentials.token = "fake-token" + + mock_client = AsyncMock() + mock_client.get.side_effect = Exception("connection refused") + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + + from gchat.chat_tools import download_chat_attachment + + with patch("gchat.chat_tools.httpx.AsyncClient", return_value=mock_client): + result = await _unwrap(download_chat_attachment)( + service=service, + user_google_email="test@example.com", + message_id="spaces/S/messages/M", + attachment_index=0, + ) + + assert "Failed to download" in result + assert "connection refused" in result diff --git a/tests/gcontacts/__init__.py b/tests/gcontacts/__init__.py new file mode 100644 index 0000000..22da5e5 --- /dev/null +++ b/tests/gcontacts/__init__.py @@ -0,0 +1 @@ +# Tests for Google Contacts tools diff --git a/tests/gcontacts/test_contacts_tools.py b/tests/gcontacts/test_contacts_tools.py new file mode 100644 index 0000000..a3e9a8b --- /dev/null +++ b/tests/gcontacts/test_contacts_tools.py @@ -0,0 +1,339 @@ +""" +Unit tests for Google Contacts (People API) tools. + +Tests helper functions and formatting utilities. +""" + +import sys +import os + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +from gcontacts.contacts_tools import ( + _format_contact, + _build_person_body, +) + + +class TestFormatContact: + """Tests for _format_contact helper function.""" + + def test_format_basic_contact(self): + """Test formatting a contact with basic fields.""" + person = { + "resourceName": "people/c1234567890", + "names": [{"displayName": "John Doe"}], + "emailAddresses": [{"value": "john@example.com"}], + "phoneNumbers": [{"value": "+1234567890"}], + } + + result = _format_contact(person) + + assert "Contact ID: c1234567890" in result + assert "Name: John Doe" in result + assert "Email: john@example.com" in result + assert "Phone: +1234567890" in result + + def test_format_contact_with_organization(self): + """Test formatting a contact with organization info.""" + person = { + "resourceName": "people/c123", + "names": [{"displayName": "Jane Smith"}], + "organizations": [{"name": "Acme Corp", "title": "Engineer"}], + } + + result = _format_contact(person) + + assert "Name: Jane Smith" in result + assert "Organization: Engineer at Acme Corp" in result + + def test_format_contact_organization_name_only(self): + """Test formatting a contact with only organization name.""" + person = { + "resourceName": "people/c123", + "organizations": [{"name": "Acme Corp"}], + } + + result = _format_contact(person) + + assert "Organization: at Acme Corp" in result + + def test_format_contact_job_title_only(self): + """Test formatting a contact with only job title.""" + person = { + "resourceName": "people/c123", + "organizations": [{"title": "CEO"}], + } + + result = _format_contact(person) + + assert "Organization: CEO" in result + + def test_format_contact_detailed(self): + """Test formatting a contact with detailed fields.""" + person = { + "resourceName": "people/c123", + "names": [{"displayName": "Test User"}], + "addresses": [{"formattedValue": "123 Main St, City"}], + "birthdays": [{"date": {"year": 1990, "month": 5, "day": 15}}], + "urls": [{"value": "https://example.com"}], + "biographies": [{"value": "A short bio"}], + "metadata": {"sources": [{"type": "CONTACT"}]}, + } + + result = _format_contact(person, detailed=True) + + assert "Address: 123 Main St, City" in result + assert "Birthday: 1990/5/15" in result + assert "URLs: https://example.com" in result + assert "Notes: A short bio" in result + assert "Sources: CONTACT" in result + + def test_format_contact_detailed_birthday_without_year(self): + """Test formatting birthday without year.""" + person = { + "resourceName": "people/c123", + "birthdays": [{"date": {"month": 5, "day": 15}}], + } + + result = _format_contact(person, detailed=True) + + assert "Birthday: 5/15" in result + + def test_format_contact_detailed_long_biography(self): + """Test formatting truncates long biographies.""" + long_bio = "A" * 300 + person = { + "resourceName": "people/c123", + "biographies": [{"value": long_bio}], + } + + result = _format_contact(person, detailed=True) + + assert "Notes:" in result + assert "..." in result + assert len(result.split("Notes: ")[1].split("\n")[0]) <= 203 # 200 + "..." + + def test_format_contact_empty(self): + """Test formatting a contact with minimal fields.""" + person = {"resourceName": "people/c999"} + + result = _format_contact(person) + + assert "Contact ID: c999" in result + + def test_format_contact_unknown_resource(self): + """Test formatting a contact without resourceName.""" + person = {} + + result = _format_contact(person) + + assert "Contact ID: Unknown" in result + + def test_format_contact_multiple_emails(self): + """Test formatting a contact with multiple emails.""" + person = { + "resourceName": "people/c123", + "emailAddresses": [ + {"value": "work@example.com"}, + {"value": "personal@example.com"}, + ], + } + + result = _format_contact(person) + + assert "work@example.com" in result + assert "personal@example.com" in result + + def test_format_contact_multiple_phones(self): + """Test formatting a contact with multiple phone numbers.""" + person = { + "resourceName": "people/c123", + "phoneNumbers": [ + {"value": "+1111111111"}, + {"value": "+2222222222"}, + ], + } + + result = _format_contact(person) + + assert "+1111111111" in result + assert "+2222222222" in result + + def test_format_contact_multiple_urls(self): + """Test formatting a contact with multiple URLs.""" + person = { + "resourceName": "people/c123", + "urls": [ + {"value": "https://linkedin.com/user"}, + {"value": "https://twitter.com/user"}, + ], + } + + result = _format_contact(person, detailed=True) + + assert "https://linkedin.com/user" in result + assert "https://twitter.com/user" in result + + +class TestBuildPersonBody: + """Tests for _build_person_body helper function.""" + + def test_build_basic_body(self): + """Test building a basic person body.""" + body = _build_person_body( + given_name="John", + family_name="Doe", + email="john@example.com", + ) + + assert body["names"][0]["givenName"] == "John" + assert body["names"][0]["familyName"] == "Doe" + assert body["emailAddresses"][0]["value"] == "john@example.com" + + def test_build_body_with_phone(self): + """Test building a person body with phone.""" + body = _build_person_body(phone="+1234567890") + + assert body["phoneNumbers"][0]["value"] == "+1234567890" + + def test_build_body_with_organization(self): + """Test building a person body with organization.""" + body = _build_person_body( + given_name="Jane", + organization="Acme Corp", + job_title="Engineer", + ) + + assert body["names"][0]["givenName"] == "Jane" + assert body["organizations"][0]["name"] == "Acme Corp" + assert body["organizations"][0]["title"] == "Engineer" + + def test_build_body_organization_only(self): + """Test building a person body with only organization name.""" + body = _build_person_body(organization="Acme Corp") + + assert body["organizations"][0]["name"] == "Acme Corp" + assert "title" not in body["organizations"][0] + + def test_build_body_job_title_only(self): + """Test building a person body with only job title.""" + body = _build_person_body(job_title="CEO") + + assert body["organizations"][0]["title"] == "CEO" + assert "name" not in body["organizations"][0] + + def test_build_body_with_notes(self): + """Test building a person body with notes.""" + body = _build_person_body(notes="Important contact") + + assert body["biographies"][0]["value"] == "Important contact" + assert body["biographies"][0]["contentType"] == "TEXT_PLAIN" + + def test_build_body_with_address(self): + """Test building a person body with address.""" + body = _build_person_body(address="123 Main St, City, State 12345") + + assert ( + body["addresses"][0]["formattedValue"] == "123 Main St, City, State 12345" + ) + + def test_build_empty_body(self): + """Test building an empty person body.""" + body = _build_person_body() + + assert body == {} + + def test_build_body_given_name_only(self): + """Test building a person body with only given name.""" + body = _build_person_body(given_name="John") + + assert body["names"][0]["givenName"] == "John" + assert body["names"][0]["familyName"] == "" + + def test_build_body_family_name_only(self): + """Test building a person body with only family name.""" + body = _build_person_body(family_name="Doe") + + assert body["names"][0]["givenName"] == "" + assert body["names"][0]["familyName"] == "Doe" + + def test_build_full_body(self): + """Test building a person body with all fields.""" + body = _build_person_body( + given_name="John", + family_name="Doe", + email="john@example.com", + phone="+1234567890", + organization="Acme Corp", + job_title="Engineer", + notes="VIP contact", + address="123 Main St", + ) + + assert body["names"][0]["givenName"] == "John" + assert body["names"][0]["familyName"] == "Doe" + assert body["emailAddresses"][0]["value"] == "john@example.com" + assert body["phoneNumbers"][0]["value"] == "+1234567890" + assert body["organizations"][0]["name"] == "Acme Corp" + assert body["organizations"][0]["title"] == "Engineer" + assert body["biographies"][0]["value"] == "VIP contact" + assert body["addresses"][0]["formattedValue"] == "123 Main St" + + +class TestImports: + """Tests to verify module imports work correctly.""" + + def test_import_contacts_tools(self): + """Test that contacts_tools module can be imported.""" + from gcontacts import contacts_tools + + assert hasattr(contacts_tools, "list_contacts") + assert hasattr(contacts_tools, "get_contact") + assert hasattr(contacts_tools, "search_contacts") + assert hasattr(contacts_tools, "manage_contact") + + def test_import_group_tools(self): + """Test that group tools can be imported.""" + from gcontacts import contacts_tools + + assert hasattr(contacts_tools, "list_contact_groups") + assert hasattr(contacts_tools, "get_contact_group") + assert hasattr(contacts_tools, "manage_contact_group") + + def test_import_batch_tools(self): + """Test that batch tools can be imported.""" + from gcontacts import contacts_tools + + assert hasattr(contacts_tools, "manage_contacts_batch") + + +class TestConstants: + """Tests for module constants.""" + + def test_default_person_fields(self): + """Test default person fields constant.""" + from gcontacts.contacts_tools import DEFAULT_PERSON_FIELDS + + assert "names" in DEFAULT_PERSON_FIELDS + assert "emailAddresses" in DEFAULT_PERSON_FIELDS + assert "phoneNumbers" in DEFAULT_PERSON_FIELDS + assert "organizations" in DEFAULT_PERSON_FIELDS + + def test_detailed_person_fields(self): + """Test detailed person fields constant.""" + from gcontacts.contacts_tools import DETAILED_PERSON_FIELDS + + assert "names" in DETAILED_PERSON_FIELDS + assert "emailAddresses" in DETAILED_PERSON_FIELDS + assert "addresses" in DETAILED_PERSON_FIELDS + assert "birthdays" in DETAILED_PERSON_FIELDS + assert "biographies" in DETAILED_PERSON_FIELDS + + def test_contact_group_fields(self): + """Test contact group fields constant.""" + from gcontacts.contacts_tools import CONTACT_GROUP_FIELDS + + assert "name" in CONTACT_GROUP_FIELDS + assert "groupType" in CONTACT_GROUP_FIELDS + assert "memberCount" in CONTACT_GROUP_FIELDS diff --git a/tests/gdocs/__init__.py b/tests/gdocs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/gdocs/test_docs_markdown.py b/tests/gdocs/test_docs_markdown.py new file mode 100644 index 0000000..804c390 --- /dev/null +++ b/tests/gdocs/test_docs_markdown.py @@ -0,0 +1,455 @@ +"""Tests for the Google Docs to Markdown converter.""" + +import sys +import os + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +from gdocs.docs_markdown import ( + convert_doc_to_markdown, + format_comments_appendix, + format_comments_inline, + parse_drive_comments, +) + + +# --- Fixtures --- + +SIMPLE_DOC = { + "title": "Simple Test", + "body": { + "content": [ + {"sectionBreak": {"sectionStyle": {}}}, + { + "paragraph": { + "elements": [ + {"textRun": {"content": "Hello world\n", "textStyle": {}}} + ], + "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"}, + } + }, + { + "paragraph": { + "elements": [ + {"textRun": {"content": "This is ", "textStyle": {}}}, + {"textRun": {"content": "bold", "textStyle": {"bold": True}}}, + {"textRun": {"content": " and ", "textStyle": {}}}, + { + "textRun": { + "content": "italic", + "textStyle": {"italic": True}, + } + }, + {"textRun": {"content": " text.\n", "textStyle": {}}}, + ], + "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"}, + } + }, + ] + }, +} + +HEADINGS_DOC = { + "title": "Headings", + "body": { + "content": [ + {"sectionBreak": {"sectionStyle": {}}}, + { + "paragraph": { + "elements": [{"textRun": {"content": "Title\n", "textStyle": {}}}], + "paragraphStyle": {"namedStyleType": "TITLE"}, + } + }, + { + "paragraph": { + "elements": [ + {"textRun": {"content": "Heading one\n", "textStyle": {}}} + ], + "paragraphStyle": {"namedStyleType": "HEADING_1"}, + } + }, + { + "paragraph": { + "elements": [ + {"textRun": {"content": "Heading two\n", "textStyle": {}}} + ], + "paragraphStyle": {"namedStyleType": "HEADING_2"}, + } + }, + ] + }, +} + +TABLE_DOC = { + "title": "Table Test", + "body": { + "content": [ + {"sectionBreak": {"sectionStyle": {}}}, + { + "table": { + "rows": 2, + "columns": 2, + "tableRows": [ + { + "tableCells": [ + { + "content": [ + { + "paragraph": { + "elements": [ + { + "textRun": { + "content": "Name\n", + "textStyle": {}, + } + } + ], + "paragraphStyle": { + "namedStyleType": "NORMAL_TEXT" + }, + } + } + ] + }, + { + "content": [ + { + "paragraph": { + "elements": [ + { + "textRun": { + "content": "Age\n", + "textStyle": {}, + } + } + ], + "paragraphStyle": { + "namedStyleType": "NORMAL_TEXT" + }, + } + } + ] + }, + ] + }, + { + "tableCells": [ + { + "content": [ + { + "paragraph": { + "elements": [ + { + "textRun": { + "content": "Alice\n", + "textStyle": {}, + } + } + ], + "paragraphStyle": { + "namedStyleType": "NORMAL_TEXT" + }, + } + } + ] + }, + { + "content": [ + { + "paragraph": { + "elements": [ + { + "textRun": { + "content": "30\n", + "textStyle": {}, + } + } + ], + "paragraphStyle": { + "namedStyleType": "NORMAL_TEXT" + }, + } + } + ] + }, + ] + }, + ], + } + }, + ] + }, +} + +LIST_DOC = { + "title": "List Test", + "lists": { + "kix.list001": { + "listProperties": { + "nestingLevels": [ + {"glyphType": "GLYPH_TYPE_UNSPECIFIED", "glyphSymbol": "\u2022"}, + ] + } + } + }, + "body": { + "content": [ + {"sectionBreak": {"sectionStyle": {}}}, + { + "paragraph": { + "elements": [ + {"textRun": {"content": "Item one\n", "textStyle": {}}} + ], + "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"}, + "bullet": {"listId": "kix.list001", "nestingLevel": 0}, + } + }, + { + "paragraph": { + "elements": [ + {"textRun": {"content": "Item two\n", "textStyle": {}}} + ], + "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"}, + "bullet": {"listId": "kix.list001", "nestingLevel": 0}, + } + }, + ] + }, +} + + +# --- Converter tests --- + + +class TestTextFormatting: + def test_plain_text(self): + md = convert_doc_to_markdown(SIMPLE_DOC) + assert "Hello world" in md + + def test_bold(self): + md = convert_doc_to_markdown(SIMPLE_DOC) + assert "**bold**" in md + + def test_italic(self): + md = convert_doc_to_markdown(SIMPLE_DOC) + assert "*italic*" in md + + +class TestHeadings: + def test_title(self): + md = convert_doc_to_markdown(HEADINGS_DOC) + assert "# Title" in md + + def test_h1(self): + md = convert_doc_to_markdown(HEADINGS_DOC) + assert "# Heading one" in md + + def test_h2(self): + md = convert_doc_to_markdown(HEADINGS_DOC) + assert "## Heading two" in md + + +class TestTables: + def test_table_header(self): + md = convert_doc_to_markdown(TABLE_DOC) + assert "| Name | Age |" in md + + def test_table_separator(self): + md = convert_doc_to_markdown(TABLE_DOC) + assert "| --- | --- |" in md + + def test_table_row(self): + md = convert_doc_to_markdown(TABLE_DOC) + assert "| Alice | 30 |" in md + + +class TestLists: + def test_unordered(self): + md = convert_doc_to_markdown(LIST_DOC) + assert "- Item one" in md + assert "- Item two" in md + + +CHECKLIST_DOC = { + "title": "Checklist Test", + "lists": { + "kix.checklist001": { + "listProperties": { + "nestingLevels": [ + {"glyphType": "GLYPH_TYPE_UNSPECIFIED"}, + ] + } + } + }, + "body": { + "content": [ + {"sectionBreak": {"sectionStyle": {}}}, + { + "paragraph": { + "elements": [ + {"textRun": {"content": "Buy groceries\n", "textStyle": {}}} + ], + "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"}, + "bullet": {"listId": "kix.checklist001", "nestingLevel": 0}, + } + }, + { + "paragraph": { + "elements": [ + { + "textRun": { + "content": "Walk the dog\n", + "textStyle": {"strikethrough": True}, + } + } + ], + "paragraphStyle": {"namedStyleType": "NORMAL_TEXT"}, + "bullet": {"listId": "kix.checklist001", "nestingLevel": 0}, + } + }, + ] + }, +} + + +class TestChecklists: + def test_unchecked(self): + md = convert_doc_to_markdown(CHECKLIST_DOC) + assert "- [ ] Buy groceries" in md + + def test_checked(self): + md = convert_doc_to_markdown(CHECKLIST_DOC) + assert "- [x] Walk the dog" in md + + def test_checked_no_strikethrough(self): + """Checked items should not have redundant ~~strikethrough~~ markdown.""" + md = convert_doc_to_markdown(CHECKLIST_DOC) + assert "~~Walk the dog~~" not in md + + def test_regular_bullet_not_checklist(self): + """Bullet lists with glyphSymbol should remain as plain bullets.""" + md = convert_doc_to_markdown(LIST_DOC) + assert "[ ]" not in md + assert "[x]" not in md + + +class TestEmptyDoc: + def test_empty(self): + md = convert_doc_to_markdown({"title": "Empty", "body": {"content": []}}) + assert md.strip() == "" + + +# --- Comment parsing tests --- + + +class TestParseComments: + def test_filters_resolved(self): + response = { + "comments": [ + { + "content": "open", + "resolved": False, + "author": {"displayName": "A"}, + "replies": [], + }, + { + "content": "closed", + "resolved": True, + "author": {"displayName": "B"}, + "replies": [], + }, + ] + } + result = parse_drive_comments(response, include_resolved=False) + assert len(result) == 1 + assert result[0]["content"] == "open" + + def test_includes_resolved(self): + response = { + "comments": [ + { + "content": "open", + "resolved": False, + "author": {"displayName": "A"}, + "replies": [], + }, + { + "content": "closed", + "resolved": True, + "author": {"displayName": "B"}, + "replies": [], + }, + ] + } + result = parse_drive_comments(response, include_resolved=True) + assert len(result) == 2 + + def test_anchor_text(self): + response = { + "comments": [ + { + "content": "note", + "resolved": False, + "author": {"displayName": "A"}, + "quotedFileContent": {"value": "highlighted text"}, + "replies": [], + } + ] + } + result = parse_drive_comments(response) + assert result[0]["anchor_text"] == "highlighted text" + + +# --- Comment formatting tests --- + + +class TestInlineComments: + def test_inserts_footnote(self): + md = "Some text here." + comments = [ + { + "author": "Alice", + "content": "Note.", + "anchor_text": "text", + "replies": [], + "resolved": False, + } + ] + result = format_comments_inline(md, comments) + assert "text[^c1]" in result + assert "[^c1]: **Alice**: Note." in result + + def test_unmatched_goes_to_appendix(self): + md = "No match." + comments = [ + { + "author": "Alice", + "content": "Note.", + "anchor_text": "missing", + "replies": [], + "resolved": False, + } + ] + result = format_comments_inline(md, comments) + assert "## Comments" in result + assert "> missing" in result + + +class TestAppendixComments: + def test_structure(self): + comments = [ + { + "author": "Alice", + "content": "Note.", + "anchor_text": "some text", + "replies": [], + "resolved": False, + } + ] + result = format_comments_appendix(comments) + assert "## Comments" in result + assert "> some text" in result + assert "**Alice**: Note." in result + + def test_empty(self): + assert format_comments_appendix([]).strip() == "" diff --git a/tests/gdocs/test_paragraph_style.py b/tests/gdocs/test_paragraph_style.py new file mode 100644 index 0000000..b21b158 --- /dev/null +++ b/tests/gdocs/test_paragraph_style.py @@ -0,0 +1,139 @@ +""" +Tests for update_paragraph_style batch operation support. + +Covers the helpers, validation, and batch manager integration. +""" + +import pytest +from unittest.mock import AsyncMock, Mock + +from gdocs.docs_helpers import ( + build_paragraph_style, + create_update_paragraph_style_request, +) +from gdocs.managers.validation_manager import ValidationManager + + +class TestBuildParagraphStyle: + def test_no_params_returns_empty(self): + style, fields = build_paragraph_style() + assert style == {} + assert fields == [] + + def test_heading_zero_maps_to_normal_text(self): + style, fields = build_paragraph_style(heading_level=0) + assert style["namedStyleType"] == "NORMAL_TEXT" + + def test_heading_maps_to_named_style(self): + style, _ = build_paragraph_style(heading_level=3) + assert style["namedStyleType"] == "HEADING_3" + + def test_heading_out_of_range_raises(self): + with pytest.raises(ValueError): + build_paragraph_style(heading_level=7) + + def test_line_spacing_scaled_to_percentage(self): + style, _ = build_paragraph_style(line_spacing=1.5) + assert style["lineSpacing"] == 150.0 + + def test_dimension_field_uses_pt_unit(self): + style, _ = build_paragraph_style(indent_start=36.0) + assert style["indentStart"] == {"magnitude": 36.0, "unit": "PT"} + + def test_multiple_params_combined(self): + style, fields = build_paragraph_style( + heading_level=2, alignment="CENTER", space_below=12.0 + ) + assert len(fields) == 3 + assert style["alignment"] == "CENTER" + + +class TestCreateUpdateParagraphStyleRequest: + def test_returns_none_when_no_styles(self): + assert create_update_paragraph_style_request(1, 10) is None + + def test_produces_correct_api_structure(self): + result = create_update_paragraph_style_request(1, 10, heading_level=1) + inner = result["updateParagraphStyle"] + assert inner["range"] == {"startIndex": 1, "endIndex": 10} + assert inner["paragraphStyle"]["namedStyleType"] == "HEADING_1" + assert inner["fields"] == "namedStyleType" + + +class TestValidateParagraphStyleParams: + @pytest.fixture() + def vm(self): + return ValidationManager() + + def test_all_none_rejected(self, vm): + is_valid, _ = vm.validate_paragraph_style_params() + assert not is_valid + + def test_wrong_types_rejected(self, vm): + assert not vm.validate_paragraph_style_params(heading_level=1.5)[0] + assert not vm.validate_paragraph_style_params(alignment=123)[0] + assert not vm.validate_paragraph_style_params(line_spacing="double")[0] + + def test_negative_indent_start_rejected(self, vm): + is_valid, msg = vm.validate_paragraph_style_params(indent_start=-5.0) + assert not is_valid + assert "non-negative" in msg + + def test_negative_indent_first_line_allowed(self, vm): + """Hanging indent requires negative first-line indent.""" + assert vm.validate_paragraph_style_params(indent_first_line=-18.0)[0] + + def test_batch_validation_wired_up(self, vm): + valid_ops = [ + { + "type": "update_paragraph_style", + "start_index": 1, + "end_index": 20, + "heading_level": 2, + }, + ] + assert vm.validate_batch_operations(valid_ops)[0] + + no_style_ops = [ + {"type": "update_paragraph_style", "start_index": 1, "end_index": 20}, + ] + assert not vm.validate_batch_operations(no_style_ops)[0] + + +class TestBatchManagerIntegration: + @pytest.fixture() + def manager(self): + from gdocs.managers.batch_operation_manager import BatchOperationManager + + return BatchOperationManager(Mock()) + + def test_build_request_and_description(self, manager): + op = { + "type": "update_paragraph_style", + "start_index": 1, + "end_index": 50, + "heading_level": 2, + "alignment": "CENTER", + "line_spacing": 1.5, + } + request, desc = manager._build_operation_request(op, "update_paragraph_style") + assert "updateParagraphStyle" in request + assert "heading: H2" in desc + assert "1.5x" in desc + + @pytest.mark.asyncio + async def test_end_to_end_execute(self, manager): + manager._execute_batch_requests = AsyncMock(return_value={"replies": [{}]}) + success, message, meta = await manager.execute_batch_operations( + "doc-123", + [ + { + "type": "update_paragraph_style", + "start_index": 1, + "end_index": 20, + "heading_level": 1, + } + ], + ) + assert success + assert meta["operations_count"] == 1 diff --git a/tests/gdrive/__init__.py b/tests/gdrive/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/gdrive/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/gdrive/test_create_drive_folder.py b/tests/gdrive/test_create_drive_folder.py new file mode 100644 index 0000000..0860e73 --- /dev/null +++ b/tests/gdrive/test_create_drive_folder.py @@ -0,0 +1,147 @@ +""" +Unit tests for create_drive_folder tool. +""" + +import os +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +from gdrive.drive_tools import _create_drive_folder_impl as _raw_create_drive_folder + + +def _make_service(created_response): + """Build a mock Drive service whose files().create().execute returns *created_response*.""" + execute = MagicMock(return_value=created_response) + create = MagicMock() + create.return_value.execute = execute + files = MagicMock() + files.return_value.create = create + service = MagicMock() + service.files = files + return service + + +@pytest.mark.asyncio +async def test_create_folder_root_skips_resolve(): + """Parent 'root' should pass through resolve_folder_id and produce correct output.""" + api_response = { + "id": "new-folder-id", + "name": "My Folder", + "webViewLink": "https://drive.google.com/drive/folders/new-folder-id", + } + service = _make_service(api_response) + + with patch( + "gdrive.drive_tools.resolve_folder_id", + new_callable=AsyncMock, + return_value="root", + ): + result = await _raw_create_drive_folder( + service, + user_google_email="user@example.com", + folder_name="My Folder", + parent_folder_id="root", + ) + + assert "new-folder-id" in result + assert "My Folder" in result + assert "https://drive.google.com/drive/folders/new-folder-id" in result + + +@pytest.mark.asyncio +async def test_create_folder_custom_parent_resolves(): + """A non-root parent_folder_id should go through resolve_folder_id.""" + api_response = { + "id": "new-folder-id", + "name": "Sub Folder", + "webViewLink": "https://drive.google.com/drive/folders/new-folder-id", + } + service = _make_service(api_response) + + with patch( + "gdrive.drive_tools.resolve_folder_id", + new_callable=AsyncMock, + return_value="resolved-parent-id", + ) as mock_resolve: + result = await _raw_create_drive_folder( + service, + user_google_email="user@example.com", + folder_name="Sub Folder", + parent_folder_id="shortcut-id", + ) + + mock_resolve.assert_awaited_once_with(service, "shortcut-id") + # The output message uses the original parent_folder_id, not the resolved one + assert "shortcut-id" in result + # But the API call should use the resolved ID + service.files().create.assert_called_once_with( + body={ + "name": "Sub Folder", + "mimeType": "application/vnd.google-apps.folder", + "parents": ["resolved-parent-id"], + }, + fields="id, name, webViewLink", + supportsAllDrives=True, + ) + + +@pytest.mark.asyncio +async def test_create_folder_passes_correct_metadata(): + """Verify the metadata dict sent to the Drive API is correct.""" + api_response = { + "id": "abc123", + "name": "Test", + "webViewLink": "https://drive.google.com/drive/folders/abc123", + } + service = _make_service(api_response) + + with patch( + "gdrive.drive_tools.resolve_folder_id", + new_callable=AsyncMock, + return_value="resolved-id", + ): + await _raw_create_drive_folder( + service, + user_google_email="user@example.com", + folder_name="Test", + parent_folder_id="some-parent", + ) + + service.files().create.assert_called_once_with( + body={ + "name": "Test", + "mimeType": "application/vnd.google-apps.folder", + "parents": ["resolved-id"], + }, + fields="id, name, webViewLink", + supportsAllDrives=True, + ) + + +@pytest.mark.asyncio +async def test_create_folder_missing_webviewlink(): + """When the API omits webViewLink, the result should have an empty link.""" + api_response = { + "id": "abc123", + "name": "NoLink", + } + service = _make_service(api_response) + + with patch( + "gdrive.drive_tools.resolve_folder_id", + new_callable=AsyncMock, + return_value="root", + ): + result = await _raw_create_drive_folder( + service, + user_google_email="user@example.com", + folder_name="NoLink", + parent_folder_id="root", + ) + + assert "abc123" in result + assert "NoLink" in result diff --git a/tests/gdrive/test_drive_tools.py b/tests/gdrive/test_drive_tools.py new file mode 100644 index 0000000..420b504 --- /dev/null +++ b/tests/gdrive/test_drive_tools.py @@ -0,0 +1,970 @@ +""" +Unit tests for Google Drive MCP tools. + +Tests create_drive_folder with mocked API responses, plus coverage for +`search_drive_files` and `list_drive_items` pagination, `detailed` output, +and `file_type` filtering behaviors. +""" + +import pytest +from unittest.mock import Mock, AsyncMock, patch +import sys +import os + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +from gdrive.drive_helpers import build_drive_list_params +from gdrive.drive_tools import list_drive_items, search_drive_files + + +def _unwrap(tool): + """Unwrap a FunctionTool + decorator chain to the original async function. + + Handles both older FastMCP (FunctionTool with .fn) and newer FastMCP + (server.tool() returns the function directly). + """ + fn = tool.fn if hasattr(tool, "fn") else tool + while hasattr(fn, "__wrapped__"): + fn = fn.__wrapped__ + return fn + + +# --------------------------------------------------------------------------- +# search_drive_files — page_token +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_search_drive_files_page_token_passed_to_api(): + """page_token is forwarded to the Drive API as pageToken.""" + mock_service = Mock() + mock_service.files().list().execute.return_value = { + "files": [ + { + "id": "f1", + "name": "Report.pdf", + "mimeType": "application/pdf", + "webViewLink": "https://drive.google.com/file/f1", + "modifiedTime": "2024-01-01T00:00:00Z", + } + ] + } + + await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="budget", + page_token="tok_abc123", + ) + + call_kwargs = mock_service.files.return_value.list.call_args.kwargs + assert call_kwargs.get("pageToken") == "tok_abc123" + + +@pytest.mark.asyncio +async def test_search_drive_files_next_page_token_in_output(): + """nextPageToken from the API response is appended at the end of the output.""" + mock_service = Mock() + mock_service.files().list().execute.return_value = { + "files": [ + { + "id": "f2", + "name": "Notes.docx", + "mimeType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "webViewLink": "https://drive.google.com/file/f2", + "modifiedTime": "2024-02-01T00:00:00Z", + } + ], + "nextPageToken": "next_tok_xyz", + } + + result = await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="notes", + ) + + assert result.endswith("nextPageToken: next_tok_xyz") + + +@pytest.mark.asyncio +async def test_search_drive_files_no_next_page_token_when_absent(): + """nextPageToken does not appear in output when the API has no more pages.""" + mock_service = Mock() + mock_service.files().list().execute.return_value = { + "files": [ + { + "id": "f3", + "name": "Summary.txt", + "mimeType": "text/plain", + "webViewLink": "https://drive.google.com/file/f3", + "modifiedTime": "2024-03-01T00:00:00Z", + } + ] + # no nextPageToken key + } + + result = await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="summary", + ) + + assert "nextPageToken" not in result + + +# --------------------------------------------------------------------------- +# list_drive_items — page_token +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock) +async def test_list_drive_items_page_token_passed_to_api(mock_resolve_folder): + """page_token is forwarded to the Drive API as pageToken.""" + mock_resolve_folder.return_value = "root" + mock_service = Mock() + mock_service.files().list().execute.return_value = { + "files": [ + { + "id": "folder1", + "name": "Archive", + "mimeType": "application/vnd.google-apps.folder", + "webViewLink": "https://drive.google.com/drive/folders/folder1", + "modifiedTime": "2024-01-15T00:00:00Z", + } + ] + } + + await _unwrap(list_drive_items)( + service=mock_service, + user_google_email="user@example.com", + page_token="tok_page2", + ) + + call_kwargs = mock_service.files.return_value.list.call_args.kwargs + assert call_kwargs.get("pageToken") == "tok_page2" + + +@pytest.mark.asyncio +@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock) +async def test_list_drive_items_next_page_token_in_output(mock_resolve_folder): + """nextPageToken from the API response is appended at the end of the output.""" + mock_resolve_folder.return_value = "root" + mock_service = Mock() + mock_service.files().list().execute.return_value = { + "files": [ + { + "id": "file99", + "name": "data.csv", + "mimeType": "text/csv", + "webViewLink": "https://drive.google.com/file/file99", + "modifiedTime": "2024-04-01T00:00:00Z", + } + ], + "nextPageToken": "next_list_tok", + } + + result = await _unwrap(list_drive_items)( + service=mock_service, + user_google_email="user@example.com", + ) + + assert result.endswith("nextPageToken: next_list_tok") + + +@pytest.mark.asyncio +@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock) +async def test_list_drive_items_no_next_page_token_when_absent(mock_resolve_folder): + """nextPageToken does not appear in output when the API has no more pages.""" + mock_resolve_folder.return_value = "root" + mock_service = Mock() + mock_service.files().list().execute.return_value = { + "files": [ + { + "id": "file100", + "name": "readme.txt", + "mimeType": "text/plain", + "webViewLink": "https://drive.google.com/file/file100", + "modifiedTime": "2024-05-01T00:00:00Z", + } + ] + # no nextPageToken key + } + + result = await _unwrap(list_drive_items)( + service=mock_service, + user_google_email="user@example.com", + ) + + assert "nextPageToken" not in result + + +# Helpers +# --------------------------------------------------------------------------- + + +def _make_file( + file_id: str, + name: str, + mime_type: str, + link: str = "http://link", + modified: str = "2024-01-01T00:00:00Z", + size: str | None = None, +) -> dict: + item = { + "id": file_id, + "name": name, + "mimeType": mime_type, + "webViewLink": link, + "modifiedTime": modified, + } + if size is not None: + item["size"] = size + return item + + +# --------------------------------------------------------------------------- +# create_drive_folder +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_drive_folder(): + """Test create_drive_folder returns success message with folder id, name, and link.""" + from gdrive.drive_tools import _create_drive_folder_impl + + mock_service = Mock() + mock_response = { + "id": "folder123", + "name": "My Folder", + "webViewLink": "https://drive.google.com/drive/folders/folder123", + } + mock_request = Mock() + mock_request.execute.return_value = mock_response + mock_service.files.return_value.create.return_value = mock_request + + with patch( + "gdrive.drive_tools.resolve_folder_id", + new_callable=AsyncMock, + return_value="root", + ): + result = await _create_drive_folder_impl( + service=mock_service, + user_google_email="user@example.com", + folder_name="My Folder", + parent_folder_id="root", + ) + + assert "Successfully created folder" in result + assert "My Folder" in result + assert "folder123" in result + assert "user@example.com" in result + assert "https://drive.google.com/drive/folders/folder123" in result + + +# --------------------------------------------------------------------------- +# build_drive_list_params — detailed flag (pure unit tests, no I/O) +# --------------------------------------------------------------------------- + + +def test_build_params_detailed_true_includes_extra_fields(): + """detailed=True requests modifiedTime, webViewLink, and size from the API.""" + params = build_drive_list_params(query="name='x'", page_size=10, detailed=True) + assert "modifiedTime" in params["fields"] + assert "webViewLink" in params["fields"] + assert "size" in params["fields"] + + +def test_build_params_detailed_false_omits_extra_fields(): + """detailed=False omits modifiedTime, webViewLink, and size from the API request.""" + params = build_drive_list_params(query="name='x'", page_size=10, detailed=False) + assert "modifiedTime" not in params["fields"] + assert "webViewLink" not in params["fields"] + assert "size" not in params["fields"] + + +def test_build_params_detailed_false_keeps_core_fields(): + """detailed=False still requests id, name, and mimeType.""" + params = build_drive_list_params(query="name='x'", page_size=10, detailed=False) + assert "id" in params["fields"] + assert "name" in params["fields"] + assert "mimeType" in params["fields"] + + +def test_build_params_default_is_detailed(): + """Omitting detailed behaves identically to detailed=True.""" + params_default = build_drive_list_params(query="q", page_size=5) + params_true = build_drive_list_params(query="q", page_size=5, detailed=True) + assert params_default["fields"] == params_true["fields"] + + +# --------------------------------------------------------------------------- +# search_drive_files — detailed flag +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_search_detailed_true_output_includes_metadata(): + """detailed=True (default) includes modified time and link in output.""" + mock_service = Mock() + mock_service.files().list().execute.return_value = { + "files": [ + _make_file( + "f1", + "My Doc", + "application/vnd.google-apps.document", + modified="2024-06-01T12:00:00Z", + link="http://link/f1", + ) + ] + } + + result = await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="my doc", + detailed=True, + ) + + assert "My Doc" in result + assert "2024-06-01T12:00:00Z" in result + assert "http://link/f1" in result + + +@pytest.mark.asyncio +async def test_search_detailed_false_output_excludes_metadata(): + """detailed=False omits modified time and link from output.""" + mock_service = Mock() + mock_service.files().list().execute.return_value = { + "files": [ + _make_file( + "f1", + "My Doc", + "application/vnd.google-apps.document", + modified="2024-06-01T12:00:00Z", + link="http://link/f1", + ) + ] + } + + result = await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="my doc", + detailed=False, + ) + + assert "My Doc" in result + assert "f1" in result + assert "2024-06-01T12:00:00Z" not in result + assert "http://link/f1" not in result + + +@pytest.mark.asyncio +async def test_search_detailed_true_with_size(): + """When the item has a size field, detailed=True includes it in output.""" + mock_service = Mock() + mock_service.files().list().execute.return_value = { + "files": [ + _make_file("f2", "Big File", "application/pdf", size="102400"), + ] + } + + result = await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="big", + detailed=True, + ) + + assert "102400" in result + + +@pytest.mark.asyncio +async def test_search_detailed_true_requests_extra_api_fields(): + """detailed=True passes full fields string to the Drive API.""" + mock_service = Mock() + mock_service.files().list().execute.return_value = {"files": []} + + await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="anything", + detailed=True, + ) + + call_kwargs = mock_service.files.return_value.list.call_args.kwargs + assert "modifiedTime" in call_kwargs["fields"] + assert "webViewLink" in call_kwargs["fields"] + assert "size" in call_kwargs["fields"] + + +@pytest.mark.asyncio +async def test_search_detailed_false_requests_compact_api_fields(): + """detailed=False passes compact fields string to the Drive API.""" + mock_service = Mock() + mock_service.files().list().execute.return_value = {"files": []} + + await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="anything", + detailed=False, + ) + + call_kwargs = mock_service.files.return_value.list.call_args.kwargs + assert "modifiedTime" not in call_kwargs["fields"] + assert "webViewLink" not in call_kwargs["fields"] + assert "size" not in call_kwargs["fields"] + + +@pytest.mark.asyncio +async def test_search_default_detailed_matches_detailed_true(): + """Omitting detailed produces the same output as detailed=True.""" + file = _make_file( + "f1", + "Doc", + "application/vnd.google-apps.document", + modified="2024-01-01T00:00:00Z", + link="http://l", + ) + + mock_service = Mock() + mock_service.files().list().execute.return_value = {"files": [file]} + result_default = await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="doc", + ) + + mock_service.files().list().execute.return_value = {"files": [file]} + result_true = await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="doc", + detailed=True, + ) + + assert result_default == result_true + + +# --------------------------------------------------------------------------- +# list_drive_items — detailed flag +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock) +async def test_list_detailed_true_output_includes_metadata(mock_resolve_folder): + """detailed=True (default) includes modified time and link in output.""" + mock_resolve_folder.return_value = "resolved_root" + mock_service = Mock() + mock_service.files().list().execute.return_value = { + "files": [ + _make_file( + "id1", + "Report", + "application/vnd.google-apps.document", + modified="2024-03-15T08:00:00Z", + link="http://link/id1", + ) + ] + } + + result = await _unwrap(list_drive_items)( + service=mock_service, + user_google_email="user@example.com", + folder_id="root", + detailed=True, + ) + + assert "Report" in result + assert "2024-03-15T08:00:00Z" in result + assert "http://link/id1" in result + + +@pytest.mark.asyncio +@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock) +async def test_list_detailed_false_output_excludes_metadata(mock_resolve_folder): + """detailed=False omits modified time and link from output.""" + mock_resolve_folder.return_value = "resolved_root" + mock_service = Mock() + mock_service.files().list().execute.return_value = { + "files": [ + _make_file( + "id1", + "Report", + "application/vnd.google-apps.document", + modified="2024-03-15T08:00:00Z", + link="http://link/id1", + ) + ] + } + + result = await _unwrap(list_drive_items)( + service=mock_service, + user_google_email="user@example.com", + folder_id="root", + detailed=False, + ) + + assert "Report" in result + assert "id1" in result + assert "2024-03-15T08:00:00Z" not in result + assert "http://link/id1" not in result + + +@pytest.mark.asyncio +@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock) +async def test_list_detailed_true_with_size(mock_resolve_folder): + """When item has a size field, detailed=True includes it in output.""" + mock_resolve_folder.return_value = "resolved_root" + mock_service = Mock() + mock_service.files().list().execute.return_value = { + "files": [ + _make_file("id2", "Big File", "application/pdf", size="204800"), + ] + } + + result = await _unwrap(list_drive_items)( + service=mock_service, + user_google_email="user@example.com", + folder_id="root", + detailed=True, + ) + + assert "204800" in result + + +@pytest.mark.asyncio +@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock) +async def test_list_detailed_true_requests_extra_api_fields(mock_resolve_folder): + """detailed=True passes full fields string to the Drive API.""" + mock_resolve_folder.return_value = "resolved_root" + mock_service = Mock() + mock_service.files().list().execute.return_value = {"files": []} + + await _unwrap(list_drive_items)( + service=mock_service, + user_google_email="user@example.com", + folder_id="root", + detailed=True, + ) + + call_kwargs = mock_service.files.return_value.list.call_args.kwargs + assert "modifiedTime" in call_kwargs["fields"] + assert "webViewLink" in call_kwargs["fields"] + assert "size" in call_kwargs["fields"] + + +@pytest.mark.asyncio +@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock) +async def test_list_detailed_false_requests_compact_api_fields(mock_resolve_folder): + """detailed=False passes compact fields string to the Drive API.""" + mock_resolve_folder.return_value = "resolved_root" + mock_service = Mock() + mock_service.files().list().execute.return_value = {"files": []} + + await _unwrap(list_drive_items)( + service=mock_service, + user_google_email="user@example.com", + folder_id="root", + detailed=False, + ) + + call_kwargs = mock_service.files.return_value.list.call_args.kwargs + assert "modifiedTime" not in call_kwargs["fields"] + assert "webViewLink" not in call_kwargs["fields"] + assert "size" not in call_kwargs["fields"] + + +# --------------------------------------------------------------------------- +# Existing behavior coverage +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_search_free_text_returns_results(): + """Free-text query is wrapped in fullText contains and results are formatted.""" + mock_service = Mock() + mock_service.files().list().execute.return_value = { + "files": [ + _make_file("f1", "My Doc", "application/vnd.google-apps.document"), + ] + } + + result = await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="my doc", + ) + + assert "Found 1 files" in result + assert "My Doc" in result + assert "f1" in result + + +@pytest.mark.asyncio +async def test_search_no_results(): + """No results returns a clear message.""" + mock_service = Mock() + mock_service.files().list().execute.return_value = {"files": []} + + result = await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="nothing here", + ) + + assert "No files found" in result + + +@pytest.mark.asyncio +@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock) +async def test_list_items_basic(mock_resolve_folder): + """Basic listing without filters returns all items.""" + mock_resolve_folder.return_value = "resolved_root" + mock_service = Mock() + mock_service.files().list().execute.return_value = { + "files": [ + _make_file("id1", "Folder A", "application/vnd.google-apps.folder"), + _make_file("id2", "Doc B", "application/vnd.google-apps.document"), + ] + } + + result = await _unwrap(list_drive_items)( + service=mock_service, + user_google_email="user@example.com", + folder_id="root", + ) + + assert "Found 2 items" in result + assert "Folder A" in result + assert "Doc B" in result + + +@pytest.mark.asyncio +@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock) +async def test_list_items_no_results(mock_resolve_folder): + """Empty folder returns a clear message.""" + mock_resolve_folder.return_value = "resolved_root" + mock_service = Mock() + mock_service.files().list().execute.return_value = {"files": []} + + result = await _unwrap(list_drive_items)( + service=mock_service, + user_google_email="user@example.com", + folder_id="root", + ) + + assert "No items found" in result + + +# --------------------------------------------------------------------------- +# file_type filtering +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_search_file_type_folder_adds_mime_filter(): + """file_type='folder' appends the folder MIME type to the query.""" + mock_service = Mock() + mock_service.files().list().execute.return_value = { + "files": [ + _make_file("fold1", "My Folder", "application/vnd.google-apps.folder") + ] + } + + result = await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="my", + file_type="folder", + ) + + assert "Found 1 files" in result + assert "My Folder" in result + + call_kwargs = mock_service.files.return_value.list.call_args.kwargs + assert "mimeType = 'application/vnd.google-apps.folder'" in call_kwargs["q"] + + +@pytest.mark.asyncio +async def test_search_file_type_document_alias(): + """Alias 'doc' resolves to the Google Docs MIME type.""" + mock_service = Mock() + mock_service.files().list().execute.return_value = {"files": []} + + await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="report", + file_type="doc", + ) + + call_kwargs = mock_service.files.return_value.list.call_args.kwargs + assert "mimeType = 'application/vnd.google-apps.document'" in call_kwargs["q"] + + +@pytest.mark.asyncio +async def test_search_file_type_plural_alias(): + """Plural aliases are resolved for friendlier natural-language usage.""" + mock_service = Mock() + mock_service.files().list().execute.return_value = {"files": []} + + await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="project", + file_type="folders", + ) + + call_kwargs = mock_service.files.return_value.list.call_args.kwargs + assert "mimeType = 'application/vnd.google-apps.folder'" in call_kwargs["q"] + + +@pytest.mark.asyncio +async def test_search_file_type_sheet_alias(): + """Alias 'sheet' resolves to the Google Sheets MIME type.""" + mock_service = Mock() + mock_service.files().list().execute.return_value = {"files": []} + + await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="budget", + file_type="sheet", + ) + + call_kwargs = mock_service.files.return_value.list.call_args.kwargs + assert "mimeType = 'application/vnd.google-apps.spreadsheet'" in call_kwargs["q"] + + +@pytest.mark.asyncio +async def test_search_file_type_raw_mime(): + """A raw MIME type string is passed through unchanged.""" + mock_service = Mock() + mock_service.files().list().execute.return_value = { + "files": [_make_file("p1", "Report.pdf", "application/pdf")] + } + + result = await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="report", + file_type="application/pdf", + ) + + assert "Report.pdf" in result + call_kwargs = mock_service.files.return_value.list.call_args.kwargs + assert "mimeType = 'application/pdf'" in call_kwargs["q"] + + +@pytest.mark.asyncio +async def test_search_file_type_none_no_mime_filter(): + """When file_type is None no mimeType clause is added to the query.""" + mock_service = Mock() + mock_service.files().list().execute.return_value = {"files": []} + + await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="anything", + file_type=None, + ) + + call_kwargs = mock_service.files.return_value.list.call_args.kwargs + assert "mimeType" not in call_kwargs["q"] + + +@pytest.mark.asyncio +async def test_search_file_type_structured_query_combined(): + """file_type filter is appended even when the query is already structured.""" + mock_service = Mock() + mock_service.files().list().execute.return_value = {"files": []} + + await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="name contains 'budget'", + file_type="spreadsheet", + ) + + call_kwargs = mock_service.files.return_value.list.call_args.kwargs + q = call_kwargs["q"] + assert "name contains 'budget'" in q + assert "mimeType = 'application/vnd.google-apps.spreadsheet'" in q + + +@pytest.mark.asyncio +async def test_search_file_type_unknown_raises_value_error(): + """An unrecognised friendly type name raises ValueError immediately.""" + mock_service = Mock() + + with pytest.raises(ValueError, match="Unknown file_type"): + await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="something", + file_type="notatype", + ) + + +@pytest.mark.asyncio +@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock) +async def test_list_items_file_type_folder_adds_mime_filter(mock_resolve_folder): + """file_type='folder' appends the folder MIME clause to the query.""" + mock_resolve_folder.return_value = "resolved_root" + mock_service = Mock() + mock_service.files().list().execute.return_value = { + "files": [_make_file("sub1", "SubFolder", "application/vnd.google-apps.folder")] + } + + result = await _unwrap(list_drive_items)( + service=mock_service, + user_google_email="user@example.com", + folder_id="root", + file_type="folder", + ) + + assert "Found 1 items" in result + assert "SubFolder" in result + + call_kwargs = mock_service.files.return_value.list.call_args.kwargs + q = call_kwargs["q"] + assert "'resolved_root' in parents" in q + assert "trashed=false" in q + assert "mimeType = 'application/vnd.google-apps.folder'" in q + + +@pytest.mark.asyncio +@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock) +async def test_list_items_file_type_spreadsheet(mock_resolve_folder): + """file_type='spreadsheet' appends the Sheets MIME clause.""" + mock_resolve_folder.return_value = "folder_xyz" + mock_service = Mock() + mock_service.files().list().execute.return_value = {"files": []} + + await _unwrap(list_drive_items)( + service=mock_service, + user_google_email="user@example.com", + folder_id="folder_xyz", + file_type="spreadsheet", + ) + + call_kwargs = mock_service.files.return_value.list.call_args.kwargs + assert "mimeType = 'application/vnd.google-apps.spreadsheet'" in call_kwargs["q"] + + +@pytest.mark.asyncio +@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock) +async def test_list_items_file_type_raw_mime(mock_resolve_folder): + """A raw MIME type string is passed through unchanged.""" + mock_resolve_folder.return_value = "folder_abc" + mock_service = Mock() + mock_service.files().list().execute.return_value = {"files": []} + + await _unwrap(list_drive_items)( + service=mock_service, + user_google_email="user@example.com", + folder_id="folder_abc", + file_type="application/pdf", + ) + + call_kwargs = mock_service.files.return_value.list.call_args.kwargs + assert "mimeType = 'application/pdf'" in call_kwargs["q"] + + +@pytest.mark.asyncio +@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock) +async def test_list_items_file_type_none_no_mime_filter(mock_resolve_folder): + """When file_type is None no mimeType clause is added.""" + mock_resolve_folder.return_value = "resolved_root" + mock_service = Mock() + mock_service.files().list().execute.return_value = {"files": []} + + await _unwrap(list_drive_items)( + service=mock_service, + user_google_email="user@example.com", + folder_id="root", + file_type=None, + ) + + call_kwargs = mock_service.files.return_value.list.call_args.kwargs + assert "mimeType" not in call_kwargs["q"] + + +@pytest.mark.asyncio +@patch("gdrive.drive_tools.resolve_folder_id", new_callable=AsyncMock) +async def test_list_items_file_type_unknown_raises(mock_resolve_folder): + """An unrecognised friendly type name raises ValueError.""" + mock_resolve_folder.return_value = "resolved_root" + mock_service = Mock() + + with pytest.raises(ValueError, match="Unknown file_type"): + await _unwrap(list_drive_items)( + service=mock_service, + user_google_email="user@example.com", + folder_id="root", + file_type="unknowntype", + ) + + +# --------------------------------------------------------------------------- +# OR-precedence grouping +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_search_or_query_is_grouped_before_mime_filter(): + """An OR structured query is wrapped in parentheses so MIME filter precedence is correct.""" + mock_service = Mock() + mock_service.files().list().execute.return_value = {"files": []} + + await _unwrap(search_drive_files)( + service=mock_service, + user_google_email="user@example.com", + query="name contains 'a' or name contains 'b'", + file_type="document", + ) + + q = mock_service.files.return_value.list.call_args.kwargs["q"] + assert q.startswith("(") + assert "name contains 'a' or name contains 'b'" in q + assert ") and mimeType = 'application/vnd.google-apps.document'" in q + + +# --------------------------------------------------------------------------- +# MIME type validation +# --------------------------------------------------------------------------- + + +def test_resolve_file_type_mime_invalid_mime_raises(): + """A raw string with '/' but containing quotes raises ValueError.""" + from gdrive.drive_helpers import resolve_file_type_mime + + with pytest.raises(ValueError, match="Invalid MIME type"): + resolve_file_type_mime("application/pdf' or '1'='1") + + +def test_resolve_file_type_mime_strips_whitespace(): + """Leading/trailing whitespace is stripped from raw MIME strings.""" + from gdrive.drive_helpers import resolve_file_type_mime + + assert resolve_file_type_mime(" application/pdf ") == "application/pdf" + + +def test_resolve_file_type_mime_normalizes_case(): + """Raw MIME types are normalized to lowercase for Drive query consistency.""" + from gdrive.drive_helpers import resolve_file_type_mime + + assert resolve_file_type_mime("Application/PDF") == "application/pdf" + + +def test_resolve_file_type_mime_empty_raises(): + """Blank values are rejected with a clear validation error.""" + from gdrive.drive_helpers import resolve_file_type_mime + + with pytest.raises(ValueError, match="cannot be empty"): + resolve_file_type_mime(" ") diff --git a/tests/gdrive/test_ssrf_protections.py b/tests/gdrive/test_ssrf_protections.py new file mode 100644 index 0000000..93f95e0 --- /dev/null +++ b/tests/gdrive/test_ssrf_protections.py @@ -0,0 +1,165 @@ +""" +Unit tests for Drive SSRF protections and DNS pinning helpers. +""" + +import os +import socket +import sys + +import httpx +import pytest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +from gdrive import drive_tools + + +def test_resolve_and_validate_host_fails_closed_on_dns_error(monkeypatch): + """DNS resolution failures must fail closed.""" + + def fake_getaddrinfo(hostname, port): + raise socket.gaierror("mocked resolution failure") + + monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo) + + with pytest.raises(ValueError, match="Refusing request \\(fail-closed\\)"): + drive_tools._resolve_and_validate_host("example.com") + + +def test_resolve_and_validate_host_rejects_ipv6_private(monkeypatch): + """IPv6 internal addresses must be rejected.""" + + def fake_getaddrinfo(hostname, port): + return [ + ( + socket.AF_INET6, + socket.SOCK_STREAM, + 6, + "", + ("fd00::1", 0, 0, 0), + ) + ] + + monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo) + + with pytest.raises(ValueError, match="private/internal networks"): + drive_tools._resolve_and_validate_host("ipv6-internal.example") + + +def test_resolve_and_validate_host_deduplicates_addresses(monkeypatch): + """Duplicate DNS answers should be de-duplicated while preserving order.""" + + def fake_getaddrinfo(hostname, port): + return [ + ( + socket.AF_INET, + socket.SOCK_STREAM, + 6, + "", + ("93.184.216.34", 0), + ), + ( + socket.AF_INET, + socket.SOCK_STREAM, + 6, + "", + ("93.184.216.34", 0), + ), + ( + socket.AF_INET6, + socket.SOCK_STREAM, + 6, + "", + ("2606:2800:220:1:248:1893:25c8:1946", 0, 0, 0), + ), + ] + + monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo) + + assert drive_tools._resolve_and_validate_host("example.com") == [ + "93.184.216.34", + "2606:2800:220:1:248:1893:25c8:1946", + ] + + +@pytest.mark.asyncio +async def test_fetch_url_with_pinned_ip_uses_pinned_target_and_host_header(monkeypatch): + """Requests should target a validated IP while preserving Host + SNI hostname.""" + captured = {} + + class FakeAsyncClient: + def __init__(self, *args, **kwargs): + captured["client_kwargs"] = kwargs + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + def build_request(self, method, url, headers=None, extensions=None): + captured["method"] = method + captured["url"] = url + captured["headers"] = headers or {} + captured["extensions"] = extensions or {} + return {"url": url} + + async def send(self, request): + return httpx.Response(200, request=httpx.Request("GET", request["url"])) + + monkeypatch.setattr( + drive_tools, "_validate_url_not_internal", lambda url: ["93.184.216.34"] + ) + monkeypatch.setattr(drive_tools.httpx, "AsyncClient", FakeAsyncClient) + + response = await drive_tools._fetch_url_with_pinned_ip( + "https://example.com/path/to/file.txt?x=1" + ) + + assert response.status_code == 200 + assert captured["method"] == "GET" + assert captured["url"] == "https://93.184.216.34/path/to/file.txt?x=1" + assert captured["headers"]["Host"] == "example.com" + assert captured["extensions"]["sni_hostname"] == "example.com" + assert captured["client_kwargs"]["trust_env"] is False + assert captured["client_kwargs"]["follow_redirects"] is False + + +@pytest.mark.asyncio +async def test_ssrf_safe_fetch_follows_relative_redirects(monkeypatch): + """Relative redirects should be resolved and re-checked.""" + calls = [] + + async def fake_fetch(url): + calls.append(url) + if len(calls) == 1: + return httpx.Response( + 302, + headers={"location": "/next"}, + request=httpx.Request("GET", url), + ) + return httpx.Response(200, request=httpx.Request("GET", url), content=b"ok") + + monkeypatch.setattr(drive_tools, "_fetch_url_with_pinned_ip", fake_fetch) + + response = await drive_tools._ssrf_safe_fetch("https://example.com/start") + + assert response.status_code == 200 + assert calls == ["https://example.com/start", "https://example.com/next"] + + +@pytest.mark.asyncio +async def test_ssrf_safe_fetch_rejects_disallowed_redirect_scheme(monkeypatch): + """Redirects to non-http(s) schemes should be blocked.""" + + async def fake_fetch(url): + return httpx.Response( + 302, + headers={"location": "file:///etc/passwd"}, + request=httpx.Request("GET", url), + ) + + monkeypatch.setattr(drive_tools, "_fetch_url_with_pinned_ip", fake_fetch) + + with pytest.raises(ValueError, match="Redirect to disallowed scheme"): + await drive_tools._ssrf_safe_fetch("https://example.com/start") diff --git a/tests/gforms/__init__.py b/tests/gforms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/gforms/test_forms_tools.py b/tests/gforms/test_forms_tools.py new file mode 100644 index 0000000..97e5284 --- /dev/null +++ b/tests/gforms/test_forms_tools.py @@ -0,0 +1,344 @@ +""" +Unit tests for Google Forms MCP tools + +Tests the batch_update_form tool with mocked API responses +""" + +import pytest +from unittest.mock import Mock +import sys +import os + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +# Import internal implementation functions (not decorated tool wrappers) +from gforms.forms_tools import _batch_update_form_impl, _serialize_form_item, get_form + + +@pytest.mark.asyncio +async def test_batch_update_form_multiple_requests(): + """Test batch update with multiple requests returns formatted results""" + mock_service = Mock() + mock_response = { + "replies": [ + {"createItem": {"itemId": "item001", "questionId": ["q001"]}}, + {"createItem": {"itemId": "item002", "questionId": ["q002"]}}, + ], + "writeControl": {"requiredRevisionId": "rev123"}, + } + + mock_service.forms().batchUpdate().execute.return_value = mock_response + + requests = [ + { + "createItem": { + "item": { + "title": "What is your name?", + "questionItem": { + "question": {"textQuestion": {"paragraph": False}} + }, + }, + "location": {"index": 0}, + } + }, + { + "createItem": { + "item": { + "title": "What is your email?", + "questionItem": { + "question": {"textQuestion": {"paragraph": False}} + }, + }, + "location": {"index": 1}, + } + }, + ] + + result = await _batch_update_form_impl( + service=mock_service, + form_id="test_form_123", + requests=requests, + ) + + assert "Batch Update Completed" in result + assert "test_form_123" in result + assert "Requests Applied: 2" in result + assert "Replies Received: 2" in result + assert "item001" in result + assert "item002" in result + + +@pytest.mark.asyncio +async def test_batch_update_form_single_request(): + """Test batch update with a single request""" + mock_service = Mock() + mock_response = { + "replies": [ + {"createItem": {"itemId": "item001", "questionId": ["q001"]}}, + ], + } + + mock_service.forms().batchUpdate().execute.return_value = mock_response + + requests = [ + { + "createItem": { + "item": { + "title": "Favourite colour?", + "questionItem": { + "question": { + "choiceQuestion": { + "type": "RADIO", + "options": [ + {"value": "Red"}, + {"value": "Blue"}, + ], + } + } + }, + }, + "location": {"index": 0}, + } + }, + ] + + result = await _batch_update_form_impl( + service=mock_service, + form_id="single_form_456", + requests=requests, + ) + + assert "single_form_456" in result + assert "Requests Applied: 1" in result + assert "Replies Received: 1" in result + + +@pytest.mark.asyncio +async def test_batch_update_form_empty_replies(): + """Test batch update when API returns no replies""" + mock_service = Mock() + mock_response = { + "replies": [], + } + + mock_service.forms().batchUpdate().execute.return_value = mock_response + + requests = [ + { + "updateFormInfo": { + "info": {"description": "Updated description"}, + "updateMask": "description", + } + }, + ] + + result = await _batch_update_form_impl( + service=mock_service, + form_id="info_form_789", + requests=requests, + ) + + assert "info_form_789" in result + assert "Requests Applied: 1" in result + assert "Replies Received: 0" in result + + +@pytest.mark.asyncio +async def test_batch_update_form_no_replies_key(): + """Test batch update when API response lacks replies key""" + mock_service = Mock() + mock_response = {} + + mock_service.forms().batchUpdate().execute.return_value = mock_response + + requests = [ + { + "updateSettings": { + "settings": {"quizSettings": {"isQuiz": True}}, + "updateMask": "quizSettings.isQuiz", + } + }, + ] + + result = await _batch_update_form_impl( + service=mock_service, + form_id="quiz_form_000", + requests=requests, + ) + + assert "quiz_form_000" in result + assert "Requests Applied: 1" in result + assert "Replies Received: 0" in result + + +@pytest.mark.asyncio +async def test_batch_update_form_url_in_response(): + """Test that the edit URL is included in the response""" + mock_service = Mock() + mock_response = { + "replies": [{}], + } + + mock_service.forms().batchUpdate().execute.return_value = mock_response + + requests = [ + {"updateFormInfo": {"info": {"title": "New Title"}, "updateMask": "title"}} + ] + + result = await _batch_update_form_impl( + service=mock_service, + form_id="url_form_abc", + requests=requests, + ) + + assert "https://docs.google.com/forms/d/url_form_abc/edit" in result + + +@pytest.mark.asyncio +async def test_batch_update_form_mixed_reply_types(): + """Test batch update with createItem replies containing different fields""" + mock_service = Mock() + mock_response = { + "replies": [ + {"createItem": {"itemId": "item_a", "questionId": ["qa"]}}, + {}, + {"createItem": {"itemId": "item_c"}}, + ], + } + + mock_service.forms().batchUpdate().execute.return_value = mock_response + + requests = [ + {"createItem": {"item": {"title": "Q1"}, "location": {"index": 0}}}, + { + "updateFormInfo": { + "info": {"description": "Desc"}, + "updateMask": "description", + } + }, + {"createItem": {"item": {"title": "Q2"}, "location": {"index": 1}}}, + ] + + result = await _batch_update_form_impl( + service=mock_service, + form_id="mixed_form_xyz", + requests=requests, + ) + + assert "Requests Applied: 3" in result + assert "Replies Received: 3" in result + assert "item_a" in result + assert "item_c" in result + + +def test_serialize_form_item_choice_question_includes_ids_and_options(): + """Choice question items should expose questionId/options/type metadata.""" + item = { + "itemId": "item_123", + "title": "Favorite color?", + "questionItem": { + "question": { + "questionId": "q_123", + "required": True, + "choiceQuestion": { + "type": "RADIO", + "options": [{"value": "Red"}, {"value": "Blue"}], + }, + } + }, + } + + serialized = _serialize_form_item(item, 1) + + assert serialized["index"] == 1 + assert serialized["itemId"] == "item_123" + assert serialized["type"] == "RADIO" + assert serialized["questionId"] == "q_123" + assert serialized["required"] is True + assert serialized["options"] == [{"value": "Red"}, {"value": "Blue"}] + + +def test_serialize_form_item_grid_includes_row_and_column_structure(): + """Grid question groups should expose row labels/IDs and column options.""" + item = { + "itemId": "grid_item_1", + "title": "Weekly chores", + "questionGroupItem": { + "questions": [ + { + "questionId": "row_q1", + "required": True, + "rowQuestion": {"title": "Laundry"}, + }, + { + "questionId": "row_q2", + "required": False, + "rowQuestion": {"title": "Dishes"}, + }, + ], + "grid": {"columns": {"options": [{"value": "Never"}, {"value": "Often"}]}}, + }, + } + + serialized = _serialize_form_item(item, 2) + + assert serialized["index"] == 2 + assert serialized["type"] == "GRID" + assert serialized["grid"]["columns"] == [{"value": "Never"}, {"value": "Often"}] + assert serialized["grid"]["rows"] == [ + {"title": "Laundry", "questionId": "row_q1", "required": True}, + {"title": "Dishes", "questionId": "row_q2", "required": False}, + ] + + +@pytest.mark.asyncio +async def test_get_form_returns_structured_item_metadata(): + """get_form should include question IDs, options, and grid structure.""" + mock_service = Mock() + mock_service.forms().get().execute.return_value = { + "formId": "form_1", + "info": {"title": "Survey", "description": "Test survey"}, + "items": [ + { + "itemId": "item_1", + "title": "Favorite fruit?", + "questionItem": { + "question": { + "questionId": "q_1", + "required": True, + "choiceQuestion": { + "type": "RADIO", + "options": [{"value": "Apple"}, {"value": "Banana"}], + }, + } + }, + }, + { + "itemId": "item_2", + "title": "Household chores", + "questionGroupItem": { + "questions": [ + { + "questionId": "row_1", + "required": True, + "rowQuestion": {"title": "Laundry"}, + } + ], + "grid": {"columns": {"options": [{"value": "Never"}]}}, + }, + }, + ], + } + + # Bypass decorators and call the core implementation directly. + result = await get_form.__wrapped__.__wrapped__( + mock_service, "user@example.com", "form_1" + ) + + assert "- Items (structured):" in result + assert '"questionId": "q_1"' in result + assert '"options": [' in result + assert '"Apple"' in result + assert '"type": "GRID"' in result + assert '"columns": [' in result + assert '"rows": [' in result diff --git a/tests/gmail/test_attachment_fix.py b/tests/gmail/test_attachment_fix.py new file mode 100644 index 0000000..5fbd96a --- /dev/null +++ b/tests/gmail/test_attachment_fix.py @@ -0,0 +1,101 @@ +import base64 +import os +import sys + +import pytest + + +def test_urlsafe_b64decode_already_handles_crlf(): + """Verify Python's urlsafe_b64decode ignores embedded CR/LF without manual stripping.""" + original = b"Testdata" + b64 = base64.urlsafe_b64encode(original).decode() + + assert base64.urlsafe_b64decode(b64 + "\n") == original + assert base64.urlsafe_b64decode(b64[:4] + "\r\n" + b64[4:]) == original + assert base64.urlsafe_b64decode(b64[:4] + "\r\r\n" + b64[4:]) == original + + +def test_os_open_without_o_binary_corrupts_on_windows(tmp_path): + """On Windows, os.open without O_BINARY translates LF to CRLF in written bytes.""" + payload = b"\x89PNG\r\n\x1a\n" + b"\x00" * 50 + + tmp = str(tmp_path / "test_no_binary.bin") + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + os.write(fd, payload) + finally: + os.close(fd) + + with open(tmp, "rb") as f: + written = f.read() + + if sys.platform == "win32": + assert written != payload, "Expected corruption without O_BINARY on Windows" + assert len(written) > len(payload) + else: + assert written == payload + + +def test_os_open_with_o_binary_preserves_bytes(tmp_path): + """os.open with O_BINARY writes binary data correctly on all platforms.""" + payload = b"\x89PNG\r\n\x1a\n" + b"\x00" * 50 + + tmp = str(tmp_path / "test_with_binary.bin") + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_BINARY", 0) + + fd = os.open(tmp, flags, 0o600) + try: + os.write(fd, payload) + finally: + os.close(fd) + + with open(tmp, "rb") as f: + written = f.read() + + assert written == payload + + +@pytest.fixture +def isolated_storage(tmp_path, monkeypatch): + """Create an AttachmentStorage that writes to a temp directory.""" + import core.attachment_storage as storage_module + + monkeypatch.setattr(storage_module, "STORAGE_DIR", tmp_path) + return storage_module.AttachmentStorage() + + +def test_save_attachment_uses_binary_mode(isolated_storage): + """Verify that AttachmentStorage.save_attachment writes files in binary mode.""" + payload = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 + b64_data = base64.urlsafe_b64encode(payload).decode() + + result = isolated_storage.save_attachment( + b64_data, filename="test.png", mime_type="image/png" + ) + + with open(result.path, "rb") as f: + saved_bytes = f.read() + + assert saved_bytes == payload, ( + f"Binary corruption detected: wrote {len(payload)} bytes, " + f"read back {len(saved_bytes)} bytes" + ) + + +@pytest.mark.parametrize( + "payload", + [ + b"\x89PNG\r\n\x1a\n" + b"\xff" * 200, # PNG header + b"%PDF-1.7\n" + b"\x00" * 200, # PDF header + bytes(range(256)) * 4, # All byte values + ], +) +def test_save_attachment_preserves_various_binary_formats(isolated_storage, payload): + """Ensure binary integrity for payloads containing LF/CR bytes.""" + b64_data = base64.urlsafe_b64encode(payload).decode() + result = isolated_storage.save_attachment(b64_data, filename="test.bin") + + with open(result.path, "rb") as f: + saved_bytes = f.read() + + assert saved_bytes == payload diff --git a/tests/gmail/test_draft_gmail_message.py b/tests/gmail/test_draft_gmail_message.py new file mode 100644 index 0000000..fae369a --- /dev/null +++ b/tests/gmail/test_draft_gmail_message.py @@ -0,0 +1,288 @@ +import base64 +import os +import sys +from unittest.mock import Mock + +import pytest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +from core.utils import UserInputError +from gmail.gmail_tools import draft_gmail_message + + +def _unwrap(tool): + """Unwrap FunctionTool + decorators to the original async function.""" + fn = tool.fn if hasattr(tool, "fn") else tool + while hasattr(fn, "__wrapped__"): + fn = fn.__wrapped__ + return fn + + +def _thread_response(*message_ids): + return { + "messages": [ + { + "payload": { + "headers": [{"name": "Message-ID", "value": message_id}], + } + } + for message_id in message_ids + ] + } + + +@pytest.mark.asyncio +async def test_draft_gmail_message_reports_actual_attachment_count( + tmp_path, monkeypatch +): + monkeypatch.setenv("ALLOWED_FILE_DIRS", str(tmp_path)) + attachment_path = tmp_path / "sample.txt" + attachment_path.write_text("hello attachment", encoding="utf-8") + + mock_service = Mock() + mock_service.users().drafts().create().execute.return_value = {"id": "draft123"} + + result = await _unwrap(draft_gmail_message)( + service=mock_service, + user_google_email="user@example.com", + to="recipient@example.com", + subject="Attachment test", + body="Please see attached.", + attachments=[{"path": str(attachment_path)}], + include_signature=False, + ) + + assert "Draft created with 1 attachment(s)! Draft ID: draft123" in result + + create_kwargs = ( + mock_service.users.return_value.drafts.return_value.create.call_args.kwargs + ) + raw_message = create_kwargs["body"]["message"]["raw"] + raw_bytes = base64.urlsafe_b64decode(raw_message) + + assert b"Content-Disposition: attachment;" in raw_bytes + assert b"sample.txt" in raw_bytes + + +@pytest.mark.asyncio +async def test_draft_gmail_message_raises_when_no_attachments_are_added( + tmp_path, monkeypatch +): + monkeypatch.setenv("ALLOWED_FILE_DIRS", str(tmp_path)) + missing_path = tmp_path / "missing.txt" + + mock_service = Mock() + mock_service.users().drafts().create().execute.return_value = {"id": "draft123"} + + with pytest.raises(UserInputError, match="No valid attachments were added"): + await _unwrap(draft_gmail_message)( + service=mock_service, + user_google_email="user@example.com", + to="recipient@example.com", + subject="Attachment test", + body="Please see attached.", + attachments=[{"path": str(missing_path)}], + include_signature=False, + ) + + +@pytest.mark.asyncio +async def test_draft_gmail_message_appends_gmail_signature_html(): + mock_service = Mock() + mock_service.users().drafts().create().execute.return_value = {"id": "draft_sig"} + mock_service.users().settings().sendAs().list().execute.return_value = { + "sendAs": [ + { + "sendAsEmail": "user@example.com", + "isPrimary": True, + "signature": "

Best,
Alice
", + } + ] + } + + result = await _unwrap(draft_gmail_message)( + service=mock_service, + user_google_email="user@example.com", + to="recipient@example.com", + subject="Signature test", + body="

Hello

", + body_format="html", + include_signature=True, + ) + + assert "Draft created! Draft ID: draft_sig" in result + + create_kwargs = ( + mock_service.users.return_value.drafts.return_value.create.call_args.kwargs + ) + raw_message = create_kwargs["body"]["message"]["raw"] + raw_text = base64.urlsafe_b64decode(raw_message).decode("utf-8", errors="ignore") + + assert "

Hello

" in raw_text + assert "Best,
Alice" in raw_text + + +@pytest.mark.asyncio +async def test_draft_gmail_message_autofills_reply_headers_from_thread(): + mock_service = Mock() + mock_service.users().drafts().create().execute.return_value = {"id": "draft_reply"} + mock_service.users().threads().get().execute.return_value = _thread_response( + "", + "", + "", + ) + + result = await _unwrap(draft_gmail_message)( + service=mock_service, + user_google_email="user@example.com", + to="recipient@example.com", + subject="Meeting tomorrow", + body="Thanks for the update.", + thread_id="thread123", + include_signature=False, + ) + + # Verify threads().get() was called with correct parameters + thread_get_kwargs = ( + mock_service.users.return_value.threads.return_value.get.call_args.kwargs + ) + assert thread_get_kwargs["userId"] == "me" + assert thread_get_kwargs["id"] == "thread123" + assert thread_get_kwargs["format"] == "metadata" + assert "Message-ID" in thread_get_kwargs["metadataHeaders"] + + assert "Draft created! Draft ID: draft_reply" in result + + create_kwargs = ( + mock_service.users.return_value.drafts.return_value.create.call_args.kwargs + ) + raw_message = create_kwargs["body"]["message"]["raw"] + raw_text = base64.urlsafe_b64decode(raw_message).decode("utf-8", errors="ignore") + + assert "In-Reply-To: " in raw_text + assert ( + "References: " + in raw_text + ) + assert create_kwargs["body"]["message"]["threadId"] == "thread123" + + +@pytest.mark.asyncio +async def test_draft_gmail_message_uses_explicit_in_reply_to_when_filling_references(): + mock_service = Mock() + mock_service.users().drafts().create().execute.return_value = {"id": "draft_reply"} + mock_service.users().threads().get().execute.return_value = _thread_response( + "", + "", + "", + ) + + await _unwrap(draft_gmail_message)( + service=mock_service, + user_google_email="user@example.com", + to="recipient@example.com", + subject="Meeting tomorrow", + body="Replying to an earlier message.", + thread_id="thread123", + in_reply_to="", + include_signature=False, + ) + + create_kwargs = ( + mock_service.users.return_value.drafts.return_value.create.call_args.kwargs + ) + raw_message = create_kwargs["body"]["message"]["raw"] + raw_text = base64.urlsafe_b64decode(raw_message).decode("utf-8", errors="ignore") + + assert "In-Reply-To: " in raw_text + assert "References: " in raw_text + assert "" not in raw_text + + +@pytest.mark.asyncio +async def test_draft_gmail_message_uses_explicit_references_when_filling_in_reply_to(): + mock_service = Mock() + mock_service.users().drafts().create().execute.return_value = {"id": "draft_reply"} + mock_service.users().threads().get().execute.return_value = _thread_response( + "", + "", + "", + ) + + await _unwrap(draft_gmail_message)( + service=mock_service, + user_google_email="user@example.com", + to="recipient@example.com", + subject="Meeting tomorrow", + body="Replying to an earlier message.", + thread_id="thread123", + references=" ", + include_signature=False, + ) + + create_kwargs = ( + mock_service.users.return_value.drafts.return_value.create.call_args.kwargs + ) + raw_message = create_kwargs["body"]["message"]["raw"] + raw_text = base64.urlsafe_b64decode(raw_message).decode("utf-8", errors="ignore") + + assert "In-Reply-To: " in raw_text + assert "References: " in raw_text + assert "" not in raw_text + + +@pytest.mark.asyncio +async def test_draft_gmail_message_gracefully_degrades_when_thread_fetch_fails(): + mock_service = Mock() + mock_service.users().drafts().create().execute.return_value = {"id": "draft_reply"} + mock_service.users().threads().get().execute.side_effect = RuntimeError("boom") + + result = await _unwrap(draft_gmail_message)( + service=mock_service, + user_google_email="user@example.com", + to="recipient@example.com", + subject="Meeting tomorrow", + body="Thanks for the update.", + thread_id="thread123", + include_signature=False, + ) + + assert "Draft created! Draft ID: draft_reply" in result + + create_kwargs = ( + mock_service.users.return_value.drafts.return_value.create.call_args.kwargs + ) + raw_message = create_kwargs["body"]["message"]["raw"] + raw_text = base64.urlsafe_b64decode(raw_message).decode("utf-8", errors="ignore") + + assert "In-Reply-To:" not in raw_text + assert "References:" not in raw_text + + +@pytest.mark.asyncio +async def test_draft_gmail_message_gracefully_degrades_when_thread_has_no_messages(): + mock_service = Mock() + mock_service.users().drafts().create().execute.return_value = {"id": "draft_reply"} + mock_service.users().threads().get().execute.return_value = {"messages": []} + + result = await _unwrap(draft_gmail_message)( + service=mock_service, + user_google_email="user@example.com", + to="recipient@example.com", + subject="Meeting tomorrow", + body="Thanks for the update.", + thread_id="thread123", + include_signature=False, + ) + + assert "Draft created! Draft ID: draft_reply" in result + + create_kwargs = ( + mock_service.users.return_value.drafts.return_value.create.call_args.kwargs + ) + raw_message = create_kwargs["body"]["message"]["raw"] + raw_text = base64.urlsafe_b64decode(raw_message).decode("utf-8", errors="ignore") + + assert "In-Reply-To:" not in raw_text + assert "References:" not in raw_text diff --git a/tests/gsheets/__init__.py b/tests/gsheets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/gsheets/test_format_sheet_range.py b/tests/gsheets/test_format_sheet_range.py new file mode 100644 index 0000000..780ff36 --- /dev/null +++ b/tests/gsheets/test_format_sheet_range.py @@ -0,0 +1,436 @@ +""" +Unit tests for Google Sheets format_sheet_range tool enhancements + +Tests the enhanced formatting parameters: wrap_strategy, horizontal_alignment, +vertical_alignment, bold, italic, and font_size. +""" + +import pytest +from unittest.mock import Mock +import sys +import os + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +from gsheets.sheets_tools import _format_sheet_range_impl + + +def create_mock_service(): + """Create a properly configured mock Google Sheets service.""" + mock_service = Mock() + + mock_metadata = {"sheets": [{"properties": {"sheetId": 0, "title": "Sheet1"}}]} + mock_service.spreadsheets().get().execute = Mock(return_value=mock_metadata) + mock_service.spreadsheets().batchUpdate().execute = Mock(return_value={}) + + return mock_service + + +@pytest.mark.asyncio +async def test_format_wrap_strategy_wrap(): + """Test wrap_strategy=WRAP applies text wrapping""" + mock_service = create_mock_service() + + result = await _format_sheet_range_impl( + service=mock_service, + spreadsheet_id="test_spreadsheet_123", + range_name="A1:C10", + wrap_strategy="WRAP", + ) + + assert result["spreadsheet_id"] == "test_spreadsheet_123" + assert result["range_name"] == "A1:C10" + + call_args = mock_service.spreadsheets().batchUpdate.call_args + request_body = call_args[1]["body"] + cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"] + assert cell_format["wrapStrategy"] == "WRAP" + + +@pytest.mark.asyncio +async def test_format_wrap_strategy_clip(): + """Test wrap_strategy=CLIP clips text at cell boundary""" + mock_service = create_mock_service() + + result = await _format_sheet_range_impl( + service=mock_service, + spreadsheet_id="test_spreadsheet_123", + range_name="A1:B5", + wrap_strategy="CLIP", + ) + + assert result["spreadsheet_id"] == "test_spreadsheet_123" + call_args = mock_service.spreadsheets().batchUpdate.call_args + request_body = call_args[1]["body"] + cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"] + assert cell_format["wrapStrategy"] == "CLIP" + + +@pytest.mark.asyncio +async def test_format_wrap_strategy_overflow(): + """Test wrap_strategy=OVERFLOW_CELL allows text overflow""" + mock_service = create_mock_service() + + await _format_sheet_range_impl( + service=mock_service, + spreadsheet_id="test_spreadsheet_123", + range_name="A1:A1", + wrap_strategy="OVERFLOW_CELL", + ) + + call_args = mock_service.spreadsheets().batchUpdate.call_args + request_body = call_args[1]["body"] + cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"] + assert cell_format["wrapStrategy"] == "OVERFLOW_CELL" + + +@pytest.mark.asyncio +async def test_format_horizontal_alignment_center(): + """Test horizontal_alignment=CENTER centers text""" + mock_service = create_mock_service() + + result = await _format_sheet_range_impl( + service=mock_service, + spreadsheet_id="test_spreadsheet_123", + range_name="A1:D10", + horizontal_alignment="CENTER", + ) + + assert result["spreadsheet_id"] == "test_spreadsheet_123" + call_args = mock_service.spreadsheets().batchUpdate.call_args + request_body = call_args[1]["body"] + cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"] + assert cell_format["horizontalAlignment"] == "CENTER" + + +@pytest.mark.asyncio +async def test_format_horizontal_alignment_left(): + """Test horizontal_alignment=LEFT aligns text left""" + mock_service = create_mock_service() + + await _format_sheet_range_impl( + service=mock_service, + spreadsheet_id="test_spreadsheet_123", + range_name="A1:A10", + horizontal_alignment="LEFT", + ) + + call_args = mock_service.spreadsheets().batchUpdate.call_args + request_body = call_args[1]["body"] + cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"] + assert cell_format["horizontalAlignment"] == "LEFT" + + +@pytest.mark.asyncio +async def test_format_horizontal_alignment_right(): + """Test horizontal_alignment=RIGHT aligns text right""" + mock_service = create_mock_service() + + await _format_sheet_range_impl( + service=mock_service, + spreadsheet_id="test_spreadsheet_123", + range_name="B1:B10", + horizontal_alignment="RIGHT", + ) + + call_args = mock_service.spreadsheets().batchUpdate.call_args + request_body = call_args[1]["body"] + cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"] + assert cell_format["horizontalAlignment"] == "RIGHT" + + +@pytest.mark.asyncio +async def test_format_vertical_alignment_top(): + """Test vertical_alignment=TOP aligns text to top""" + mock_service = create_mock_service() + + await _format_sheet_range_impl( + service=mock_service, + spreadsheet_id="test_spreadsheet_123", + range_name="A1:C5", + vertical_alignment="TOP", + ) + + call_args = mock_service.spreadsheets().batchUpdate.call_args + request_body = call_args[1]["body"] + cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"] + assert cell_format["verticalAlignment"] == "TOP" + + +@pytest.mark.asyncio +async def test_format_vertical_alignment_middle(): + """Test vertical_alignment=MIDDLE centers text vertically""" + mock_service = create_mock_service() + + await _format_sheet_range_impl( + service=mock_service, + spreadsheet_id="test_spreadsheet_123", + range_name="A1:C5", + vertical_alignment="MIDDLE", + ) + + call_args = mock_service.spreadsheets().batchUpdate.call_args + request_body = call_args[1]["body"] + cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"] + assert cell_format["verticalAlignment"] == "MIDDLE" + + +@pytest.mark.asyncio +async def test_format_vertical_alignment_bottom(): + """Test vertical_alignment=BOTTOM aligns text to bottom""" + mock_service = create_mock_service() + + await _format_sheet_range_impl( + service=mock_service, + spreadsheet_id="test_spreadsheet_123", + range_name="A1:C5", + vertical_alignment="BOTTOM", + ) + + call_args = mock_service.spreadsheets().batchUpdate.call_args + request_body = call_args[1]["body"] + cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"] + assert cell_format["verticalAlignment"] == "BOTTOM" + + +@pytest.mark.asyncio +async def test_format_bold_true(): + """Test bold=True applies bold text formatting""" + mock_service = create_mock_service() + + await _format_sheet_range_impl( + service=mock_service, + spreadsheet_id="test_spreadsheet_123", + range_name="A1:A1", + bold=True, + ) + + call_args = mock_service.spreadsheets().batchUpdate.call_args + request_body = call_args[1]["body"] + cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"] + assert cell_format["textFormat"]["bold"] is True + + +@pytest.mark.asyncio +async def test_format_italic_true(): + """Test italic=True applies italic text formatting""" + mock_service = create_mock_service() + + await _format_sheet_range_impl( + service=mock_service, + spreadsheet_id="test_spreadsheet_123", + range_name="A1:A1", + italic=True, + ) + + call_args = mock_service.spreadsheets().batchUpdate.call_args + request_body = call_args[1]["body"] + cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"] + assert cell_format["textFormat"]["italic"] is True + + +@pytest.mark.asyncio +async def test_format_font_size(): + """Test font_size applies specified font size""" + mock_service = create_mock_service() + + await _format_sheet_range_impl( + service=mock_service, + spreadsheet_id="test_spreadsheet_123", + range_name="A1:D5", + font_size=14, + ) + + call_args = mock_service.spreadsheets().batchUpdate.call_args + request_body = call_args[1]["body"] + cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"] + assert cell_format["textFormat"]["fontSize"] == 14 + + +@pytest.mark.asyncio +async def test_format_combined_text_formatting(): + """Test combining bold, italic, and font_size""" + mock_service = create_mock_service() + + await _format_sheet_range_impl( + service=mock_service, + spreadsheet_id="test_spreadsheet_123", + range_name="A1:A1", + bold=True, + italic=True, + font_size=16, + ) + + call_args = mock_service.spreadsheets().batchUpdate.call_args + request_body = call_args[1]["body"] + cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"] + text_format = cell_format["textFormat"] + assert text_format["bold"] is True + assert text_format["italic"] is True + assert text_format["fontSize"] == 16 + + +@pytest.mark.asyncio +async def test_format_combined_alignment_and_wrap(): + """Test combining wrap_strategy with alignments""" + mock_service = create_mock_service() + + await _format_sheet_range_impl( + service=mock_service, + spreadsheet_id="test_spreadsheet_123", + range_name="A1:C10", + wrap_strategy="WRAP", + horizontal_alignment="CENTER", + vertical_alignment="TOP", + ) + + call_args = mock_service.spreadsheets().batchUpdate.call_args + request_body = call_args[1]["body"] + cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"] + assert cell_format["wrapStrategy"] == "WRAP" + assert cell_format["horizontalAlignment"] == "CENTER" + assert cell_format["verticalAlignment"] == "TOP" + + +@pytest.mark.asyncio +async def test_format_all_new_params_with_existing(): + """Test combining new params with existing color params""" + mock_service = create_mock_service() + + result = await _format_sheet_range_impl( + service=mock_service, + spreadsheet_id="test_spreadsheet_123", + range_name="A1:D10", + background_color="#FFFFFF", + text_color="#000000", + wrap_strategy="WRAP", + horizontal_alignment="LEFT", + vertical_alignment="MIDDLE", + bold=True, + font_size=12, + ) + + assert result["spreadsheet_id"] == "test_spreadsheet_123" + call_args = mock_service.spreadsheets().batchUpdate.call_args + request_body = call_args[1]["body"] + cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"] + + assert cell_format["wrapStrategy"] == "WRAP" + assert cell_format["horizontalAlignment"] == "LEFT" + assert cell_format["verticalAlignment"] == "MIDDLE" + assert cell_format["textFormat"]["bold"] is True + assert cell_format["textFormat"]["fontSize"] == 12 + assert "backgroundColor" in cell_format + + +@pytest.mark.asyncio +async def test_format_invalid_wrap_strategy(): + """Test invalid wrap_strategy raises error""" + mock_service = create_mock_service() + + from core.utils import UserInputError + + with pytest.raises(UserInputError) as exc_info: + await _format_sheet_range_impl( + service=mock_service, + spreadsheet_id="test_spreadsheet_123", + range_name="A1:A1", + wrap_strategy="INVALID", + ) + + error_msg = str(exc_info.value).lower() + assert "wrap_strategy" in error_msg or "wrap" in error_msg + + +@pytest.mark.asyncio +async def test_format_invalid_horizontal_alignment(): + """Test invalid horizontal_alignment raises error""" + mock_service = create_mock_service() + + from core.utils import UserInputError + + with pytest.raises(UserInputError) as exc_info: + await _format_sheet_range_impl( + service=mock_service, + spreadsheet_id="test_spreadsheet_123", + range_name="A1:A1", + horizontal_alignment="INVALID", + ) + + error_msg = str(exc_info.value).lower() + assert "horizontal" in error_msg or "left" in error_msg + + +@pytest.mark.asyncio +async def test_format_invalid_vertical_alignment(): + """Test invalid vertical_alignment raises error""" + mock_service = create_mock_service() + + from core.utils import UserInputError + + with pytest.raises(UserInputError) as exc_info: + await _format_sheet_range_impl( + service=mock_service, + spreadsheet_id="test_spreadsheet_123", + range_name="A1:A1", + vertical_alignment="INVALID", + ) + + error_msg = str(exc_info.value).lower() + assert "vertical" in error_msg or "top" in error_msg + + +@pytest.mark.asyncio +async def test_format_case_insensitive_wrap_strategy(): + """Test wrap_strategy accepts lowercase input""" + mock_service = create_mock_service() + + await _format_sheet_range_impl( + service=mock_service, + spreadsheet_id="test_spreadsheet_123", + range_name="A1:A1", + wrap_strategy="wrap", + ) + + call_args = mock_service.spreadsheets().batchUpdate.call_args + request_body = call_args[1]["body"] + cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"] + assert cell_format["wrapStrategy"] == "WRAP" + + +@pytest.mark.asyncio +async def test_format_case_insensitive_alignment(): + """Test alignments accept lowercase input""" + mock_service = create_mock_service() + + await _format_sheet_range_impl( + service=mock_service, + spreadsheet_id="test_spreadsheet_123", + range_name="A1:A1", + horizontal_alignment="center", + vertical_alignment="middle", + ) + + call_args = mock_service.spreadsheets().batchUpdate.call_args + request_body = call_args[1]["body"] + cell_format = request_body["requests"][0]["repeatCell"]["cell"]["userEnteredFormat"] + assert cell_format["horizontalAlignment"] == "CENTER" + assert cell_format["verticalAlignment"] == "MIDDLE" + + +@pytest.mark.asyncio +async def test_format_confirmation_message_includes_new_params(): + """Test confirmation message mentions new formatting applied""" + mock_service = create_mock_service() + + result = await _format_sheet_range_impl( + service=mock_service, + spreadsheet_id="test_spreadsheet_123", + range_name="A1:C10", + wrap_strategy="WRAP", + bold=True, + font_size=14, + ) + + assert result["spreadsheet_id"] == "test_spreadsheet_123" + assert result["range_name"] == "A1:C10" diff --git a/tests/test_main_permissions_tier.py b/tests/test_main_permissions_tier.py new file mode 100644 index 0000000..2805521 --- /dev/null +++ b/tests/test_main_permissions_tier.py @@ -0,0 +1,60 @@ +import os +import sys + +import pytest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +import main + + +def test_resolve_permissions_mode_selection_without_tier(): + services = ["gmail", "drive"] + resolved_services, tier_tool_filter = main.resolve_permissions_mode_selection( + services, None + ) + assert resolved_services == services + assert tier_tool_filter is None + + +def test_resolve_permissions_mode_selection_with_tier_filters_services(monkeypatch): + def fake_resolve_tools_from_tier(tier, services): + assert tier == "core" + assert services == ["gmail", "drive", "slides"] + return ["search_gmail_messages"], ["gmail"] + + monkeypatch.setattr(main, "resolve_tools_from_tier", fake_resolve_tools_from_tier) + + resolved_services, tier_tool_filter = main.resolve_permissions_mode_selection( + ["gmail", "drive", "slides"], "core" + ) + assert resolved_services == ["gmail"] + assert tier_tool_filter == {"search_gmail_messages"} + + +def test_narrow_permissions_to_services_keeps_selected_order(): + permissions = {"drive": "full", "gmail": "readonly", "calendar": "readonly"} + narrowed = main.narrow_permissions_to_services(permissions, ["gmail", "drive"]) + assert narrowed == {"gmail": "readonly", "drive": "full"} + + +def test_narrow_permissions_to_services_drops_non_selected_services(): + permissions = {"gmail": "send", "drive": "full"} + narrowed = main.narrow_permissions_to_services(permissions, ["gmail"]) + assert narrowed == {"gmail": "send"} + + +def test_permissions_and_tools_flags_are_rejected(monkeypatch, capsys): + monkeypatch.setattr(main, "configure_safe_logging", lambda: None) + monkeypatch.setattr( + sys, + "argv", + ["main.py", "--permissions", "gmail:readonly", "--tools", "gmail"], + ) + + with pytest.raises(SystemExit) as exc: + main.main() + + assert exc.value.code == 1 + captured = capsys.readouterr() + assert "--permissions and --tools cannot be combined" in captured.err diff --git a/tests/test_permissions.py b/tests/test_permissions.py new file mode 100644 index 0000000..66f5d62 --- /dev/null +++ b/tests/test_permissions.py @@ -0,0 +1,201 @@ +""" +Unit tests for granular per-service permission parsing and scope resolution. + +Covers parse_permissions_arg() validation (format, duplicates, unknown +service/level) and cumulative scope expansion in get_scopes_for_permission(). +""" + +import sys +import os + +import pytest + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from auth.permissions import ( + get_scopes_for_permission, + is_action_denied, + parse_permissions_arg, + set_permissions, + SERVICE_PERMISSION_LEVELS, +) +from auth.scopes import ( + GMAIL_READONLY_SCOPE, + GMAIL_LABELS_SCOPE, + GMAIL_MODIFY_SCOPE, + GMAIL_COMPOSE_SCOPE, + DRIVE_READONLY_SCOPE, + DRIVE_SCOPE, + TASKS_READONLY_SCOPE, + TASKS_SCOPE, + DRIVE_FILE_SCOPE, +) + + +class TestParsePermissionsArg: + """Tests for parse_permissions_arg().""" + + def test_single_valid_entry(self): + result = parse_permissions_arg(["gmail:readonly"]) + assert result == {"gmail": "readonly"} + + def test_multiple_valid_entries(self): + result = parse_permissions_arg(["gmail:organize", "drive:full"]) + assert result == {"gmail": "organize", "drive": "full"} + + def test_all_services_at_readonly(self): + entries = [f"{svc}:readonly" for svc in SERVICE_PERMISSION_LEVELS] + result = parse_permissions_arg(entries) + assert set(result.keys()) == set(SERVICE_PERMISSION_LEVELS.keys()) + + def test_missing_colon_raises(self): + with pytest.raises(ValueError, match="Invalid permission format"): + parse_permissions_arg(["gmail_readonly"]) + + def test_duplicate_service_raises(self): + with pytest.raises(ValueError, match="Duplicate service"): + parse_permissions_arg(["gmail:readonly", "gmail:full"]) + + def test_unknown_service_raises(self): + with pytest.raises(ValueError, match="Unknown service"): + parse_permissions_arg(["fakesvc:readonly"]) + + def test_unknown_level_raises(self): + with pytest.raises(ValueError, match="Unknown level"): + parse_permissions_arg(["gmail:superadmin"]) + + def test_empty_list_returns_empty(self): + assert parse_permissions_arg([]) == {} + + def test_extra_colon_in_value(self): + """A level containing a colon should fail as unknown level.""" + with pytest.raises(ValueError, match="Unknown level"): + parse_permissions_arg(["gmail:read:only"]) + + def test_tasks_manage_is_valid_level(self): + """tasks:manage should be accepted by parse_permissions_arg.""" + result = parse_permissions_arg(["tasks:manage"]) + assert result == {"tasks": "manage"} + + +class TestGetScopesForPermission: + """Tests for get_scopes_for_permission() cumulative scope expansion.""" + + def test_gmail_readonly_returns_readonly_scope(self): + scopes = get_scopes_for_permission("gmail", "readonly") + assert GMAIL_READONLY_SCOPE in scopes + + def test_gmail_organize_includes_readonly(self): + """Organize level should cumulatively include readonly scopes.""" + scopes = get_scopes_for_permission("gmail", "organize") + assert GMAIL_READONLY_SCOPE in scopes + assert GMAIL_LABELS_SCOPE in scopes + assert GMAIL_MODIFY_SCOPE in scopes + + def test_gmail_drafts_includes_organize_and_readonly(self): + scopes = get_scopes_for_permission("gmail", "drafts") + assert GMAIL_READONLY_SCOPE in scopes + assert GMAIL_LABELS_SCOPE in scopes + assert GMAIL_COMPOSE_SCOPE in scopes + + def test_drive_readonly_excludes_full(self): + scopes = get_scopes_for_permission("drive", "readonly") + assert DRIVE_READONLY_SCOPE in scopes + assert DRIVE_SCOPE not in scopes + assert DRIVE_FILE_SCOPE not in scopes + + def test_drive_full_includes_readonly(self): + scopes = get_scopes_for_permission("drive", "full") + assert DRIVE_READONLY_SCOPE in scopes + assert DRIVE_SCOPE in scopes + + def test_unknown_service_raises(self): + with pytest.raises(ValueError, match="Unknown service"): + get_scopes_for_permission("nonexistent", "readonly") + + def test_unknown_level_raises(self): + with pytest.raises(ValueError, match="Unknown permission level"): + get_scopes_for_permission("gmail", "nonexistent") + + def test_no_duplicate_scopes(self): + """Cumulative expansion should deduplicate scopes.""" + for service, levels in SERVICE_PERMISSION_LEVELS.items(): + for level_name, _ in levels: + scopes = get_scopes_for_permission(service, level_name) + assert len(scopes) == len(set(scopes)), ( + f"Duplicate scopes for {service}:{level_name}" + ) + + def test_tasks_manage_includes_write_scope(self): + """Manage level should cumulatively include readonly and write scopes.""" + scopes = get_scopes_for_permission("tasks", "manage") + assert TASKS_SCOPE in scopes + assert TASKS_READONLY_SCOPE in scopes + + def test_tasks_full_includes_write_scope(self): + """Full level should include write and readonly scopes from lower levels.""" + scopes = get_scopes_for_permission("tasks", "full") + assert TASKS_SCOPE in scopes + assert TASKS_READONLY_SCOPE in scopes + + +@pytest.fixture(autouse=True) +def _reset_permissions_state(): + """Ensure each test starts and ends with no active permissions.""" + set_permissions(None) + yield + set_permissions(None) + + +class TestIsActionDenied: + """Tests for is_action_denied() and SERVICE_DENIED_ACTIONS.""" + + def test_no_permissions_mode_allows_all(self): + """Without granular permissions, no action is denied.""" + set_permissions(None) + assert is_action_denied("tasks", "delete") is False + + def test_tasks_full_allows_delete(self): + """Full level should not deny delete.""" + set_permissions({"tasks": "full"}) + assert is_action_denied("tasks", "delete") is False + + def test_tasks_manage_denies_delete(self): + """Manage level should deny delete.""" + set_permissions({"tasks": "manage"}) + assert is_action_denied("tasks", "delete") is True + + def test_tasks_manage_allows_create(self): + """Manage level should allow create.""" + set_permissions({"tasks": "manage"}) + assert is_action_denied("tasks", "create") is False + + def test_tasks_manage_allows_update(self): + """Manage level should allow update.""" + set_permissions({"tasks": "manage"}) + assert is_action_denied("tasks", "update") is False + + def test_tasks_manage_allows_move(self): + """Manage level should allow move.""" + set_permissions({"tasks": "manage"}) + assert is_action_denied("tasks", "move") is False + + def test_tasks_manage_denies_clear_completed(self): + """Manage level should deny clear_completed.""" + set_permissions({"tasks": "manage"}) + assert is_action_denied("tasks", "clear_completed") is True + + def test_tasks_full_allows_clear_completed(self): + """Full level should not deny clear_completed.""" + set_permissions({"tasks": "full"}) + assert is_action_denied("tasks", "clear_completed") is False + + def test_service_not_in_permissions_allows_all(self): + """A service not listed in permissions should allow all actions.""" + set_permissions({"gmail": "readonly"}) + assert is_action_denied("tasks", "delete") is False + + def test_service_without_denied_actions_allows_all(self): + """A service with no SERVICE_DENIED_ACTIONS entry should allow all actions.""" + set_permissions({"gmail": "readonly"}) + assert is_action_denied("gmail", "delete") is False diff --git a/tests/test_scopes.py b/tests/test_scopes.py new file mode 100644 index 0000000..502df3d --- /dev/null +++ b/tests/test_scopes.py @@ -0,0 +1,231 @@ +""" +Unit tests for cross-service scope generation. + +Verifies that docs and sheets tools automatically include the Drive scopes +they need for operations like search_docs, list_docs_in_folder, +export_doc_to_pdf, and list_spreadsheets — without requiring --tools drive. +""" + +import sys +import os + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from auth.scopes import ( + BASE_SCOPES, + CALENDAR_READONLY_SCOPE, + CALENDAR_SCOPE, + CONTACTS_READONLY_SCOPE, + CONTACTS_SCOPE, + DRIVE_FILE_SCOPE, + DRIVE_READONLY_SCOPE, + DRIVE_SCOPE, + GMAIL_COMPOSE_SCOPE, + GMAIL_LABELS_SCOPE, + GMAIL_MODIFY_SCOPE, + GMAIL_READONLY_SCOPE, + GMAIL_SEND_SCOPE, + GMAIL_SETTINGS_BASIC_SCOPE, + SHEETS_READONLY_SCOPE, + SHEETS_WRITE_SCOPE, + get_scopes_for_tools, + has_required_scopes, + set_read_only, +) +from auth.permissions import get_scopes_for_permission, set_permissions +import auth.permissions as permissions_module + + +class TestDocsScopes: + """Tests for docs tool scope generation.""" + + def test_docs_includes_drive_readonly(self): + """search_docs, get_doc_content, list_docs_in_folder need drive.readonly.""" + scopes = get_scopes_for_tools(["docs"]) + assert DRIVE_READONLY_SCOPE in scopes + + def test_docs_includes_drive_file(self): + """export_doc_to_pdf needs drive.file to create the PDF.""" + scopes = get_scopes_for_tools(["docs"]) + assert DRIVE_FILE_SCOPE in scopes + + def test_docs_does_not_include_full_drive(self): + """docs should NOT request full drive access.""" + scopes = get_scopes_for_tools(["docs"]) + assert DRIVE_SCOPE not in scopes + + +class TestSheetsScopes: + """Tests for sheets tool scope generation.""" + + def test_sheets_includes_drive_readonly(self): + """list_spreadsheets needs drive.readonly.""" + scopes = get_scopes_for_tools(["sheets"]) + assert DRIVE_READONLY_SCOPE in scopes + + def test_sheets_does_not_include_full_drive(self): + """sheets should NOT request full drive access.""" + scopes = get_scopes_for_tools(["sheets"]) + assert DRIVE_SCOPE not in scopes + + +class TestCombinedScopes: + """Tests for combined tool scope generation.""" + + def test_docs_sheets_no_duplicate_drive_readonly(self): + """Combined docs+sheets should deduplicate drive.readonly.""" + scopes = get_scopes_for_tools(["docs", "sheets"]) + assert scopes.count(DRIVE_READONLY_SCOPE) <= 1 + + def test_docs_sheets_returns_unique_scopes(self): + """All returned scopes should be unique.""" + scopes = get_scopes_for_tools(["docs", "sheets"]) + assert len(scopes) == len(set(scopes)) + + +class TestReadOnlyScopes: + """Tests for read-only mode scope generation.""" + + def setup_method(self): + set_read_only(False) + + def teardown_method(self): + set_read_only(False) + + def test_docs_readonly_includes_drive_readonly(self): + """Even in read-only mode, docs needs drive.readonly for search/list.""" + set_read_only(True) + scopes = get_scopes_for_tools(["docs"]) + assert DRIVE_READONLY_SCOPE in scopes + + def test_docs_readonly_excludes_drive_file(self): + """In read-only mode, docs should NOT request drive.file.""" + set_read_only(True) + scopes = get_scopes_for_tools(["docs"]) + assert DRIVE_FILE_SCOPE not in scopes + + def test_sheets_readonly_includes_drive_readonly(self): + """Even in read-only mode, sheets needs drive.readonly for list.""" + set_read_only(True) + scopes = get_scopes_for_tools(["sheets"]) + assert DRIVE_READONLY_SCOPE in scopes + + +class TestHasRequiredScopes: + """Tests for hierarchy-aware scope checking.""" + + def test_exact_match(self): + """Exact scope match should pass.""" + assert has_required_scopes([GMAIL_READONLY_SCOPE], [GMAIL_READONLY_SCOPE]) + + def test_missing_scope_fails(self): + """Missing scope with no covering broader scope should fail.""" + assert not has_required_scopes([GMAIL_READONLY_SCOPE], [GMAIL_SEND_SCOPE]) + + def test_empty_available_fails(self): + """Empty available scopes should fail when scopes are required.""" + assert not has_required_scopes([], [GMAIL_READONLY_SCOPE]) + + def test_empty_required_passes(self): + """No required scopes should always pass.""" + assert has_required_scopes([], []) + assert has_required_scopes([GMAIL_READONLY_SCOPE], []) + + def test_none_available_fails(self): + """None available scopes should fail when scopes are required.""" + assert not has_required_scopes(None, [GMAIL_READONLY_SCOPE]) + + def test_none_available_empty_required_passes(self): + """None available with no required scopes should pass.""" + assert has_required_scopes(None, []) + + # Gmail hierarchy: gmail.modify covers readonly, send, compose, labels + def test_gmail_modify_covers_readonly(self): + assert has_required_scopes([GMAIL_MODIFY_SCOPE], [GMAIL_READONLY_SCOPE]) + + def test_gmail_modify_covers_send(self): + assert has_required_scopes([GMAIL_MODIFY_SCOPE], [GMAIL_SEND_SCOPE]) + + def test_gmail_modify_covers_compose(self): + assert has_required_scopes([GMAIL_MODIFY_SCOPE], [GMAIL_COMPOSE_SCOPE]) + + def test_gmail_modify_covers_labels(self): + assert has_required_scopes([GMAIL_MODIFY_SCOPE], [GMAIL_LABELS_SCOPE]) + + def test_gmail_modify_does_not_cover_settings(self): + """gmail.modify does NOT cover gmail.settings.basic.""" + assert not has_required_scopes( + [GMAIL_MODIFY_SCOPE], [GMAIL_SETTINGS_BASIC_SCOPE] + ) + + def test_gmail_modify_covers_multiple_children(self): + """gmail.modify should satisfy multiple child scopes at once.""" + assert has_required_scopes( + [GMAIL_MODIFY_SCOPE], + [GMAIL_READONLY_SCOPE, GMAIL_SEND_SCOPE, GMAIL_LABELS_SCOPE], + ) + + # Drive hierarchy: drive covers drive.readonly and drive.file + def test_drive_covers_readonly(self): + assert has_required_scopes([DRIVE_SCOPE], [DRIVE_READONLY_SCOPE]) + + def test_drive_covers_file(self): + assert has_required_scopes([DRIVE_SCOPE], [DRIVE_FILE_SCOPE]) + + def test_drive_readonly_does_not_cover_full(self): + """Narrower scope should not satisfy broader scope.""" + assert not has_required_scopes([DRIVE_READONLY_SCOPE], [DRIVE_SCOPE]) + + # Other hierarchies + def test_calendar_covers_readonly(self): + assert has_required_scopes([CALENDAR_SCOPE], [CALENDAR_READONLY_SCOPE]) + + def test_sheets_write_covers_readonly(self): + assert has_required_scopes([SHEETS_WRITE_SCOPE], [SHEETS_READONLY_SCOPE]) + + def test_contacts_covers_readonly(self): + assert has_required_scopes([CONTACTS_SCOPE], [CONTACTS_READONLY_SCOPE]) + + # Mixed: some exact, some via hierarchy + def test_mixed_exact_and_hierarchy(self): + """Combination of exact matches and hierarchy-implied scopes.""" + available = [GMAIL_MODIFY_SCOPE, DRIVE_READONLY_SCOPE] + required = [GMAIL_READONLY_SCOPE, DRIVE_READONLY_SCOPE] + assert has_required_scopes(available, required) + + def test_mixed_partial_failure(self): + """Should fail if hierarchy covers some but not all required scopes.""" + available = [GMAIL_MODIFY_SCOPE] + required = [GMAIL_READONLY_SCOPE, DRIVE_READONLY_SCOPE] + assert not has_required_scopes(available, required) + + +class TestGranularPermissionsScopes: + """Tests for granular permissions scope generation path.""" + + def setup_method(self): + set_read_only(False) + permissions_module._PERMISSIONS = None + + def teardown_method(self): + set_read_only(False) + permissions_module._PERMISSIONS = None + + def test_permissions_mode_returns_base_plus_permission_scopes(self): + set_permissions({"gmail": "send", "drive": "readonly"}) + scopes = get_scopes_for_tools(["calendar"]) # ignored in permissions mode + + expected = set(BASE_SCOPES) + expected.update(get_scopes_for_permission("gmail", "send")) + expected.update(get_scopes_for_permission("drive", "readonly")) + assert set(scopes) == expected + + def test_permissions_mode_overrides_read_only_and_full_maps(self): + set_read_only(True) + without_permissions = get_scopes_for_tools(["drive"]) + assert DRIVE_READONLY_SCOPE in without_permissions + + set_permissions({"gmail": "readonly"}) + with_permissions = get_scopes_for_tools(["drive"]) + assert GMAIL_READONLY_SCOPE in with_permissions + assert DRIVE_READONLY_SCOPE not in with_permissions diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..a14289c --- /dev/null +++ b/uv.lock @@ -0,0 +1,2195 @@ +version = 1 +revision = 1 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version < '3.13'", +] + +[[package]] +name = "aiofile" +version = "3.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "caio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539 }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303 }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592 }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615 }, +] + +[[package]] +name = "authlib" +version = "1.6.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/98/00d3dd826d46959ad8e32af2dbb2398868fd9fd0683c26e56d0789bd0e68/authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04", size = 165134 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/23/b65f568ed0c22f1efacb744d2db1a33c8068f384b8c9b482b52ebdbc3ef6/authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3", size = 244197 }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313 }, +] + +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181 }, +] + +[[package]] +name = "beartype" +version = "0.22.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658 }, +] + +[[package]] +name = "cachetools" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/af/df70e9b65bc77a1cbe0768c0aa4617147f30f8306ded98c1744bcdc0ae1e/cachetools-7.0.0.tar.gz", hash = "sha256:a9abf18ff3b86c7d05b27ead412e235e16ae045925e531fae38d5fada5ed5b08", size = 35796 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/df/2dd32cce20cbcf6f2ec456b58d44368161ad28320729f64e5e1d5d7bd0ae/cachetools-7.0.0-py3-none-any.whl", hash = "sha256:d52fef60e6e964a1969cfb61ccf6242a801b432790fe520d78720d757c81cbd2", size = 13487 }, +] + +[[package]] +name = "caio" +version = "0.9.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/80/ea4ead0c5d52a9828692e7df20f0eafe8d26e671ce4883a0a146bb91049e/caio-0.9.25-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ca6c8ecda611478b6016cb94d23fd3eb7124852b985bdec7ecaad9f3116b9619", size = 36836 }, + { url = "https://files.pythonhosted.org/packages/17/b9/36715c97c873649d1029001578f901b50250916295e3dddf20c865438865/caio-0.9.25-cp310-cp310-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db9b5681e4af8176159f0d6598e73b2279bb661e718c7ac23342c550bd78c241", size = 79695 }, + { url = "https://files.pythonhosted.org/packages/0b/ab/07080ecb1adb55a02cbd8ec0126aa8e43af343ffabb6a71125b42670e9a1/caio-0.9.25-cp310-cp310-manylinux_2_34_aarch64.whl", hash = "sha256:bf61d7d0c4fd10ffdd98ca47f7e8db4d7408e74649ffaf4bef40b029ada3c21b", size = 79457 }, + { url = "https://files.pythonhosted.org/packages/88/95/dd55757bb671eb4c376e006c04e83beb413486821f517792ea603ef216e9/caio-0.9.25-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:ab52e5b643f8bbd64a0605d9412796cd3464cb8ca88593b13e95a0f0b10508ae", size = 77705 }, + { url = "https://files.pythonhosted.org/packages/ec/90/543f556fcfcfa270713eef906b6352ab048e1e557afec12925c991dc93c2/caio-0.9.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d6956d9e4a27021c8bd6c9677f3a59eb1d820cc32d0343cea7961a03b1371965", size = 36839 }, + { url = "https://files.pythonhosted.org/packages/51/3b/36f3e8ec38dafe8de4831decd2e44c69303d2a3892d16ceda42afed44e1b/caio-0.9.25-cp311-cp311-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf84bfa039f25ad91f4f52944452a5f6f405e8afab4d445450978cd6241d1478", size = 80255 }, + { url = "https://files.pythonhosted.org/packages/df/ce/65e64867d928e6aff1b4f0e12dba0ef6d5bf412c240dc1df9d421ac10573/caio-0.9.25-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:ae3d62587332bce600f861a8de6256b1014d6485cfd25d68c15caf1611dd1f7c", size = 80052 }, + { url = "https://files.pythonhosted.org/packages/46/90/e278863c47e14ec58309aa2e38a45882fbe67b4cc29ec9bc8f65852d3e45/caio-0.9.25-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:fc220b8533dcf0f238a6b1a4a937f92024c71e7b10b5a2dfc1c73604a25709bc", size = 78273 }, + { url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983 }, + { url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012 }, + { url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502 }, + { url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200 }, + { url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979 }, + { url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900 }, + { url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523 }, + { url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243 }, + { url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978 }, + { url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832 }, + { url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565 }, + { url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071 }, + { url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087 }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900 }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283 }, + { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504 }, + { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811 }, + { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402 }, + { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217 }, + { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079 }, + { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475 }, + { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829 }, + { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211 }, + { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036 }, + { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184 }, + { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790 }, + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344 }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560 }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613 }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476 }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374 }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597 }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574 }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971 }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972 }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078 }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076 }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820 }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635 }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271 }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048 }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529 }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097 }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983 }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519 }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572 }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963 }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361 }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932 }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557 }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762 }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230 }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043 }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446 }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101 }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948 }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422 }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499 }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928 }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302 }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909 }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402 }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780 }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320 }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487 }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049 }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793 }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300 }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244 }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828 }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926 }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328 }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650 }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687 }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773 }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013 }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593 }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354 }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480 }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584 }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443 }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437 }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487 }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726 }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709 }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814 }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467 }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280 }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454 }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609 }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849 }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586 }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290 }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663 }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964 }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064 }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015 }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792 }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198 }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262 }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988 }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324 }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742 }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863 }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837 }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550 }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162 }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019 }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310 }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022 }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383 }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098 }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991 }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456 }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978 }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969 }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425 }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162 }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558 }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497 }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240 }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471 }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864 }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647 }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110 }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839 }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667 }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535 }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816 }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694 }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131 }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390 }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091 }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936 }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180 }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346 }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874 }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076 }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601 }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376 }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825 }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583 }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366 }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300 }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465 }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404 }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092 }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408 }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746 }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889 }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641 }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779 }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035 }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542 }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524 }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395 }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680 }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045 }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687 }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014 }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044 }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940 }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104 }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743 }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402 }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289 }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637 }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742 }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528 }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993 }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855 }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635 }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038 }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181 }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482 }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497 }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819 }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230 }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909 }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287 }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728 }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287 }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291 }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539 }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199 }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131 }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072 }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170 }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741 }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728 }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001 }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637 }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487 }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514 }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349 }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667 }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980 }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143 }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674 }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801 }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755 }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539 }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794 }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160 }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123 }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220 }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050 }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964 }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321 }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786 }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990 }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252 }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605 }, +] + +[[package]] +name = "cyclopts" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/93/6085aa89c3fff78a5180987354538d72e43b0db27e66a959302d0c07821a/cyclopts-4.5.1.tar.gz", hash = "sha256:fadc45304763fd9f5d6033727f176898d17a1778e194436964661a005078a3dd", size = 162075 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/7c/996760c30f1302704af57c66ff2d723f7d656d0d0b93563b5528a51484bb/cyclopts-4.5.1-py3-none-any.whl", hash = "sha256:0642c93601e554ca6b7b9abd81093847ea4448b2616280f2a0952416574e8c7a", size = 199807 }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604 }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094 }, +] + +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896 }, +] + +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196 }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604 }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740 }, +] + +[[package]] +name = "fastapi" +version = "0.128.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/93/6f8464c39697dfad67c0c37cb1d23f784096b08707506b559b36ef1ddf87/fastapi-0.128.3.tar.gz", hash = "sha256:ed99383fd96063447597d5aa2a9ec3973be198e3b4fc10c55f15c62efdb21c60", size = 377310 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/fb/6e46514575f7c4689d5aec223f47e847e77faada658da4d674c7e34a018f/fastapi-0.128.3-py3-none-any.whl", hash = "sha256:c8cdf7c2182c9a06bf9cfa3329819913c189dc86389b90d5709892053582db29", size = 105145 }, +] + +[[package]] +name = "fastmcp" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "authlib" }, + { name = "cyclopts" }, + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "jsonref" }, + { name = "jsonschema-path" }, + { name = "mcp" }, + { name = "openapi-pydantic" }, + { name = "opentelemetry-api" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, + { name = "pydantic", extra = ["email"] }, + { name = "pyperclip" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "uncalled-for" }, + { name = "uvicorn" }, + { name = "watchfiles" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/83/c95d3bf717698a693eccb43e137a32939d2549876e884e246028bff6ecce/fastmcp-3.1.1.tar.gz", hash = "sha256:db184b5391a31199323766a3abf3a8bfbb8010479f77eca84c0e554f18655c48", size = 17347644 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/ea/570122de7e24f72138d006f799768e14cc1ccf7fcb22b7750b2bd276c711/fastmcp-3.1.1-py3-none-any.whl", hash = "sha256:8132ba069d89f14566b3266919d6d72e2ec23dd45d8944622dca407e9beda7eb", size = 633754 }, +] + +[[package]] +name = "google-api-core" +version = "2.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/10/05572d33273292bac49c2d1785925f7bc3ff2fe50e3044cf1062c1dde32e/google_api_core-2.29.0.tar.gz", hash = "sha256:84181be0f8e6b04006df75ddfe728f24489f0af57c96a529ff7cf45bc28797f7", size = 177828 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/b6/85c4d21067220b9a78cfb81f516f9725ea6befc1544ec9bd2c1acd97c324/google_api_core-2.29.0-py3-none-any.whl", hash = "sha256:d30bc60980daa36e314b5d5a3e5958b0200cb44ca8fa1be2b614e932b75a3ea9", size = 173906 }, +] + +[[package]] +name = "google-api-python-client" +version = "2.189.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, + { name = "google-auth-httplib2" }, + { name = "httplib2" }, + { name = "uritemplate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/f8/0783aeca3410ee053d4dd1fccafd85197847b8f84dd038e036634605d083/google_api_python_client-2.189.0.tar.gz", hash = "sha256:45f2d8559b5c895dde6ad3fb33de025f5cb2c197fa5862f18df7f5295a172741", size = 13979470 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/44/3677ff27998214f2fa7957359da48da378a0ffff1bd0bdaba42e752bc13e/google_api_python_client-2.189.0-py3-none-any.whl", hash = "sha256:a258c09660a49c6159173f8bbece171278e917e104a11f0640b34751b79c8a1a", size = 14547633 }, +] + +[[package]] +name = "google-auth" +version = "2.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499 }, +] + +[[package]] +name = "google-auth-httplib2" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "httplib2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/ad/c1f2b1175096a8d04cf202ad5ea6065f108d26be6fc7215876bde4a7981d/google_auth_httplib2-0.3.0.tar.gz", hash = "sha256:177898a0175252480d5ed916aeea183c2df87c1f9c26705d74ae6b951c268b0b", size = 11134 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/d5/3c97526c8796d3caf5f4b3bed2b05e8a7102326f00a334e7a438237f3b22/google_auth_httplib2-0.3.0-py3-none-any.whl", hash = "sha256:426167e5df066e3f5a0fc7ea18768c08e7296046594ce4c8c409c2457dd1f776", size = 9529 }, +] + +[[package]] +name = "google-auth-oauthlib" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "requests-oauthlib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/dd/211f27c1e927e2292c2a71d5df1a2aaf261ce50ba7d50848c6ee24e20970/google_auth_oauthlib-1.2.4.tar.gz", hash = "sha256:3ca93859c6cc9003c8e12b2a0868915209d7953f05a70f4880ab57d57e56ee3e", size = 21185 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/21/fb96db432d187b07756e62971c4d89bdef70259e4cfa76ee32bcc0ac97d1/google_auth_oauthlib-1.2.4-py3-none-any.whl", hash = "sha256:0e922eea5f2baacaf8867febb782e46e7b153236c21592ed76ab3ddb77ffd772", size = 19193 }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.72.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515 }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, +] + +[[package]] +name = "httplib2" +version = "0.31.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/1f/e86365613582c027dda5ddb64e1010e57a3d53e99ab8a72093fa13d565ec/httplib2-0.31.2.tar.gz", hash = "sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24", size = 250800 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/90/fd509079dfcab01102c0fdd87f3a9506894bc70afcf9e9785ef6b2b3aff6/httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349", size = 91099 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960 }, +] + +[[package]] +name = "id" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/04/c2156091427636080787aac190019dc64096e56a23b7364d3c1764ee3a06/id-1.6.1.tar.gz", hash = "sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069", size = 18088 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl", hash = "sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca", size = 14689 }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865 }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777 }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/9c/a788f5bb29c61e456b8ee52ce76dbdd32fd72cd73dd67bc95f42c7a8d13c/jaraco_context-6.1.0.tar.gz", hash = "sha256:129a341b0a85a7db7879e22acd66902fda67882db771754574338898b2d5d86f", size = 15850 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/48/aa685dbf1024c7bd82bede569e3a85f82c32fd3d79ba5fea578f0159571a/jaraco_context-6.1.0-py3-none-any.whl", hash = "sha256:a43b5ed85815223d0d3cfdb6d7ca0d2bc8946f28f30b6f3216bda070f68badda", size = 7065 }, +] + +[[package]] +name = "jaraco-functools" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481 }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010 }, +] + +[[package]] +name = "jsonref" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425 }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630 }, +] + +[[package]] +name = "jsonschema-path" +version = "0.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pathable" }, + { name = "pyyaml" }, + { name = "referencing" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/45/41ebc679c2a4fced6a722f624c18d658dee42612b83ea24c1caf7c0eb3a8/jsonschema_path-0.3.4.tar.gz", hash = "sha256:8365356039f16cc65fddffafda5f58766e34bebab7d6d105616ab52bc4297001", size = 11159 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/58/3485da8cb93d2f393bce453adeef16896751f14ba3e2024bc21dc9597646/jsonschema_path-0.3.4-py3-none-any.whl", hash = "sha256:f502191fdc2b22050f9a81c9237be9d27145b9001c55842bece5e94e382e52f8", size = 14810 }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160 }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321 }, +] + +[[package]] +name = "mcp" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "more-itertools" +version = "10.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/5d/38b681d3fce7a266dd9ab73c66959406d565b3e85f21d5e66e1181d93721/more_itertools-10.8.0.tar.gz", hash = "sha256:f638ddf8a1a0d134181275fb5d58b086ead7c6a72429ad725c67503f13ba30bd", size = 137431 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl", hash = "sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b", size = 69667 }, +] + +[[package]] +name = "nh3" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/a5/34c26015d3a434409f4d2a1cd8821a06c05238703f49283ffeb937bef093/nh3-0.3.2.tar.gz", hash = "sha256:f394759a06df8b685a4ebfb1874fb67a9cbfd58c64fc5ed587a663c0e63ec376", size = 19288 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/01/a1eda067c0ba823e5e2bb033864ae4854549e49fb6f3407d2da949106bfb/nh3-0.3.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d18957a90806d943d141cc5e4a0fefa1d77cf0d7a156878bf9a66eed52c9cc7d", size = 1419839 }, + { url = "https://files.pythonhosted.org/packages/30/57/07826ff65d59e7e9cc789ef1dc405f660cabd7458a1864ab58aefa17411b/nh3-0.3.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45c953e57028c31d473d6b648552d9cab1efe20a42ad139d78e11d8f42a36130", size = 791183 }, + { url = "https://files.pythonhosted.org/packages/af/2f/e8a86f861ad83f3bb5455f596d5c802e34fcdb8c53a489083a70fd301333/nh3-0.3.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c9850041b77a9147d6bbd6dbbf13eeec7009eb60b44e83f07fcb2910075bf9b", size = 829127 }, + { url = "https://files.pythonhosted.org/packages/d8/97/77aef4daf0479754e8e90c7f8f48f3b7b8725a3b8c0df45f2258017a6895/nh3-0.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:403c11563e50b915d0efdb622866d1d9e4506bce590ef7da57789bf71dd148b5", size = 997131 }, + { url = "https://files.pythonhosted.org/packages/41/ee/fd8140e4df9d52143e89951dd0d797f5546004c6043285289fbbe3112293/nh3-0.3.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0dca4365db62b2d71ff1620ee4f800c4729849906c5dd504ee1a7b2389558e31", size = 1068783 }, + { url = "https://files.pythonhosted.org/packages/87/64/bdd9631779e2d588b08391f7555828f352e7f6427889daf2fa424bfc90c9/nh3-0.3.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0fe7ee035dd7b2290715baf29cb27167dddd2ff70ea7d052c958dbd80d323c99", size = 994732 }, + { url = "https://files.pythonhosted.org/packages/79/66/90190033654f1f28ca98e3d76b8be1194505583f9426b0dcde782a3970a2/nh3-0.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a40202fd58e49129764f025bbaae77028e420f1d5b3c8e6f6fd3a6490d513868", size = 975997 }, + { url = "https://files.pythonhosted.org/packages/34/30/ebf8e2e8d71fdb5a5d5d8836207177aed1682df819cbde7f42f16898946c/nh3-0.3.2-cp314-cp314t-win32.whl", hash = "sha256:1f9ba555a797dbdcd844b89523f29cdc90973d8bd2e836ea6b962cf567cadd93", size = 583364 }, + { url = "https://files.pythonhosted.org/packages/94/ae/95c52b5a75da429f11ca8902c2128f64daafdc77758d370e4cc310ecda55/nh3-0.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:dce4248edc427c9b79261f3e6e2b3ecbdd9b88c267012168b4a7b3fc6fd41d13", size = 589982 }, + { url = "https://files.pythonhosted.org/packages/b4/bd/c7d862a4381b95f2469704de32c0ad419def0f4a84b7a138a79532238114/nh3-0.3.2-cp314-cp314t-win_arm64.whl", hash = "sha256:019ecbd007536b67fdf76fab411b648fb64e2257ca3262ec80c3425c24028c80", size = 577126 }, + { url = "https://files.pythonhosted.org/packages/b6/3e/f5a5cc2885c24be13e9b937441bd16a012ac34a657fe05e58927e8af8b7a/nh3-0.3.2-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7064ccf5ace75825bd7bf57859daaaf16ed28660c1c6b306b649a9eda4b54b1e", size = 1431980 }, + { url = "https://files.pythonhosted.org/packages/7f/f7/529a99324d7ef055de88b690858f4189379708abae92ace799365a797b7f/nh3-0.3.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8745454cdd28bbbc90861b80a0111a195b0e3961b9fa2e672be89eb199fa5d8", size = 820805 }, + { url = "https://files.pythonhosted.org/packages/3d/62/19b7c50ccd1fa7d0764822d2cea8f2a320f2fd77474c7a1805cb22cf69b0/nh3-0.3.2-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72d67c25a84579f4a432c065e8b4274e53b7cf1df8f792cf846abfe2c3090866", size = 803527 }, + { url = "https://files.pythonhosted.org/packages/4a/ca/f022273bab5440abff6302731a49410c5ef66b1a9502ba3fbb2df998d9ff/nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:13398e676a14d6233f372c75f52d5ae74f98210172991f7a3142a736bd92b131", size = 1051674 }, + { url = "https://files.pythonhosted.org/packages/fa/f7/5728e3b32a11daf5bd21cf71d91c463f74305938bc3eb9e0ac1ce141646e/nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03d617e5c8aa7331bd2659c654e021caf9bba704b109e7b2b28b039a00949fe5", size = 1004737 }, + { url = "https://files.pythonhosted.org/packages/53/7f/f17e0dba0a99cee29e6cee6d4d52340ef9cb1f8a06946d3a01eb7ec2fb01/nh3-0.3.2-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2f55c4d2d5a207e74eefe4d828067bbb01300e06e2a7436142f915c5928de07", size = 911745 }, + { url = "https://files.pythonhosted.org/packages/42/0f/c76bf3dba22c73c38e9b1113b017cf163f7696f50e003404ec5ecdb1e8a6/nh3-0.3.2-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb18403f02b655a1bbe4e3a4696c2ae1d6ae8f5991f7cacb684b1ae27e6c9f7", size = 797184 }, + { url = "https://files.pythonhosted.org/packages/08/a1/73d8250f888fb0ddf1b119b139c382f8903d8bb0c5bd1f64afc7e38dad1d/nh3-0.3.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6d66f41672eb4060cf87c037f760bdbc6847852ca9ef8e9c5a5da18f090abf87", size = 838556 }, + { url = "https://files.pythonhosted.org/packages/d1/09/deb57f1fb656a7a5192497f4a287b0ade5a2ff6b5d5de4736d13ef6d2c1f/nh3-0.3.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f97f8b25cb2681d25e2338148159447e4d689aafdccfcf19e61ff7db3905768a", size = 1006695 }, + { url = "https://files.pythonhosted.org/packages/b6/61/8f4d41c4ccdac30e4b1a4fa7be4b0f9914d8314a5058472f84c8e101a418/nh3-0.3.2-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2ab70e8c6c7d2ce953d2a58102eefa90c2d0a5ed7aa40c7e29a487bc5e613131", size = 1075471 }, + { url = "https://files.pythonhosted.org/packages/b0/c6/966aec0cb4705e69f6c3580422c239205d5d4d0e50fac380b21e87b6cf1b/nh3-0.3.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:1710f3901cd6440ca92494ba2eb6dc260f829fa8d9196b659fa10de825610ce0", size = 1002439 }, + { url = "https://files.pythonhosted.org/packages/e2/c8/97a2d5f7a314cce2c5c49f30c6f161b7f3617960ade4bfc2fd1ee092cb20/nh3-0.3.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91e9b001101fb4500a2aafe3e7c92928d85242d38bf5ac0aba0b7480da0a4cd6", size = 987439 }, + { url = "https://files.pythonhosted.org/packages/0d/95/2d6fc6461687d7a171f087995247dec33e8749a562bfadd85fb5dbf37a11/nh3-0.3.2-cp38-abi3-win32.whl", hash = "sha256:169db03df90da63286e0560ea0efa9b6f3b59844a9735514a1d47e6bb2c8c61b", size = 589826 }, + { url = "https://files.pythonhosted.org/packages/64/9a/1a1c154f10a575d20dd634e5697805e589bbdb7673a0ad00e8da90044ba7/nh3-0.3.2-cp38-abi3-win_amd64.whl", hash = "sha256:562da3dca7a17f9077593214a9781a94b8d76de4f158f8c895e62f09573945fe", size = 596406 }, + { url = "https://files.pythonhosted.org/packages/9e/7e/a96255f63b7aef032cbee8fc4d6e37def72e3aaedc1f72759235e8f13cb1/nh3-0.3.2-cp38-abi3-win_arm64.whl", hash = "sha256:cf5964d54edd405e68583114a7cba929468bcd7db5e676ae38ee954de1cfc104", size = 584162 }, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065 }, +] + +[[package]] +name = "openapi-pydantic" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381 }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.39.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/b9/3161be15bb8e3ad01be8be5a968a9237c3027c5be504362ff800fca3e442/opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c", size = 65767 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356 }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366 }, +] + +[[package]] +name = "pathable" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/67/93/8f2c2075b180c12c1e9f6a09d1a985bc2036906b13dff1d8917e395f2048/pathable-0.4.4.tar.gz", hash = "sha256:6905a3cd17804edfac7875b5f6c9142a218c7caef78693c2dbbbfbac186d88b2", size = 8124 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592 }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731 }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + +[[package]] +name = "proto-plus" +version = "1.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/02/8832cde80e7380c600fbf55090b6ab7b62bd6825dbedde6d6657c15a1f8e/proto_plus-1.27.1.tar.gz", hash = "sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147", size = 56929 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/79/ac273cbbf744691821a9cca88957257f41afe271637794975ca090b9588b/proto_plus-1.27.1-py3-none-any.whl", hash = "sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc", size = 50480 }, +] + +[[package]] +name = "protobuf" +version = "6.33.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769 }, + { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118 }, + { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766 }, + { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638 }, + { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411 }, + { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465 }, + { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687 }, +] + +[[package]] +name = "py-key-value-aio" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/3c/0397c072a38d4bc580994b42e0c90c5f44f679303489e4376289534735e5/py_key_value_aio-0.4.4.tar.gz", hash = "sha256:e3012e6243ed7cc09bb05457bd4d03b1ba5c2b1ca8700096b3927db79ffbbe55", size = 92300 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/69/f1b537ee70b7def42d63124a539ed3026a11a3ffc3086947a1ca6e861868/py_key_value_aio-0.4.4-py3-none-any.whl", hash = "sha256:18e17564ecae61b987f909fc2cd41ee2012c84b4b1dcb8c055cf8b4bc1bf3f5d", size = 152291 }, +] + +[package.optional-dependencies] +filetree = [ + { name = "aiofile" }, + { name = "anyio" }, +] +keyring = [ + { name = "keyring" }, +] +memory = [ + { name = "cachetools" }, +] +valkey = [ + { name = "valkey-glide" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371 }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259 }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580 }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298 }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475 }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815 }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567 }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442 }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956 }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253 }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050 }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178 }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833 }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156 }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378 }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622 }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873 }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826 }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869 }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890 }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740 }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021 }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378 }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761 }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303 }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355 }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875 }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549 }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305 }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902 }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990 }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003 }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200 }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578 }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504 }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816 }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366 }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698 }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603 }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591 }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068 }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908 }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145 }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179 }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403 }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206 }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307 }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258 }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917 }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186 }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164 }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146 }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788 }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133 }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852 }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679 }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766 }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005 }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622 }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725 }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040 }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691 }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897 }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302 }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877 }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680 }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960 }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102 }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039 }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126 }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489 }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288 }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255 }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760 }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092 }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385 }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832 }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585 }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078 }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914 }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560 }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244 }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955 }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906 }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607 }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769 }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351 }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363 }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615 }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369 }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218 }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951 }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428 }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009 }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980 }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865 }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256 }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762 }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141 }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317 }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992 }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302 }, +] + +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880 }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, +] + +[[package]] +name = "pyjwt" +version = "2.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726 }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781 }, +] + +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063 }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801 }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075 }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230 }, +] + +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579 }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432 }, + { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103 }, + { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557 }, + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031 }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308 }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930 }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543 }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040 }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102 }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700 }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700 }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318 }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714 }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800 }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540 }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227 }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019 }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646 }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793 }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293 }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872 }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828 }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415 }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561 }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114 }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638 }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463 }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986 }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, +] + +[[package]] +name = "readme-renderer" +version = "44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "nh3" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/a9/104ec9234c8448c4379768221ea6df01260cd6c2ce13182d4eac531c8342/readme_renderer-44.0.tar.gz", hash = "sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1", size = 32056 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl", hash = "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151", size = 13310 }, +] + +[[package]] +name = "referencing" +version = "0.36.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775 }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 }, +] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179 }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481 }, +] + +[[package]] +name = "rfc3986" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/40/1520d68bfa07ab5a6f065a186815fb6610c86fe957bc065754e47f7b0840/rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c", size = 49026 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", size = 31326 }, +] + +[[package]] +name = "rich" +version = "14.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/99/a4cab2acbb884f80e558b0771e97e21e939c5dfb460f488d19df485e8298/rich-14.3.2.tar.gz", hash = "sha256:e712f11c1a562a11843306f5ed999475f09ac31ffb64281f73ab29ffdda8b3b8", size = 230143 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/45/615f5babd880b4bd7d405cc0dc348234c5ffb6ed1ea33e152ede08b2072d/rich-14.3.2-py3-none-any.whl", hash = "sha256:08e67c3e90884651da3239ea668222d19bea7b589149d8014a21c633420dbb69", size = 309963 }, +] + +[[package]] +name = "rich-rst" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docutils" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/6d/a506aaa4a9eaa945ed8ab2b7347859f53593864289853c5d6d62b77246e0/rich_rst-1.3.2.tar.gz", hash = "sha256:a1196fdddf1e364b02ec68a05e8ff8f6914fee10fbca2e6b6735f166bb0da8d4", size = 14936 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/2f/b4530fbf948867702d0a3f27de4a6aab1d156f406d72852ab902c4d04de9/rich_rst-1.3.2-py3-none-any.whl", hash = "sha256:a99b4907cbe118cf9d18b0b44de272efa61f15117c61e39ebdc431baf5df722a", size = 12567 }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490 }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751 }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696 }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136 }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699 }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022 }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522 }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579 }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305 }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503 }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322 }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792 }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901 }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823 }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157 }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676 }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938 }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932 }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830 }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033 }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828 }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683 }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583 }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496 }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669 }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011 }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406 }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024 }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069 }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086 }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053 }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763 }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951 }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622 }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492 }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080 }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680 }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589 }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289 }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737 }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120 }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782 }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463 }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868 }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887 }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904 }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945 }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783 }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021 }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589 }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025 }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895 }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799 }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731 }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027 }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020 }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139 }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224 }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645 }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443 }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375 }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850 }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812 }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841 }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149 }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843 }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507 }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949 }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790 }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217 }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806 }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341 }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768 }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099 }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192 }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080 }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841 }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670 }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005 }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112 }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049 }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661 }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606 }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126 }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371 }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298 }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604 }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391 }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868 }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747 }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795 }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330 }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194 }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340 }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765 }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834 }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470 }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630 }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148 }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030 }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570 }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532 }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292 }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128 }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542 }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004 }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063 }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099 }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177 }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015 }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736 }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981 }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782 }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191 }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696 }, +] + +[[package]] +name = "ruff" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/39/5cee96809fbca590abea6b46c6d1c586b49663d1d2830a751cc8fc42c666/ruff-0.15.0.tar.gz", hash = "sha256:6bdea47cdbea30d40f8f8d7d69c0854ba7c15420ec75a26f463290949d7f7e9a", size = 4524893 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/88/3fd1b0aa4b6330d6aaa63a285bc96c9f71970351579152d231ed90914586/ruff-0.15.0-py3-none-linux_armv6l.whl", hash = "sha256:aac4ebaa612a82b23d45964586f24ae9bc23ca101919f5590bdb368d74ad5455", size = 10354332 }, + { url = "https://files.pythonhosted.org/packages/72/f6/62e173fbb7eb75cc29fe2576a1e20f0a46f671a2587b5f604bfb0eaf5f6f/ruff-0.15.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dcd4be7cc75cfbbca24a98d04d0b9b36a270d0833241f776b788d59f4142b14d", size = 10767189 }, + { url = "https://files.pythonhosted.org/packages/99/e4/968ae17b676d1d2ff101d56dc69cf333e3a4c985e1ec23803df84fc7bf9e/ruff-0.15.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d747e3319b2bce179c7c1eaad3d884dc0a199b5f4d5187620530adf9105268ce", size = 10075384 }, + { url = "https://files.pythonhosted.org/packages/a2/bf/9843c6044ab9e20af879c751487e61333ca79a2c8c3058b15722386b8cae/ruff-0.15.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:650bd9c56ae03102c51a5e4b554d74d825ff3abe4db22b90fd32d816c2e90621", size = 10481363 }, + { url = "https://files.pythonhosted.org/packages/55/d9/4ada5ccf4cd1f532db1c8d44b6f664f2208d3d93acbeec18f82315e15193/ruff-0.15.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6664b7eac559e3048223a2da77769c2f92b43a6dfd4720cef42654299a599c9", size = 10187736 }, + { url = "https://files.pythonhosted.org/packages/86/e2/f25eaecd446af7bb132af0a1d5b135a62971a41f5366ff41d06d25e77a91/ruff-0.15.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f811f97b0f092b35320d1556f3353bf238763420ade5d9e62ebd2b73f2ff179", size = 10968415 }, + { url = "https://files.pythonhosted.org/packages/e7/dc/f06a8558d06333bf79b497d29a50c3a673d9251214e0d7ec78f90b30aa79/ruff-0.15.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761ec0a66680fab6454236635a39abaf14198818c8cdf691e036f4bc0f406b2d", size = 11809643 }, + { url = "https://files.pythonhosted.org/packages/dd/45/0ece8db2c474ad7df13af3a6d50f76e22a09d078af63078f005057ca59eb/ruff-0.15.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:940f11c2604d317e797b289f4f9f3fa5555ffe4fb574b55ed006c3d9b6f0eb78", size = 11234787 }, + { url = "https://files.pythonhosted.org/packages/8a/d9/0e3a81467a120fd265658d127db648e4d3acfe3e4f6f5d4ea79fac47e587/ruff-0.15.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcbca3d40558789126da91d7ef9a7c87772ee107033db7191edefa34e2c7f1b4", size = 11112797 }, + { url = "https://files.pythonhosted.org/packages/b2/cb/8c0b3b0c692683f8ff31351dfb6241047fa873a4481a76df4335a8bff716/ruff-0.15.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a121a96db1d75fa3eb39c4539e607f628920dd72ff1f7c5ee4f1b768ac62d6e", size = 11033133 }, + { url = "https://files.pythonhosted.org/packages/f8/5e/23b87370cf0f9081a8c89a753e69a4e8778805b8802ccfe175cc410e50b9/ruff-0.15.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5298d518e493061f2eabd4abd067c7e4fb89e2f63291c94332e35631c07c3662", size = 10442646 }, + { url = "https://files.pythonhosted.org/packages/e1/9a/3c94de5ce642830167e6d00b5c75aacd73e6347b4c7fc6828699b150a5ee/ruff-0.15.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afb6e603d6375ff0d6b0cee563fa21ab570fd15e65c852cb24922cef25050cf1", size = 10195750 }, + { url = "https://files.pythonhosted.org/packages/30/15/e396325080d600b436acc970848d69df9c13977942fb62bb8722d729bee8/ruff-0.15.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:77e515f6b15f828b94dc17d2b4ace334c9ddb7d9468c54b2f9ed2b9c1593ef16", size = 10676120 }, + { url = "https://files.pythonhosted.org/packages/8d/c9/229a23d52a2983de1ad0fb0ee37d36e0257e6f28bfd6b498ee2c76361874/ruff-0.15.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6f6e80850a01eb13b3e42ee0ebdf6e4497151b48c35051aab51c101266d187a3", size = 11201636 }, + { url = "https://files.pythonhosted.org/packages/6f/b0/69adf22f4e24f3677208adb715c578266842e6e6a3cc77483f48dd999ede/ruff-0.15.0-py3-none-win32.whl", hash = "sha256:238a717ef803e501b6d51e0bdd0d2c6e8513fe9eec14002445134d3907cd46c3", size = 10465945 }, + { url = "https://files.pythonhosted.org/packages/51/ad/f813b6e2c97e9b4598be25e94a9147b9af7e60523b0cb5d94d307c15229d/ruff-0.15.0-py3-none-win_amd64.whl", hash = "sha256:dd5e4d3301dc01de614da3cdffc33d4b1b96fb89e45721f1598e5532ccf78b18", size = 11564657 }, + { url = "https://files.pythonhosted.org/packages/f6/b0/2d823f6e77ebe560f4e397d078487e8d52c1516b331e3521bc75db4272ca/ruff-0.15.0-py3-none-win_arm64.whl", hash = "sha256:c480d632cc0ca3f0727acac8b7d053542d9e114a462a145d0b00e7cd658c515a", size = 10865753 }, +] + +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "sse-starlette" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/8d/00d280c03ffd39aaee0e86ec81e2d3b9253036a0f93f51d10503adef0e65/sse_starlette-3.2.0.tar.gz", hash = "sha256:8127594edfb51abe44eac9c49e59b0b01f1039d0c7461c6fd91d4e03b70da422", size = 27253 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/7f/832f015020844a8b8f7a9cbc103dd76ba8e3875004c41e08440ea3a2b41a/sse_starlette-3.2.0-py3-none-any.whl", hash = "sha256:5876954bd51920fc2cd51baee47a080eb88a37b5b784e615abb0b283f801cdbf", size = 12763 }, +] + +[[package]] +name = "starlette" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272 }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663 }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469 }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039 }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007 }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875 }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271 }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770 }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626 }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842 }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894 }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053 }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481 }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720 }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014 }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820 }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712 }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296 }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553 }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915 }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038 }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245 }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335 }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962 }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396 }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530 }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227 }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748 }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725 }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901 }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375 }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639 }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897 }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697 }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567 }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556 }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014 }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339 }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490 }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398 }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515 }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806 }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340 }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106 }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504 }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561 }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477 }, +] + +[[package]] +name = "tomlkit" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310 }, +] + +[[package]] +name = "twine" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "id" }, + { name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" }, + { name = "packaging" }, + { name = "readme-renderer" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "rfc3986" }, + { name = "rich" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/a8/949edebe3a82774c1ec34f637f5dd82d1cf22c25e963b7d63771083bbee5/twine-6.2.0.tar.gz", hash = "sha256:e5ed0d2fd70c9959770dce51c8f39c8945c574e18173a7b81802dab51b4b75cf", size = 172262 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl", hash = "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8", size = 42727 }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, +] + +[[package]] +name = "uncalled-for" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/7c/b5b7d8136f872e3f13b0584e576886de0489d7213a12de6bebf29ff6ebfc/uncalled_for-0.2.0.tar.gz", hash = "sha256:b4f8fdbcec328c5a113807d653e041c5094473dd4afa7c34599ace69ccb7e69f", size = 49488 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/7f/4320d9ce3be404e6310b915c3629fe27bf1e2f438a1a7a3cb0396e32e9a9/uncalled_for-0.2.0-py3-none-any.whl", hash = "sha256:2c0bd338faff5f930918f79e7eb9ff48290df2cb05fcc0b40a7f334e55d4d85f", size = 11351 }, +] + +[[package]] +name = "uritemplate" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488 }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584 }, +] + +[[package]] +name = "uvicorn" +version = "0.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502 }, +] + +[[package]] +name = "valkey-glide" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "protobuf" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/2a/dafd4ba79f1346c70246a95b1dd5bd41a7de7561f3313c9f4ba29f49a1f7/valkey_glide-2.2.6.tar.gz", hash = "sha256:6ec569669f058b3be9b873f9007ed5f29b3ffc732951cc9d02d66d28cdeb280d", size = 704774 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/99/f05878f266f58032d14c1b8e5fe78b774d26b9b9a9ef05287ba25b924c13/valkey_glide-2.2.6-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:dec36067aa1c008f11d735dde689e2b534c29b71a7e1f78504bb3cfa4909ca9a", size = 6831043 }, + { url = "https://files.pythonhosted.org/packages/bd/fe/793e6da01e8be3a8fe08612e2c97cd32f135ca8aca63f87aace922f8f372/valkey_glide-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ce4d905b70a69ab02706c64a23368a1de78f3236d3124449faa38405db0d428a", size = 6382717 }, + { url = "https://files.pythonhosted.org/packages/90/91/67e934700f0cf9606560432b3a9d4a4602680716c6d7679d3cb7d06daf80/valkey_glide-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8ab6075a526c0001199361089ed19c2afb126451db370ca8e276ed39596a6f0", size = 6524805 }, + { url = "https://files.pythonhosted.org/packages/a9/f7/aea272b7427120d243f218e7857a05c9a6d83705e1fa41508a1b83983eb8/valkey_glide-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:702d8c3ccda452013e3c2e7d43ca637316e8a256b78445f1633cb4a1da371410", size = 6958788 }, + { url = "https://files.pythonhosted.org/packages/1b/f6/51db72260175c2758f9b3a5d256a533bf4a0299ffc62b9779a248e1140a9/valkey_glide-2.2.6-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:93ac79ca5f71d474a9e131f41c7eaa18fe72ef3835569f1767c49948c69cf012", size = 6831829 }, + { url = "https://files.pythonhosted.org/packages/be/0d/9c9fa3cf45973c3189dc5cde6a6e1478fedc9e4a46116d98ae97da3cd6fa/valkey_glide-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:605c4d50db9235e78a212e21154c786f675a9c864afe8be2aa7e59176b1cdeb4", size = 6383035 }, + { url = "https://files.pythonhosted.org/packages/3a/f2/f4835c94602c3676dfd06c3b91fe0f2b695254b001c60d433816341e8888/valkey_glide-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fa86480c755aa924c29ea5482516057b92483c06ebb85d41eaeb86ce778bd114", size = 6525806 }, + { url = "https://files.pythonhosted.org/packages/cf/ba/ecbc6abfecb8b2fb5176c8d53ee69c3a2277cee24f835e7f2b4b41cf7339/valkey_glide-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:110d71bc797e49383a226c855017ed71d3aaf54c4f3edfb459612da433e6ab70", size = 6960487 }, + { url = "https://files.pythonhosted.org/packages/0d/43/568c34fda55493550bdd6b0f0ef00a175eeef76df0a2ffbccee018d873bb/valkey_glide-2.2.6-cp312-cp312-macosx_10_7_x86_64.whl", hash = "sha256:4528e777198c086d675764c4763273dc3cf1b6915b4fff44c0b43a12fada8be2", size = 6828769 }, + { url = "https://files.pythonhosted.org/packages/e7/e0/c6de405a5cee52d14166e2afe0146b72fd2761fa626be5b75a8b64591029/valkey_glide-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a5363f64e3c8237b1db00f6792028046b23ab79a4972d4e76a0b7e0c1a036178", size = 6387669 }, + { url = "https://files.pythonhosted.org/packages/ba/70/66c7895de54aa3b51cd98289e00cca63134f6a04272a7a346bcafe27dc36/valkey_glide-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c370af7fef84055625063188f5c6e3f6152b62f8ae0fc1f8d13e8f82befc6715", size = 6523627 }, + { url = "https://files.pythonhosted.org/packages/55/af/8d2bdf443e304c9c3c0189ad0ab07f3b03ce689a968ecfffefbb9fbd13a9/valkey_glide-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:664edac2378147fb8b428ce28b0a393c9df495b517a8db1d11bc65899e61497e", size = 6957718 }, + { url = "https://files.pythonhosted.org/packages/65/00/8ed00d30bde9bd15506917e2a8c32f44a00858ad09dd5d42d73aa441b1c7/valkey_glide-2.2.6-cp313-cp313-macosx_10_7_x86_64.whl", hash = "sha256:431e4ac956f820c322d081ccc87020bc4c5ebb98677c93a9cf4ea29fc3ed58cc", size = 6829410 }, + { url = "https://files.pythonhosted.org/packages/73/cb/0b71e2d68edf616b170f63ce7609666c7536ec6b094a44410b85c60d840c/valkey_glide-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c2bca92163f28dd1655678e4132af8d9e484cf2bf171aff351a61774bae24133", size = 6388026 }, + { url = "https://files.pythonhosted.org/packages/86/38/e80716266a0eb72e966f4490e818d6df8f446321314aa7ccf90243a93108/valkey_glide-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c0a867393797d6b2d07f45b209520bcf489508ff48b372415f28e214afe738b", size = 6524361 }, + { url = "https://files.pythonhosted.org/packages/b2/17/a9f652202c43d1a27ccea2e4fdd0bedda942c6ea85efedc07a2814f4fb04/valkey_glide-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1dd2604f82bfe79a819ba1fa0a00f855c73d6b4053c5713649be34f0256cd0ab", size = 6957282 }, + { url = "https://files.pythonhosted.org/packages/69/e4/33677b405109c0cdb9750794e9eedac3db12ec3d0b70208f1c2a57b94844/valkey_glide-2.2.6-cp314-cp314-macosx_10_7_x86_64.whl", hash = "sha256:fba9ac89f179256552dc5487b5e3757fba517e633aa6b75ffb87d5a824928926", size = 6835592 }, + { url = "https://files.pythonhosted.org/packages/5d/e6/15b6ae86f524968193d25fd838e631a4a8f14e1c413fd60e14291ead752a/valkey_glide-2.2.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:896dabf07d74632e6b909b3176ae38e81ba06dd446c28e607e3dbf6d93c84d55", size = 6384464 }, + { url = "https://files.pythonhosted.org/packages/c9/1c/b2d81b8c12e0009f96bcd3e001664ab234d10d729f0555529d353d7c2fa5/valkey_glide-2.2.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebb1348b6a47b7ce3cc416771d5b1b58499f452b898a3d6f6cec52795667980f", size = 6521546 }, + { url = "https://files.pythonhosted.org/packages/81/f5/4a74fd203a13fde1b848114aeb4d619647b4cd018dd79c7eedd03bdcb9e1/valkey_glide-2.2.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c30a959b7a9dd2ef6f68b5a8c56b401e6ed800fcfd86d88a8752f7b85a3c2468", size = 6959845 }, + { url = "https://files.pythonhosted.org/packages/1f/74/88b9c540cb92ae3bd49c71c139a6bd293c6d05e281840f4650b7a34a706e/valkey_glide-2.2.6-pp310-pypy310_pp73-macosx_10_7_x86_64.whl", hash = "sha256:61c7ebb88a85e9763a1500734083aed3f2409cbb318c0182ed328c38126f558d", size = 6839267 }, + { url = "https://files.pythonhosted.org/packages/12/e2/742576d79fdb21c4002e45bd4cfbcfbbf217a521e6c11f88e8d3e8905134/valkey_glide-2.2.6-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9adb78292a18720e7df0af77732fcfdebfea4dc0798e9f7bb5aff8d2315972e6", size = 6381962 }, + { url = "https://files.pythonhosted.org/packages/a9/5a/fc55c502bbc96c8bc0d195e7c62ec7d1ef43a59d1140f3315c00e927e7fb/valkey_glide-2.2.6-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:335471472ae1e806e4f783ea229660ba72b5b4d456cac8f2b79b1eec9a94c727", size = 6512230 }, + { url = "https://files.pythonhosted.org/packages/45/a3/43cbdd5f44c54d103709839be34358d8571654a791aa136f2eb69bbf70b6/valkey_glide-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cc6ea869a07cbb81ec2f737a5023a95e7b4df6c07fbbd50aa6a48d81b7a8f5f", size = 6950588 }, + { url = "https://files.pythonhosted.org/packages/ce/27/21827596e991f863c1e2ec1db597a267b63e027045196072c840a71ee0fc/valkey_glide-2.2.6-pp311-pypy311_pp73-macosx_10_7_x86_64.whl", hash = "sha256:25d7d7e91618ae30fc19a78464861dd0172ce5b61fa505fe75212466b62d4aa5", size = 6839430 }, + { url = "https://files.pythonhosted.org/packages/96/cd/7c62119f5c72ffed4abea9ce814eeb762ca1f39abf3d2dcc13c4dbbca838/valkey_glide-2.2.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f582009a4266a95d3e746fe06f79a9220dc763bf1d34bfb8993075fca69c71c8", size = 6382391 }, + { url = "https://files.pythonhosted.org/packages/b6/9d/cb3060c1b1cd24b4664f11c10f24b1a49eeb074bd019d3c209d859729594/valkey_glide-2.2.6-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41252603516737dc6d4342e82979c3bf5a2dbb424dc3089e082a83a76c3b6827", size = 6511956 }, + { url = "https://files.pythonhosted.org/packages/77/b9/89e65f9b17c321378f8f75dce5067f4abb0b5810092c6a4d7b3a46c8a6ab/valkey_glide-2.2.6-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:394767456abc8364dbfa6e4e5a782cbaab2adf1f50b85420b0e1c829f1c5dfbc", size = 6950881 }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/1a/206e8cf2dd86fddf939165a57b4df61607a1e0add2785f170a3f616b7d9f/watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c", size = 407318 }, + { url = "https://files.pythonhosted.org/packages/b3/0f/abaf5262b9c496b5dad4ed3c0e799cbecb1f8ea512ecb6ddd46646a9fca3/watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43", size = 394478 }, + { url = "https://files.pythonhosted.org/packages/b1/04/9cc0ba88697b34b755371f5ace8d3a4d9a15719c07bdc7bd13d7d8c6a341/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ca65483439f9c791897f7db49202301deb6e15fe9f8fe2fed555bf986d10c31", size = 449894 }, + { url = "https://files.pythonhosted.org/packages/d2/9c/eda4615863cd8621e89aed4df680d8c3ec3da6a4cf1da113c17decd87c7f/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0ab1c1af0cb38e3f598244c17919fb1a84d1629cc08355b0074b6d7f53138ac", size = 459065 }, + { url = "https://files.pythonhosted.org/packages/84/13/f28b3f340157d03cbc8197629bc109d1098764abe1e60874622a0be5c112/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bc570d6c01c206c46deb6e935a260be44f186a2f05179f52f7fcd2be086a94d", size = 488377 }, + { url = "https://files.pythonhosted.org/packages/86/93/cfa597fa9389e122488f7ffdbd6db505b3b915ca7435ecd7542e855898c2/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e84087b432b6ac94778de547e08611266f1f8ffad28c0ee4c82e028b0fc5966d", size = 595837 }, + { url = "https://files.pythonhosted.org/packages/57/1e/68c1ed5652b48d89fc24d6af905d88ee4f82fa8bc491e2666004e307ded1/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:620bae625f4cb18427b1bb1a2d9426dc0dd5a5ba74c7c2cdb9de405f7b129863", size = 473456 }, + { url = "https://files.pythonhosted.org/packages/d5/dc/1a680b7458ffa3b14bb64878112aefc8f2e4f73c5af763cbf0bd43100658/watchfiles-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544364b2b51a9b0c7000a4b4b02f90e9423d97fbbf7e06689236443ebcad81ab", size = 455614 }, + { url = "https://files.pythonhosted.org/packages/61/a5/3d782a666512e01eaa6541a72ebac1d3aae191ff4a31274a66b8dd85760c/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:bbe1ef33d45bc71cf21364df962af171f96ecaeca06bd9e3d0b583efb12aec82", size = 630690 }, + { url = "https://files.pythonhosted.org/packages/9b/73/bb5f38590e34687b2a9c47a244aa4dd50c56a825969c92c9c5fc7387cea1/watchfiles-1.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1a0bb430adb19ef49389e1ad368450193a90038b5b752f4ac089ec6942c4dff4", size = 622459 }, + { url = "https://files.pythonhosted.org/packages/f1/ac/c9bb0ec696e07a20bd58af5399aeadaef195fb2c73d26baf55180fe4a942/watchfiles-1.1.1-cp310-cp310-win32.whl", hash = "sha256:3f6d37644155fb5beca5378feb8c1708d5783145f2a0f1c4d5a061a210254844", size = 272663 }, + { url = "https://files.pythonhosted.org/packages/11/a0/a60c5a7c2ec59fa062d9a9c61d02e3b6abd94d32aac2d8344c4bdd033326/watchfiles-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a36d8efe0f290835fd0f33da35042a1bb5dc0e83cbc092dcf69bce442579e88e", size = 287453 }, + { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529 }, + { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384 }, + { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789 }, + { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521 }, + { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722 }, + { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088 }, + { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923 }, + { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080 }, + { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432 }, + { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046 }, + { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473 }, + { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598 }, + { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210 }, + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745 }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769 }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374 }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485 }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813 }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816 }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186 }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812 }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196 }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657 }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042 }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410 }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209 }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321 }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783 }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279 }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405 }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976 }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506 }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936 }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147 }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007 }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280 }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056 }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162 }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909 }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389 }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964 }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114 }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264 }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877 }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176 }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577 }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425 }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826 }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208 }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315 }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869 }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919 }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845 }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027 }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615 }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836 }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099 }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626 }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519 }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078 }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664 }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154 }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820 }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510 }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408 }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968 }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096 }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040 }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847 }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072 }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104 }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112 }, + { url = "https://files.pythonhosted.org/packages/ba/4c/a888c91e2e326872fa4705095d64acd8aa2fb9c1f7b9bd0588f33850516c/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:17ef139237dfced9da49fb7f2232c86ca9421f666d78c264c7ffca6601d154c3", size = 409611 }, + { url = "https://files.pythonhosted.org/packages/1e/c7/5420d1943c8e3ce1a21c0a9330bcf7edafb6aa65d26b21dbb3267c9e8112/watchfiles-1.1.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:672b8adf25b1a0d35c96b5888b7b18699d27d4194bac8beeae75be4b7a3fc9b2", size = 396889 }, + { url = "https://files.pythonhosted.org/packages/0c/e5/0072cef3804ce8d3aaddbfe7788aadff6b3d3f98a286fdbee9fd74ca59a7/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a13aea58bc2b90173bc69f2a90de8e282648939a00a602e1dc4ee23e26b66d", size = 451616 }, + { url = "https://files.pythonhosted.org/packages/83/4e/b87b71cbdfad81ad7e83358b3e447fedd281b880a03d64a760fe0a11fc2e/watchfiles-1.1.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b495de0bb386df6a12b18335a0285dda90260f51bdb505503c02bcd1ce27a8b", size = 458413 }, + { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250 }, + { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117 }, + { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493 }, + { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546 }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343 }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021 }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320 }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815 }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054 }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565 }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848 }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249 }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685 }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340 }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022 }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319 }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631 }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870 }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361 }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615 }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246 }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684 }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365 }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038 }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328 }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915 }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152 }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583 }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880 }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261 }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693 }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364 }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039 }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323 }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975 }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203 }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653 }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920 }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255 }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689 }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406 }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085 }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328 }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044 }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279 }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711 }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982 }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915 }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381 }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737 }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268 }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486 }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331 }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501 }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062 }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356 }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085 }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531 }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947 }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260 }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071 }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968 }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735 }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598 }, +] + +[[package]] +name = "workspace-mcp" +version = "1.14.3" +source = { editable = "." } +dependencies = [ + { name = "cryptography" }, + { name = "defusedxml" }, + { name = "fastapi" }, + { name = "fastmcp" }, + { name = "google-api-python-client" }, + { name = "google-auth-httplib2" }, + { name = "google-auth-oauthlib" }, + { name = "httpx" }, + { name = "py-key-value-aio" }, + { name = "pyjwt" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "requests" }, + { name = "ruff" }, + { name = "tomlkit" }, + { name = "twine" }, +] +disk = [ + { name = "py-key-value-aio", extra = ["filetree"] }, +] +release = [ + { name = "tomlkit" }, + { name = "twine" }, +] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "requests" }, +] +valkey = [ + { name = "py-key-value-aio", extra = ["valkey"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "requests" }, + { name = "ruff" }, + { name = "tomlkit" }, + { name = "twine" }, +] +disk = [ + { name = "py-key-value-aio", extra = ["filetree"] }, +] +release = [ + { name = "tomlkit" }, + { name = "twine" }, +] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "requests" }, +] +valkey = [ + { name = "py-key-value-aio", extra = ["valkey"] }, +] + +[package.metadata] +requires-dist = [ + { name = "cryptography", specifier = ">=45.0.0" }, + { name = "defusedxml", specifier = ">=0.7.1" }, + { name = "fastapi", specifier = ">=0.115.12" }, + { name = "fastmcp", specifier = ">=3.1.1" }, + { name = "google-api-python-client", specifier = ">=2.168.0" }, + { name = "google-auth-httplib2", specifier = ">=0.2.0" }, + { name = "google-auth-oauthlib", specifier = ">=1.2.2" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "py-key-value-aio", specifier = ">=0.3.0" }, + { name = "py-key-value-aio", extras = ["filetree"], marker = "extra == 'disk'", specifier = ">=0.3.0" }, + { name = "py-key-value-aio", extras = ["valkey"], marker = "extra == 'valkey'", specifier = ">=0.3.0" }, + { name = "pyjwt", specifier = ">=2.12.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3.0" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8.3.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.23.0" }, + { name = "python-dotenv", specifier = ">=1.1.0" }, + { name = "pyyaml", specifier = ">=6.0.2" }, + { name = "requests", marker = "extra == 'dev'", specifier = ">=2.32.3" }, + { name = "requests", marker = "extra == 'test'", specifier = ">=2.32.3" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.12.4" }, + { name = "tomlkit", marker = "extra == 'dev'", specifier = ">=0.13.3" }, + { name = "tomlkit", marker = "extra == 'release'", specifier = ">=0.13.3" }, + { name = "twine", marker = "extra == 'dev'", specifier = ">=5.0.0" }, + { name = "twine", marker = "extra == 'release'", specifier = ">=5.0.0" }, +] +provides-extras = ["disk", "valkey", "test", "release", "dev"] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.3.0" }, + { name = "pytest-asyncio", specifier = ">=0.23.0" }, + { name = "requests", specifier = ">=2.32.3" }, + { name = "ruff", specifier = ">=0.12.4" }, + { name = "tomlkit", specifier = ">=0.13.3" }, + { name = "twine", specifier = ">=5.0.0" }, +] +disk = [{ name = "py-key-value-aio", extras = ["filetree"], specifier = ">=0.3.0" }] +release = [ + { name = "tomlkit", specifier = ">=0.13.3" }, + { name = "twine", specifier = ">=5.0.0" }, +] +test = [ + { name = "pytest", specifier = ">=8.3.0" }, + { name = "pytest-asyncio", specifier = ">=0.23.0" }, + { name = "requests", specifier = ">=2.32.3" }, +] +valkey = [{ name = "py-key-value-aio", extras = ["valkey"], specifier = ">=0.3.0" }] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276 }, +]

q-XoXUg)JbUwfpa8svXjvn(P766T(IGwRSzm^!PO1c4P_6_Ui@hc5~~Swf)y z^6{siRxgq#6VY#qa-yxWMMpm7cAz`7=0p9drT)jv`M>!Fme6kUqrUPZW7OEeSi%Ybl?;16YAZsJicOF+?H$_BlZlIs&? z^Z-jn?%#s)*~nc(cG=?Y`4kBz4~(Rttljs9Ic_wBQ-r30Z&F5aBpP7AhIm{kRcQed z5u@__q?jt3OroAFrdH+>>Rjq_qo}D#N*Ylk)s;pvekM>ryV908*edew?3XCGG@I7K zh(z)}N}R>*?5+o+JF5MWLu?)HzS2O00WzKzrW*(R`%(pD@1seRR8G*PX;}(g81pdd7L)NZ)Xt zb18VSg_6O16J?Q~IcLsh0SwG~s(_2Mfu5R!vrYDghGF!p8-}Dai&|e$G9XzH^(H}I zA)jTr*4gv-C3E_nlVy@eccmc#=LH3Z|11NflMY&OROTr zmTne)vtN0iSTEhQoe|dA#^KHUB85DOjwkY%1^E#VM!%r9XGM&{)y%>fM1j zab0mZCX*3~2cda^aroAUet{4dbj#@Zy~geCe;j}9Kiz$7e`6^Xx>088Sj8w89i}E_ z#;NDF6NR|g%o1gsBmdj``1-heYDS?F+2O*fsK&=rPpT<1;Jsi8Pw_p`6$QW8AQ5g{Dn6Jvx;lv+&C|_Pxh-iI@$Txl6r*%sAw%vg$45@; zaT5Y^u0+h?TgK&3*nq#+oEg6Lkk*Ca)6>;=6#= zmsHYmFOR1v?8SDvOUp{}f~g}<$90R-sn6;8s$+E-%Y&~Al&|d29^H@%a0*FJ{(|zW z)nxPw3(?pTzbmDxoEkzyexD@h?uPFk8T;|Po7Hoal`5B2_pY)2@t6j+4FonSnJk&( ztWj>>(RW4mx%Xw|yF1Z}Z@YoHsWSF?7$N+!QXeqKvl_sFcL_xtA>|8Rp>U_xmdzoI){0d-lX)eok zOe%NoM(cZ;=cJ}zybKjNq=_B3Z-I2)W5*P0T+zW6yoL5JLmaIqsmFTLhnKqCZD*0= zE-&BrW=66aYFa@ z^@HHc7_rL<%P%d4)hIsmkS+N9APhlH>xI&9EjX#R^G``rqm~k(?|Rw$?#KTA{h=@< zv)WP zb5iDo+#zB6vV?-{WBAL6D5TY7r@qh2W!v#;hbDRgFV4Z=9tI|8KG$O4Dr1|Eg8b!M zJ3bw~T(<(CPV*R`HKn+~v7t{NcGTJ&7Z?#~Sr|UKn!n^b?fC)EHVQ-=u?55&J!ca5 zTHolN?q0GPw%cx|1tvQXwDr~Kcr66&j?O<%fml;Y3bey3!oKy5nOiu~GVXcTR>$1u zwK_>E>W!@+B- zo}Ahp%*=?r!cPRlCX(sS8Ix$;PoLTlCz?^PijVw%&vU@N4 z>w#;Bc;s+8=>76`ljNpS<@iaBf3jgELi%QBX!i10p8+KwhZ9=kPCMzf2u&ysAQGrq-L=ywo2-!5|#DG)aI+B2LT+Wc)_{ceJ32 z(VX&F37^Zqg5xDFJQLIT)!QNDi~lU1hkr*%O@gg$zBzo@{>t0(&19SI^?n2Tye(&b z-*Eqo(S6;K9IbSzM(fjlqgI_!Yy7FenqMRb_D-TsB~)0Etb}Zbqw7r_PkcdZlf+qV zm36&u(+D9G`NPRk^q=d*Qrkaat^b_x>?BsYP}F)J`n1_z=kheC5%dlVGxlg5y@Of( zs~Klp3xOduIiv`6v@9NO{!&>!=S6+f-v6tl*mHh-b_n~CuPRjO{97$Tmr4%SnKkAt zBZKaO4<_9$0mMU*M}8S7BYf~|FtP;Mxo2h_I)i1kyo9RxItP{NpqGjYOb2wv92%($ zg$lITTD3Y0cK7S3RyAItpm=3eY^s~ercpsB8QTWz)oZ-bC*ZBYWg6ME14}0D*bqXg zh~K8Mg|0-eX@+r3pY81H>FZ7S_RRC{t1}2(rOOH0x1nyh0Pb_|@%7s^UahE|ZERPz zDI!hBnG9jsEf#KDEj1R01K{f8vC~Jy+TfLhUERhfxRON2cJwfPKX^O*R^%LSK<71$ zqR8C#(Hs-W*wYv*{^+w!WN)1~xO} z9vPs`RhZVkM*o3OLS1Qt``S!_JGGD{x-IFJ+M7u(+C8`T-B>GftSx~<$U3?4dZH-a zvqKRVMc+VhS^UrY`XG4!gm*}@r^DEsT#xr-tu?E*O0H=WPJbB720JT&p9-<1iAvW&yk^p zmQ2ydp95mRG@oWGhTiz}d?DHtD$RVhEX(YySR0=w^joMc06b}MWq8)bsI2Ao3~%ZD zMfI*%Ruu~tzm4#S_)!2LN*3OID*)>*V?9B)D7HZ`V z`?qdGjT_b&JC{)Lt9<6e7yPwLTTlC|=UPC=%3Df?b>otP*~`||QP|&HoE%qjqSKxyYP6L#ECaN-!=zm1TizGJ12bL;v=l>3YkpfUDD3 zw9UG#kmXR^?fK+c9|yn2(s73AJ;!)y4vJZm1ZP8;Af||@BEF9_6Hs?n#EsYfb~+0A zh!DbM8rf$?ysBw#~g#uEG>Ypf{GwbH|cy|2Z8T^tIe73oS zjT}^rx!8IL8}f`&Hq9f1_myEFhI^`G*ez_H8j;qk8wsxCgtN%oL6&ep`B{2}H1#An)U zn2>0Cljv+l$Qg-syAa}@d&OCCV}FjS3avMl+0vFf-uhVizI7|%N9FVp)W%1%{{HHS z&b?on)&5pAKy=$Rls#y6rN}xgp4LMIyVRUln-A{pF|X5B+6Da=!x0!^RLJvF6;t4JxyiW~$tWsJ%Km`R=01%p;c!|c5R z*_0HkBZTcla3a_MHYSMHMJnS|Y=Dpo`EuQMXeFev$~dHNO{)e*e&Fw3Mf}qvFA4Nf zi7V-U>{b{dX{+Rp$&*QOdo&BRG}^eHspzl4+vUtR!92qu1F`4`_i)7{B|i;FG}oqW zx^48mRe+nYP0{OLZV4T1*H7*U!~4q!807^dHe9c1w{J^(LbAfKtGYxEym)X2?JwCB z73aGJKyeeM^(E}!0qxv*Fpn^}o%suB4RX;j)0jUT(<%;99z{hTwu@B~J0s?O3;$oG zt;=+7!e@u!Bdg)!Ba4USBb3VR4N~>SPT`hLx&mPlqJmmvm?*c6+QFRx^$nHw-V&+B z&4+VrxD-WA2903@tRPdkl({>j+UP!}g4!m7wOg~+?wE^>vU+0ZwMre#@cDkE9=hl- zw*iwSj|iTcP!yC0dJe|E2nBUTCv} z1C9*odvWuvI(Tq|(G(DDm@UKwjPDQ!_LJRx;pnvVVvU${zhFATEnM_N87YkbD~XJu zLro?+(!--TpRi)vm>$G18mwzEjE;imT(Egey01n#=9!?Wg3(lLf$M7EZ?llMnz`@Xc9DTu`K6pw*jVi*3NYHsII z(0Dy9g2_$@rklS1#!xs_sR~E(dH@`U;1ii{_r~8R;Wu6-g{JM(QhbrcLkX1&$-iUM z6?S^+s#(6bP;|C3Ts;xuUnSIo!45Z7QP_zlh1$isPbxe?V z@=KMPgZSD2Q)2lHTb;Fup6>+Hl4|_v%CYV|X|Z3TDhR+;Eg{Qb;$#F5M)0C2ejeEcoE?6xcuPk~Z0 zrSquVQ>%VZ-62ZA((-TIsR``>6Cg8il1q6ejl@YHX;(pq+c<;8UHkw_P>2;O1dRw7 zW8K_FfI)z_K``!lJ>su?7NUXLXLCVHb`{spU+u_1IK#T^tRv(c>LU`%v8CYV9 zkJa@vDFm{)GvbOs^H=Y`5I?gMU;-KNU5VbP*6A%4SN~?T*8ydlLLasTt$_s9nR)#{ z5KGezqLs#HNb=pcEQwrr77N1`3nKn^#OrS~5O+0|zr#~nLlSDVmFM>9^nD1rIDQaW zPMZ7oZ7xrkDf~$b2;T-Zw1L?U*rwUXLKp6cvuM{C%xN=wwjeTiMhz=AlmYDySh!~w7Fy)E@+^94uEw%gN)|MCK_L=H3iWi zxu3?G8O}RlxC>Ph%j`7yLqm+Ix>9s2Xwf*DqBR(w>m~nB7`1zsC z!Gh#0hP8a1@5!Hp3n~bIdp=vSR2;Sa_~%Yvr(g?83@0Gwb^iXejU81M?spkTSMd}z z3I`ILd~{Peb-&eT+)`kLMAY1uLLZkf_`I{XSdszX+3Fec4HCa9Sa|z+4&SV417^29 zKHw0GC*aem1u#M)^Na@!Rh6GAh%?(ZkIC+6X-b(#r1YNzaKFXk$-xLdJat9coM?x6 zUvy0@7X({(5AzaD< z6y*yS-ZW&dvB{3BP6g}dD^Wot9-=s6U*Cq&Y-;*BVtX88pC*7qma=Cec3TDp^$6`S z#vf?7*mUrGr1V-r?g{TBM8mFwi@S;_TZu+=EO&EA>0QE_>Hc?Q`Zg6xLNA~0IC%|c z2%qjHFB%R#G@JX3_UwFWKoXJ`d|m^IW}Db4Rpcj8BRfQfl8r<_%R{dMqDhcCLpAe; z6xUyO?f%s~Y3Y9%Muf6Z+wHqk-cuy^tZQ54{Z80(G51N(%p`qE8CtNRSZuAZ5H2WY z@!Pz^nD11f?}HWydA5a7H@GLFbpQ6^Htz2};a$Zlk+7A%*ZM*#z7r^ctS{tO@ zg+0`mQZQRrQx7+|r6(lW*e9^hS=3q6{`UGFbDXqJnU^CJ37hC~Y{ZI*Dm^12sNyx5 zA=^|H27%96<^f<520k(f)h!oeGYf0DB$gkoBo42PS~LdV8FWu6Wa9ECCY2l7+x#Tx zA5vcvBj-H1I1rLHN`D}vh18)qf&JsjcNsqL6%wn|#+Yf0|Ms=|y7}n&-wOo@^Jsrk z5IAmi!i*f=jAd3k%Q-(%gk^@xe=4vs9s zAt0LUm4x=Kl4w2z0bNFaFD{zb|NVptzqmX_9O~8D=iJ6eFP{w-ic${jx zpg*zr8YR7%u{=^&2e)1oSa+2PG>ADV=qtA33Y1_EpV7b1bmI|r0F$EOO9n20nE9tY zbX#mwOc-p{X&x7A+;kv`=U}pI3~oYrTkbp7;4xfp`nVA`j#pLM>H8?~y_1O!cw+^w z6;IVV4K&If4{z5j$b`e@b77WEy#9^d$W)h)YRPjn1Dd{m2-<}ol+3(LtYw8kVGpgw zkQJM+MeLKMbtT4NIzV7B2mej(Y7s}c1?gvCW!bVA%PE#IW-zA~*bUSl^3*KyEDHHK zTx^gjZcp|v@eq{3i1f_24^poZ_S3Xr47Fdd)Mvh5Jq8Wi?bgt@@YXrQ(1e2%a~ zT!(dDxQ{X9<8x$H57OTZ?-E&=__f%>@`^A;Z|VL~+a&oFebRkSiJE?69gAbRuf=|9 z20)gU!pin{nkl&HwR2Z7o|#fC+{;G54E1!KV**^vh#0mBackN)7TjH{&XGX#Th z4yU$ygor!{U$~taV2QeFINpw%C`+OL#Cvt*SAH@c&yC>Fa-^EJC6{Wh4T#znnCS>_w_-xeeIZmd3;Og8Gue1(j{#%8PFKAXv0 zrHqKZ(EIr71Q|jjCHDG6y{Dl%?jFc2{RV3bLd6}EA_0*E6`n94ai}xsOAuv zjIF{!p?$jFQ-OR}V` z8Z6ub2s{}d)>G^H*F&dz4}Jq)R;aPHFYqGX!Z`Vl7$R0-ma-3+5vgS^>**EDm$t=DX^aKR>Vbe|9KXJu_t8M zjF7g9gy}TYLfZi#==bQa$tJN22W}bJ)+zi}xBY(-j)1OD~zQVN5&&MJjOtDuboWBpuzwt5IqNnBvzxkAJt>QjmzJafO%` z5>{B1RN_dpQu)b3)FEn-BQ-X`p${Jfp`kA0VF+ud-H>EO^qZHz|F)B@oXe|!h_Zp3 zEl7FtB6J~+@X1#$M4C+|U=^x`(WL6hW@W46naKp7N1S^A&ezl!{4YcvsmP-T*S)v= zWk&N53D@em&OL;b*-F+2SX(J%UsG_ zK$p3kw@%w*h%-~9Y5&Q9akGO@wayS;k9;2`Po)yRs5CUcO1r_BwdID+1 z2#lA43#||~5J$)%5LFH?ftrZ}R2E2s%qZ+(RV48Xs|ph_coDQnDw2Mh-sI-}Hrl87 zeGCK;msenBY=UCF|Dzlwr*Pyz@CEj~2N~?N8PRtnzZqfaM;@vkP4-I4T9dweV8B1I zk_fR-s~ys3+^7*xwP<~x&Ti|p(n@`<56f7MY`nVI_dD}EEFyT@TxbxUfjW? zkJWC;{9Ga%2A!%rqUr4LJegfEgT-b6;38|mvDJqriK7??Epe!~!#~f0gA8<>FBJn4 zw1Wg{W@kp}Bvt=VvRKO6=(Z`js{SriX!c(N7SqJO@!<%IKHNN3IDZ_bUY##2;udSl zlV((F`m-$|-n0GyK@*ebN>^|}?}`>>Giyq9j@0ff#Vd5`*v`^Djvj2QJ)2C^Ea)aC zVGpb4$?I5}id0AB-Vr?N$=^5vN)o6;>F{-V3Akhex!QxT3*}XVoc3?Na<43ZF$LIV zM8*YPk72R1P(sY7rDBsUZ+fnxFi=xiv(VaPrc}UsAAaV^cH%&(mv*4-j{gJf(v#S# z>FjsV+?u7CB87sC_<2%ul5J3i02T6>yODJ0tQyi;|TGqQ(bv#pW#2M@hB}eDL zdhxrkrVf}xBrM|)4cX)i;jOnH_->88n+mk}?@ka(Yp*5CarM9Iie&24tMk z4${OG_~g~$XUJKn3=4^GllzC|yuaSwRyv6`$#6hMO&TO}EM<*2-XX<#B<;q2C-Vqj zzhWbJ`jY3^BNq!Qcj((ur{7Bmgz#&jMWHAOH{jk6YOYgGus=F3GItjR<2fuhT;En? zNfI2FLn4g7iA!7I+l=R8HlrGj{uNQ@ac+!c0%SD5UL)gUXeBL z(J^JHu~b`V&HeQ3G&Vc=K@YD#C2sLLL9L(BIYXZUXrLig;afJheh<;&zPdWYk)t^k zMu?(Bk)g|8+8fy+Vr8`=1&|?Q{IVx1xEZc>1Xhp!H=Ab|9mcrLFo`YQ><;v2N^J~R z0JbvBc&wDAEHNY5YN%6c;r4LVn3qnNwxL6XVBYkFI16%WLTzcZWsLij;U_PY!E)Zz zUc;{WSA2BIajY%0ASuxxu(=<_)1+#|-`_oa15H687jh8S758^loqW_3D zNoz?7q9?vCg0TS1`~vg}gN25iEo`6gZtKkfGQ8 zkdB@1xbX#zr=5rJr3jSM5QwHL1!SBlS&IKBcF{eq2>c ztLC_CAC7+M3pxJE=r1fhEI18AMnyV~VNq}`86+o?giG0SaH`G0og&EKXYsT6%E;*X zqUAeiG;XDyJ=K|Q%X1PuNz{+nj-fEmnKe4awBmTp@)(BT=e#H83QFJ)3?OItzX*HB zAYG!SOSEmq_o;O-PZbrbP-dss=jW!tlA zi4PJ?X0Oa8(DS@fwAS6R(NR&$+vM4vj1{1q!5qOZ=cPyG=?lP- z8Kfzju-Sf2t+T+NnNUAR{xfh$V9Dxu+PAs|DZ8tppcgE#{MOSZaaaq+*!1L5y z^L?qHPlE8W^Do7)o}Vjv>x|H&1_ap z&ag3>1DXv|S6vm+7pa=QzBX&+y(Z~|OQrHNEdbLpmKWjluNBp*ZG_1p(BG626duKk z!hw}}Wl2K^HvZMNJP|I2yRDs+n6T$MD=d;gSN-;b349Q`LAIXFprf|mlHyLt<`(gL z7=Uz@&w0ZlL)ifPb7L*0MxhNjVe{6rRwO{B%84iWN(3?n2v*LNs0JX+ix3mVLG#zG ze_U@tiE>8vu+oEA zqAM@wwL>=bE!JiE&53IZMK0W$77svlIeXvAb$AMjAdc;7tsknOZ{^C%;Y_f6`2L5`Q)S$asG zaxd!ig4Slg{aV+sgKdhq!dP#th-DL~kzPtqgzEWL*y*kT+wh68teD*kIq%W*Zzpx0 z0kaoOo|2j8!E`Yn@f;zwT9yPT`xfgIA0#u3PeXxBpmxOm`BWi2CciE+v4yP4MC$ar zDa!fs2!$?J?H=q~DZ|I~7-KFU$t=d?T(DezQ%_dKICkG1t40v>Aa{E+)Nyohk!&qU zhl(vp(?~w1cp4%`fvSBfS8LgUEr9Eh6%REsax>Au`tzu-N#l>|oIzbYbCy%O)-V`) zs>vEFzs$Q2wKIo7E!uAdPH!dzm>aO%bX^@=akTIIT1EdBg9dHrphycTTUN~QaxRvHPW!(iwu9`trCuGf(Zo`(lxOsC zCj{sW3JS7ygzU7tjQ;Hvs2GFAe6^UR7vK;IMthJGN`zdV6&>Kt0K>RwK)SH_ob0Da zg}r4ANqU8FY|!jEQP}-!c4Q^n+n+yQUi14!h$by4pJ@aUZY2<`mba_zy5DBWu=aZ; z8xiI9AsX~!1HhH!RQfeQx$O6LhhGptg!KZs5r+kL>afTx z-gznWE_Wn)s_u;Aegft??gam=1sh$8;%q!?3)SCV-fQ^G#8N4cqB6}Nigw!aI>Gfy zBpu_}kCve|V*r8#Eiflv0tgP?QD6m?i9jJ92Wjuh<6K3yo(!X0d;! zcWDA%6n*2zLMBf*1T-R@|Q1+lW?$*cXu8Eb9Pz;MOE;czl4??!B}%A+#a; zA2c7819lhSVTZ#e)hB7=PXBb6rN9fR-uU|}O4E?;*OMs&YMn=}tUk<{_5*V5Ug=$L zQxv{|xZZd;s_=D+d4&_*fo9!!wIPS@2p4U~8RgJTGy5aj052aX`y6qZFVxsQnvA-w zb58JI5XujdPTCMFkA}pj2IFsx>P>J>c2cthaxVjAH1>NAsD&)>QEl5sQ?VPJ9|hn3 zrLUQ`#><$VQ&!Hl^4M*x6Sn=Lk4lwIqZ$G*#K18Aq1{#b)q}nUgqhb3z#vL-xf8vn z3+E)Z=!H*A+Z2qt7CX%MWyD(M#cI%z4LP_*osRiqYTcr!G#94Ald&hAZ=B=u=nK^4 z4YpRPyhbOPC?YLm2tol4_%`<<>HU7@uIcsg`!y&_mYxpIvr**r59;#eSzDG0AWt|&~yUs&?bH&S}*tT7IE!aJx$1?m*H|%P`)p?kcoioJk^=8G$ zS~CS9=1kdx5lYUzBdx(;g>9?|l99e4njV|AzE^~qL?wy6Uw#Y}K_od1WEo}3bfO5bK%zlKPKJIrMIfhO|JI=@Ur~}f%ecs z*Pn4Hl5Qw4W~Bo*VfM%9dwbuoCbX&K%1zu(k(sCqX2u?Mj%b178q5IRE9N!-(KBb z_B5i--#2ldAyYtOGe45YLI*g%fE#1fre}KKhpoqKzyW?tpCm|DKc7i0^fU52vDjRg zcC2@baycjs7+^!>{+Z)t7usC{9y>Fp|h~X`~J|+TO;bB#X%>tk-y#(vhzg3slZjLgQF|d; z!0>MXmH33ekCBU9^y2?`tuLOXBKF`-Kma)q77@GEsuN6Km$}pjhmz_duaS!oqm1w= zXB>XaJDb~4(T?wRp+FChwP)}g zQUX3r(psAlRFOdJ&dVq)uyGGFYEQj2NNcglh~mEcM{)U>3bg|p%<6VNBt_S6%TzJj zJyk-3a{fk2LUH(QtT+{aAVsi`YzI>Tficj7zCGOBig>GJ8>5Udn}80KFg2W78>brwB}YedN-_Jr@EOuh zMy-u8c@SlxXeUE+j@EhYRvC{N zu-0qP_a8$5ynLYZ4Kg(FIvJ7E1&f8ia>&)1o1| znHr-gW!)kL%Q+1!V7bn;HPtswpN^YXnl-WCzOyul1>4|w+O&aCeP+K+54iEuEeN!o^t?`oxVG)j8?ijU$q>9I20W*&G#_ne51IDjjI6)1T_%h6~k5ukPCd66P zucRRc*80*ZN?x)(Gc0`?N|NloY|m`jhzhRtD8xO~>!aFO;JlWq(j`_=$e}vCG}sWH zPPX)0B~RsJCK(#BvSJLG|6|bbbd*Hx7VR0uU~M~Ic(B&@c!78`a|*RdB3L?OWV7+{ zldFbNPPEsVhdL_w(!Mo~FLHEK2ynW-2cyDyGnGz}gd?h-9XojZ5Z<)03L^%=)G~&3 z8LFR^LAQ_csSU%<44>3&;|9APy3zzcz9N)%$(*t1Yz<@;N$6LbHxkW$U8KL&swqG_ zzUdyR*DF&cxAnr3iDkr)NS)Ey){}LsvE>)_0dF!$Gi53XO-TT2u#Ur!{7cp1 z0m{@4WR>KTiCEI8)}*GNiAVp2*?WOT{Qx7L8_A0@g5HTbB>~&uM=O92;D%#qZ({|%otp!yCs-Em4a8Rv3r?cRnyQAS5F38p0S7$!`^w6eIdJg{&djzUY?I!{%gx>*~` zv$d~SimeHn*HREH%Z~eJR))C_OZ`iUO9B4j|9Y8PcibXKcqqZ_fiF|$LoA{JgkP77 z@5(KJwb@dl0Rqpb7XA4~3cKvAaDNi;Av48o&;QJdV#y=MDk8pT|0UeyHa}lRMLm|P z0?nR>+Axb|e2 zRavn>nJ)3N>X#)fB$$)3;l@*K^bFThFU$MT`wDlJZ}lIih^N|C!0410)ShkIiBA}Y zh{Ww!dqjcTtq1y%KTH>2k{%3 z1-EWFONm+ScPEU6e?;7kBVt+eT{*R3NfG!?T*L(Dn)3}_AFcWTw92cFS1~G`*22-8 zEK4i8Q2^UG+{O6kD)V*RwBw7u3>(C8XH#~BRCuEeJDS+nEuT>Y39O{slYhs7r||1qEJ=^$yEBo5k<}f3};V=WR%VMzfXrIP=T{C&I4J=L?qV= z&?|R`Ur{k7thxZdR?E+600B4}ygU4BV3Y?6=?0Nale5+L_@d4DU0oVFBFSbd$RXaD z7NKj&X3T!v_N!t{Cv%JD|NGnr;f)dh{p;L3Mr$_($;Z9Pl|G`+H=M-y`G8M7h!Xg#b4F zB6W^DTR<>B59)zM3U4UE#D^SfRN1ZHuz)o{?n2Fyzpfe(E`{6PCTDv=)+D>KzjtAX z+hBbCJVPS&Tg&&k5Y3>rb&+|JN2rLH-zmZpB@+IVdq5y05~zjJZE{mk8GU$?hsa?E z;-;NGR;gj?*JT9~O1KO9v1H~5nV50|q8J_^rB5o&GRj@e9rgr_c)eiIP4NqrU;jH^ z`)m_@2yJf~osmA3pUY_PBy9d+aWWbp9qlhg_sNvCMWh~n;@n+d1l^{~5!cH$F=bq} zSOMJjTU7RpgYjerG-P5>XrL?@;V=O~@&1k3^2!YU0VM6XB(yc5Jfrb~(wC`&dqZC$ zlWgo+1bs_QN30Z}YBk-LX~t%OAL@Znoud+KAxQXY%pj>peD0e%y|IS^^URRJ9LIUX z2};n}%Pal;)O!1foSlQ?CBaAD1_am3vrE$lFHF*orz^5M{?J{vu?|?qfkK8m68Qy) zZvE%OQR=+wh$RSuHPVl+;_7$4RzP3WZ39&(Ey(k|wU0*FUTj}?O_$*V*GGJHcsgIZ zbF44$$?5l9O5_hj#&qNb$evVAF(MnX(QDwE7{Hu`sPlgyYkQgxMzF`(b&)c#{~$yh z0I*HjB}j~*uax&+C4Sk?xLYk*yu7i!ViQl&7OGr5VCiH^skW=9?*LwRk-sNZC-p?o zSA*7O)M4(AfJeqA2~q(*zhe`AF+MNyv{^zsl=qC zMj`~Tz_c@|G&7caQHLooMonm1g|@G4uJ#JKh%YXne0ls;a##z=iRw+Y6@S}YVmhq($scXp_6eKyD*txUTb#hC_!8=JI zOzT1oYNH4d$;6_4t>)F}T%*r9;=tONcdhl26O{H*b=&istlIPMqVxl}Q%s zOPkqOG`(0dmNlBH-6MG}*EsS!A-~~Cc@0CNo93wN z`|W9Cr59~Ec!B-XEq)$V=oN%bnkA43f#6azUf7yv(xK~eFkF>`8vC+S*eR`f*(+n= z;@F|9lV&sQB}Zyvt=X;Pn#Eh$2CBBVV<0Rk#NFc*|yOzwKfXZ^p;gNs}2Bo$~x7q&V~_*N?6vuj9kf`3N!n_mX5{5 zo~o0zw{YQpTJIb_Q7Ch=^wUF;+k&UAjR$LRXvO*D8T`T}BG`kx zpUWgcTp-=SC8tFHf>=jR0y0P~t_E7XvuE~MhURzGS3QCYacaATtowP`BSs%$>YE#& z8VUi9C)Po-jVax~hEmW9qC`DAmP!zhJb+ubyi8{NH;P*yav`WkP_{gf2^OMc(RdK068z{ne5my8o+8FqRAeSaV|71jYMb;8KJeseb~^ z!#*qZa#b&CBL*J>9r)4|_%d+@l~#QWJnVhqGj45(A>UTt7z3_}ZlL34$&;k_L%W~Q z8A=M9)urjZP(9U#Iq`)B@ZV;`oIjULh;(cR`DRNqrRvsCC`g4ucXqMN-06y90g%x$ zg!8FQ_E6()ce^r#h@%VFm_-uYPEJ7MaoORlwq$V*-EZvvNf#)x1Wz|03A6>sT>c=M zfJq}d_(Fsj=Rf3%f7NzT-?_ys`1({HJ>o7XjHZS^*x6SDMFZG20UE9SFpt&hB-Ztp z{>E9?X|X@8#};RTnPhEku!Evwtg(&_3s}?5J{r7KQf7Q~gNG1YLk)q9Fud}I+Ry2W z>4>kH)0{GZhDX<1Q}jS^sDo0(>;ME5bE{+;m7`rn)=^VJncWWe z=z2UvXaMy;sF7zdKy_IJ!zycfxff-c;j*`0-=c0vx7WTEgw#JD?UOM!p1vjuG^ zAz*S~mjvd)qOgfw*2A8e@um}ka2?M}Ee~3jbjhE%NSdN{ttd%^7!r81AIFtufIs>d zk(rrnP)}yHr}=b9umfmT;_(W{0TA4qL_7>YV9_g?1;E%)5pgWUN2BKQBru>Y7nICj z#n%Y7j2FO=By{S!K!;MB|BazGj^Wn@_$*oi_mN51Eu8uD*_^ z-D6|eoRd%u>BpmfH!iB`Sl$Df{-kp1xZ`I<^UWS+nNe$cG%)ZDR5qVB9tqH&sn447 z3aj@mYfEBj%y|T?_YbAd2KdcDKpSc)&}}KE+nvpd40=_hURrBX*O!#1ufzI|0;ELr zK_FYPV_V(yrNCggK@Uk+fe^oOpF8PDjaI@^sD5Q}PDolT z3@p#(=wk~>;lmRr+iv77O)|dblO)VTxFCguGM95?|9kU|o3C1f;stIBO>O#;BWm_w zxYQ1s#e3_3T9G1cxgJih)ZNtA`{|fUYb&99O-5Hm{2iydK^Uo*JyTG*@^+f zUPX3KR+hI{J@@DH1<&(6=oOXkuN-QG`^x^N?FX6lt8($CxFRVOQ^+$}OD zAoZcC?J`|vDysmFZNpW77w7mk$RmUTLYB2of$GoK&;Loiw5p*Z!~ILUME0xQ`Tt9c z^S}6)wEw4ct@f{U?b)A3!@!_8wSKT5%2E4k5Dx1l7g`wEo8E(&U^0J==5ogboLs{f zN7dWDJD(;_S5@3T?4!aKZ&f#3+qNCIo0?vyWdF4l6z)1vSV*QgL2F{lJfj5v%lVX= zo2J?sg?yp$Wiu4AK;{HjJbDm_`NrQ)8Xu#nro(fd;dBj%7-hz3wetL$MRO}Vs<3=s zvh^d+pV{4lAqm=$s3*z-RT2uRwX!Ldyb6^2Ar>WVE)vs5*b0w(D@lU@6lwtw6f=h& zC@PX2_B`esN(lL6#i{1SJ`zt!FC>UlClv%My9qhSvV`&xcf}?$!peYWIR(C>vKmS&79DO=zGRiV^h?rwArQqTx98`Y6pV9;@DAtZ<$> z+jIx7KW_b(h?=%DY}?U#10ILolFMAJ(gcBEh)vV-!KiEpT(Y zUX0ik2Ey!~o*W^TQFP^Q0hcu5ajO*jw2}`Sjke7LEfInD0c8NML z_HIG%9o5VnePCVDJ{e^SU%H};AK3WwMBY;JGS)2* z-XqkWyA4CM=p@gZ1^odK@+io=%|PF)`L65Ur__~7YY-}n(*kzup04>tN+B3Ef4Z>l zo&Ut-UY#5EAX`r^pbx>dniN?vQcJ6eW&Y?0)DWx^dI3yflt3>mziJU#q_qEZ@1`Qb z#4nP{Hc9Hu+v&Fjv13S0U3ijia8`<%;`mK2T^l=Kwn5}!Nn;-Wd#8tPc9PC*F^lBJ zl7=#f5~*4lU$O|(b+Bl-YN-?`KednQksQK)(jGiDyMZrjY#d7W!0~yP;t~{`{3EL3 zgA?=Xi~h&|;&8($$w|EPdW%l1;-8K&;&@7TD+x)l7adlpBqRMe61^S%?3lbpE6x^d z@UvNwTqAH<*IOYR0H|C@lffcD2T5bh0l*ysOsS|)U~mWFdc(jX`J}gZ+z|`{#L{-i zPH9a96Z=f$LgTas>8QUkB3d*TBwrb?9hAZ5TO$T%^f2%&R_kU1st%{d)-x}@KO{bd ztq%<}aw3b(ODwgcgN>syiPcK9IF7v`i1k0y*CUJduN>@?!2<^N{cRGJ15<-*CHyWY zA?`Mzj?DfuCl>RxkT7xChj{FXR}&&Z-h#h}(6D=p3%e>pG%Ch2M@s*_^9`yj;|?Tn zR$3Pt53=hz0A+Fw`Sq~)B0tVBsjX%pTi?Diue0w}F=CN=DQzXEHNdc8p;!xV81brL z8L?cS_lw^f?^vOpGNU_z7oQODF1Q~>msc}{=X8{{As-*8h&?JdtM~TynqiIsIjGFp7TNc}g^UEu5XP*L$&H3Ei z-p#Pd*vmho7bZHbCe??bZ=Emfdi~pv==Nh&pHP__nv_-Q>46m1wxI2r7aBR%|96t( zj5BLB>R&QLvEQKn{|xB=l}(iuf7zw~B7D*Dq>kX2-$0p zSAN|{byOzD6`DPArJuayJp5s(vg@jP64=k{YSdWIo02igh&44cwb;Ma{v$zJYLpE{ z^0d}T9tgLLf$56Y6;N5n-PEZy7}$|IPGM#3`>L^UIQ~P8$e8*t3|x86 zT$HE92SDq1yFVH^kskveZ*k&kp;`OdwRq+~nSEsZhnG&1$povuM{VIcTKuGZiI=g; zv}U0h&Jvdu1L@wgd)ArD+N+6r;eKSS&!nCWVxe@gQmsgpgX3ttpm0vIGWxzAJX(S{ zXlT&l_`Cc{cl_Ku3yovoZ`fdmR(QO9aWTL@VqP1SmPar~k*E?78VUnJekCLNTFuTR1G894YdB`L%khAQOIJtTUYcDbSVw$A? zA~X!{ov8b)DReWjOSD7SS`m@FP-%llwuPmUHEb2_V{~581c%PKh~C~Za&@Qk)!`Wm zUq}B$vfp-OnF`G_6`1jCXaB` zAc!|~0D$vZK!9K2bpTpxcRP1oLw!RtV>(7!dIq}xWDYBHga0RHLv72%jh3ZvDqQdF zOAhy~sSU42M{L;xHe}8xDNbBCVFfA=3I&pKZCj4-8*ey(fIOjyA~!^mobe`0zal@! z+bnQ!wzf}K`wI}41nM5mF7IugpF{BNeG~m^7k-3wZ1^=zPHpgSr1nbfj!5pw9jijN zW)*+$*xB!=o6O)`HBC+#Z_c^|r&gf>_qvI>e@h^>$zn=8rHPEcaN6kgB z`WdBTrzTiWVa4l_$=y2q)$7}I=QGI#9Vcq9W_8!9qi?Gq>v~gJ(2;1XC;p29{H#m& z>!Qb4>_^$?=&G!2P3U`@BzI41AYWetGxcL?c0?)CkAAB>pNiMBaq50JoJKl`xvuwO z@n&xKT;vrK2mkfw!VKzdwyM9aDuc2+ZkLb>;YXV!jf{R_OolITAiMP4`y}n~j{uk@ z_?6@=&!xg^?Y1FRg}m_OYzNAx%|&nny1Qg?(_dD=w&s)V>n|bSW%--_OK`I`mVqXyk`jaK;WlP2VWM|h|5aa%ibOAEQrJGFX7eFx0 zB$K_D0VC95Y^pVnI02ta4>u_I!F9Lu;m>g>sK1m{9Q*h|1fe=l)#P#=Lu1Mm|Jt9- zMhoue(k?*B!Oq#^CZAgBPgcvpoyYHCTe~{inrXOPG@(e=)i^ISh>~H8Ia+IVe1Fj1 z7XQ5puKP!orCaO=<`0m@Kvg6U9w(R~9qcTz(AXk32+q{-S89+Az8mrI|A zz@{7ppMp?VVzzCo_+6B0pN<{>Gm}u@+}c}%J{*2Q`vNF`1qFmQSFZP!2WA?O`C#ds z4qnisZ!B2t1=j{yo`^;6;|05-MxrC2H)6~PQ_7)8M+{e|Lo$XW;#O= zB%nbFmQVNSxJv}VNr}cQhEe$Zvpx)0Cq2lMP7Fh!F`ZT7`&#IxQ{+em?tV;lOo7D=~AG`EZdtl?F}-wq_Azza2A(odO! z9K4uE_?-&x!Kyiz1Mhu^Y39o7U(`PGfY?x>p0fw)-z$9!-<8wbzVqrEuHq{%Fr-sq z<)UI;RN>IuXlN}@G`dpM1#K=d;NeDDrtk@IspnJneGgg;ERg%=s1O8V}H z!=79n?tM&@9#fc-Q>H=zxJ2EUy<6S_x?VbG%TlVaj#oe~mJ)I>NBWnpsI&pORm_*Y zc^5fxN@o!9{jTxA8nn=xUhPQw5@p_j|UDNjCDl%2&9;f8l^z0P1hrCi#Sspm) z5BM%VfM6_}CXbEwRFb-GUt`RpBfTf#XcV2mjk#3E>+$hX&JuN&pNW!?uzXULy=g}t zl%AhM5_!?7h@cT>F%x0sS`0~sq~{QJW$TB!vlHk5f%s6CR*lb7c-hUj+lx0=q^{_b zBp9*c5jddaGvpc>*Akgj0J(HWLst}M8qP&%+CT&FkL*n3_P9YHfyaQROq0J7k93$&nrE~(if>XkV?K=Fcq0;BaePu!LYHy` zTH}VrIuDcnk#`=HItEiI_<`tl(5boDR)(j?$ZYFjyIurs%dk*nZJl= zWg%4zkQ8OM<-SnIlAv_9*_qP?HoKs7mRY>pxsgS-(enGIqywG12HjZ|{SEhV)3~nW zny^%_qI#v6Q`Gna>Fr=Bo`{e%nOqT3Q$eFqt|Hl$FfT)R!a^K=iHQTIaKF=*NB&A< z)LV9G4BJn?V}X@A07U~`tbfQzw!V#9KE$a!_ucj{Y7yJLFic2y@CWM6t!&4wj92@@ zyP{d!g?V*}+@2Yv#ViP0$R&og%jsTE^Anlyv-z}?N@V8>qi*~*Jw5intoDrndYE`I zxyZTF))U7>IhtvPr9%N~j&k|zdqkDS$cCvZ19y5aF-Yvq}MX}2L!|`YK>&HB?>V-cDH}E9aP`H@BhtSZW&I2;rB~8!iiJT}V z5TOD=>q5DBBu#;v^vsHV+0MoA zyC|m1-YCLw5=XzB(U2gxRBe^MCt}xu0@A@PeLVKpYJXlC_GrTL&{sSk&&H`qA)bqu zp?JLd62U^V8cU1g4aXP94Gvj2hcW~*rRAqgyHfU-pGaHrt(reOwM#ZcHMtboW$~h)}3SQCIg<8m9)TNxV1r_D_a8LY|h3NdL@-Q>-_ zxs_!Q$Wae5+F|e1`;fozrNot%%XPaf0fI2zZn7AQFT3<-M+2Um*oPKtAtF^yWq9RE zv!t7qGhk~JN^LUJo5s{)1$VRuU)2NEsXOSHsQa%cH9!V^hhgD@fNd zu@-08Ky#U{_k%ql&7qpWht@t%oWL5iFLX!#HIrX!pZ~GW4lTh~hDZDlhAUgRP6P%< zT724FQSGTJpRy^dqcXAWyLpH6)&vgPD7=W55=XL`Hq%Yn>X-d^H&3b_4ArhfqE`*N zXD7p-^9x#~JLhEEDq6K%s#vkvHAmmk`Na8<5BV0pmcGh5zU%pR(VIRpd_x?y0Vjuc zCjnS;8Ke`qY}h#1+kM!$56A0S&bjd$0)vvo%1Ga{e&CFiNc*@7Q`?N-{s>(Mj4YbW z{}?yIGhayLqcYuU`ZNT;b!CFZULW&d`rec`>XtOHu$IX0nKb4 zJsxc1(SOYKPEW3kf&X+)P*HhRuV=c(t0siirq20cB)Xr)Che+!veMoC31PBQQ!gqN z&jDnAAQexCb=_SXu68mVc9p$ls{V-HU~5$;uM(5=>8%r~tee(?QwfLSC1hy- z%%PMRh2{1m<+!OpN*d@{$?>hDcwl$7jVK_4t z5jfo>`p!s(hUF9N@3)qS1)It~_O1QYS#M3@2&v65799HstwB2_kJk$dhjEk1T-*{B zdQBQ1uL&HfA3ZP=FrH-pR{z)R%2GXwQhhdcu*AQKcB)8dY6fT~XgtZmiR5lL@xhhH zwYS+fFyjLXGJ_5cQFx{&7RH%$(7F@KB2eg4Wm9Ej4CmemG@_3dw%;249A(UC>G2fo zELOyK10a?h{O*DFRdHOcf08d96R#;67*6oO)MG-84v!pFQ4$IN+*eO{Ro;U>lV(o# zeDo-zXpd!RR!iI`#3G3hZ)F_~OoO1R>2IvQom>?-!B!SLW5E|_cx)kw%-k-x3&(@2 zFK~ac*Ge5YM`jA)9p$fF86L*MMLH2Ek! z!=)Npbhyv#(-zOqsLSd*SFOJ=es_N=0C#XV9Aa1+{MG~CXurpSw8$T+SGR);YuKu7 zOqhG{k(VS~wxBq;y_m|$uTk9u6>acBs z+ELI*3vbYf*Q}Z6;3_@Brrn2?B;d-)g-$FCDc`REwyo$H7G7)*zN&Fc55bSra2+3B zTAv6>wU3hYS`onISvF{8$ZGARDg>p@!(-5}+%+AJlGxe>58A&P-xEV`e_Efx-etw}!kniP5e&Z__`o0CYFkwZ<;K zAiiZJ%zac~@nsy-E!`b%qs5U?-~r)I+ToZW3zTvyjz?k_TdS^w9_fvo!;eG~Xr}Bnfks&sdS~l;K7o-4k{^QI2PV!Dz_lYB+Am`WF?x*-cqqGpB(L$fB zicT$C6YJ3Z4DEP=lELv8cb1d(EjIr#75_+6*v==i+;O)5^6NO`GyWB|0?@^uXnm!T z_?#8!jOIX79^fApICV#^A@4G>)^|c&!wRqZz`S?+I+A%c+~({(OAV*6vHJ}^O3nThP=HX4d=Kv;wO>I+2aV>XTsI-R=pO{h8WLG6jq z#EI)G?pHF-hwuFZW^r5obk-8TDtgBp?U^ZAa%XJ9+|FAxKQ$r@m+|7sqC6*z>|=tz zK)A(IY9WbN~_#xaW-SNtF#LU!h#rUK*8$#5lHb*>Br0&dEHjgw`XZ60j9 z+?9xXVTK!?FB3B&KjYF^g&G2P=3&COB!FG5cSx^PnU_IP4yfYE^D)(M-Hj;k@t2Ou zqH>_rP(WGCy2(!ho)yp<$^)mAWH1?0FcoDb$iP!aJ|zl1bW)~}` z`xw?Dy@$%4_pw4{a^P|V1{a#Jc^-yxI!0LYGhu&bUri6rIi_u^&@$O%vt{I}9mjy0 zA9HD=&Aol+ivw`_J3OL*T>W6?ZwVAZfi|=rMbA8fx(G#(v9e|db~Fi3gERs3-Z+83 z@C13#9cfbo@q+cs5^g(%QuXcWQP|W!wJ{}kfxgCYPZoRxtrde)dgjz8KzbT5hp*Ae z7d?x&ZG@$|3W4j_nMtnc=MM0H>_utS>D3=b7gU%cB2+$2W9`omIHXNbew3)uy}amnQrz%+6q_+Ce}; z!`itFZr;kkcW!D+mlana&@lRNut!Q*rdTMCU)&*-9ED+ehdwJxry{G}XbmO1UgP69 zN_+SvwXLAD$Gry;ha_mS0c&!J@NHSR)r`iZh4-<__-BA-`6`T;I#?Y-c7%#R5A&{= zU-&Rf*bF8SH@=BlusLxbCbA1tk!sISssL^DZ7!U|vdy|p$Kz{Ecxr+>V8y(`?YB)# z?L3X{xV*J34T*GFQzh!DbYo232OU?6EeerXTF2s6!CSYS>{_WUwjzIe)G}_9Ddn`^HgB` z-_sp1Z49rthmJ6Nd^f;|0cpLIX#>iVks*MrW24}XhkZ#Kbkf$FhpUcuM#fs9R6V0E zzp5O3Eh$wn%*XbLNJN`~`;U`*Nw)(GwD8%o0>!WQH_;>QS%Pq|$4tvv2cwsvQnT03 zPY4I~gpPt#C(-pG{Q`@y(AL-ud1v7sxom8dd(z;dLttk;N{wnaJT*PF#p>t^XY;g2 z8>s;^yB@3;n9khQnI%4EIywI6 zDgWrnbvXz!1+wX*ie*301^A~XiPWQM^A*unqirmsrw)jBX#AIeThB_$ynbyMY92Ez zk@o?-{!3^ypvJspP$3d^{!7j)^nWHSb2gbw4LG8|ZNixWU~6t$&fZRzZ<32%0sry2(?WTaxDbrX-QK39TX>` zu|n%khZytj&) zJnN?p{6N@e3U8rW9Er*OO1*lY>+Ixr>My%V>DDd6<4l5c)&8jNec;++yC7|_hhGxR ztnfFLE%7!H?K1Gx<{9&Sj?QW(7#^xylc>b(QG4GnGM`NVDP(GRa~k71@EbthhgMD6Mr-d~DFCv#9LjV&&12l6V?`^By(Z0d1ceoq6FKufKwP{-DGbh!~rVUDqeLHGra~N1@qNH`*<* zgEVPdS3fd!lZ!TYr9$C*>P8u~8XKSf>ywEc3qEjvUdG^&*rBd%hU|81p54qwrm;eA zydPioZ0^s7N~Yv|BRdZQ?`OWh-)s6ZQ@6IS01i4X9ou92zdJtPGPyU(&b*5-7~0l( zY;&*c({nmVoQKx`#$3@biZ@lRUpDl--2E3jLZynWmdBrIRqc-@;s4zP;h$qG<%#MI zHkeQJn(R8=1tXVD6bdM(MH@{NFcAgU4G*hvy~3qw+xT0)pS`Y{98tA?wjCM{jaUtIR?&h&;f*b33079|C4Z`TU4oHPy?d5D(V;K*A?l| zbznHEE{`aXT*zQjz@+65=EOyf|9?0g{*+kaO!5MO^y#3QQF7tGr!Vr&q59HjP7hDo zR`j!oAuuWtO9kX~F<}A7j`xIMDYQ8QIe+6rDx{Et`4*r9Z`C_3?x{V!G(vJ*lJD%^3lm29o(+q8zaPed0dHr6oiW{}hfOA2);1}1U?ebr(=I`U+ap>}1DlX?DQ<4N;l<0-OtZx5mFNF}?76?lZO z5)?&;jM6-|6f_o*MoAFXlB<{QBpab4YnpMMYPx0H`E(ruhGd2qZ=xn6Y8@vJYD;1I zSFyH^FhY?zV&7!2Yx-Qr234C6PU&7HN--Fty@yS=#b)G~V`8o~+5Lt&l|`IIr{PtfjtU#Qa9#8Dj$wQVNFuGLmW zA+7R*nbd{sGQY%7#7dP!!%di%^v?8~Y zJQVk|!h=s?o%N~(7O?*|1fN{hwSf~dl*@yxte0C1E=--{&R@6y#2q+v(RBPmpR|h@ zQ%ZoYd8Fs&Zm-u{j~?d-QqOOOex;iR+gs$ZszIce;TMmK>rFDseIPPV7hDl; zCUY9e_DyRAiGd`B-<<4a(QWl+24u2A6nriKTuz_9-V}(ejk2PVK7nExpl)DQ>=bh6 zRRawhN`MCZ`~tmb6$XN=LwTGOlwOHJJ-^{$E)3nQ+*w%g6P}cZIAlo6y=^kh7k-ZC zzBHxQdo=rb_KYAt1X)jpBd6JLavp>SGt)f25}3Dpppz9MxHm`O+1Pb*;PwITW7+;3 zcF6s$&F$|yQWn4*r&sAR7gJ~`vltnM>lQgkZ0>zo1GWECt;uYB<&p z)bLigoU{`^fhH&6mWDQ%_rYA z-A$7*^PGyiGm6Ucap7T{ycdeK3db;(036fy`{iN)$+AODv{CbSgW9VnS|?LLZKe?% zSNpxA34_X<)yktpzOWHzj|9M>#MhE_)8>PY7oBj$qSD6EOUA-B+O1vnkVEqs$?pB6 z2mDTj+{vETeMa8ou%6Bh&_N0tfs{cKEO|oNhDq^F9<{Z)BeP$6jdT%|WTHr}Q3#X! zsDW*S|N8*mE%^cpfNE3tjc{uVEw=bff2qpo|?%-kua{S@67M9{Yz*5~gs z0&p0FUdn{{WOltxm9ri0;>YHkfU-z9>J9dfbnt!0f*;$1?5#T)4vvCVYIbayoFXEs zo6M4qsV4~8c&w2O6x=eu%#&>VoI_f{8;#8XU;Lh+IdavtvtJ~DG?G6KTuM zc{I$xEQbdGkO5*K^lFGrSGR`W?DSonPsba6!9vUvP@Ub1(_XK^Q%1#9B9jSAr>&4@X)-DHJi&wD<-`r zxu|yWcNX^-wij)4wsRC}sSvKrI}xk}9A1R1@D90 z7&#hO5gg0Gz9#Um!0|6aU-Cl;J}2rV^f5gR@~tMP`+Zk0heC0l>B&&nm7ypUBdd<= zGMy28H3c)}ssvjNq@vTM%t1U}(<@_Cof%Vi5BY(Tl7V}|gj^0hquna0KuJ>tqm&J% z&XSRXz=Z@+Ltr{$y)0NlxJCn%{NhV038qbh1tY69$}%~HbtB@M-#G*!;zW>;gu@`8 z;Ont_=fXG*seU$c7Ja!q*FUJic?@_{`W?Q?nB5rLLx=0t z1uCXH;ID0(Hvh{;q*hJX8yZfVk+UpuOz`={vQHQBjh5qU?VvzPI>oFp-&ZvN?;o{n z^-BD+sN=4>3$EWO29Bnw z#`qdI%G+D|v#EHTa$CxuN+UVZs{7wFE2DHUm+sV;y*EM_QEVDEZTH51aZ1Vnzz%Y;LK-5(qa*9jpk!?19XH>39ix_C$w*p=U ztIM72yIP-|=!6Tvholekw0gAk^S)JQ_B*GTFw{#6ncW+{Hlz&JtSFKoS0~@F13mbfAE9dr zXEyL6AtXGVjIMpk_2@|>{#rd|o&kueyb#m5RjWD6>vmC^DY84pHVHGO@$Fn| z)xmntk3^Sgq<OsQmIa> z#POvC%cKJf*I*di4E*bpd4;nOlK8&G3UThz-EfQ%aKbTJ6E{fhI!6&%(j!(m7}?&1 zh+k~6DwZR+z&z^?qg%46C#&E)ZdxDlhTK9@10m5c6y9LiJPDmWdZ<_D$H@jXb%@E5 za^C(tR3q%}I5zZHpxmLj^yBl`*O1hxWG(Mn)J72OX7+HwyTmGbi}gA-BnY)YQ`UxC zCZ@C4Q;#pw(!Km4#;LSrb*Pv4{E}RxPAdhN>{nfz1v~9~4}|5_KSOKRFrN_;6cK&e z=qzhJ3_z1rca;$`3O5d=)?kpXoktdSF$KztUwJ3U37)7{F4u@xycO`3HgmJ8IIv_` zirsYEM!bj8&zjlv$OPhid+6-hOLU7a102gV+S6ns$}C;ZlV_i&tTx&eDoI%VxLjOA)W9s!~#Eyx)m49`p>8jPJee zwvPiVIB2aAMK734BmVWI#wP&Ttg9rJ=rIH9G%Z;!N`8_jS>pPI=e97qpUx00KI-Ci zRS_nWU2_1AQdx77hI>={@T&aUhERcif&l`1jw3ZVFXq?#`Wm)5Ce-OXb6gp5TQ-Cd};VsBB_v` zrQp>yi$$A1{15SRxVKgA8xhn6!^X1z^GO+3rCgFWn5Ijl&Dmvuu9_| z2ob?#`W$f=lyCFK6=w<4ng2zF{mk^QejD+Gr`}rQ_V!5q{KS4; z|6FI8eG?MJm{vR+NqIkP+ddl8PP%Goo-YBtVeXt;+RmM5_%;jvY)(z|mc6d$ zS*N_bT`JyfUD)ILHC%_R^`2O=)2JGSKT-d;xejnT_-9gd<;$_0M@OYo`0|GS?`K&= zSH>{`9sod_7YKmlzhz9@I$F5>`@+qoj=+!$>uajCnZ?R_`*_FN+4<;axLdjmGb{&-YAUPy8+Kw}-2J z+m5Yw4Dh>66)fo8qKlNAt-7!xFVeCut7ZGTm*E0G-B;Q?c3(y0xg#>~8RnF?-4dE# ze-P={Nu)O#cJAzPFGcCl&N)VfjyCTJ^c5CeRniV7;%ZO=B|Tz@150=2vI`B&=Y7|W zr9YHnZj{nyy?zCOQM%ctsTc8#?LClrGjV34!scIOyJE-~OSBEy@3U zMGzz*-oCdTxgkVLeN+?5AUm`DYoPd68sWbHLuh38zIDzJ)#JM(I2-3voRJF=xBdw8 zXZ(I(_TIz%w6aG9l@k25Tz9I+E8I4@Ili65#HTi&=`B^Pg+@k$zvs$(w+noR0dplnO~`DIEe$0i_bm>Yj=)zTRltLh*?X2@{{@7~ZRe#3IwN!7#Bs zB}8Vks8JS%}g_jW_!iQ=KXmu4D|*ZUiLh?jD_Py*f6{_g~o=xdceS)UdS zO_!4tr$4m66dTQT%B5n?5{*x8}o~UPQkfS8=3PcALAt1;#rd^w!bbnar(XZ$eVl zM^s}Lk%VIxSBHAMAeBRQ(4v|#boHT{rlai&m8^Gt67)445uGJGHdHBZz?_Qc57=_} zaM9jtC+8x% z)U$r3poCEJ5hHexyLG2*jE6|wLz8Q5QQnitNDwMrmKW$j1r5zzGqqz~RPrn7G4Y|? zRGjF8XHqjMDj!@g&g%Dt#|4Lj;b+4!>%`gJpHP-fZjFx;OAlf>qztMF4tkRIocr55 zYGP}TItr^=xL?~J+LDpl4%dBh?tR5-%b%KB&(sI&ATYZLb`s=*Pz@|{h1w;zYg$9` zC=Fp>8Kodouog6Dpd``S2=tkRwt^VtB{G1}LOBp`B{*wPd3Y1L<4m9w81FfCY6shg zKAOaW3jLM>qr_!_ownOr()3jMl#!S&qcDkQk|Sz|>vP<4H=Q|t!=bIv00w>x{1^lm z3=rQ7LU;^Aa#j&?YA|w8_W(9x!$`H(`rVV04k0iA%>L=UlldWZsFVjph-Q2)f@;WL zDW5AbYQz+Nc>s1u0)Vc8Kpx@%AviFc7!H2qmN0EZrfoL^(zV9wIRkYP*yU5E5z0OA z_F+B3EZK|tDx!y18x67c%0$&5VcHDi)@EZ9=jb^r!s&9&6;7|vqBPzDwFIY}zT;vt z6R!@ulVa&jvK3~U60TY9c5&{abRt92FDPA9T8Z3hPR-$Sp=)f+V^Kj>&wp-D{4IAF z>@YZBaB?u}}vwMBWi?=!} zokk%Eu!!NpaxjE*$x}h+;DosmF8LdcH0NT-$s2mDf>y&2Lsw6tR@F_XEB79`$YR#* zCBsCgG~l5qQ9@VEoh#1IEGn#y$wW980$n#ye(|nk@!i?gjf}vrL4VN3u+M(Gl}7^@ znhIKWDLO}NYQ>S>*Be{yv*q2EijSPfz5lV0pyZRuX^GvtfCtrw1CS&1`}u!vP8{<% z4ui=JtZSu-&_iCZ%<4MYj+Jnb;J%mexR_ww&> z64zF6TU0Ui=t#U;x^dhaYhg2P`rnF{SCQV^$fj@xf$T)xf^o<}WYu}1&B6N7{^)En zxlH$K2em;sBJR(&Sf+gL5EZQDB&8PeZjFl@E|gH@?Qepcl13+*{!w6V;C|Qor6#9k z(%R;W(@=t z{^XU7hfGQ9>JVuvn?ikj56jfL!uX+!Trbjgz@3-!ZF5xz<{&u1l(l|zR_s02Moi3f zP@#Rq0zRVY*{V!{1~-R74*OBO_EyNdhU>1DY2KCRX7^q9p=VunxwAIehwe1>&&&NlyWbt`l{%53HN@r4xQb0Xm7kMb4J5=Reo#2N_CVe*^QL#FjL;- zl!0ihcci0f;~Zc)%1AdQj17Z?i?EqvjBYr478ZV@&7Z<1Q>BzMPk$?J3tdp{Q@LOz zZnPw-wm#_~YBHH&=~e}eTc01b9@0Y&cxOEVo(i&OT8HL}I&9SYxD!7ZM?07DnIfAg z?XGZmtZ;OA9ht7|jyYqtDiN!Rscm#@r*O%BSu;nD{Kz(Ba5BILV!%=b3Z2}Ug-G2$ zH)7oh@FvLF4+B>KZU)kVM^dF#W#vC8LAYHbepo!c;#Y^*00EsAkHO`Mu@^m`oy1$= z#?aWQl;O}4RUHVdc1_<);9qRWsFMLq0g)wah4q!8ffOboq}F>VBE`@^E4AQUw`VuI zLH&QKPx$Kw;j)W>^5h_1nC{Np!E|z=A#y=%p&@hPq&S2LJyvYjHQG<%0fPe$EeRPA zjj8viyc&=0z(O|jS!_xkz3LC!uea3tuf^u@1^0iK8sNE@a1rhtk0ZEA(X?m``?Tn* zv}l=r@%UULiT<#0_Kd|(am^mq4+YXzYzv-y!7pI!FM`Vwl1WnRuZ045?5#->X0*hu z@Ud2SNO>3vFcjbjoo?_k$%YNmP_;VfIqW6j2k+7Acu*g#4utp!u(H3q9c88nkb-7n@+5_SB-JO-A6k;@j#+txaoz! z%NfQ}S*#aJ74IR(1g?M46YD7ytyPYcet8d~stnc17hf-xV=YqDK*bPbuR&N#5R_Xm zx?+6l;ZeJ~*T@NA*fSQK`!L-@qhnz&Fjo~i?g|pWwa$~kpMObfcSKA)wYszizmXDQ zi!)YcAZmdtoVqHz*9Nxmch`>crfKukFtRRebk9+_I{d25Awyw zBZcE#HObUA>60=u_5Ho`cz8At@$+^af4{U)RPYhSzzchb+!OW#j}jXPD#&h?M!Q6# z4kYYp^z|fl$VDc04%^;CRp!dn-I5=>|Du&aNJ3g#!f6YY@2c|0`qUJC0eH|&bSp5P zRn9FAZKAiYf|7yKKv9#Lb(Y1YK)Q{AuvDcD%yY2Y$s4fltFy~3pDG3!4Ysng`cDJk zDFke3=ZjZ|;r#oQmgiLLmKmKm)_a=1u|XNhftQ+c+Ous1o|;gJkIQ>=;7m`p0-9!@ z1P;hj1r1gB`LxGqqGo;b*okU)T;k>`>yF$p&mSWlCc3`r9Kukbd5jB>RlekNJYxqz z6fVc7bovr>7-GOSTqw5(q3C>%e@023$CNfNvr*`J@;w7J-1G;|+9~z@*u?PaVi$A+ zG#H@p$W~++&CL=a-t78+hRw<4y|zrS9|zDLL>Mtaj339t2?)@hTz4EyHHo%FYl0o= zuB0Gn`;}xlEMWa2!6Cf2#`}@OxYTdcRCQfvEj4)+OvOpDIxc9J{uaJTj&ft$CAhCK z(qT~g?ZL)?Y~WK4->X+%+-Mpn^PeT;{~h8ZR)6O&9oDmQ!SO}VN=^p7`=+HUk8f6HemTef=d&iIrJbP$c-ao&diaaPsBomGlg7KS7L7^hvz9Ud4aU5QsXRB z6j7_oYpy6k7xUXocJ)Pp2=x4TQgN>m5QJA2wMkz}(2p!WsCHdZ5DUnPWJP@Ta)uokveC&?PsXaTLPc?*|S3I7d*POTX3`=|Iejfj9PGNL=_W5){z1~r+8b8}V z98VngXhSL%%^dLDT&J~a+9ph?nAGZq(@<>dBtjeC$jpz@XHi5)DNn>~upxY)XCET!O^s|gKq7#QQnrymfK8b?hK7qGkxUIE4}4!ORd&@9@)KkR z8@j4U)LpB*t;(E1u7skU3T)JULol4`FqO+yPk(zm(B-j5z?a_sLm=fBiVPt};^8#TFLdlEM-ae3 zRv^}sG3o-q-UIDUh60Ekn4?l}5@C%L43G>ciUUnt)hdZXJ8hLpI>ZVhB>5;pjmIFa z(lCMi*dNUs{>4Dx|IoY%B;`esFG4_Amv;t~oHoT)1_f#+Wqd`ctaZEi@^Lh_nx98l zKi_(&W-n%MW|wV#qc+x9_u|i6E)TuNtJp%|LCq=c!j!t_V7j!a4zh+v-G#F?K$# z=9rz1wCM|v6gf)OP*FsJzNC;>proMFOw&aCV6wg3U*=t#V5#xyv%i>r_b@Ay=B_~v z&+S9yAPKraGOiq)Uhgjyz&*7utWuTaN%g|b`FsHlN-~~3q?B^~T9|rIUY_t22;-DO zqv`m~^gA$!%I!x%CM7-^RskqgAuJW~nX9J7NK^aUQt4SEVg%yY=EZ2=8QaAxssSfj zE4^e_SX>S7ss^~)$-3ra_s8lhcY&`ZcYbpH`oZkuvE>$h!~#PLFJ;6ho80rS>B`zP zK3bvgZE(x(lRDl2J9?-!z1#ykpxi{kua1tM##4t6koJ2ge6ib}PkO=Dovh01iTb-{ z37DMbF3Ap}zC();-cu*V&13q12eUBgE)tqx001Bh{wMODyU9PWtp9fX-`a7V4e1~2 z|1PBDQ%=>dM=P?^j?Ec$S=t`i*l;3(Y*-09etAZZ!{47SfMFn%ibSLt0oJm`G9Z4v z-8_*xo!_=BPeAMvqSs<-6K=2ZV8%`?4rmq#!ZzXroVp32E2wep;v(bWLIY{`yEH)0 zXuR+DJz~Te9CZ_*&cNh|r#L=t+nZW1Zo6QR@vmvT9`tTde07E|f6YCbVUB59?T_mp z6U{YJxlMReZaAcnLK8s`B21(U)){@ASl(ob@&EF^#yPVuFfj}Zn5`jcWTyB8>b-c}+yTPG1Q)7nS zBiACYY(?5Hd>>dtCw61rN`QgJ;s7ij=zSP_jt2ry5?&JK+W zFEVZy;%mY)h5<}n(SXcP+gvd23su6Mo!N}?j8=|7fPWw43vlQ@A^C1s*c<`Xzo2k` zqnK_ciBO3`xzwi^(ZzlG89!L{N=H^9MA0%9$+whx^*1!JFGL`tQw`+qm^w9gO>vmA z`CdU|)lzoan^i8}egnxZ3Jlica;$w9X8<25M~pZ~-t;8_;35J!A%UQpcsL*#WGq68 zj+TJ{80Ms8S+a{c!samAGpe6OqiCcaQLWS_4k`)FS|bZo!9$WrM6*~aqYQ*FFv-{B zOLig+@h&gL22X|_&L1D*cOoDjksP$6qhZ48S=3+_iP`nD7cok;d?Eu%aLf*jRt#Ja zt0Sw#@A6o9sP{#^vz}Y$$xPH!{j#i|8Ap*LVQA?gRJPYhg?WFlA+y&4SokBQb&?EQn@#Nxtpz_*Sf*ur(FKf#_HY zIH!Mm1P)mol>4fT(t#}Zp{SU>)s7Zp>@&E#WDq~&zmyPH@l`%**w)l`?}l^ba6u1H zCoz>?n&)V#aF3>I@52g++1GS|Pu%3L^BQgd>daS_E0RiU2@l#h!2r#{`o`=f4~9`gC{-|ms9 z?D_ymjkwEMvsqWB9l(R&A@H-oLH5-r%si+{CsXk&?sB|}OKf7y?h9othE-$%j~~w= z#f*L#k-{EB9pH#t!?FGCi5C8%obI32e?lp=YRFbr%2=1w`I=vo8dH~KWAc0B&*L@1 zV>A2VhDww|o{nDt&eb5yaiP^>PmvPIkkpvKGQI&=febq}0gru6wde-3$s68ro z<_P8}Hl?@f0SxIPZ~mS9gsk2#_h1^%(hOK&E}X~<=X`2aHeb(BjL`$fbOHyLf-wC3Sw zACLJO>_S`E9_k%>_v8e4^kPv%z~5VGswk?DicKq+vO1>H2#y&T0T_3MxBAx48AuA) zPBzKtAm~RD%<-9qwn!9#4B~er%u>UuM>%MV$%Rq|^flb05}B!8+?FeFEqmk$_s`)o ztQx~@HsGmv!D&4CV@0FqUqLi4Y$$TXuyf@NXr-RV6$B~8>^?QWBndE-sg(0=`~Y!5 z0UW^l2T`RDf)aQz1mh4OfQWe{_$cxu?+dMpB18gudrO`zo479ydpZ!=@$s4DO&U*dBUf zk-3}LI2%GQo`wE--a~#fZT4`(>cH)Q;}{UCW!yC^JAkomBdSuA?f{Y0)J@-M*dAy* zf>nr1P~5*cE-RtojwmD9zR0=p+x47r&(HSsCsI|(CHR&g-tY!3-GnZX=`RM%pa;x= zkS!z2x&u*<2lX!XRe6oOstg#HW>0pv*5=&j?F;Rga&5Ro76467hoHJ&3j@F<)sa6B zmX|Oi3(43x3gDOEw{5bSUE#1Rw8m2(J|5-5uj&kZ!oILcnF9jd6*5n{>`e|&r{qWa z2y*oAZMi!AeJp;pNuYCSo`?(LqJ15!fCh-{?wT}wJ} zD_1R7oppaYH8h3)V&ua3d61?3^?ne$>NE3YnKqUt$pmE7UH*J&sZ->!fJZ{y6^bNoObv;VtljSgbTP72UhPnQM$s?nLcOhc$Nih2 zRw8}=zUFv2`y2^jtAP#`e_f@G%B+HH&c}8BAocX{)#_-{Mz+|DJhew|hI{!ebE~QS zXUzas&D*G=dE08f*~izGUd9yui_q+)h4^l(M-aPR<|b@X?$#kdC8n|y#caS%X9!F~ zu@l9W#=*yat1}tM)i_gwt#Wxg;SYSb0D4n~m<~34rA#FiB%1G2Ynd}HIc_L&4-q9S zINIM1sWpR7^Z^#~k*q@&YOjIg_--~z`z+aZnu5qRl>C&;X^)i0sLl3RkJ(3Bd)5!- zyQlQ!mMoRT5HB@ISRyZ!Y$7i}HTegbHk)~ZUs4t#+&)|Em+dlvPc%iSmmGw*tp2i^Ab+RvR3!VEhwqstAKYj|m5}GszYPY@{SJu|)cnP^L+_LU;EP0hQZ!OxljSF=OcFZJcvCDq zo+%sRJRrYhIvf*UpE-1mN0M4x@u(Ycj81a(ccRSS!tq_+4y$IkK}4QH%k`@C8d+UZ z<){jI-wDoRR?{zp(EVJ$YMf!w)zSqLn)D%ugXAu4`$aR!rdHc#Ev<>(tnp@`HY4OH z!V#oW&Ianzv@%ICQ{5b#coAJc@$V=@ivJ|!h5!DTaO;HMEk2957n;m)&iQ7%ga@B} zn=826>+6>rQVz~Hs?SGs{K4@14Br-T~mIw+ascKCsI>#%E6(? z?YuJ=NQa1$=Ca7eP3xEc^M}wRm`Q#yjk1lO(&B-4#JC{34;(5vA2J&_Ou}r?(1ftm zTskiQ3`3hJT2qr6yD`Bafspf#4K17$y&8+0m_sAsfcW^Uy`^b7NV? zWkulR3m8S?QW{X)1V|Os(6NlkUn-$7(h@t^P{d1Q^BS<+a}%}a+^@=vg=!O>rmiI{U#X@vG(cDa7+%S-Z@P% z&DN_=wn)!KvxDcmZq>}bQjMGGnP_w{oC^CZ+56UA+cyd&gg3SA0o2zl$+Q40=cTaJ-fkAUq2$UpWgwG z?+ROU$1iUN7{|95AvWGIJXB1^FYQYLh0|i`iykP1&his=a#84*DNAvBSeVU2IFhT3a(K=1xoJXQ5r_S&e z^1|2tOgs`}`)xhXo#FW1CGp2ikB6LHbP;+(ZA9wf_%Y9V|IFQ+d_zGmEHJr4b8sD#BExOcGD@`ETMw%Onta4`hD3!peDl>`*6EP=T}y6qex3 zOCy}u=*iT0rr0}%dqeV>9gkyZj>;cHEl(raI%_R95#$Lnx3`G2fs};$TCLsbiShO2 zjoLdG8f?`YGc*r@{9YZL&d2@ktT!@2GWx-O9DeycAWTSYXJsU+2K*L=g_Py~11G%DB*doB(am!s6p~Fvq``8;%Jf6fn{(I#OxWj+ruxwAv;9$ zFk%77B_IMrq!sH~Y#Fi*gtf7iCKecU2-fGOur*3M%M*c5VV#50WOOh}43dW5$raJ0 zc`R_4K3E7bWYDt=tz)csirOX6|L*KW?gL;-q6j&#a0$U@O_O@0fIZ&!!W=`@t^NvS z`&wMIzL;fVA^kh43jS!nk~NBIRFK(zFOrk)SrK~djgNd+o|yqUuLC0#$1^aLPZ4vz z4`OiM9L)+elVmDGiiN>34o2`$4>#$BpxUsuF*VevO70FMA$cm zXsE9l#064}Wp0!H_rsGzap-n?0>Sl~3))KdFdYUb0_$!|0-;fs{K_iC+TH3J#Gdv# z@Q+L8Tz~wi9d)>E8!{TJwf;bTkw*Lm^d7cNprDIE9UvxBHS?pB{p62QA|?W^F42St zclgIO>us0ozG3$!WM#w2Uzoiryqs-s=~3!V#MatM7x0StzmLw$M zHd~$!)1J3pEp2|nmio9N10nS4p8+);Ae&PA&8h>A?U4{|BqzmQm~05~X3YKmWHI{% zt!cI|y7Jy4@xxBkVaKh=DD~$V$P={qp}!9jhwkI4Vs3Rq@;t3=I2mp%m4p$T^(8Ra zsz-(VD`KRK{_#kPRVR35Tq3)FSanWR7TJ|ws0cQd=9GquddHB+0QJTIO10l>Jler1 za1J4udY_@)vUUV(X{ct{!gJ{80(D~u0=4Gfrjo9V^yJV}1?a`X1Rf2v|7ZwQgzmQ? za}Q7#=_VAmrHWbfQ+V4J*xI>P<~1$B!@QlD&myOIhU=9-)@zRVpDK`}lY&y74;&qY zF|LetuHWcTmul&svfT^KDmtwfp4QX7oq}!zZ{JN8=;jO@-P$O2nYCNHbEB;#G|;e&z*ip_ZhMS~x7^Us zTOsS7$D3zdwVF$X(9W8qd?Axfr6G#N8WnKu?eH&-C%~V9!TWy1`kc9FF?Zx%GkIM3 zic08?b9kNv0yDzAjj;AM%CuN|DNlW^IN|)b`B2a)i1n>9%l#XN`2Cz~JEi{3_)#c$ zt;R0WEt!ZvyzO1X#DVvaj|q|19wMan`!u7fpWnn?-b}4U)Y@jMI4tS^Rrn9wUcVH6 zEsMlpLASj--10*;X^~(m<0*TaSwRY`0Cb3^~lr!om z+AL=~v$GZgwB2${&5+z6Iz^j%4o|(XtfSWXqt9Se;+4jY1P8@)SxM}!h9;SvUe6_0t&$ZNEO zj1^rSGPL4ouTm32-)$JA0!ir;1weELKj}b~-x}qJCSTP9j{xV@XS2pDPZYEB?DK{4 z5%%Jwytyob`F4;;D8)EcF2sgn0^pdX)YgPP4A*x1l7`V(@!I8ga{U(m3P7K;oEjq-{P|L5_xv)Rk$u!0-0Y2E*NA@L$EYNGx*lY&3$#1#Ku7ZPh*V-u@? z-bXh7Mch8f7WRrtm5oJnT$5~tMZ;!K9)2X;sw7V4^O`#*dry$-1m)4lb&7L@_u4&| zto~#DUMQsKLAKK-9@Q)fvF0$J!z^k)h@XRc)F>o5w-C`FF48d>I>0=kNH9Nbms|bg zQ*5FD$wZwZ0yBXMzfZ_5_F6v@gIQv{Gscr#^5CYlB{Vt4Par-@k!`R*aRBo$jduK* zA7&wlC>aS7Nk|nH^U$?hW9jaSGM3c>fw37qvt2>}Q7i2RTdF+li+ zWf%z%XjVLV6L>>cc+{6}n*-K_9yx(R1O(`qV8&n|#l_yo&yf!GC?5|s5{lJb8Hl(t z$c!-WKjJqY?3oBfnviQZYp;?;#XZvJk%jbe-{WMDe`4 zaO$VQevpfrsCY#Uu<>5zxS1;??`Pz>z`oy;8dzzPp4ccE8|vJTpJ}(#TQu^T7A9d; zH(Xq}Emdw8`z_|y6L;xm$EG9?^XCI&l z*XCF4YpOZw2#A;uYynl>CU~$z($%ZX@qj8G3eGEQSoxp}KFYX)$ED4^UVESlBGuwD zK>e-Eq2rAUZ{W1bzFUlHuvlbGzEOWB)bR-`2gLtk>>Y!2iPB}^wr$(CZQHi(-rc)x z+qQSxwr$%scYi(e#kq54?zuN^X4CQ^pT0|z&-YY)bc z&d~5`y1ygigQZ0kce2>*Y4}^FEXjdgc?%O0v?Nf*84+n#sg+j|D|2?P5zi?53=!76 zM%=O$uSM2)X+tz)F8-Vl_G#u@)0)~z>kx8CIpVFN` zdXy60D+FqL6XvdgbT%)@WnfVB1{J`zT#|*EVk3bCYD%U6!tK3_6PY1fh z>uXN~*#v?A#jOB!f-w@Pl4wbL7Cpm*Dd8nAJD$q({^Iww9>7tK!nkpm6Ym z%0bZK;1U67V#W*jK4bPDg&K{~GjK$PR)0vkVwB}~K~T`@26O{f{6=7l;*|)TurpHt z1sE_}R2QLkhEY8x>qb~Uibpj!IM%l}4W?nwoj&+aR7p-cY$Eu3|J_SRw>!e(8p`R^ z=F#najRPGvqDjv&dllH#qC{j!3XT=IOal;yBQNSABjD3~cA`}{FYYoxnZn5Kr&82! zva%7&I}W>DQ$3~}L!7blV{A5)nlxaJ%`HpfVM)lm=-TPG9M7QLb~#4WAhr2Chkz$u z=;*7isqr*mO{Hk*kAbsvnx}}jPp~*PYsR1wMhIvfKj0Fuy+&7@D&*|>Wew9?Q4|!j$v-TEzE4bTKxs_27|Zr z?u!}yVR>FGE-~o^tv?f+xNUi)YTi=2J_Df8*~^XdDG<%Vca19Fr`xhc9pNL5!@7G{ zNGA_&wudY`!*>EwpDU9HAuabQO2bOErnSZH_7R!TC%ni$hsUF%6TbgGCv;7q^PhtF ziS=;<03i6E9>I>Trmp|yO=0`v2#@%e8uNFz#K~f!<^7baO>Aj%>6Pw#emWc&Q6I85 zNiV|gTT;xompfn_2-KWjs!_9z7a8X+-BK($1Bh3)w!4s4n&<`Mo{alncTF zWaISW>g8(;EiY&^@1i=kN}zC`;0xR7?MRUV{=?wOq7Ww*4qa;9SqbC{*S?ivF|UjO z6?S_lgI%M#lJO+EWm0)obgQ>xKBG28w8gOUk`jI(TBzhvB4R4@JXCF7GX<^MG?U4$ z)g%&I8UF1yH#r~f zuFd&i=!;MywJlgkO)N1s*rHTaAcH_|S*e$K4Adl64ND7vOTPwt{6XQ+MxV^lEF1d= z;z0CJq>)F?V*)=j(O%ITp~3P^E41waXml-ylYz=q_6DR(ht%q6h!nW?x0RwyO^hVz z^S=YR^z;DLXIa22b(V)+MTn6^h)DX3(@&}htTnM>?JaL&N=!{HZo%Y0)!IeMmCLPt z7fj)nZ>N^-@joy|a17=;A5pbDM?k0%PK1rMRPU0~_SZdtOyTH0A zhmA~C-wmHlc}I_B`(p|QjIXYA`Qh^7^%1kpCdpppi^{<0T;MoWPiTcLOv%p&SNdD@ z3rMMUe}I+X0(r|Y`6pN7|MOjc{~nyA87xb zz-~}-szzCcp>FN!{!GSBD}lEfQVv3vkR>`E#v9pro-HQ#u#;t%bJw%;u-o=#E(N(= z=h~k&q;2XJ{)ib_*3XTMPu@`4KyCvN*=&;haCp$H<}kz(rZy-mg}jGW)MTp~3^<5a zR|qpI8sQa!&5x?1vsJ_dV5sgj6A|zG6x5$i*#PXC^uVBx+qoHRb!}!kh=NvDKLr5(b_m0qD0@?{_q315Qc>=V}q5--R>i7i-NT z4x012B?OJEXU#7D@@q}rL|aedn0smVM@ICfkEN?E z4X|G=d8mT|nHd|e>7{2~AUE@f0P%tssPel{;jJ)Xnfs<=f)`pjC3sAYdkNU1QJ9m`(Oh9f?Egxt>G`L3?d!qkW-iNcg56lYlk~wGumy+G54orsJk-@R?b&IZFaEYZ1o?Ng! z!--sIT-uO}VY8sJ2(nAnM2sRKoH%0j3>$luYV&wq4;u4*`u7E+RU`CHxe~SY1FEOQtkFJP1b+nUCh?(rrV9D!R$^u`($JP#GhLcdj%b`N${KJ%Gl8equ{E6qQv z@n$Iz@1e6DdQPgsW61y9sKe5d@DiSJvPyBfMz{Ai~v&uAKwnVEemUr^NI1_ zIwoxzCc%yf#7GwnNGSjxJ{&U6-Yrg$K5%fIKwfL_$!oAfj+_d8dh3mxV&Pa-fuW4!i4M6OVnkPOg92(^ z?$T)&H($&X$SsC~49-f6)Xk!7DCSRjb^s#_+jFogZ%sng$H=MH8bQh&j*I=;t&1Mw zuOgB%IcgaoeCMn{mKb>XtPCazyed9$PaOOl9#+#ho@?QBXM}RYp~P`=IHXA(WKQ7n zpG^|1NB#|{4CWt!kmxQ--FiMOow|EOev(fu!hFS{_V~GuyUVCc*k$eSs&zuuEL$b@ zul>rP3mr>L1#=$@WiT z5j`~epS4Q0)DKX2ds5ww{p(=T^5BY{Jx*gQ z_Cr=amUlFDU0}BTC>ozlm#=JRT2V=jG^8072|-euaeyuKjY{m+)FK?81s@mDli+60S~I>T@t6M z;HYX>lhF-pQZWjasxR+bA=v8cciCG4A%OwJ~wpJ?~ z(t23b4fPkg{I6{mTK7If*+ess2GhZ?^g#cJAJn3z`VhEzr!bPdmJFCcNC2fWEr~qz zvN`9~hh!{g?jno*7l{@f{!-(XN1KlqJes>$-tpSYMKeXrzv@aRRnQZo6l1 zwd{m$1r^=*bKyIQ1&iBBz|rv5LDxX>=DYjfTez(+@YjpK z#yK8eO`kbFe@$P<9Ir!i+TYr?O6GE!&X#=+;F(i;?xI(&|7PWGp&zunR&u{vY*@}VFk~&Rf48s`PNbDRG0s5$sciLGeWFtl{rkiqnCr?H`2P4G7LLO? zW^ZNq002D%AON`k6VG@0Hg~PgDU)Cj_yFRF@|+w;yb1eLA~ZriwlB{bL(Kn zfn*FfM#>qJB)a{(lHP=HV(@cnZ;4!#>gWP~(~mreILZ>zQ}!Zi7EJx2+C!U2%Us2bFvS6qlxF z?Zsth{n;oq*Zj(ypL{L_ZjJd&?y6cpmo2ilqY3?UC{^`@50u*NZ|23H)4`GCh7#gj z)^qmHff$Us1fJ*nri%@{rn#BXI(7N|(u+UY6i#X`j|L+t4;a6$=VhyInp<3_%e346 z=IYnGhZ8?DyCn>UR~=Foq2<?YQ z^S=(H(y!ndbWi-t0#?L$cbThLi+hMc{QdxbX0e@|E^jgk3 zHtf(ruq>5`O#hW30^XInwQ{s%A8x;yq}{{Q(J*nb)8*^@QkVQImDhr&82!;qDi;r{ z@-d*7tJwGD?fL$FT_P#Ih%%;hJbWuqm*B38#JmJm&#N+)|JFX_UY1yd~Q#tuh-jCS9+A%A!W>k?Qy-Hq9!%) z^C1ZbLB;6xrIC@@FXX9sj4R>ut&yjbmeduqj8qa?M=oBr)UAMit(?e_=9aR@VLC8Z zdnlKi;c>QN_n&Oqyo7+ZEL2~8_f8o&lX`b>Z@q#uDV3WwXysB z%w0_Bnorl>1V=*@n}DEcX0(mSG>=Xhj?}FoHp4d-pML4Z2>9ezG| zv>xt>Aj-jUIh(&sk$5K~7X<4Xj;9LrgI(02(+Ogl*y4I+(^cVsSk?AxJMNwM+*ZSH zc`7n^PJO>y(qlfG7fk2f%V8eTye>%F&=muOa<*C>Wt60l6ZSy^IyqLwq(|yCk44XF zP^&~2lG}SXS9{6bm^qKrlkFf#L4RihLDj7 z#sXjWoV?!OhV;Klu+(G3qsV_#Li{=kif?E{u6JAOBjQ7$)l-_B^+#%MMrdYnOn(|B z$!_po9#0A;5j2sEUU44Cq%gQy)>A65a}C#@K(g<4N9qrXHf3eb&4hY*t9?opB4+j? zY?W$uxdsKNiRAUWyBvBzpY$jbO?bt=Vf!eJPrghH&I)oE0Hqgm!OGl*iRx{?=JZiUwH zNk2+`pY1F~CW%YDNaz2vy1|N{rPxkpEVbEd{wfDItOdrW?3w#l9cU*kOxT`a!om1g zt`Zw7B?)IK7S4?GK$;K4lLT@d>iIW~2EVi@B4;>A)%_sc&EeN_3={Sdt=;f;^^93{ zmA&tw-qer~*??l|2#Sa(0$((QEVrAaV>&>rUD+Z+gowH;Vi9d|??CP{l4r%cIv`hX zTsPyON#M!4n%&84IG}5Rdan+r90!vH9L5Z*uw3;_2H%O6m+-8x5qp}<4Vkl4e43Jp z-$P?xTWQ_%$&K70=<~QHWm3)XoYd-KXtPY{0=DKs%z3ACiXd0%piIAjI-*2L7$6hr z0!AMX|Hf);QVF0HGQcg7^DR+w_}!&#O|By8j&a368D^uft<+)(eb&kSSgpjg)+{S) zqKqlC^iK&m@rmQxIQCC3U9vMRI$tg9fF(?+ybVWnB?USzyV3HR zVHbWS(&>>RwZDcs^A!U4*U|H{D=Y#W1U^K+WHhzZANTfk?R2OvMwoN7mlXpseP-x^ zMdO76TBSn~vL3iJl{lLDV3HWcvS^{JJwSgKTN#GHWRl@XF z@iLr~&C9|awM~F=dEBGYm)-D>;;v@EQq!n~3fAeUUK8y376nE;znlL8N5&0CsQ}tS zSen=%3sG}k@EP$@WD-45-kwtTlq00DJPa@69861>Ae+6A zBF>i|2i1QaeYU}P5y+^FO^id~j?x7st8jO-cD{!xNn{Sc7jYNDfyIM3Ou6O`o26E? z1U|jHrqHS+?m_0E&y}#8J!>EP*j!!#ED_JG&voE`A-CW{EE>-#^OkAUR*t(RJb_2# zBkb9KAnY0Oig^7GWoIED>HY^^w$;rfT_|*+Uu)9n<33FMIsRqRcUNgFh?VHn!+3))TF?ay%DbeC6q4(+_& zUJ3-xt&-JzSlh-ubgmPTzXlT%v6!7Gv(Wj_0?>J&vlS4aYn~v1UIBu?QcCVVApi+$ zT^($?ZZupyC#?48tL*)k?^zu7poj8t>i~w7WUSub`I4?&Kad2Bn=!cTU^IZV!hro% zd#MamvEJijKDLB<*g62aHvQq{Xg*4v!&XQl4t`HPs^UGhOw4po1LDQjT9x%hx#z@W zm_nHC(=8lmZSpEwT-n<PQfa?OqV5fjl0{<5Xt zbU?E-N*Ix-GhF53AYst0gwG>*bn(3ZWHL>D4Ya{D0Iy}HUPWZYTr-@mFs7GCBCDG- z?mMOZg+Q*&Byb(vV6@XQM`sqRzOi>1`<|jGK&bv?#-*wAsyG;&Y0yMEj<>n=qxH^S zl~862*#IrCykwobJ$nefn24R~mwS5^TGa&EVT~NFxO;kA3zUucrl1Ms5G$Yys3D>ao_v7#A!!Y@zs-+Z%(u89 zr3E#XJtbTJqe}yT&QIoO^u}9Rd5Dd<5xGvdF(tFGVD@ zN?X)5mO#p08j5Qs5N9W+VO+ay8&+N8W-+D$9f9Fdk3o)s$paqtkX2NFrr_FANaeu6;S<uZZUC^E0wU92T4cUr$5MnrQ6Ix$oB>|e z!cZ>k+V88^$AG^Ue|2X?AXtwEP{Zf72FQQuP~+|8PrUPR+((+Q-PX`g()du2QU485 zlqg0d!!t;~hAPM~o7hi6PZV+>Q!P}pg&Kvn0t6f3p$XFkRQB&$wX3AYZ>8`wb(<3u zGn+)|T(t~XseS|6#JZFPI@rwk3t=B)oC&*Wa}Obuh(3=JD;^5W%=Ai;2nbAd6CLpY z?^8*5ZCPR)5l}1~$baC`Bq9c6$YwNUhDtl8fbFn4uOKWQk(gLaEGi14twoA6UaXIt z_hm}(c7)RUr-x7yk()9Ga9d_;cuQ<93|?zl1E0?VvM@7w&e2{b)!swLKxFTi7%bIF z)q8Mj!QYoOqs5W!%Z>8SVi?MnM?#tZ#7J{TBn*$ENO+_mi4(p~WssJn`nWL3T!M z>Ob99+GnbU^crm7%zX)*9NZ`plVe47GIna+@M^%7eeERgSfIRsly2 z56~$2OSc67KQw?sgE&%p2<3x$P+3squmPEXOh86|T)1exph>9}qPp$p36GRYG%Em) zPAMXB0sD&kS$iEu_1ssOX(o4o#VDr0!2kn~!v$f2jYeW%G>Zu&`DTf^(D&8FL_Aq!!BY1K_r1p0}G)dbMEDj*8AnB`eYU^%CF zg3Tf>UwR1SBR7OI=%olO3p*WWiCO#GxQk943XBjCb%cNK6|lqziZM zq>yUtp(_`e%$BzI!;;NBP8bKQ!`6uBg{dIQ2`o621TmV3P=izulmrj3RNEO=*iB98 z4lq-W(KHHRf^iKU=@MKd=5bdH-(-1~*(M)rB^27o&^Zv+75gKSP{}i5*xwyO|7c%g z#@AlwFvc>KpQjUb8_6;mpw>cZWRq9rJPKWMf-IyF4#2EIPRGISV$D;sa)1wR3h3w2 z&!V43&TgiezAV5lGuj5Xqb)WG3@EMyPY%-)(1B5+IRIRPhQmu&(p6E1|ykTYeEx)A;M zg%cYg{gexn%rFI*%u0Z;E&@fDT6Z)k5Sm{|CtXRzPyVcb)aa6#DG8Jb zrvNL;RI_ZnJ%vqX|Dey&A7C!?;2>1MK!|2uDYb>EpfkE=8Q>vEdPQ|8F*=){Rt%Am z9lPm2pFs;tWdWJfN_C-4j)%eLlUzN7liJVfNtq`TLMX&cnTwe&gbM`Wc$ae>Vf?`{ zSM=oOy41@>r}n>U$5PkgY1=81#m}vmdsqs4IiGU=w&gR+JU)gxM!w64NTr+K7@~^+ z7Mh%7bB5dy1n_(Ww-4zh;a#|oKkLdA*p2VD>NK6EOxSfip-B7b?3nHz{gqtbeNSnP zq(`{2;@gg9sRED9|dKRBxF zSsFihxH@`zasee8RY#JHjYgHM4wgZ$W+`IADz+lMy<0e;zuD3A`OUAZSVl*YerlVD zI_**?6-b$g7!iH>BE9>tF>!I05kjIrM#P&_C16KJ9f?f}_Pn41xhST{?*8*1R@8G# ztWhh#rHHf^Oh_O8Nkh-zI7r^oW$KNUK@+CDRUi*9Oib@o!~MUqt|1QG>WNxai46Fo zQ3b0(ws$_AetUB3y2rZ;j0rc%sL{A5CXZwzfqYn2fcRA5Zg2f=|Ml^<esuVU!N5zAlO_ zJrE1s->vkMWOpP-&$c5vYh-{EYB;AM&;!S<*0`2{ixO^OnfO1CU<<$E6_;Bjx`oEBt(q!TDfrn^W#JYo zIp`#-4!kNYew%b-@{-oY+`t0g$8*~0)av#f>07^9*X1($?gAhNUC&w|h-AsC zb%KVSt>l?iMM_?Zd%QY}jcJ zhQ53qx~9Y|c;unas+^r_Tao|B3>h)+mf65|<}~zfYn%e}SE0vD!RhBpjcO3%!$zaX zu^#hcRht_I8K&D>X8Ge%e8!S;mzhs|^;~gk3`uUHNEvDPvbYVro9(^`_S3lvxDIfY z2y$h}R~Rze6OEpZ?TyjOjoFE~2-!VqAenV|3z!163w{gyDv$j-^7aW_&og=tpcj9{ zIYc9WK)ZrCL6iczN|Yi>q?xLV#tpn_R z@3ZjZjb|-yQkMm%-ioKCpD#BQujQZXlVoO zf=0_qT)#;v?LK7hmVd7l_9{)S(vI8Rnhx}fCpK>IgLdb2-JM`yRjpclc^em&tI)2t zz$hsu4XakaS5cAQ8{e zzjrJoHhj+r-WGp+?fXJQ}W5qz^xs*%a3M$~@YV$Z^AhgXb!)2N;d+-X}u zQ+8>jn74z%!a|Ia4e%)S7)|sV1lUhJ5pi$LRL@49IX<1p5P&1zTrrqx5Y#wh^07)S zQ3hyYOuEFDI-XD~0M)tC%jV_Bs2hGyrkf^BD2p!GQe$^7QaL_%~7h!!or6Z5F#V@u(r9{Y3Hj zVzrS5=$=L#&>WG^n8xis1C*Rr;~a(iht9)TRjIDTpxgiOP=JQ~}QrjaN6v5d`W;1UGFJf7gN%0ZMw z)KRBsbF@tT4yX>OU}k};i_2~|#ZXy5^Y|c{(I5v>&LGDa%noZA!g^2zJU|DeE$zau!LNbAxt^Ge?xe?=^OsYaGJ};} zYrLTS<;aidCA8?itGs@Epc}jhyuolCuj@T>^&xSwc{6-1mZG~1Uwf*z{_{`p%*WU~Bgho>2w={eE{?aFhYK}aZ z4xarO$$~5g92q4PVu{j8AjKoi?^{d2;^05!OrJ|eM22pJBXh*Zc3&MSt2~zJ$u(k} zM}7A$5}O5b=ikA;v&3fFWQ0dm7A57)Hin(S&RvtHLtI z#5_VCLAMZlir;(jSsy!e7Qa4Q6L+Fk(!S_UBIhC9g5K;wY9MqFTT{GX4I6$^bXqnT zl(&gckIr+=v0b?v3kOYS6J2{O!?%jRN%XrN57KG}iHHIy_QVn+c;TAo5;*qwj~D?2XHgp?q8Yxs04 zN`7TpuO7(4WytO>GDLccfu%e@rDO9eqknx~>OOy9Qclf#K2Ip$3$%QC{JlT&or}gE zm#@xg>t6nQ@Yc|=>iJCnt74P&c<1{--$gP@!RXwZT(il1oXFVM%s@-}k21O}$3{Q? z%`Ud-=^a?8CXGfg%o?F~rra89 zzZ|a-M1S&>lm#NZeoq2kxz=W%1njEp{S)~%4i#pcsyR{w_KyNHU3vLdDEcJNiRdD} zt$LkMpOFv6UEU6K%{%m~>W7;*)9^d`IyQ9rpWSb_FTLURsoxJR-+vEvzYagY`km3y z3Gv_OM_-miWvbI4?mTF}kZ?|+m5YSIVJjFLJF#8$> zP@e|}4D$)jLosFJ+)?uryWKRG2Ln_=v#qj6w~l4k{d(57HIDRKmS3=ztoawEt-i)Nsu>58Iw>1aj-;td=Pe=``y zH>`Xu?cxl9d>~L^z{ox<-Cg=r^QIdOn+<0`ql@I+cl|CKs2^#E~HSrBVgmu4+k zHbX^DsK)wabzmSbbW(LC#x)IvcmY(Eps_NS$BZ%T3b0P{D$Fv5p+l8=nIV)v43?^^ z)c#oaYyWUUD?QS|>WnRx8jD-|31u{KVOz_iGBMDOYZx&ojwn6;X3q}w z;W2rxwv)BiUJ=4xUoEJ5a-ZycT!`^ktIQ?mfv?>5<3Yo7{x-|xfdxLP&K-Udp_I1f z*|vAmM(;j6V|xMSS3hWa5xli#1FTAJ8hP<2D^vzrOy1OZ&FobKjx9wB@6GW8ZTyc(L=g^LnB`fFDzb z{k}LAd&L#~TqN7_jNOw->6?eH*Rf&jR<;VVvz< zos9oY6l1G=!0}^-ru{aFko)UxS=DGq8{QiAgyr$}>n&370Av z<|%g}wd{{jlE{{Easod1`_a<{s4OX2WJV(YQ-Z!F`jWCUcnms>SiczNd(ATirJqVP zG+}u;s=|o;jEK5=B#%n;UV3tlPxn%yOp+qW?p5Wb#_U;hA9x1S$DHYXrwokr3KTL? zl}n_YU>Xku-_}q&$SU5Ry73B(T$o!srQ+=LHo1R>*~B;i{3e?>nK&5{;lHcti%m$d zVe>1Z9lP^6@S8x=iIk17k~Iao>qmt`+zh%YqO82aX*l#?_Dy&;AmBcI@ahRu*{rjR9Y&_Brrgaqg@nb%Oh6-PT7)qetJHZ{0;@IW#!0v&mNmJv9@J^q|k1 zXL^}GW1?~(8hQNZScfdW73em{bWk$}qwKeqc{J7-e-&ol`og92S z8V1gGEcvuLr}&#kcV}ZEzzuYh;78HCKvGEkZL=cGeX0qaE(-8sO#JS+Q~SZmh@uU# zt@A+}$l&qQfm}MpeGRAE!h9V}2Zqu!Ndd|N9N8T3n=sR|x29+_>0#l_UEgqyb6&KP zLX9c6tEXh{{beUJ8dmqV$T{JUOUPHpQAjv2xOqlSCp@{mj5kZVFNtE^D=B)@nR|b( zJ9Al_x|<)JwsWKOx4zA`DS}p4si9QoS3T*fP;G7li+o0|IW#5OAUm1F~S!lFC4`t-qpC zOtfB;8dhBDwZRTHVJGuaxGI`Ab5w`gK^${Vg!p3tKVqbpK@M_baEbAXL;ne)WLmUx zD-%H^3CiB*_`1?~9D%eP4rqV{eCS8ntIF)VFoGR9P9txe=Q%9}%FnU-4lS)rSpJM- zd!ygExUC|x9NvPaX6-m6TPM}M-DC!s!I;R930yhOkUyzuxbKM@hdWsv<>)Re@!&r+ z_9Ng^Kub;L)6hm#f@ytzw`Kl3hhg1~ly+BLrqpl|T`*!??FwEMzUUb13(Q=Dz1C}j zTsiNJM5$V z)$}$EU)63N_qqVGL)PzNt2+}--tQY#3*_MI`<9iyUB2fS9#_K4g}z?C5S3gj_-KeV zCgx*I=Aa}D?Jt_XP6L*oBC2yNzNmJo$>h%7F)p}Vim@%1qzePxAE0n>Nt>TcWf$!c zMC%ZG$^d2nQbr5g)6pmr1NA2%rQN~rlwORocEX1^)!rD@gfQ3ZJZ-ZDJ7NoBc^QSWrU2nUNS4Cilyuh3B}XI_^LxROc;*>bjL zXX`>-7n?#xS(vyCUr4Wz}M2_jU2wE-Ab_t5zN^fedgJqao@+ff0g!5?6E!+EOKjx$NKvI6MFX7N<-8940 z>t12uYSh0fL5dlIX>>q`woZ32)O!2EqkKCk6|^mt!EIy9#C~AnQugPUfK<@MX`F57 zjbDGgJ)!NKT*b$)#L4hafUeiv_(410ZqYtz7NY}jr?DNBOIqyxgfGF^JU8^Y-fSzb zn|Gw2%QEfjAB@&vpY(P$Phw+PP!lXZ^lB_OI~BySr-9xEap-flu^MwnQwxfeJ`&+@ zCZqp6mjTo7S$JFX*W8r;`Xo^POHNhy{U{kS}78n{py>>4a(he!WKBArRbed|QY zsLr<1(u|IY@En--$*=>c`I0%d%#(a zK7RUzzr-?S3%oAtCp3D)>p@b{$ws`%{em#X?cC^!Rq+AbLVa4*cPd-@)#z>yO^{1i z(nIZ1pmny)L_`&?_)Tt{*44%g`C7*18p`Fh2*OR*)1oTYMY}NywTjjcE3gXHALUJBYnxnL z$Q7(95lzWZei#+5?sNrO#Gvj$0eaP6xn6=z*ZIqQ=677nWGdD)s27x|a7JazTWw)h z(I}gUP+(1Pz87#$?Xh~HRZYwFj0I-KeHzMrw*zc?=|zlCn}Kn=ius< z47h*gDZC(@$2EEoVq-_JqD4NTK*usBUGTYxjESLLLqT_0blm8Pj$IS3(V5;cfkBV6 zqD4HRKts4%}wiT+p#95`*dte5F7XO^iUr@oK7*ek8 z|Fw@=B`kopgIe3{6uS}j=i3CtL}_3B1$(BxbFY!a`2pwSgKC7(QQ^@?pnG_RqwMvU z5YHa>^p~n%_;<|{%Mb*{>w9gnA_0|D7)E;J1}JFulpul2h#b_Z@16Omjv>7!{7zi zYIP*>qwJ-5FjK5}OD9Z;lfJr>p4E(ljDY05}{R_PNXVq`MsaV?xV+^yP26Pz|4gRKk8 z%)zJFXm3d^2b5~SibeZ;y1}Rf5Y_S+gr4B|Aq|NNrRB8>^U6$PR14?avoGFoy4>u` ze(N;5k%@*SU7W>BY$b9?Rn<(&?+X(LK51Z3N%V49AW<{=Vw?3ZUqtVyXpGXPn9jjF z$*7Cq;{|L28zw2=?V+@Tjof=u*68rfGG)WW&F@;CGV*RU6({ zE5XObx_fi6S?8%da94za?FwEUEexg7T?-u@Hh*r8W-dZG=I=>=L+A3n;B!aV>eprC zU6|rb3ZA)@Q+H(&d<8JmUOh4LuB*GRn@(vzRr-XUDZcF4op5C}mlDJbRNr9NE%H|d z#^^bFXOi*N)pm4Py62>za$KpE1xA2V=6nPFdmC(g>JTg<-CC1Lv+*puBE4U-_oU@F zekr6RAc)RI)DQ02<7HtgW%ZanB#ZBRJU?9iLciW-j-8%1o924r$+e+m8Cfi>BndOJ z&@Yw5QVg{nAdt#x5@);JP2HI>qj3{LB8~Ep%PNv?)%Z<{?cpuknRfrN%J;$flbk%L zK9JZp&41NA(c3{Pz5Si3zpb|rbontPySx8KO%Y*4PGrR)l}6q9X>sk6`a##?GZp$} z+H9j&miK&&=3Q3gTVh|l%PH;F{}zHltgkc#WLX4_l;>|c5p4h!R`6{rGdU7%uXX~ zRzeSIc_MOgW@kwKm*jFNJdiV+cE7-FSA*$zXVBXnkH%mBKYqC#B!pQq;1G8Qt_Zf- z97O;*Lqpm-H`C~HuGU=+>+zazi94-^(9FFOIM5V%|Qtw70?PICPGfR%b- z(d|efJQ1mJfMAK+i6BAIAPSP9^8Bd79BXCE+K6>5!~FULnHsw-U`(7LAv&WyfG`AV zlPa)&Z;1gSwWJ1NvEeGqj^y)=`W>(!FJABlyz@P&2RLdm9R!h3ZgPhO)N!7f3|^j1cwgMH{*uhv8fR1(F$qr!>X)zFg)KOtl)%i`EQ z2pk~uhGk1`{9s2KdjY5?c}fscHwZu6qK!1Jl^s`$WP~8tCx})f*s?emfmZi7pv}+= zl)wdt_!}5#3P8^w^H+`Sey$hAPu3wlgs`_6Mu`s5EBG$QqkNu4L0rt+yYCJ79vVTX zKM5$-I$k#rgp}r1@ud%nv$9v*?cUgyLS4C#Ml4rdaLH(r1Ruv~o2ey3o!q*g z!$+2ORdyG8a?+v+;XE0AaL7*XR1N|WQ!s6)jFo)xNFgT}&m0*tr>fH_NeopDCartk zSg=44SZ79J7a-`|KyMg9H`w1ydf8waiXFq9*jVQtXWJucP&3r20Nf|Hs!m28k9lTcX{kZQHhO+qP}nIBnauZQHhOowob+ zH}6H(=W)Ia!P%aH@C5XQAa!KtseDpILD;s(%PED->o(nL(f?WYb z4^K%e?Hi8t!a@HA5NB&cW8pympFmHxdYl7$*;A4xIzZ+_D6JSAZFEg?n7<;c@e zF?(1NYR$*TW`Wf@EJUKV$aZCb2LqB}`-BU_j_)%44x6zE#RNl{|wJ1^2`)4#sE;=iE3lYpI~7{gWhtQt9S- z(Rv-d%uHG=|2fr0zK=Yt2Bwrb8;8?b%quemwMK7l=ad0i0H5~`Nnd|rr?D%O|&R7?n2iK6MDAd&EV#*f9^{)5VG*4>q3!a-1Mn(3utRr zgq|OOD~#%s`ZPjdWMr*Gu7q!a`Kb|JT%2z*Ly}&QHm+ap#8@c~{h*iGq-TW%+kE=1 zh+mH+ntx}lI(^h_@M5|F808~WvtFqri+XqAQ)?#mbEbTQ(*}qm+@tTar{il)7^Sng z$<^|3b$YWD+%$dZl|UsLTRaDc%$Po)t(sdIcvH$19l{jeI=}cD1u}1dc;x()tRaru zvD;I)eRx((OB>LoT?dfF_l?z(x2$`pju)nBax%u=TGrth+%;PhUu8Lywc?>N8#$_| zS#2I37g!UWYbH&H{QY#2FY|S-CUY)7M zebFMwck5>ZPs>)s(Ka%GZ&YY1(Hvemt`=W{?0PUYZb*6qi^IamimPU9BPg^C3YH3r z7+;BK&kd1(zT92BEV2mDgqVcmbPmESvnZu#W9p021S}kuhI)vlYlFxv?>W9G4rFmDq=jd^o@Fev(QKvxfeeGXj%DHTkM;gigUJb7B$k$P)~hf=z|0iNuwrVv|Gk z6OF+r53Q7d$`W-Z&Oj#>50HYwT1HGmt#$QD-2WlLGQ_8mkH5%o{Bv>GxdPkmSr;qi z2b9;Q4Ym1_>(v4W@dLqacfbIW5N4pY6U$*Bt-voH=~C`8=3cP0j6N?M9xOD)p;N@5 zQke!e87xB`z}F;#th3HNNRTN%Kt8)>_z~xtzp##;k}o)Peh)p%WJ~pj_qwf`8k^Nt ztGA3iDtgm;%heFB*jNbql^mszE!+VCN@-lkTU*;q zM#Ozhb9v-$AQ(DHn||5+;?>!^{W44eHz!?X)+UQ#UT+5MFvvl~&A!=m=`hjZX7Pok zMk}?)FBR;k>0ukWpG4R3ihlksTu@dNyutbnd|Q+ONjJ^f3rYa8OIo_2;Tlq{RI2yS zKSSQ1gS^RqH*Bq6TRawSbiJYC{o<~vK)j>%#$?;~k%104o;`MMW&IE{>}NM>+$vU( z+G}jl_lqszw_xy9eS3#Jp|-6*;N<}*!9?|u#3!<@w7X|5W~4#TCzgkw)`wOFk%X-C z>@iqXB@=K0nlW${26W#dVZ#8e#2VZG*v%>qspZqKY|FdDmztYh5H%hnM@Gd zj+(?-mQK=$zpi7wE@FlQBA6u|{#IV&OM@I|>#FCt!7&i~L3Bn!*&6Z^^NlcGDr%-S z)Jwgcz40tFl4@f$9@R#)m8yq~Z{S-6jWi0#Rbi&8kXA|`QXt8Jh3Z1mb~NV4IivIg z=#l^JPe?nZ9@!|VRcoJKouD?4!8R>%?8xW!WT>ani?zupW}HqZi7sZ5TA^lPkxG18 z{L9StxLBGS)iRURthr*DJ(7Mv^=SWxWHr27YOSy)JV|_Nsl@RRqIx*IUINX!K^oyP znWI267n6&0DjX6i<6zf(gq$v`xy`$)(o)OuaVsQ17N1wQN|l!GpNs64!IGB-4VmY< z#ZdH6>|hc^ZWnV~--`Z~D@Jt=#fnUmtzoK269xM5z3LQ1=x&+KtWErsCc2fu6=J#y zNu{LGv2#bV`JUTeP;@81DwYL;#Y*S<2^76L+%6`J6?0Xn9gnw_LDk=S+@AlK&(03= zwOM1$&m9uFRoMA{6MQe(p<~?_=8DFL2NEewR}F93w)fz3KgqaYoUANpNUbhe)ZyJi zBQ=WKc-tC8vIwq#ua6B|CN7th+j5`;%R16!|6<*dw&xDg5L}Q`#^?L%ZSs7>sR5R~ zg|kq3usxQIhex8uDA;ue+AWYHGyo&}$Si%0U;71)1%iEz+gK3xT<*yHifggJ6UeQK zMU7FpWW*qN!qmwEv17AUd{939poUgzG@9IhOp1(?_)yxM7YNi6MVXm5em4C$Px>Nd zVG3)-6!rU?;K)#l+<4NLI90)5AFu71D~A_3D{Nv;{-QNin-Mm5c#$B{m|uU);9d*$ znc?m6vi_{DmL@~fPs9-~q0C3{sXjd4`y+iMJ5Q0!zmbaT+ za2xzR(A_Pnd0@EUg4P)_0Mb~ZfyfnV9)nDBVsWQ&)EHDJA+n;XlxAX~>mQi%&Q5YI ztJLpPVNiC>Kd+%v`8%aO{l^w(tz*CZy=10;OU;NlQU@B(e_g?dgjEytskz;e3TNNE zY8I##nK!psp#MYlq*RXt)d&-J9tN2+K$lD4}77}U~dx!&~5QE!H1CTNwI*3^)z zxLLUbPPLsNKfySARVeML3X!ZhD;!VU1NDsqFDhM~fAs+IW=}|9_pRTJ|cei?=@wDolj(z#Ssf~ley<;k3G+%y=_80T~(&d!?YpRl z83tEtrnWuy2ZBclJNn+|Sc7SrEma>|!Lx1lIl+C^R1fC6!^^JiI-A(-?(Dz`IynRl zV%oxK%qUz?1uv;(A+-3CUeDdRA_<8ybZ}Wu@gtoyn9wySdWu@nTL z1HS(S?m-PL5*@oXRk7+|*cI&tl~S!G3<=FI+kttjetmHFe_s6n{xwtA|0l%oo3+1JkF$U#d?Q~}NFSJ@FoZgApo%&y1#wvXvL4|eNUDl#RatS?i(T7`Q$8^*} zVTE$q!D8Jo{A?y8ClkPc7Mc?8w5ACr%!tSC+B zGv+iGpoge>ELqZKdEx(C^srU3dW5q&o^inu5G8B0yoWuK0SILvu1YGT?(D!wN&>|} z;rk>1t4l=@cCB2>wJ31PesJ3RWMcDB51XCP_4@QHs}2V)^S5M+*LeDYU2$iV+XhWK z9cv>8%P;h+9p1|C>@EIOICDq0pWpA6*=BS^=w1x##WiH`Ck9(rs~uCV)Fpcr4MZ0E z8&NGkTt`tI-K2_<*MDY0Ov@PJ3M~S-Vnm%KcNy}KjgxZfQlYk6=0OrDrm*eiGKih& zx=D4)c_Vdy9mArs93`?G5&|~a=I@Xs&?J&nh2r?2$?gsio)Ou}@eyH7?r_(xIgTy_ zU_4^xXk0aC{z969XQ`9827}AAvZD0)-4Ww~?&;G^CuEIZvxN6P@r{_@7adl$R_~!x zmWRdG1ou;F)SKwnVfQG@?*AvR&h<9>^})XQ?DWm@`MUTTRJ;~T^EBzyJDu20zHMhy zDhGAK_b@~-ggvQQ9rqCc_V+PT_djzpYvLBzy4y-d#d^AKs6f!ub z7)bfuj@FJ&^RtB?&rzjcGJ>7k?n{-n4riWHx249D^3%8H1wf~(osSl<5s!?bc%$}7 z`Y=SRoAnp_g)L$`manp4Co zkBRR_o`soxPiik>V1qDn7@`B&TL{fy#S&G{-d{%D=M`@IpUm{VxPpYlx=rY%ct$nb z8h?qZtEr6AWVzic@O^nMvNveUE9U^*Y8J4qJ{*xLB|QFb`GM<{Zd)X=}EM zzua2*939$43MdVfMblzHxk*yuPhALB@o*anfu3(z`Baq z7emeYE@ZCMv9CFNJGgOQ$3Z@?``ekg>P4hby;JdKgUl_4^MQ27pv+*gqqEYRotMIg z<2m5-0C`4x?Shf4bgtM>1)mYv^EBd|FB_w!ecqA6GC6RYK%uMKS)B&P(l^3^q9o8Y z^oDc7X7ZJ=GWFI9q&64ipf+c`oNkI3r8?p&t=I$a2l0xavB9z=U+(oAqW%Ck?KBDS zh9z|99=!jnza(3q*Rq$#nb};CgCX_fSOm`Ywk*Sd+0nG4tu^m=(?Rmay9PgAVt4gH z6BBx|3xil~JqkC(&Z>lk`ia8!3NcFgm6sHT=a29>l>+w}lHs?db775i$!d=ql{N^= zCUzhsNsI!1vxE8Ui$&VZEy#3Df;pQ(19wJH^B^8P4}aPNi|r9C_8U)zx?an{gvOWc zf{+HZ&nwF5xCb5(3~t1hjY{nC{Tp;Puy=i@l?tLmZyXO=)6lg>4TT+zV^?FJ3aW>% z#R6oxR-TG()^}7DL%Yo$5>6#WNX)j7%r0I?5oOB$fmWs7ubrVQ_>uVvYF>sZJS5TI z&K#itkbubCKgtXp@GTJ+ai@V70E`pih;FZw9|XR!29=QYP7Gt9O;wo&H-CR*m+{yRYcDhrFtRt)d{~$^D1adoy zxCItTB0GVw1KJ4d+}On)YnoYt_&C)ro+m@52B9{cwnsYqm-k7Jtp{|R5>Ea>JcX%H zbYdp4P~xuVbd;b-5+`P)pgjxasl0!T%bAx;E}9#~D7VWxH2^Lyr@IHIRYt|C-9AK4 zE+BdTj>0}f;d8u^7p5p3I#qV|MscRbwpQaI_7(cOIDq2^JYnePvJ=7V9U*6?=W{C4 zdr17?`*V94Yvu^boAYC$FVhz=VdQ5z?rDwg4&ze_$mtt_IQ%ElYUPoLJKN_io$W0w ze(38s6l!Mcx5MkVeehdFLXsR!R!VV;&#yLT}j{$`9nS*UJ1 zOu057yxG1cd(*uD5(htn{%$FBcjuo}Kz83C;+Q{P?)=-7KO==#1ASS3z`q8$Za^!A zy?vz*wa{H}X!sJJv9vqBM^pdRWAPblr(Vkp?hX30@W199{_s`d)!=gn`TR@gc=P}L zme;_SAdvt$Gd&FVMLPb4*IphYp6CBI!H@Z5n(u(=H5K&aljwI7hk*Z{E`k32my5@K zrg7}P0dvQD>FrPP_!C}xJ8iyDnpn?&9rW?*pnC+O=#TkpsQ>1TqIssHz1-TU-@aq`Jy27)YTlxt;rP&BX*gD1&Ip5-DqM&(E{_Avxk1hgr$ z$HHaJl9UX2bjr4i=!*}scJs1R1yDdtN#B94mmBRKD~2H|6h1KKBRvs^C|2A_@Pk1( zXilz(-cFT3P)wfS^R6&0YQ~hK`ny8DWRyW z1vaLjJgHMk{+NNX6{1EQ<)#vOq41K-nT_^|^`n`K`Mqp+kj&X^!|P@f8FGZ2HN;Zrp3uQo~0ujQq=HJ^Wcq?vtku$4!& zr386*u(ncAC9!s;t#$L%T-6e&nnWAwVOdIW0`wx4ecf`fO>|5K$)XyuM$bQr9INw~ z>(u)4C(bp>frf%nTAyIev7lN)ZYdw@-UAW3+kO?K9uWQtm3(Jp&w03-iaQ!8#YE^I z>G;3eP{GyI)|G#kq^Xj$JUqkUYZ~ro2o-6diW}#kLhV)l+F8VBFhn_u$<>k#sG_!@ zh*=uvPrV*75?QMlXoeM;0u(x&2qo5tU^zO%K2T+N+JnUhWsV|QIOnP z#6XL(m=vl|n)>mbJj%pCQ=nKBbpm{)$Qn}3z#UDs9AknUP78BMalZoQ)RKy2LSC1r zKuvw05_%Vtnx#R0$0Z+4*WlOJ3%@OHhuT?}hx8+_mu@r9^b8(b9wEXR?p0sQ2aRF5 z6V%k3`^NT_&c^-B%E--awq6tUJSl+}amd|OmkF$C{d2Cy&%OkOnrE-Kx>Xt1#VzET z1d%;0J}o*~klYk=Ccdn`rx*+;H{Q>T<_OL+aH-&u+whxJ`=e#RD9d0|V>6*&`;jtaQ-Ra39JhC%*o`bG6o{sdH?0dUX&v1$x*mKXq z7`=v;C$9t%kwHDDeE8-VY?rzw*)bravfaj)!})ynd%82Uu# z=eFoO%PoJKtXZBDO(Fx8>0 zfmgb63cK*iv-lih6E9Lu{whSoSftQcKAM6K&`l9JA--(=4X@JLtB`>4u(bfjp}R%z zuv84n(ZUoM{kPO}i|I38apG@leH64Nvr`T9YvyBH9%|b^^woM;S>NYGHo9u4j<=uU z=R6#;{XMz;*%r(!kODYHh1^${Tx1WOU`hm1A?`2E0&y_`psYxuGvP783mWeg`(iY= zPuSKCi0vK@BmtveiSEr-?6~Z^bZW1SRF#!QI7&I&T}>>GZgBw%YwL$(MSxT|b6{(9 zvF-S(@|OOf<5S1UJiI!d03RLrFzO?EXe&=6bsP;Qe@J%)QOB4F+qx!l4EFvw~|sj+v>2=LN@CDPdy`&)1u##)_hRL%*Kqv_&&dKXR1 zMsm{;t0IporGQJT?G?F)@IVS&-y1f)rA^esE|1ZQxMho=bsy*Yn*q(%5qUh(R@e@M zxf^F!H9pFuu98w%hhZh%+boeA^2SnS(z^DEkKV#z-QAf*HgHdo(r&n6*{(^$%CX7O zIcV-Y2jUEVGUsErFrVJ zZ4@U`lL((92IuZcmroysP5H?mk4Z(3m5tF|I&SouScf8Wsd%Z|6aN>DT>1p%Y)$ks z`ser?jhrBdKz@&9nVDH{)q~b;H6%#HIb9WZ_P8fqVwkvg@Uv`l$@eQq2`>EV-@dNk z%Gkq8(thH$71R0Z?MSOu&B+!Gd+qd~TWAblkjBM2^_&&$IVEz-t!Md;zMZul# zR>Zs*)*JiQprUrthEx&csIx;-zOB3%ekUKH%FPn={_eb|xxnkqpc8L;Gx}TKEt7T| z^%iCDoHSnw^=5=^^ioIbI_p&r`7J|U_wlOjYw04f>-XkfF?;#NKstf9%MGoCIyGA3>=ukS8Lum6p6k?pk%xAHgm!S);c z`2V{J{#WEfX?iA071{@F#klQS3kg*rIt&b5cMK(bOQbGkV3*M%*4a99OSPa#vvt zf)Op)@@HDK{UW}vQ<|V`f;0slhgdH#Btt4n6i+@-KMxb{&ygf9q>y0g93!$);6$0q zejczTzP!As*DXxJ-7+|#Ka;;is4xIx3i1nRgj!!SXPIWA_w2ytGLVaqQH zT@SDzFpfTuCCBn^hH47JsyrUbQ>u$9Pm(~++Gu%L$raT`c{$Nci&1H89#YExj%=O5y8GoKpA zUhCQP;rDG>Ts$|9n5oOF(MvD;{U0{m)og>-?|C>azhJIx@Ay;`&7IDaAzsDl_Hask zLf>c3RNT&H{2Oj>_vXeSHly#r0vD0P}11&GBU-kD5u2|fm5|aN5agHz- z-8rfS2DSap_I+2<@gaAS`!VZr@Z|Q+^!2`Z7kRh^!{hqk)G1TQW3gEMHG*fF=eIv$ z<>ogtdz0z2-I0v*rFhHC_fgn9+Kfl{vBP%yj{*9?^G88BZaS`85AUJtt|Q_t)WB|` z^7H4oP9WErxBTP*?SIBRNW<&BUVh6mUPb`_FDKp3$jZd|zeFysb$-RS*%N;8=HFT2 z4~*l}H*6v$I6B4>MB~p>Ha7L-;Sufe5;!8m|9CUyU2b+g+qzf6%l;dqg63y#_N0tTLV*GGG>xUCD> z9agb!spP*N^lE1Uvpb&-i0@c$XxHiXM(;CUP|R0%OivF_%RaC0B)UR#!|3E06U^k` zBF&jzP}SXU7p`SUh0J!ad=a`?BUI0p?F8Ba!PKbyV0s0W#}KzX85T0lBOcYk@>>HK zUzwh}mzCbl;A_6vpVjUzcQ)If?3l)+ z1-0y$HUdDS+o)Bp?OxM%hw2AiNV6k9eNipLLN}r+d_SRe0Slwa@&&WbgR+CiRf*c| z;=s`qFhZfwc=~3ce&{Cx@87t<6aFpZiAY1_igxFM+W~hZPltJ67jIg;1OWA+M`|sY{Eo zJBb#BEfVHD-?Uq((izrNs|mwh0`}ZESb4VMZobNjW#J(Io5P(+Y(rEqa9x6$JviK!NY?rRe zmInKpJxrhf3Q|cFDOR`TWv}XyVaVWE+=SH+UOv(np5O{XSW&|)l(1vP+QQ^{H=}G= zJ2E*rOgN{E9>1<#t-C$bPJ@PAju-h$!HkDmVQr+n^<6wqH4&J@?L8KBqaTctGE@!^ zIVT8lF6Hdl>hK6pa)-UVt98`}FbSNAuYQG;R3Ct6o$xumL=R;bnw#^s|Goc&FOB_* zCeV712q)zjpw^}(_AjV&^L;YP{&-NSZ^!3uJn}8kMP0rr_Pe_;d5<|vQJlmIw%E{m z;}%!&G56;WSIsBP7>Je%W&3?=eeWZpd5SPW3qheJq^79^q2+uW#$oX?>2w_N@Em92 z!II~g^XeUN*gVu&pgx%VGlAx{z@nq+oqzXxF`Jb!G`GehW zMuBA;KATg`4L4u||AolFAATbUK{g+RkJ#rfNUP48qL;fxQQoEu7DHCa6VueY`uORy z_$fjLJZz;#8DI=GJL9|fX-Woc9HBf+_J z8E|kCk+fgIanj@m7qfdckh-PvDe5>8fa=_T>z#)r=)4K#@B+7K!Fx@`5*06tRMtg4 z+#wKh;0aCIRkqBtQS&v92?l5R`GN_WV%e4+i{QaT_PUTL19LyFQR&r%y|&UW7=6S3 z5n!X}3)f}4W|HUQ6MxVEdr!D80I0q@HACWyr}hpFfjMZ`N;t;_r?q^5G)ZHYaw<&S zA$F`V4M9tYL?8$$x;MBJ#J@<-3leq%1Z!8tbyZ-oS?7$H7Nm-rUdc9O-Yb_1$Y#!y zV`RLQbop9kotiRo>Wqab!Q8S+dop2=E-D=YXP*yxrQZn5d}EGszR|| zHVo!?SxstEstJ*)8Z)(egd83f`5n}E83&7 z%muW;->4lo4Yk6}l7|_o-Ix7$av+Jx==M{fvn14HM=}>f1|Q{a4jflH)5si3jtCF4 zLG#){9+ERAzUx@nkbM#8Mv8z+FyNPaoi{ieXlM>1N!}?lPwo{JR0JoCxY2Z;4?+_3 zgd@S~ed>g?RHo8~IoKnIT4tUc*t--BB}dvPiRbRTH?b;MSR|*6Vv|P!3ArZaZ&jmT zbPs%F6J1P>yiV!b*aVl~Xm=BOz~QW)V|o&m~AGPAF%UsD-g$ zo?8K3DR0;L!dF690Rqwfn+9?g_Ed~zkZoAI&~&^o`4m{oRp`z4BP!(9b3?TCQzMg+ zNz!J7Te@ve@#7(A*!bGC@WJbFqeQ)*2yF+|t1c{B= zapicQU|B~oF_MDBfu+&+ihb?Mpv2ST#qp!2D1)G-XbiJUT+UUC`idznKOq|IG3`T- z@$=^|coN6mIo(QOF^Awn=-Qj99LiRxefB|fE+Z-I_rnDruv=d=WCTU2jlCH%W630? z!WqHx!A~X2qZyVASB#SPql-}+b1W=imIPN)7k8GMnG_wvOi%?0)HGGael3(pdWYnp zH2i+^QT3n?NxDIuNsEs`fOwfdAOsj@E zlWlw}i(Dr1MEpuGp4WDM%Z9K=E>q1JhBiXVP*-)o>k$pla}uF4sbnF6_4%qT~=XpRY5k`5YXiK6Irz%9y4-=Dm`8Nk95K>7fVKPMF5D zdbmC0U-ZgaP}$$ApE*TL1t;}gU+0h9?C;d|Xw6yU^S3LykwT7)^YVt$$3M@QPJSU- zv)H7PgZ)qZHVKmpQ0tdxx=hhQr9V8lQA=o}B(UBHLZxn(XXE-BcYn2oDy{&AW#oO5 zZBz~%#h3Qv%QOVx@7Re!hpiq#3~0R-h)!cUl+{&4MQ6--&!_qHX6=;I&5r(@=;^C> zDr<={9C@E8mHJhlv#X;T5_G^8^%bf{+3H+mLwE`y*`|m@!}F|Z{EjTXoko_&ge3*2 z_jU0Fe@fs$1PAd4r+m9~6q8kk3&+S|VIg67$(m)W=7p$Frgr8}c5})Qn_NT~RG6cQiwAiO-Dx#@9ShKoO#K zO*Yf*fKxSs%iThn*Jf^K5)@+QybG|6G0%DxqHq~1^&mhRO@A(x)Bqs?MNu9PPdQnh z1b49vRb2b{RU;qUy7gwDTtIFROlNTO)w}IUSjpLbnLZouzThhCJB^#0B4snp6PjkXR|JyOJG2?8^uwY#Ck4&-jH!e-35nyE3oINw6 z2+18j-_=u9;H8EWkQqz>D)x`&iuOu*t?@Jo-=<5Ij1a%5j+)Tb>R&0F*2O5#?+!CA*IN6*Vd>BjHswVL8=k04Qx?L7?Dx)@zl$9 zvdpl$7*MhQ_GyM8GUHoIOhZXiuxqClIZj^ z5unhew$5oyrEer_8AaK4WU_Zv?km+TX)pgB-=a~bCLA?sw=C!p!~dh*dZt6pv%Nj| z1w;ZOjn_)wyPr0xv^XRQPl^*{Mk`(vHOh30B}>IPGSckRTjOJ#!*s}8MRX0^?C1#u zxAqB&WorCvh(Lr=hBtE9fapJ(bGE%#B^^(~t6}){1YqSTWAlqr7c~(V*O|F;YIhSz!=%_YB(Q^Hv*SI*ivH?JtN= zaRz`YG6y22g}%iQ??*&zD}oaDFSr+<%G%wZc%X}EFRvqEi7cx-#_1zfYXua1C^vfN z7j&jJNJMc3OjA~qP!6$N%|{TNaW)9EtL9yU5RrypNUh%x5y2oyE#BY}#2^VJp(fei zte}kS$VwSpxqkYXF|L`)C_b%UZFg_wpE2^?_wG<=PYp|OOqVpp4MS(*`cOHspT6j) z;R8HON*aBPJe2kv9L!}zqMsS}mmDS;uu?yWBY~6i-#Sny=@}zlEFJPeadQxB z7X6}MTTFUIOI)krS{~yh?i78mXsSsm=!!qzS;cIt{c8ZY3deU2e??TlBOKUZyW+>% zM2jFU+1P^{0mrNV%I`uXYL)-Jf3wwH5cQ<;E6AQSPg=R4NvZY9eZP>Xj@nbSNAc00 zGJ#sdI{7E`E=7>IJiO^uQ`u_a-VT>a5!V#mFG;jM$h0OK*c=jxj$hg90rHbi3T7hA zRwuBL9Y?WXO1r*r!I+wl;T@_Yh4xT0{La!0Xk#fYfefZM&j^d^#84H%Z#P7s($>&- zF=z>{5ltO~OckTszi;FFfls0#WH_`NEP#x-UMK0r4~=OEyfCdz-T;RJ*4EJ(@ zj(K^tC>8;T-w7^eY5RRM*3Q?(P0rxpYb*qCiIT&5WiA~N08s+4^oq6+zWE;hTMP&O z>K8-_s0ENsuoeI|g{AXsoocgs0`hH&WbJk*h6z44l3@7oqt*0{wRmU9w;5$J#TYTr z)NVHUUh73y!h@I!>b;~j4VD@6K9cF~{)y$TxtQemKsJavU^K~jmmF}H<{ z{0Y>$XT}YLNKR-DwbBbaFyLhElpt*gqx!1&P&+)Zc8&e520?)1V$;;f$Xd1a zfQE&VVoQ{0DVp1kR}=4O&N6PshE+M_)SGJipZ?tzU$Zw=qaQ2K_t}G=HS*ujwyd@c z3@{g<$s5r9iO^%cL*({9?Xor4MPr4Z-SLZJc*71Te7=K+isek6=ZFczqex;Tnce&| zCN+r`Iz?y=x3S8IN*a~~m;L6xl?fplMW+p;VB0E+|1b<`59eP8FrJlrY)2KL)Mw3R zz2WN}D5Hiyy-=hYvw; z=j}rAQ>2QJR-OxGQ8^dDA~j+=+&t6An-}dN1}~#A!>=kRMi`NM(vQkP0gu-Cl{(F2 zdGsc#5JEP%5R$@F0gwNc@GAgUBG6nY6Ev^7@5xXo)kZ7Pv^FTr*l$jSSSF01cPSt_ zfNZBHdymCKoNa_at*xzhWMVEJLLr{)9|?e{KAUtzV(F-$hYBfjP6pUSgid`mzd$RI z(D&3}mN~h%+wX-to??j|u}awMk9{6fPcJNuofC%v@&%UFvD%rnp;d{Ta~<>=%D!i6 zHfO%HnS%>bpUjY8YVy-nmol4%^7-E)1x);uH z@v*?FIj8_w%q{d*D0U`chveSbCBm@itN_r0pU>bJ876!XrLA(`LD5)1osDNlhb(w> z^NBtamNgUWEB=Gj8QuH>Z#}07A&KlVMG``B zC>*KfQY=;%F;+?G%I`pX@4%QE+1+Z@o!*k5^mw%FTS-4GsGJOeiktMLl;{97c*y;E zN$NpevazWcz7CwQ-2zTzxbGZ-A=oOr=ut+|A~)`X+;O|zdRrGyKpuEx8jFN4#CIi|@3)44B0S^G;O!cN}zXtO1sr)mCj1|Ac=l-8p1o_RTOmpI~9!dz`g zz&Rx+%5h{VejQfXQ54g_?4Xp>ZIAT#sNIdYO$KX_f_p`c&U}0k+rW*}36X3or{p=$ zo}NUklQ%Mc-WZDWxT@}9YOE4Hlfu!L`itfqx(R+eeb@)T)5_)uRMHgTx*KVvX>^A* zfB;2C$^cF1zRjL1hzydo&+mBtm^Tmx0$pxFgWfM~yhGrFh|(&u>k<{R-H2+wwG<3)M)>$`D6)NByFI(|X<9war}+ z3AkBxdhS8f`Ca{Brb+Xc3}vZ4RG2~sBJ8htm@Z84dm$L26bYb}V8emCaJpHAHb5#g|W07)7#uf;&aBX<=~`Pm@vcQq^co z(hixz+7VZO(~nVyrTl-$-?IXuB7H`)3uEIxPNo$6h1X{_${#hxru9XJB(pm;k12-z1qLQN~ zr3(y-9gv-$Gg~FCMh9A7)n*JQXl2V$_5}eC5PO?utbtj_R?;&C%U4R`{ zlsb$}>&ul}9A;J=lbA|!u#2KjkC$8jC*_XZFS^i;JOmU&SMjw6|k5X2EhOySv3s{NjT>b8;a`j zAo%Dx3Z#0k5(2JTv$6lx==ZH4ypqlfzL0&WzvhQHP0aq?d{*?rW5MnRpeM*=?dy!8 zGwD~WVfCu3eC+e&_3nf5B_m)xq|Yyz=N#^NnH!2!3s z5(QX_<#3xxWP|j4y{Pr3Kng##aQ9-Gir~G1^OLT-1tIzmk1g)Fa`z>zOCx$5)JXh3 zi^Afc7Facw9MR|YS*73ky5jvZ-|Lnq!Si+!_ezmoAB{EVV{FGg9?NBIC4W_Ae<3Y! zYzI2E;WQ;Rt^iPf2DO~z!MUzJV<0~8*VTh$J$fgg=d}3A=^KGzajmWOmv9A5JFHlo$trcqF18^)m#&$v zJa%>v#on~#i`8P_Ep^YVzyi{_Zobn#@A0I$?~2HwLzXOMFk5a!-DN0p{e2N$3 zdh%4C8hw8$jCHR`98G$+Uz1PH{($6*@TpY-c)NZh zw&0^SiBLc&5{)K5Z-I;0Q8L-RgE%ep=1r2u?}RBVmO8jR^k>o>!UBt26MwijdN$zn zt`iE}Q9L{YLDtwihCG&;=y)v8SzvD>wmr0PT$rUBP<9xnAX39PiS;}gGOM_mzCgYd2+Z+h$9gh+2~{=k+0QG$l+Qn=Bq)Fn+!rx3~2-zhpT)(qX7TabWi zUVEl!j}?6B{&>c*^`}h{$0Os1%|eelDQ5=*X>lq`dEw1N_`3&vR1K3fS8|sXYLW*F zVBYwY;DyiAyZ-j?hWlR*tHLL`wQD6gw_o=o|J2*}M>`tbZP4vE-R^D{-{a2b$Nv3o zd~YV*&uJ@N;q;!`&+TcZzC%vN$=7A;?Tp*prmq0Z%*Vy;3|>x)-07O%&PU~gPJ^Hw z5_5}O|N8%ikB}Yz@PYm1o;drz;v@dwtnq*0BYrDG{+B3a?XM_h9`3q}QXcu7N~eVa zFtW&Y+u>Tk^%m=YR4HXQw4klm8GVEkPqE*m)GP`gT(D{uvZbNT0Tcg))=XS`=RXlz zxxH6gAqv+W2MfeWkM#pT8*3ASXW6vmipr0N|_Ak+Yn9dxAQ1gbNR!cNf?E)~X52%O8TD%EU_BS^Sp&EKw0et?`o z^BVFO!si4&FFcw>SPsI9e#tRd2RPmG)+Syd}ZbFTghx@a!XK4{NS7Td@iCfIOghbPjP_~R%L4)U)ihK9Cv38C3- zMC4qYg7)+PfmkUXbdfx1aC-J|{3lE$R zX6w!^yenTGUR%On?Ow7aA1Zc(RcJ79i<_fI)P6RM)m{bL4lf2cXUv!n! zR=x8^Sx?JtD;^iI8=oTYAHQ_3dLeFakFZ})T(5`kJ~<{|>3%YN-^|~8Brk{Z@0&)Q zQ;A%do3(?axwb>O=Q1nb?`=HqKS@`s)i-BoUZa~v?kTQ*SS1hNxfimFKG@3ugS!)f zj5$gEPrjWr4Kks|wd^)okw<$YbD6lVBMh%I56Ej&#UQ=UJ5YcGq?`*>Q=sE5FJazp zxR!KCDMkr99|`!SFFwCd?_9|x-`$v>iKNfZuFsz9__l%(Fm$5kSR)J2Z^4lllkoZ0 zuOl;TMi|M#-9Cs-%(3>p=)WFchRKV}W0g=3z!g$#_CkL^c7gOGi6rkmb$LA}7odF| z+0|Q)QbV^%!&TiAFV$~yD>T_4UQv8AL)a^6S_6@Z*4^E-_;N1q`V`PLs>8cUTi@Z$L+0j_skE{Y- zrZX!6%ca+b9(JK5?IFa%B!{Wco7v+(KX^BRkqxK4X{y=}%jM*$u!4ap{!c5td?EiS z5;N+7f*~XFR@mA!7@08R1NXSZ>|OTuGru9#-{rOj^Z#M&9fL%PqHN8wZR3`0+vY9X zwr$(CZQH(O+qPY=UcZUy?uqG``InKIaUvs9`>eCq`T}5TI&EzUK|Z68rdA^NE+=$A zLK$MFci7QQI5kf`K}_{`VBpHZAQ+Tc#=cLfRR_i98N_G~r?lh>U1K;b2zvsVNR|!C zLEk71CF_QfKyY2@@uf-Hu;qXuMv#hZgqT4vH~CjwC>Rlex7Law{`A)VwnJPWj{rGb zS6v3Pe0KFVsQNo=9zql*G0YAkB`Hqx>q^N7Y*w>4mIR_gSwcE7c+F&DOU2>m#^rQE z(i{g{2g0)-$xTm~8h=WxwoC=x+Dh8lVe|ttzY($BR^H~h8I9mCEP*D`izl%hh)n{l>z7*jOlA3(0t z3eo0$iTDE)C#x?vI@_n8#fZy?o#e1U<223#yW8%iqhg?lllRGJ#b1Qg0m_S=UJ`-C zX@=!`vSQyQNyJbzbhp&0ZVg@mHJ}Om1Pc0-s-Gj@f^NT=Gb=REMCkAdnjLaUF$6Fq0JS(P z%(*xFAiRHSk=Q;E+~pQlL$9e|tAfh0qwfDxW$y)Db#&7`Z`rJsn!eA?)+AgcvG|!|mH8&LZx1@BV#d)zpng0T%_ewikLs! z1V{UxveI244{kD%Z83xrNo{fQ*+yHoJ~nVLm-(f}n0U;LlDm~C4ZhxQP;)|4w=aJJ z1>s#~$K0s|h_Nv~ujaag;SM<>JF`i-a$4PqMsHWVmK!#N;NbM3XHocdgHVuU7O?)Y z(Pt7L1eQ<`D4<+ZeEubFZxB39OsaDreklCNsqQob)Q%`y&l zv3UJ>##KaKR44RqPNp(jNpF)6vh$0yT|AMa*5+a0X( zTLJ%n{;>bE1pYf7gUs-2j?NWY|46__Ck`5dq$NN}zPcZpJ+o=f&ZE!pE#<_F=1-}* zuXLdK1r|^Xg&wr8Ff|0)EsNW9$#bRHbL8Y@*VPZYmWT+XS3`($jY=sE0BaNjkwR{U z9187ii?$e+VAvsPjWHe~MJ>`Ai-$j#1+#@%-9+|;j-XUUXnEQJ1;ieT&_&dpO38C_ zT1u@oNY;%zP((2>j#z2i^H*Cy&Ou)w1EjBULBX!00F3s(pYls2MzE~kioZ$G!Z|Wi z=9}{2{f%+%fTwVAD0MX<)44OQeh9%HxW4c|NV1oF>Yt2Is;hRBepuv`?(WV(SKBzg zXf2kNGa8b@^~_#ZL0xgy**Qn5L;MFveIGSoC&d*Y2+x3kEZeC^bdrHB3nL7gRAUn1 zi^5CiyeYX1W9c8{N^%x38zF{BkA8!z$DfSjKt#`uZ+%xnuG~hO zboE2FC%9VgewI996l%))f?PAo=9lh=D5X#q8AU)$ygNN{Xo!T6sFcJv)aKk1H9Ogr zbEXpV$pCQgP!y8^iE^ouTWJsN!D`Emb7FtO5| zXZtyOcvUdO7{LlXT9$h#dKQGMcJXTEe+QLY4SBF`hb;o1FPs*-aF6^B9r$vneEFu8 zbLO=93BWCxWKYO@f>+3IB6(<_rfLZp`YG8=ekXy8apf7(3F0xO^9B6x<>9DHcUUeI z0052;008;_TprpuI_cXuIsVs1-3yzIHYA_qU|-WKtBt>x&$$QWHPf69yHav96i|XK zhr-p=m>oqIJY$_VaRuaxslqX4iC_`K1$$A#K08x(?2E(izO}v2SM1l`r(4&qAJ{Xl zp6EPH65f1ut+ZhWVSUwquK~%=4Y@18w;&gQ%`}gUTTRO;BNQ5{bJ?zKTF zx@*l1HNjXzn3`FrpHPM`ouw(=Qn8GvbiM%#%`GOU0T`5K<&2v$2pZ%-Q|fs;qvU%Xhbtb}GsM;Y?9!X5Y?G zrom`qRhR8kY^YhZ3jJs-Nl_UBq$PuXz)-J8-7;{>A*17IRWJHW&jOZk zNuZ%Yx*bjdneSzlqf5xudJA~C9u(_WY^1ZcSGd$wzDtpq%IRA(b$5DT$I7;-L$a?& z-w{wnG`Tj5<*hVHA1Gfpxv`ZN9Rh90t&GzQ17bKTad<1Va=fa9T}VQXS|d#x&QK#z zV={6p$D@XBS25+DP5oT7sg?I5ZE8UWxkyw|?_ZW;2G%Wo3*+6sSkJ(c@dTPh>8?W- z-)(})AEaa(Ok@PKdG%?-uIPDI1t!}K>X0q`{7&p=dY}8bSsK5Pi9@@#*4DK`euVlT z!wWxW6muggcB96`-8We0s7&TUly4)6sewJ%utY_srVMr%5^IVS!cYiWw2UA!mgqv$K z)PIPHyIaemD>D61otvsxT1A7BDEbX3QRX(DP^&0P7eq4mlF%aC$H}2|7oZpiXxm&G z2f)dx00c2+15Qg(?4jo_6#KrOI5w$Bg9q5fh*wdS(Rrbs>fdg!)0#OvVK*%p8KcQqCY_^?E zs$K>r8{9tRmE>A3p;+PnSD%i7XA5rhSe#-H2Jj$_Ez`U{yJwVrYH{284*)w@(b5uK zu0(&O%a3~A?kZ|Qpx2|j$tGsRjy?w+;Q8(tkf=aH>bOC8NyLr|wLrPP)X^5EB|-;GaNl3qQ;iXf>l2D2F+zHWs;ziT zcYdL-p1@HBJCvaLuygs?<-YPlno7K*&P5}>d4Td|D5Mt>uhxu987zV&4Z(dpV~^6) z9#39+A~gu+jvL-?E=8-RUlj$+ZBBiQ25ff?7?`q};?Hih#&TAs^%)ZgWwd5MpJra>B&>D=?**`BwR5A?P~ zzHYq((fAAgi~gASj}iac9OmJ)8~Q z-D$wKs}HJI>y7*5<47HSLOY?YigfL`V{a#GEt;cG5VV`Zo$XJbKlsM4mAn&29q$m- zi`!xg!Bsm~s98=G?!tE~>=deI7sb!W6q=u+gY8+Hv_@UXZ{daVPsw?UtPjmDsY9Vp z?yB8f@s$(spQ@-G9jDrs=;L2&EZb708d(-T9n~tOm9^`13eX47Hh(UTBkRPo$CRV^ zm;Po+Za7yo7aH|w4}P3om7|qUP;s(gX>UEYe7!%8r)kT?%1d?eEkPwCm%W$Io}QP! zRHIRRhOs5QO`n8ALjv~hmLwckNkI;X9Ctq;s~!V=SEOoey77f(WZCu8wWa0G)CcYE zsd^8goM`e=*ItJ}hcP$T!r0S8Q9U%opwp%nB5r2OOEV1i-A#hxj<@r^KF_k5jH_$w zq*Vu;8ENCpnbbkvu?@H=w80|J9$*^O8l?TX2$Dv!O(uQa&=KAv8;(z3QgM-PGRPaO zkI7y&A_nmWy(5ikA>VH2C*zv47l4)kvex#{%U|;&Vb3G_ykQ}nJH6=ODtheWL`~0+ zWt9cg?Flf)T>;0?>wq#H-4eN8mnFzn@Y*z;hYJXFB=l1HH+{)(80#Y!jVS;jK|SmO zVhJFMg$kSNupz47`7u)5G@P+_o(cWp@4qCa>nKES=S#3i`)-3>a6<~aoGz!{1~@e0evv&%N8T?$_`pj@feWEDW3e`bsYNFfJh@55%{??p}T?oNO3PE&} zL!E<77XS)&8^{T)qh29eSEF|@XMCLec}Li0=_PJn^2-b%4fnRn*thfvhmM|yjPp9< z*cIS<8n$ABc~<(h>0r!jek1lzL8aRt&_^%R7YB6Tz6!LB3%4C}_sui=dhBwA#c>y# z3L2oliW8yE#0{p&m{kr#OR^IpL@=b?ojh9P=+xs24kr}KxymmjT}?@BM_2lRo4Hm ztePHa&3f1vykftSH(-4_8N~o3;tT=g)`S4!rq?n$j0?b=jutJ&k%OJM-3|nn#bsw9 z;r*TklE}=&FCY7T1;(uXx?SNb#hUEcB{mR+L-&m)SR8rV^SvvDV^7}#l1QZx(#kL`LfFy$Zmf-XHa$dtaEdQ|KE#TbhkS@i{B~L)bEt)|3{Jg zUnW)mnQGm`cRT_LNoXGc4H2C+vM5GjiCakuzn!5tg+GG^DJ6%(^5H!o-$-A(G+)~Aa6q(k%!h{`s$Vnfe@Hi1=z|Qd71Bm~zRIm0bjwh8|x|Gxm9#6dC7Q zxvZ;!r;f#gVrlmX*TX%z?2RMlXp~{a%s}l+atNKcSm%thmm93?Yw2FwviT;$okpn2 z%dN08@d4v7!h$HouWM3=Lczc>1y}u;6KhX!E~`mFP3yNg8fc<0V2FppK)PkI++?5@ zItK~XioV5K$s2=AvY)=rP_srny?#%TV9&?>6!|XDGzo6tKbeA{#Chu6)|##}9p?J3(IykpNTOdb4l zw{;&;7o6I`+4|;h5{9OSfc*<@y0)6bGn<`g@tzW@Hb;xOb&*&zY| z{QQr>*uR>+oxX$Pe^KoJ!#T_epCr2g ztf-}7Yr4DnwN3u=z59uNUBN(iYd(#)TkXw#kKhNn4?egbRF5N!w}Z1Vmn_zM^DhHq z_XWSsHgD(WLpQ3|GY?Q(uK~Z~-D6Qul$9BvnUD~Bri>Y}1$D>{HDqBws9`8R4ZsBY zWQv5<^*d_xiQSpL7L^Ysgr+LPY2;{v`QV+u#re1*E_$LLG@qw{QjDKYVbpZS!)YplriN0IK(4~q) z;D?e(V;gZR3WgJ)BxTK%bFN1pnfu+$GwjU z;nxYB=9g4Q{0Xrxnb~GPfbhtcvyT9XL=pT=tF~SeJS70(^~FN*{`~jplwDBJe@rc@ zkW@g~_-&C6^h>cj;3qs_fVf#gNt|M$-r7f>mE2#jLv^4f0 zm~H0;!2t38qi}_t?UW=l$K{vp3*8ZOR_tvTc3}7mrUrYU9Vgcvr2bKU5BH{CUIPy< zdYhYanVKeAe1<8uwqPq;XZZQkO>{ns1)i8P<#bL{qE}DiiZaF`_wObS9BmApSDC^@ zYUst1j-Heh-o!Fp8*I4U)>VTr1cgI)bjPRN3{<#A6BI-2Z$VKPcf$QR3%^^lL8|Bs zfK<5zP!mpwD0Pe=JJH*&I7qi>^jBz+oUkxa_J(IdOHp;vcN^hA1WGCV6%~+TasHO_ zpTrSSVPM8`1xDe1aC_2)>w^%L<%|iRAKxwR8DKS8d#59Q)VTr=pCxTRcy$(`MS2e7 zX7coqO0*oX6{P+Xahgh8HPrrIv7zC|fh@nn%Za%X1!qyl5CH#- zw`HVWo#NSS8o=rerpPN25d>O$reKC%R~-#NVU5;5+=jIGh1t7hP8@(4k1xU6Ko4#N-uQR;32sff!8u_p(w ziK_~^BL}Aya$(GJ#W{aKqtB&4bH+NCyIxd@Ja|{H-zj&Y|Gl)tU?W)O1b#+ECuMcu zY81tg)h&>OCR3(^(fIS%1QANI66W=>IYwc^+{+FQL`6-wiosGNzlNg44w72Bva#Ee z7&N|Muf#X2cCJI~@9$XOFNNc-+uWGemcTxp{)Ix+jAyY1MOpv*PtAh6Uk|fnraMQw zZr}>h!}tTA8$%?}7%cS(c#0JI%G-dKhd(PJ&M8oOqp+<3TX{c!M3sRH8x|IQ3~t%a zjPKV|D&O}z+wP;U@7oU`D&aWTLXeZatui4+DPwk;jF=nA`p`)%)`bWZX(jgEjcVcK z$MFf6B0Z=Psbey*k$IMv;LtD}c;hiE$>0Q8S(|6}$T(S%PZB>+vYZWXM|Y<}`m0g= zeR%oxys6*4#20&pj;xcJ7z{B}4SAYsF#W>SnR&r_lXE<=_wF)I<1fX5tvriDwntpDr}!% z#P!Y9N0IMIEHufW7Pz?|)in6Gy6EBlP|NqQ-!>yR;AZ@ymIiGCP^5{vmWEdU?jQ{U zhIz2PAtyo!6fOR65w}7@K$%IsD3U**N)blDID(m{f^ml+FQm5zNioC{;Z>8e=5+X1 zM+-u`H!Vv^$E;%J+t*zrXW%6yc@=YTKAzDBILLqm?cF|4SmiKDvn_yREs++y?3EVh z@hc?cU4hr0BVwzFCi?i<&|OTh5=n3V4}OLJnvwab!K_8ei|W(sY#cPH%MFdZVNoQhBYa173)0?bAE%xSzrSJ)8*z z;H*{$g2+Un2Un&wTztque;~SYN zY{Su~_3mCT_8%dMal1Z*0qzGLqB*zFf&pF){yGm+gmve0A4%Vb7`3I`3&VlTl241~ zra+JZxK8X-&UiDnbtz>*!RZ<-;;5rMBeh!=>Zf%AI@M(?!V{6WwBVg#G>tK}To(Tt z)reaohNFRk+`No^B8u|YJL6o}mnG;UXJ(r(l|w^%X$@RTjOkp!Kj+*3&0( zvQh)nTqEmG+Nf{7*kyGs(LDvUYdHGK!LD8Arzw}6cVsKFj)m2U#8x~_65C=r<+870 z@?bfXqc?t*^5beFp@Nl~qG(m!+Npv=rHJI>yq@q(zK264zdm{3LAz(9y|6W()He%$XjP3+GZBDz=a=;^D%f!fv+{yHNf%Q*} z2bq3!{)N~kO@5#~rlB$P&Y^uUkntW#*U#ihdTDjgiI z%n*7c+zW9sayq;V;h))UtXy;6)wz>P74{FonXq88RtE8p;4OVwNF zX0oAVx_70y4UG$BIx|JQ3#n532un{IjYgxg>pnZk89}}w(9m^}3Y_RiHP!kkD5WQU zOL1{AeTCL1HBP>&OUj~SMFbBKYHp(#@sN+dM&4TS%`N1Owcp|Df}w-u&B4wt0B4*E z?&18Jt_n$>%Vd{tx8@TJgHK)a&`rR99^hpBYW80Yf6^{UZO{^tycM_mY;D&AMLIVg z^(@z#bFEvwPXf|UaK&@rdaI|}su??ZV+;|*U2r)e)WilcnYI6MuoyEhFX@i3%8~Aq zWE<1r^-nRO{bfn6c3HEtsPOn?gSKpv^9#0`!Pb;w4JhWPRfYrg%q-H(9U-aj*UsRlGxeDUY90WUlBh`#Qy+K^VfkJI`=Xsr$A z=Ov18*r4FjS$~;ikr!}X_M1+r(eo?Wt{@YjUYS<Hu2SWb6YEep+jGI)8L$#8(mS`KK*x?$-?pH@O%43fx3^Y|fE-ifC08J}? z{$#LBA)J?m$|Ej1hY4QJWyx2t!D+Cj=uMKjeTn%XNCfk+Eu7RQ#1;vWyxpZUp1W=q zLh22m(VTs$3zOZ#zwhxyE+#auEdql<-n7KT*BR%+e6r@R=FYE%W9M%PU!Nncn+fL!|IW_RJS2FiNaIXlwKL7(VzTv zkIUXTd)ec3K30SraSp$|ve(rc{RNf#j!rv<=TGyD_LXUNr-|<+dW{@_NDIq++Q%w$ zzsuP5HAL{G<2E&WfqoTfUsByO)$-OFJ^!wXXx>!6SI48;Wd(7N+*@!D^tRofVcj{s zf(Fy7xoj`0um8+Bw}vmS{40DcQFt?ne~xy`vV_upajzaXj7eWTTcD61XVE3jw;9P9 zG&&f@Yi`}D4KkL%Ujg?rAOYdOem+XU(AWAjT0@QAo{1%=$EWB0?IYn zYFc9Mu|Bn|&t)B&4oxvMc^kXU#i3E)TIG#Ia2~(Lyy`_`Dvv!%c(3!wj+ybkrrkF8 zjo=3)KITdOMe-@JZj?Z{$vzt+6A8T?DsLPgwUnJ#$n&mFU@q{DX`f*8z2^gUYJw6B zEPPH5K(hIZ7;(cn%jDJTH`pR`Z8H`UdS$6p0qBj_2WPKE_psIwq07LfxT1XJ^Qlxf zhTAR}#oC%5s$YHPfbY!IJ>JYr>dgygN7Z5)rt8Mda8y?2;!6vh}+iN{q_p9RkwdHv07`IXiwM{g0S zG=L0k2@!*O_D{5QUVi0b%72+d=BNJn$4+oMaU{B&F1m+-(PiK zu`Yu$64@wcHY2_}9tU$YVu-gM&lg3lx4oVM@gG=mtOJu_XrhHyx<-1dFy7y}{+D^G zgUB9TheKW9@DH)N{wy9e>z1&3nK+spl}h#DAdS-Q{pUG(Q}@WQse_4x^p=`Hqyz?F z@E`e9+Vsj&9H?(9d#i#5YGDz?$s;t}R$b@Iq_OQZ|{kAL<|4+;4|3|U;PjyjgYdTBxH`T3XoY6NYxBEh-xI(dZ zJyT_ABInLaYVwyxF2$;_%@ZP{62VV&z~ScQ1?OcBW#iUv{S}?r2!|IHP-eY>l0&De zRolgS;+rv%gJ}`5iz)wc*gON*99`xmONuGy*TQTqB;A7W%MeeX>L>fZ8R995zYOuk zvxyS7?7rYUN_E`$?&k2u_zsFuCFMtA`7*zLJ@MfpMPv+ghyE;Ml2RhSAqEvP`9NMz zV`c!1f_))a@~SL>i1pDZa{X|DymCLmnkD@uPH4VS2R&zsM#=#x$q=I@KxcWxKtX(p zy#HW`M<^C)SnLYwG2Jv#YMPT1>)jN{(lTb(-q12<6QIZImU{}z_B{muv zBgFYz$5XtbRN9~2W53_cPB(QlS6g~x z6LZ$wgt?!U;sREy&~(vO=DZ*e8}2c@$YCFvW3bzPG1rh4QEOMnDlZ4W*zHNBWdBlM zJwH`744fEO=k-)RZ$arYkRA)RGks6)~-2;V7 zcDPSTio{@!7}%$a>9CloC%qtd)4jN!%K-J~{c!!ht?AgHJMZ17HQ#@l`E>pKirr%yEywmaKOS|-7WJGj(fo+!n@al` zW&6KiTzqE1p|3e%1{ZR+gPrOIkIdR`}MwNkI+b-6|>_0*f z%5~x_KfYl5?{5S@1SoMQ8vuaiA}|2n|LYs!_+PW`y*(W_+8Rz&?T>1=G?`>^PFTvf zsuhjg3nFOVBpnzXOJwcM!(*KPByu7p54|jVy==H4fbhow;THfvvN+39`w@41tZcct z{=76UoDJyK=63tKd^LG>WYGnf_HsX5vqg%W@`1^B>CojJ`XJk?__O(HiB_(5>b~mk zCHH>6O*X!*Yra>yUDI_rceMxNFW7#oy4`K-Rsn0pPULpSs$#!}^FrA={ENxOyD1nR z>K&wUt??)fcb&s_75mDR@MXW{+_JId4k-}mb&Y>HCdkNd?fo~1sr1PUeZ8j|2K9!{ z`$07XMS>Ua!Uyj?g!kDF^|^-p=7_fqUa3s~YjgfyhxVaGe>mfQ?)iw6xrIqX8+Po7 zHXC5&?)vI?Mi}j%h|Ep7^@8~Kc^~!puYL}Gr5;}vaqye6pYRp4)@vsA02L(;eeZ7d z15K1qP?F;(zLKNqfR6O^pf9dhZ4=M|QYcanRTt#q%%UgiL;*r>md?s}&sLB=?1A^j z(Dw%x-njQ6-*L}5zs`5<#)XteGcRSF7J%!cEZA;Rni;I^rJm{g)caa%YdwNz#=i$E z%yz97k(=)y#i-kS`esbGf~^-5PE_?0LV7fkMS}MefRl(xMb1E!$JXBIKiZ-4*XL8v2xkVsEn`C%&feK2jJ!TD9w=0us9PG{kb=uPs3k8qUVubn&=|r|qJ!}?1klXre$+gsgkwP9 zPy9VS{_cAiUmcf^wKihQuP!WintH0;dKBO4Di#z2r!$%1&d1Y;+Av+WdD(uB)Jov* zhNgB5->YwnjsMmggU|tLJXQr~8?BVUdwiXjKDr%`wwy$>FSd(JqmxY`=>T+|>Vo@B z*UA7t^}IYGk^OvZ>F-9mudmAZ>P=Q_NPENq9dzmxAd{*V6+q;R0L_w>v%7?y2_NSZ zRSalv<#8|j5mJ7@x_9)kRs}#?`m*yo0qlkw(FAk$3OG;M3ipgwadq5tJ&BkZ>Odc1 zwm|c!a+(nyVbGTZN;SQOUgwXNZX;>ZCXLYfbG#P?hm1)pfPJ|9vb%L$=L5gA)c{UE zZT{iN3b+GaPL$FJ>-3A^-rl9etX!=Ea~8HCSY1<4379;tfWMf0j$aHa%!G?Dv5;J0 z`AN-a!gTjZ;F9ob58niS^Y!V&=)K#PYxvw3y;gnL0$F(9 z^(RY>H(F5U=zr|}wW&-#Q)XK};`J5(Qp!xkiwV#GO=5L-XdLBVdW_|0lc(C!ius+K=}d6{&^wJ z3_hK4M*n!2%o4#H$SubCCJec& zy+D#E=h^nW%Qy)Q{FTu`Xo51Cyg@qV|7u`eEUpDxk9ybN{G)U+u(nqDJ|TZbnYV;x zuh$Cp;B?THVUyJTWSUK><|!Z#`#6byWMAX8H$zzme`upO&A#i*vnr+i(1_*|X$x}P zr;|Tg>(xG^u}aeeo(BAp*-@9;J#WhJ3~u*9XDjWhA^qM&;{2 zL+MxDeNhVb>MRmOwY}X4cMjZsS_<~lh4pG|d$|VIQJwnXK;0!srhV6vqs))rH{6re z?+#j7<0dsj*$2NXs0+7iO9i0w<&vYFhd(XC%kpYV)%HTkc26y|wLj@;k+q|Id%i?_ zpdQ@x^z!VGqN1bZ*SXHZUAf!xJ)#qXT+QZJ-p#b_2Isymom$}%x!X-akIKNE;ZDxc)D}s ziF4A9uPoDz{1^%V#Jhp zQIGk|`gd$cM(+)Q4QEg_%UV(iaovWSKw$& zj}Rq{2vf7_#E(&)kr(Ch<8Ep5jvFtGi1NEn9-|y9ua6^0;AY}u7GLWTyw@|4&(`B0 zJFqaWDlr}>8Zm>27)zYQLSM3zexldSzHNYv$yD~kJ%Nk%{F^>SiAai?~VR|I~+EZ57l zj=>@KCV6Gd-V=Z*dt3M2D-=uk5?V795%@f<;2zM3&)>4A{jtt7?StNn@O=kl*>PhZ zG*f^ivTc4`7z*d$sy_q>atDAvRBXIl- z20h?+3;laMK@NVcLd3ZU->D`LKE$$FbN~#~D|uoR#pay)oDee50mBA8T7&q)GUg)n z#Hs>}Z7AO0PsqA#-x2;{QpZ#AR)R?fs~^uoW^l2t0wcuRz$zw;4PrY3@Oq!N!#tw8&Y3%wr;lebWELJ#R03@TlwV zcr)K$i1l97P#s*>fZTg}eHTw*Nq955D-&B7)LP?=>Rv ziA>RVlko+uamuj@1|=jrciP{kHT%f+IQs(8OH{lz)vgv30o@(ZNbo0v3&Ig$!0h%+ zS=uhq*p5{Vs1z^^bNrnf8hc9(L4Acihh}CFy5KRQzIJakv!EsjVucGUB0vXF7zr8_ zv?)C2o4eOcR|DhA;+q#k8rY8D>6%Kjd<}{p3PrzXBVPthfr~n-^6P2C6sX(%h_5LJ{rG2j{q-pOeq!68d z3b)4Ws2p-WD&gc?kR?3rLLP?zlt2w3+Kh^lmyyELp&@v+>GVCVWImmk|}bsX*4PFuS(B3<1bkJYimFaL|aH zk^B@$FSC}}81a4Xr{(H)4b8g^q6G2P&?^WUwy>gB?xd<<9~P*|U&j+NeLyh86voLkS}W)hNgX_gw59o!CTSSn;a?br`1 zV#jZf_*Nbp5ZVGWU=NG4?G-jllbm2^`*bR5x_Jf0GOe|lHv(&s72GZmjlIZ@s|ara zrFx^<@8B=8wJb2Ua^`z3V;CqWcpiO%6klSIoxfB_=Q#FkEBT^>*ufV6UYN>Hp1wl~5}t|Lt}oun5LoqlR~>1v+h z6I^)*2%-)sA(UH!pJ0To-$iX_==N$7^LaX!G#>j+OTzakdvISEBz~U=Hd!*pz!Xv% zYdRq{rtH2K2ZMGhI+0rhsZ2MzI!PC zLlV55sCFsb?zilI=2T_dFwl6g_7vw@^)xtmjO@N19;MVBx}oT0KNSeTsQsv4`8pJ+ zShh?;k>mpNEks0#EYqjWdnJSw^FkMauHt`{TDlder3mK0d#v<=?=Jb?em(z~l++!f zJN9|#I%k_%+R4(2?#)$xBXwu^$<%qO9jFA|sU}`JTne~RNwB)NIPSFK>umx)5DZg( z(PtKxd|OULzhL&k z0y5l^cEc>6s5LdB;sG!xrjrsX{Tx|N0c%~sZz(z1T1WeeC}25_g^li)F*occ`mh%D zJ0XNiidB(HB#=z%vr;=bROm8>mvjDL!_Q9)J%lK$4F$PRS!BD|LrV&Xgb-Wr`M z^B22~c&#!D+lJQteCV;C-$CMg4oVpUD*c@tOnnLJ-z}HehQpQ2-}z#?E8Zd`20hKN z^t7;WKfTYP$KYfc-^dTtS{oA-i_=u*QeDHNPaHe!pHRY#N)WC@<3?rdyaCV4`n)`s zY!pB$UkZ>gRkp0XC9X6DqL#>CvqPY4)?aPWYIen#El>jkQ!?X)Iq{&Ee&3SgaSR2Z z2u4DvTC^f2Pxp{Fqc*%jhwRXz1rmUU9R>Vb4nMe@{PP9u?~2hNziR;92f!)Sr|%S+ zZ8Jr_OW(U7drA!$I*04pkz4FH_&Lp$@5qH?D1HPk#g2}zfTe4Qh**9o5N`=HB0{W; zjpcOP4`zpv4dvFx`2c7zWD^+nO;0AW*+{U-o%1~Sie$IkNxjCe*s7EQd0mxP{%9RW zmwaCzGC^!fq9Zh)3Ap5tpS(81 zLUzK9nl;YVbKZ1EH>1<-{dE0#HR(Ehc7VP5#DYZg@e;h$*2DR6we$wELF)VX52V@9B1n7b;@ZM`iq-LF`wSygXtMI6Iw zOLg)Xf<>;f#>a}p+v$?#2y^p!IlPhTQKr1A!ivf^$(r-P+1GkvtDD{JS#Iu3@|-C% z{O=amoJE%Udep^@IkizHWOO&%f`$9!q*k}r!@1@RWYX$yCRE}b_8Q0c^}jbR8C6vS z@C~)NsqF(5p~vH^wMf^S?IZyS*T4<4tf_6F8TELthwG)c3)Na@lUd_mK>%#>6!yN$ z{=G8Q)b>dwcG4y$6EGzHKBuET{qLE@ajd-!nV1 zn?Vp89Yf!k1T$Tbos2Gq+YR$aJGL0>tuhrbXj5dU&r|P@jM%cuJ*Bw3LM2Qs4YCGP zb8~<#FG34RexxHv8W{C)!7z6|6Kix(Ey>)et?1x*9ak6Q8&Z_8AthV;X4HaN7!G@+ z@I5>U_&l*jW9VF4D@q@RN0$mIBRH00%&LW6A*ZP(L=5cep@1PWW3e9tYhNJ0J{n45 z02hi-LCm#0NZ3mE`3nI;`&}Dx5apf(6^#G|VH>g9VO1cgo`T?#UcIDdUjaxe@Z_Rq z08+GM7)%FM8!GNmbiaX~bT?aCJ|j3zpbfl5UtyO}8z{koDNlMgl1TLL7v`TgYo&41 zGR*1Dv^I{ki77Wbc0wfXXLD>z%`vfo1gbyk<*SA4y1b{V<2-9)L#iY%faK>9Hc~RC z6zFgT4*1VDi13~HID|;8*3394j>d8{8uTlgI^={Xjj>#CJzuC2-4oq+oPPRBd$tvH zzSZV8=H*Q$g^J_d%;&Y#}?~jqDha{|qu4m-bX3{k0WAs{mZK+mXkO zlOUblJxLqanSqBKLjUaz4mVN*ccBCy7Z0Deqle2o*0(3L@1!AY{N}yD>4w}wR=8oT zhpxximPzkWW8C#QWH9(as?N7bldc9h)v4vxos^+;?UIiKo&kQC?tK^i>z?~nvDmZ~ zn=HC{l)r{%)mUr32_c?G53r7{8<_}qK8>;R06iqunviN#FW`^7VN8H6|gq6i)|DgG0zDK1+V z>iHcBk2)~*-Ie&qMwe*LK42De=-hm6t*wnRt%idxcxbg10oT9aPr zzl@Lfq5pWK$0oe6;PLPRFsa)ibITGrOt^!DH4nziR(^4dXE|i_EDrgzexM&_?=)d@ zqp!Jg;__!v0j0_sH8Wiz%P_N;ux{gvef2orI0RbhBja)oDVTTXGT&Vc%mJE$IHSgj z@*%@_%II)}`$=(5P@9_71=cA$?=dj55~us0Kc>W(itEcG(lu?0DEGN$1{b3F;BT-g zYXgM;lGa-(%sAj1_SzssYMNdgv38kWya?C76jV-ahx;Pn4|#*jZG1?Vk*t3t2JLL_s@O*YHjWU}#$8TTxIB?0~2dloC#3xc-FKxW<3hk%o2H6 zIi!l<$&b{-s{X{l#?|ns?7{k)oYd7RPqh@-IxvHB4>}VDG!k4c$j~SniQy^;Anxs> zM$o;@D9LhZ3)7ZiT&}ftG^p8p!9Ue8L2)U$OV7R-S)6M9cbE0<=$y-)nnK4@ix#{w7~)B zB>OGs!12a+BPYE5<9^?pxAa0Le*PN&10O0Yi-feD3sXSP{?^-@^$A|E`vs{7<@*Cp z2=Vi|L)QDv$DR_bBYd2)+OsG<&zC*5!Y?Cu@R11p`~qn<#?0RC9uNt(2Z^)eW$)q8 z2`BrBJcO@7$kAUqkdKt%f1o?ofSF&a_+vgiRaOAk=F_azJHZgtgz4B6I6doJTjS}x zb)g^|mqp|nv&rFbG8XHZ10u4R7@!Eo=csMoaTI7aaXsGFOx}uiF?L zuTGYvS*q;b=pelEgTKNeaWD_HPIRd9SxIGf;GZqOvM;-enHFq^ z(@!mA9=C!UlgHA~apc`H&soY?nO?5UWaz*=K$BVA&T}9dIly2zzftSB9!a?l*-%TT z!{3$mv1H&&=2Y}Fk$1S2b4Qyq3XX>TdiYv+?TvxE48b#Qmx4|mVX~oye*`fQbn6U64c^eXXbWofiJ<2N28Hn!M%QLklEuX$ zN4Dy2{aZIO*J5l9gN?-mL|RV;Pz>~Mlvl2O>4OpiZv7QEf$H+IMm|oe=bp5gT=&b_ zZN$Ue@#5?>nBm(yhLsCHEw&iZZjku3-fkZ<+*_GKHbqWsQH5J=x=ZoqQQ;sTmP#fSk@prxtSwRw$n3^|S*lKQxV^+Ch z)rMBUnd|xlKu)lQL)zF7G-kBGrr79oJ+8>cV?d>X+;4_VH;>mB_tg@&_4h$ zjuvUMZpMs(*NeEkp8RI<*y6&8or(6!7rLaekPTZ2{hnfbAvw(OS&4j69$}O*rkaGc zCY?$PQRWb1H#M7zEYsJ5Nk@$|<)^pedBgq$F?d|_{L@c{|7N3CNCuc7CRKT22wh1r zG2OdH5~s<{;smJAGU3@rl#Ly8wrY6@<#W>`MfB4=RQ*uyQ6bC^grm%gcO~=sdm)q( zqoQ22nDrcTspOWlYat^= zE6OJ>iY8(#`P{_Qa=R30Y|l_`PI%0I zUlQ|r%8jP(@EJ4P;EB^cT2&73T}UOsnW^bGgYUxi2o4UzFff6th#GI+oJgVFhU><{ zZf(CGh)tF=pS1_PH3Z0uAH@VlAI)K`dOsJL9~H@=nt=KQig`%uO|LId46e{?Q7iq> zpgdS)TEig8cNAa(?SpXe_t|uNk$X?t9;Io#NP&_=oeGPlP0`7trMUMD^AAQ7r(Q$T zh*Nq5Hcl$AA#`X=Q_ULpm8P6RxTa|4S{go%AGD1E^EtxUvCSeq`N{x!Q)bY80fv)N zJ1Z_?nvaC}TVOMioUu}^GcnATD`b>rH}7X-+IgUNvw8-f$KQ6K2D|?>m}{F%ra3l zdXX6r9cS-$@wy0uxJ?@OmpGb|%;2z54zyp&%Lg_}E$EPmunnZ(&Kra-?e2ge;U4S@ zA<9n`ZZp0$?RiBo@*l9Oaz!{dJ3DQ)5HmZAS28i%aea|}2?sI9V>}s{gIt$1F3ot% z2P=6f5yAuEvKR1NKb)asF+V|6fN`9-Q?Jf8WK*`tpI_^myjFx$V)YsKe5`moYAyvQ z5lJ&80T)XjUkAG!>=4}kr7(=)d-tJMrTIGzLY~6erMk{SSM(Lb?(j{RPXvQz??9Se zu%+lFx69Z=wB1%VO34t;k;|CUHMct$QD8o4CILYZf`WBEN4Y0ru^gNlUx7rDUSY;* z0k^aqPFX`m;9^RQsGZb?x)e|}=~{y#Us<4CfLKwBI>i~5ro)QpN#wNUxnc5pChnSJ zq@{QNnxVgS@zHSJHUBg_^cTkqU73i=1(?*rTk&!e&0^O7$^RX(ta+@kxt6KEnyH?g zz=wjVf;oHB0%e)VZC`f5wbXpPNhv(w6~NmQZuFuv*%R)NG{}}wT?+LrbSHd124ZUk zVr}HQGK%1XXexilW`yT1ob0;v;zvDT3tY`ajQDUu&Ejp9-w4aie0Y?f^o8e!{JDp$4gMp+oy8sBHD5t9+*BxooZ9empeY2#v zggZ4ZPnZ|&sE=&VMqr$}LKvU0Iu(C)o1&msytE-lA z8_;xX)(x(c?8?#tD^oSs%C=NoU>6Dl`@aEa(v5>Z`(SQGMwXl_JiXX5ITzvD2i@I^ z$si0IKe_jJg}JG(FPrZd+@A-PBxhR=yAWq<&!3z!jRcmUG|u1Ld-n0+&8o5Ep=$yk zyM#E7ylXHk1m6t-9}6?PC^F)Q#>pt>=CiShyT9Zv-2H~kF3hooJcx(HU!J&5U7<5< zce3_aVU={P7lUY6{gQV-;IO!HN+yLi&E_mphb&;iRL+%BMGbxlsq77^=$J!;r+hW; z7aQ6m3&_#>73)5!@WglQNS3^9hEM(8tQUDVc*5-WpwImD`qBtD1OX-)bs3J{c%~r6 zILGi~uH$CS{4HU|jmlXZ#jNHaZCqqjooab=7?Re_7*x0=!>jx3n=E!tJ5LCiwo^vx z#N5tan)E+v?mj5Y9{w_9eh2=a-v|Y7y+cijNhRo0lP|``INq&N-{q)_iJ}!@lSe~@ z))i<;CHP_fs6xr~OC$VKwomdQAuIV&Q`~3Iu-!d1lLr=^&eUp8q}_dW@OEgVsZ9mvq=oJIzKq=Cd0@`F+(f}P<8$M#XWEN=d(@IF zAvV?al1A{%3$7-sT;k_&fM$8tI#f{iC?Y8&;Y3F;+!D-MRS;qcR$UxI!MdN8AXo~* z;8&iQi7@EiHAfswZue_!XZq+W)stLDv#?rTIGhT6y9RL?}r=j9^o-)zR%uf=Yz8&Cpi}~7( zDbVJuYl6cT(t}h2kvV+MO2ZjMA(X9AY<~5iRwhyTEXvpk#H4~*8j-jj|LG}&VzfFu zue5E?iEJ8CdF|mHlUV$cTN=^%{F2~x5p*S@WCvhUGfjKz4DcfGrC9bNZq#DuY=7fy z|8D!cmaGttZ!CTncDqvZ(G21-X%MYgJQgDENqD_XjJ;0u6yox}YM}*3pfukD`6-0u zWBX*oDMVqUrStFIIJt5J{=*gsab0we#n(URf=mVC6iN;w_s(hs0@}Cl@7t_Tp&lct zCUJ5YQf|rh-OJJ(+GY#oTCBlYrWwWb5H=?`aK^Kg1tIiLLSbnK(U5@&>C8iq5$aot z@A|Vt5yxX1ny@`3Z}pDT;PaFNfffh=N#l?s;iMUxY5VuVX`nE?>q~o$sQp{*fB?mZ zUhn$dVC%ym^ptixnVR>k_>#S1%q5DAa2nboIhp3R4?nm(v=rGY<| zCaBXI7tBPSj&Nok=5lqthES%Z$Fyq<=JMIOdB#{9&G3aI z{k~3g4UFZjrWMK;BPggx&fSmn;drOeMx{Lp4?Y#%3#W z0m;b>^0YJXMNXo{`Ho3V=xXo9InpCOvZH9~?VRuIylq`c1d@e8jpLZSV|GaG<*zIe z{6tF|5>P)Z44k6Z)15h2n}d8m88j-DJ=89+F%a2sI`WeaX$*3jcEJ+atsz!)(!M7x z!c!0Adz3rF|eIl!H@MV1#Im>6VkoX1q))F>LX{Q55@ zBvi+?i8sOB7}bwCMbv5CW;IN}1Cui$;MLryu6d4q$D^-dck#+;oJ+gD7apT~U;)*tG=iAkxVF z^@=6qXS@k}J$HxIr1p)D2P<67Fyg0zeC&da%onCao4+8FLmIsEw)e``KSN6H|7zP6!a9VmjU{_lH;DAkCeybmZ_s?%6 z?EEzVVF@OAjq@(JJw1OC>S}l4 z7&W`xcBr_uYLd}re62U1Kdr$94n&5yu^m#Gv)MJT$OT5nd_nEY#Tb*`jsJ{h{f#$3 z8EBu9rE(sf%Ss*<-1ct0Gds<@wS0TSyH(2M4Qp+w<*Ry2lis_A6%$HS$JSkK_PjBq z|7y-b{nL%UaJ)~Zp$%lHV98zrYft>3N{(`c{W*!7ET5<7KW22N1@Y05O!-aJ(Q)gJ zg<*|*_xH`?y;s<_5hBYJE=hM7TKR(OYv62FWoMRrhC%;HR4!&M<>BM;3h1UVrVZqGGej(={MWWT+VrAK8++DYy3w^ykFQaX2Y(ophWgJh| zhn6l(yez0Y7OCi^LY0+YT`G{8@27QUg?A{qgSwBMeC*isjNQfg6qV7SHz%y~`5zy? zwhZ#YzP|N=ewk3?TWoi&1VGb1d`*841^M>m4)ab4AF|L0bDz@zzzHea`_;8~3ul;N zwRJV)rlc^?mGx<9wZMUbA+x+vf`K8?i@Z|XwB9`*SufvgyJx|A=;bz^N4cTvRDA>R zeOgw#u@PMXgH?$9yEVi7=Xw-=9^~L(0z>t>vljlw@%7l+F=J$Pbf!qrC^ZF=O{*UP zorG>Br>Qev0IB_&KC#e#1Fs{_yLA;&{b!36IG{CWzjAyv zFzL5&&~=J)TF4`J#>*_vO<{pDBz9FZXv5_FM0pv}HY0W6XWEaV2GH<`W6FReyarId z1Y4Xx9YDZs5QqjfHi(gjGR=bI+xMge>*`)z!G>Ldf1g_+FN#>&V6J8)XEZ_%hb=y~ zReI!~!D>KIYw=byaxh_wW1Q4k!p*eU(5$AFXa?p}H>xoOe(4V^Ml6^dSf?$P)vdR| zE7^mXuvtCGIxOjRdcB^%LT?KH$|jA+f@b5{DzH24w!JFOHWoZSDzknMb6cJ`qupAH zIH?*NrIE;fxK|kotF=a5ydwugS(HUZKpHEP693kf7ePRq)|I1SbIEJiP~e#^`O-Ii z;>M+9@ZWDgp+!h-dibyDA|4!PCY$HXNZ37r;V>!|SHaq##q!78*7taC(hj3+G&~|% z?YeSyHP`V-L~p6er{l@1XmB&3#XSr(z(^w45hP0+ zgpsIViX`>+ta&*@tddYU@=K>Pp|;6=32PM+cUxm&#V>up_^62Aj?b{%LZX6yP)^bU zBF$Cf-(?F8yf@Mdig0ya_z{%mcOr&4=pek@wz2u+B~aNhP}y=DL-p%9Bi<)E_ERckoOf?GxCf}ve`iJYUV zSKZ1*&Faf4uiur{@;LY5e0a-mhg?~)oPE-+y0rIB-f7p|G?>wu@1pnaH{@;^gqGI( z#fFfA_I&>;_%3A+g@&Bjc1&&|cy7LR+DQ_^&Hb(DXEBe=oy@smefO`H@I7fD}#4uG$&01^1*IW?1&w;cq>&@^9c`<40I}}y|vS|nexTI zA+hoab*XRZ4SY8>W?b2^*;37F*C<@K(bLBNnSgf7f0~%oxhF4=nSxy|p473=$4=Jn zucnC+(mXx}Q!vl{y2@ZqKRnwQiSIB_TNWNf&|1kYi|=t{hb9+oHK7+x_evks-i z(uLKeWu=F^?**%W5gg?@Z)L$;d9&hXv9eN5!|2No`Sdhmi2=kkVN{V&&Bx6*D3pwy zcjTb@Z9s8*uwc1^vNeEY}lvA;sEnkiG@L`#P^8A5u6 zQptf|YBiW`GXe_=vOso}h=PZiK6-=zIH30F_a(i7YnJfqTnjl9`^jo3062fA+J)rL zTJ~!H5J-lHrcmMesY|y8XdlV@N5!bU?4B?>(B7lk00G8xSu3(jDPlqO2+hB%Jge6tRrN?V6gS&1y z$lJNHUr;woJ6|xkdblEQP)tOkT3^KMbN$hBIB!0jcz}^mJT+PSMe^RZ63$S#R+8JO z>M1Qj#}K#i@k7Z#9QwY-g!j&wo7F|G2|y>`@b7YG=6=J1qMcyb<}j>U;|CO~A-DEa zigMKY`O~UC^t7cX%zM@iA!Hbykgvdz&^<6PbFe53W@is5`J6!r{f3C?M8DO68PtRP za(@s16O-?F(){ZQ!m}%o>*HaIp9|AK_~3Bj1C3k3UJYTxJa#%0jbz-OUA`eoQ8rjo zofKO!l>ObrQR#BrvDv~&PrrY-C)X0vRHpviGE9bNKTtWeQC~H@ryZ3>U&F7AZ z8;BxZs;%%{w6!Gb)|hz#W>)$5oGYp$?j-j6`EVJ*WKR@J+8fzP%7B;#E|884SzEQ) zI}n+m!0k4thZvl~-l&>0^cC0vLUkQ$jjzq-Wa1jitRo}0xsKe_TvE`j6Jywg>9aY| z(oKax$ce9T1%^#&bvGmpH53=5igtQSRABnO7i~bfxtC7=@nhPN$6BKXpr!ory*0Lb zFa#Se0Yp0wQZ3v z_(w)^>X!Xh=T9!Py`X!v6;37^Ok_6^z>n+hs{a(_f4yAJ=&A-{O~Wceu%J zf$Nd`?0xAOIp>g(D$8BrRd;9xj~ZTSqJ4vt#lMNRf`E~F%ItcjRac5_Q>~-M&2eDV zJ=9SJTT}|j)V55QR3RW55>VkOTJ+)q;Oo3r?p9(htYK6k*(Aq*M;<0{c_~44I@p0s z34{URoQT5WR57DRR0($Adqz8yGC<6S-M8;=Zsj~g%Hvvt&K8{vi|1zBKwsaqpF>lR!?zOgW zFMh#Ni@x~1xiKeC4Tp>QJ7CN70ipt+y~!{!qSCzIc*dnI2wE^b69Oz4o_P4h1Tmow zFZNS7bP{Qk=t<-%n0zE`Gr7`2OF%Q)lD3V1VRySkDKEXSH)=>wbS_-kEXr&h0k@Np zl$?u(UisOiJT*(idiK_%0K13uiCkKEVP8}e=h?!0M*ctvT7E{o?Od?ju8x^wfju&W zpdH~y&|dHi-yMPJ&t)9rkw%5w zmX_eNyyiC@g)m%E)*}gY7?EVyDzdPG6qdGw5$w$20J+U(+A+Si?w386ei&V|a~ZM+ z4*=x{{E9M`o(KTI@CscV0Rx)c+@m$Qv0aQ?^X@<}d$jZD7NUied5gO3=O7CLt+uOv z)jr@>a=$*+p!-5&DYJ+i;J-zP+*k$NcRBy$}$X1A|g zMr7GI#3g6E(&NN}c%`cAHbo?>(lkW(lP(mgL6>8Q7$wb$D@$3Ek%=|mP`4FQvzW_f zO&AJ(kLSz#KSg^p$~s}*%{Y#=3(bodkEeA$$64B{!7Vfh%a{UAlRl-h_jcYW8hLP^ z?l~{|Td|P1@L{?78_d4uHtd8R4WT8jvB zmI1u)7=Fkc3ob$(!{IDMok=GUodm6A4lG56k-VM2BWN2a@kv!sYC#YIEMlt|wkt(e zj#umYIcX$in3_qk9SSKr;8gXD@G*A=qLKe>W$gSognlO6-1(KJoL1{oBP+X;8%4U$G>owQ1KZvfk*m5g%HQbFhb*FbeKC)Ku1q_3S za$%K_w(UDCHY1RLcb8qya2n0Ba6qdNS}Hr%SJ`IaT1yM@Hm$<#Le`w-brB`2@wui^ zPTFq|*&G08k@JT2Mju>o0*xLg;NYP!6n9%r;FZh5L_$WGp55i~^noI4@x^_Wi8Q}6 z=v5PBPCPDEz|U~+dHdbgmBK&X4b<)Far<3&|64mcu~VqemHxTEUD99hL;ZZgmQ~>< z`;(oQ+vD8}#`a9bqtfX6{jb^EGq)OsTW^f%iCNI+joI(z^#|a8m;JHPu=|Mp+U#k7 z0{nl={{9yUtW4dNFw7U7M%vcfgSYb-7$iYMAS{Fm?v@jlZlj_*yTFrqn$5DGt*RC5B5X5NA@ zWo^4UAdg#EEju!%vLHl=A+wD3VLFiMzIg+x?1aWWRLD7g(Uc)VI}{>1g>;4EGexqZ z(Bz1!vU$p7hiW)$8z%dRVdK<(&%^+m-N4#lXro|9D&=(`Nj+Ru#DD-xgd}OWCU!_- zAamI~DKBs_5R%a9k@iNyqdsHcfnmL}+2!ntL9ux@D1cfO+A>n5ssNKQ78Ai`qSGdd zK0SF2sI{s@bK!=xEoImr)fVzI5=k|iG`;GWHxH}VLMsift>w+*<{lyip&9jU?7Jr7${N?lQgz8`sh*xr4dB+BsYasm7X&S>tyIa0)xkFI*=?%P6$M)qW;wB zR`e^-jYsB^Eukctw{2w$p}yNbr;`J?+XHxm11CXj;C3!YE_VuS%uys~k zPj_G31VmHfUrUqButD>iaz_uLDakd_uT{LM5xf(fG`LaUxoIZrxK~G{oM2fkE*o)o zbt4+z+9ukq;#op>U-d5^51)D1XJ)aFRPT>E(Cb6wWGCd0+%t5j&PG|?x63W6(h#GM ztgZnX!xcIc%2zd#5xiV3ETAzfO^12OAFmFXC(%RmIje89olLciY{VO4ngO}Y_4j`q zo45N}Y|3L@qA5faicoA6itsUSe)d9@BNO{>N?gk3cA9mKdHYy7$rV@Lbp@SJUA`e? zB4Zs%s#3V}8Z}MvF1GxwvQDdV4YPmF$8Mu+4?FL}KxP?k0(+)p0(QBM`7u6sQf zNVmK{?gD<8PGw#6e!~9mpI`#p(FO%H003Pq008CxRe9^|Xklwcr>AFOYvHV?_rIFD zM|o|vBL1i`-tLB5RpU? zDfxVVj)FuO-nqkN$HskkrEkP$f&rfcN2l_05yfHr0~f7v%wF3;ERWsj-|0l2EkX?y zE|AuXPH%LY?qvSFvNi+=3%dvNhXseocmV~s8p{dJ#;g<>NjL5!=!`miAF&UbrsY@V zK2>q1ouRb*2JO~T`;uaIgr&M4Qh*qbvD}yfe;iZ4U~ey^gEh(9T42ep?&*4rU?gCZ z)h==8P=F_cV>v?13j>a4V5XNY@Re&arPIk*RC}>}r^UQgVMM2%8J!>#UFK!qAa7^i z@-WfC^(gQ5*{|gZbE&{KO>rJ)!2rw&dCqJ|?x}k~|6B@v$%-vK6Tb#mis7sz z`CP=Gi@|orp9%2q#&EzlKqpiexMl^tRAi|8{R^s)@$l;QD zBm`g{zcTe@^%pHL`~b~)J;0resO5GcgtV%R4ee_AjK;C1Ad;e-plKt-C8<2xH0&op zAS*`YkF2~;K5Z$YIw4#dX&FQ>0u#(#u=tS9rx;40@33Exbs>Inu9biOI?!HhdRPnC zllX1b#Wp8m(G)PHZHJWb;iL&~qPb+rl4Q64?80me2CN``v3_hG0yQ+re9D$H_Anl- zBEP*n#66$|ichjR?lNpBCf*q>d7#w@x4u&iQnt_ZUScSLHxouOh`S(K0Xit!I!P>P zVK7!58?uNTu0OXUeBwzd)efj1#fdT91RJ7&BJL!l_MCLrlWscicUOdV%|i^dVl+i@ zH1*Q9JbDZL2zESBCV+0_KRL28(RRlS4?5Az8RJSiL@?pn8wk7wTmGUl1ch@NvwgNd z^U;{lM&Sk?w3`LcKUL*M7enG=140!*@*0>xqmz`%;f&9OPb!uE>~POQ|AYY0)Qqnm z6WF|pshs)MuQQ$(7#>PQPA4V-XIIF=gpdp=xHp;8%2&xMg=dKvLT)nUUn^k7gO*M~?_ zp9Sfj#Jh4YxIXrwQ+o9rU(Rw*<8@sM9HEtZyN3c<6!)-dQ=*c$>zCO;Ds?_OoCZ7uc{PD!Ow`qDK5wmBz%Q2 zQuNR>mi?0joN}{up!s!F3_%aq*;%2V(6}14sWC$hUbJ_*NQZsOleoNiYAiD-p}^&94hyvs6e!vz{ZxA`c0Ac$h*w4x)Z=83Wm~;?yQ!L!#?L>= zopESvBa3|BUGd?#Mty*2BFUT$po6tWH2?u7%*`JQ27yP_|D5ycE#4pcnWD zH%}blke$)bL~KrYG*qr-Vz;sBQWg8)x%T!i@*~tTSeH4gr&6cK!TR z$_1dlXD3Z%=-;g^!6Z)c_7=yaO@Jl6TK3YFkEvMeRrNdR)Xe-`y>}2rAO5kq7laQrdTn|q#8?cwuNBjpu^V)K&tNd$k$Lq{pz-W?MA2k%QSDkO!Hze&Ad*v+jNqbu+AQDEqU_$Y`mP7Rs6>$t z=Xdt>#23ai`K3!u=FMWq(|8L*8jaf}Z&RXDPGsXr;mt({;eJiW>untqD25b!G5Ct) zi)%*3su-19hUJ%yQwqhjEICv3&>ehmM%;c;NKF8wDbYl4=aziRwVz-9tdl~pHu08h zo{;4HP`E5nZ;#YqX})*xrfP-te%u_-#l>Z#br};S?g;V6{geYRXXkSa}FAXPKl!vxLu3IxyGMj_XpG&nF~q*dET-u z+Zm~u8>?@8!3DC;ix5V~s{QE9okW&jQ=$26Pbp4LDb9SviDOkOBF_{CZAB7>MM=uB zr5tVEWbWHe&+?0jUUYa^;OkP$ca9$7M9r`AVh)k49pTj6ZD^`!x@L0af?#EujSQ4# zq%V`wK?cv-tATJJSyF5C5f_A#^M!&*WoAH)B`vDUY&Rht+2CK&Cs{wbvw1!lD9Zx~ z7|c1p2KssgPY!m>Ai+j8UzZb+Uk*NIdl9#YGGp~ymVd7hs7bd?AQHTGqn zkN@R<$@5l{NBz0Bt%FB>D(Lep^_drOT@MWae+-iWmrF__!^{4@y}0A^ZmrX&DAm4U zk;>)5!n8=Yh6+JD_Wemj9B6}Be<2|m2}eoRbp~maENGUn%^i86Y4eegOrRwxL3hzr z%rYU8J6Yo?Dby)#_^Lp_lRqWHf=g0V*r~E%(o~31j*Jsf8(u@mTzkmVhWCAM%KJG? z>wAB;^X2m9#^n1M!uxqT>-&7Jy1J_SG5h@4y2I_=OZ&dErU+LI$!Q6IDz|BjwQrd) zy9f=iyEBAwgWjiybI(*Q5L>(6?18H-nlNVUIW$5gX1D{?O z#-~8QSU@s33Sr!1dB1g6xVE01_OA2&0rK$8oX){1`;nVTEw{I#s_Tq`Qixo@7CBun z$8sCt6N(IDZ?NVPE|-iZ8jhf~22-^_Im%3k)yi}07EEhd5d{=;6D&YMpch)n+GE3U zb$LXAlwx|5B1WwMPMr;uI^=c)=r|MnM7vX%&$?P|NuwHBn&yiH?8rz>;dS z`mvj0g30y6`18vA1Z$V|mNJWZMiq)WgsLL%g@1$^FCqWS1Imft%D)mEoU$z4svR@y78>?S%R zc>ZPG80Tedu<)zkY_f6OEo+xIFyLp$V>8w%?C_QK_6DOj@~?9rPPB*4*>g}6F`SS~ znyi*xKgtPV`tRUrR3n*c94{g#rGbBz!zhZCr~`H%@0~q8GLMtMu|{qiYg|&Mn;tdd zpUhSI3zj$4q|*T?YGjQR^M@Pe;n9&1!*S{<${)MW1SLvrbaMvIX>&W>dLB5uNI1l^ zhNx4unbDj01yF-*ri+WM4Fspjj|G>Ehq`Caq-9XGKg|^%gc95EzF&4&by|H#M%gA- z+mqbSW`doLetvfjC01T%a(9(|U-{piU+Jd1z9oA{bA8c#9I{{Qj(yxOSKJ#-tY53H z-_7`6F?QY0ZDSATeHLqWUL5mv7tcfUrLFKo9v;nPV;@>sp+~QGw7>IZMLu5gqK7I$ zkoM9{PHd2&xl_E*7a7A3D#CcLoK<(ye*Ta7VGV`TWNR1z0P?>!PbB}>WYfja-qFs; z#OZ$>9+q>JxW$_4uV*6J=hb;boV7X8N8GAZ+ptr6;p%GD)~cK=?!r_c4lA;`YW=oz zwm%p}ekqtxSbkfl*g*3aXn%hnV2=a;?@!T{#XfafQJ(0XTGR>_-(OL+qI+uC!1)ZX z#BOP_DE3TG$GH{rR8ouUn6Ed&$fSE}d)(uRpRc~Egg$J#Z1(#c{ch>Sh|l!z3-0kp zCNg!;_fAQ?ujJv1PvyH*GO3Qen$slmb~n;a0KTv)8#O-R+D;=(v8faG7gI+T4Vurt z2bw(2EbmM3Iuai=8tY`#xAYtT*GHiv{cAm7Vq_gS+~v=MQWYCLPCS9&@4 z^D1`P9^b}W*F3L97N=_t*f&Xoo~i|s)O_}Rn(lT(8pX;I$!54Djye?)Od6yOOfsXg ziNoK-GRbJ?&;p9T^Ne0}Vn}Bd^=K^ipLM?Flq5YCU691ixKi1|rZiCI#->8ZTA1n_ zcsek>ZryJ;u%f7#5Tz^D`zrhNE+|)A0cfrxv!iLN!mes1h&+zwWUH+x0$qFfB?%n2 z5&W-c4DBw7bH`Uu>Xy4OlTQ~GcorB0|5O1T=g_D5%dozcE0YK#h9u8hitC)^jVY9H zSW49wbrdTo@ri%hdwjiLEo%|`qNF1GC6LzjAjo|GEse0e^+`42ei7osh=W(!f7L?y zCxa;)LwJnYC7n&2Vns(xdBE6fR2hYopVW{G!&q?h& zdeJz6R8leGY6P6}p7lmPRfYMYskNJO3hdG`2byZ_P*D=)1KyXsF6ZrD(bM`ff7i^? z039^DrrB)?MzACU|_g_JGiD9}goVS@q@{%jVj#7*XO^?s#)rVS< z-3gmFlnBeslJDV+i^s#Ku-fE?oNA?C8r}~=h^Xiu@tiWuw)+8%ulN0sdj}NqSyjW* zmsxqTC!Q?9$S*Fe%GON^rJgwh$N)YTi>6Jv)>8QpT*huotJbYqo|tl(!it& zm7Tf?`=9}R>XUrZM(fb1KA%#|A5u0-Q7`INs#+FX_3#zQXF6hK|K8~1LoFK_zaOVG zzYUvxO6Er${gTirR;*2wechA;rf}3&WH@Vo=rD3W?X7K>1tWOIbXYE@O3&7{X5vcH zh;_S7ui1o2lF+NPo%}kIF+^B9nCuwkXo{F~CA}&&G$cY7rip#ZPK>y2{6&(pP`%s? zOg&MRC-!00)vZi7cpFk%Rm`tW)mGLs?*Z3VQ|aq0zPt2R;;ls}z-h@<1{?FI?|4B= zh)I&dTBcJ4l8;$=B+wZnYEnt^BC_&ouco?d+a-El*2FEXN4rnSRk^pIMhP(#crq?E zI^ZEkEJ=@h9@JErL49m=Dcamc@E3m_izP9#nbq$cbj&Z{7vrB@{_IuNlF7U@rXQCR z^U$PbjDJ&>MTIysbH#0%BeA$ZE><%qir%YWe_I^~Pp^*)$CSvs;!C|Bj(l3!R3pP%+svcJ1 z4@GgT0k9LYKw6=41RCZi2XeM0p-7zEri==lAdFR;TNHwsd!T+4vA&WCAK4x>ej8vr`I#x998a!|PSzG#8WdwbHNX6OC3G z3bMrHVd;Sa)WR2r!dgdLua^gLJfTGjtPum33wW)e>TzlBsk*CcbgoefG8+ezZ3T!# zGe(PjB*-X2;GD}M5qIy%u*0KSooTz7i<_? zIi!DfNjH;PdFwW1vqg0TGG*RoJGI~=JG$mX$Frl<#O3rxL8H&o^P@eyHBv^*gVhn(O2j3mKfMDC37Q+P~{ zlYMZHgyhH4==S)yWii1N&Tvg{j8v&~ZYh{8ljf_<;depix-oxn5w1#wAdXDNsr&RT zT|Isd+mn-G$6CDUp2e{+#Y$N`QUHeCV(G1PyTQ?8dmXtbkf4;_vnK2LcD<~Ug`;Kc z67rFViRuJ@gjekH9&jk@IgOXl(Q42ePHX4kVst)mq;9@SKFQ^oikitZ{Ho87rI(Nj z=RVHDSVwdX#yMdAPcRVsZ@9M>`0FH+OhpYofvCW{N###_fZ9*RpYa!J{}=t$0P6VV zbOV&S05v^u{%eh27~^*%12J~k|AhbE2+_6sDcsS7h2APCf*kJ#l|Su&ZULYhP~ZW2 zO+dmr#@W+~w1%ZF@HLnEu8|_?OaBdKmA0VTTybi+pf542{GwKr<*l(e?I!=2c zmi9ojKZZZUo@oCU`z2ksVOVTKliXeZYr!Au0KT;p+!-7<{T&oKgsvIb1kFh{LM zeE;*{dCn9I{G$Iq&WL9GUIoL-#gvtE41&HMMUo7%>Cd5mO98&gPJo&Yd`gZ?4?Yd( z^t;L&{N5&yz^1Ml^-{f)VyJK&oH`5Ey0 zi+8|n9@~-?{IM_M-oU53EGDNpLwVxVmMK@buer zki&03p5hlb6QhZ8N*>M=#;0D{oOV+u1n0vZtCoyXy5LQlU9s7lL!XJgKvCQiQuSqD z5WsPNmMeI+dL~8M@S~wAysx&~C-!Ngaa>cMyS}rK7-MV?BVMH~B4l&AXeo;+u6FCL z`w(<4FHyRxJ(*>qT5(Aj^T3#9l3QIxjnh(#k!)wsfDqGmT3oNOa15UzWz4&shtN!0tV*Hoyb$zS)3r?a!;c0(^`#<_JRj$#%Yw$04A-`NG)B17*FJ&gJsn6$;N zF0d;UnO<0_{ABj1B@4flpZpnGgL1w0zLiB7`(!3$p8@_&Pg7>N^B_-5Npbg+;2Q1X z`P_}F-9$0{w@}|pr}jrT%^Gr-Q}ay)i|VI#ZA%RK41J8J1l=4HM@!ULs7cHts?7%M z#+^|Oz0aMo4_%bOE-rFUGbZ^J26&mjXN*aKpXmh2D$vLKb?XOuT__{Z2BXuFgn^(e@(|0?G7q8@J zMQN1}x>(EAT~!F1fS0Cf=tY$|5(__6*@B7RCfaQ6Y?5STN2A-ua3inUbK3W2l@h~TRp+5uGfZKzdz2)uCxgT_Ywp}Eq+ zQy+5P?N|EncE#IVHM7aPLHY4qR~NfbG)bkILTb0TcFDBw3RM!lqV<^bpuWo)x4d4` z$2@)NS84Q-r2OpTL?9l?71Jt>jlr=dJ$6;6WLGdY8e|&a$ddZ@wB5vprgltp-6E0s zz9lW+M(!cP9@1*3O^${9hAGboP3)JjEt=9Mr_|=w8gq?{lcCMtV03Bgm4##a7+L2c zm{Ms>h7^COVY5CiX(5Mw!EsT0ZIxM?{4c6m{|1zcSDfc&xlMN?S%ppp=9=EEong;l z_-+Q}<9RKFj+2qtW{==5x22b`Nc)0qQLge4tK+%y0jndgi7VW9L$1tIn^XT~c3rS? zsl(Q9s%mi5!jRXXL17@*+@~j{)``N#+i~^gJ+QTqV(5#uye+DY^;%B(`{U5zU5!7fx*^Q< zBNV6vF1?Z^JtCO;{9a1t{|{mB0A$OvFAI-t+xE;J@3C#$wr$(CvB$Q($F^;I=9}}s z@4b7^iTC1*idY%7qN}@B#ER-)S7v8!iZpj1YOsdDDOn65a!)%cSzLV@GU>+_ne(o{Fa)vcBt3|EW*J{4o747WWo3q86H?Mo5O%?c2 z#|c=TUps~HR4=`=*@v1b$hJW8eB-J4MYCxlj3Q+&)}Ps{P0ZBxJDwf1zH0(xmwf)5 zOG=fS-L54sQ}_@oO!bI}=~W5zjF*J2iAoFeY#Th(k6K@yG+)-WW(42%dh6@?uEuB@Tm8drMA3D-E% z$p4c|ZO5DCi%Xxh3HalweSI%Q%UQjj5MbjiZ}#J=S67212`NaQq}$y((j)nRjro`x@AI8890xhh&AglOwM6)}Tn9TZGtWvO3t`Ft-6K`& z(Y0pz{Vwv^C_HAviTY1d=XN3R5h9pd1#y_yQ@O9c7-lzZp_^UaA=%&)cKhy zmmMpRDUsHL$-K#^?}OZpF_^d6#Ptx+5^R7wquWBg7^W~7Oett*%A}^OCH$fSQa-9U zs3M^9AK)Yr_#suT9)>3=!OR4G;+=FSw}fYzMvo2bils7FJpRTfzLwtXuw5 zotEiTIk#{%(a(tFwl9;%Ve5pX?i#yc>hKWKx5#JSwXl;b=kDSUeaq-z{q~>CpZcTu z$%K!#1J#Rq!=(+#k-5MDEM@4j4>JQgUN`PzPydW_Yc2O1;`hM>+6wejK$jloKLlknv6rCjV_8V&K zV*(OHF|-Vr-av_cMhuuzV2K?oT%a&ufgJsy)ghI;7C+m&=og@#eLDXRgUs+;dH8Td zC97lZ6er04YwnW1%aIEx9nOI*G)P5XN#b2@2EJzi$;dGwg}L0KvI~Azb~Cy z=4I6FXva(pW#vb~>iw+T7(uVM&Ez(C3XsQjRg>wteg^ML;8{L|5*D6^x& z{y*QATlqoc?((t5!5ZD82r~Qt@an&MgcvRsp~)-dxC#D71nD_H9b-LAHLw)_A4o~$ zClj&{+w0}2h0iRP9877`n(0{dLIQn0JA#j|g!)rNjPB$3Xs((s!SzJ& z2dgz>3KmA$zi5$AMw9J^?kuZChok%{j7+iLP3w5m+nbY{wiLfk^4L_);1yX4|dz}Dh_QUfy)MUVf^)C z|NTHuU(1fRcu~_4tzIMI-y_wXcvbL`8hx?Awp6(mp1Q>=YgD{M zm3^*7+O)(|&cxCvt69-B1ov|%O!>+^%3@S|mou-#8-Cbx%gD|)!V)bgx7?$_71vk~ z#`9i&?7vvaZ1*8u9Vy?KHNldHcZUxLQ zZxB;(C%{K%>9YxkHfAKo#-|DpM|YK|k^h}(o}~C@qi?#0+T; z$6U0kK$t7DV0-f(luK12rIpZnFn{ZdHCUeg`=UN~sj#hMQG$4>CVrtG`R>gODT%Zz zi`d&T?`9DEr9YsSfCd8kKcS=W_&5S~`RWX!91;6!0pBlW7rqzYj~Bof;0xdb5d9b) z<4oKP{o9|y4Sp-|Imm;aeF1vS-z5*%4E*0f^x)}0_i-r?eMk79c|Uo>4D+7>JKR6e zkkFA>xZCG>gVzau9lnusPpmxy*9d&c_xs<0s3lv(#xivjNJq%#cl1Vq@js&jpLe`- z-L^fzNp7;an{U8({+XbkoGp32jv4}55U4)?e}LVK?xTRkV8}a1B|7cEfB%|K+E1HZ zx*6E5da>!P0l6?MUklo%u*)$AG)cyEo$rJxk{f#s8?Mlbf31mr)H^iQe&JXskTB0q za`Xfc5I_Nb1pfhIN@-;b^7@T1rT|j_S%B=oc3`!H7?q>bcVDa$?<%m{b2STPJWhv0 zt9}iDrh>6kAyeRR{Yybrgvw&53c*VM0gD-}?I;5Vg%I*n};gDmEK1{>M69L)fq^7aM6jp)UuO z1XP@ZEV0=CZ>#S#M-+>a5^yme<<9|}Yo*g;o7UHr9iWB}a&b^OqGK!4y5K1kt;PW&Y*;O}0zT0_sFN6c8BR=Y;6u^I)? zYn6L(l#R-D9--=r6Xuv85v#U?tIGI$+rsO1owRInJ!JG#AuPsw zQ*jq)7X4Rqr+e`-GxtRHN`i=<}o;vnZlpKklT)54ogUEZG7^zVk7?M)hC-R}yE3of^b%vX!L_AxR^w*J(! zs|2ARso%rp_P?{DDDayo(?6_ezz-|>|HlmdHzBGtQN6(m^NC(VZrD{ea?wsHhjLP} z*+>BsQE=7zxDs;NHbyeq6Z%BL25B^k;w%);HS)E9vQgXduS}fCe=>0{%PF~B|H;Hj z{kKdU^3XLArJMxuVN*WM48=r_^@Lw;xRx%YFGU66dQ}lqDwTalN+`nqD$R%l|B9x0 zk)R8S0!2ks5{FV`rD{N>$Y)c7b&D-W($kMxEw{pqq|_>E>hKEyDbZthYYT;0E(ESX0W$ar#o-%UwF<1@>urYED%Cp_hy|h@E)`NPaHq%+h#Q?7 z$KgiDj&cM63}l(?k-ZD?;c9L8UrZ_bIfgFc{haJ?pdY66sYQSB-o3z$hD+f)2~c{V zxAAelHHR_LsEJ1y@Z${KmWfIef!Aq1iLcTNWvs zxc^V2_f`br2;KP6oLVBcTIg6(n!ofN)j2Bv8N+*9DC#eO@{@OMPLw>n;>IiN`Ex0yxQdN!+#QSGP9^!JFiEM8rbdE z?=8PI+|}1%@drpW2Q*6RxRK|Q*WWE8p|#&teV!}U{!b!~?A!0JuaTR~{RKF0o8x~H zah6K7-=nuD#lH8UYc4MtdDm>Sn$J~S9-iTHjzxWoTzyYRY`z1TQ-74XM4?}WPDE)* zQ8ZWgoe)|^_6*Ndkdnylf$-i-r++f0b^k*f?mN_O5Gj*uc}<@ z*bUZ%FY3rwk3`IMY?}JlBctri9i-o4S-*Dzg7Z`=OHnI}V~Uzf9dCTM_aen23bq!l znw207G>s%lDswyt)oH%IHw6*i-F==PH)D_YUppw^uCJ~;Hz{L~zYzrNeWvn_T&ZN# zPlQ7aGf=nao^p4Rx4*o7(GRy~rz1vVyE@secL-m0c>5xM^G*qB$c|rk)72w$CB*h; zN;LFj!Dll2T-%yW)Fe78UamVy`XCya=Xk2O2CnUSMJ1Gi-_3n)hEl8wtn#+VIs3Yb zwUn|yJk7dblNq0P-ngv|%J+7>U}8;9i{DH$K>q3xYe2kI-Oim$w*6!;DZWM_+#oJ=_v{o3v1la$4Ww+bW|03AVn)`y62i(ABe7GCJ#QH8C-H9yh=+(`gi^u=G z2g8yGvFj_eS_xeTe|;+A2@3vB)V%;yTxWQvl#FB+3uO>-2{W%`U*lr-a#G+mzoi(Z zs*ySETYor4Xk_}ZqA(cDRLt;orP<;F-zM*HLUBx5E`12Fdz4l%(rY&vl$=*3e`J<) z)zj(uP0Ej0EZ9osS>|hK;2V+bVy#sPnaq|JhTQ~x_gF)@t3|2qFb+9Yb`Fh98$pMc2h^kkiE!0Ab z#s7R!;wjDC9>^nrFG_BWnyzv#!bD6X4wp-EKQfoq>*?*$`h1!-Z;GOiLs6O;fYXW5 zip|yeb)>lmfCSL58`Q{0aKa<1V_e`l+ME4a)n*S|(|*)Q8t2jXJOcK@WUmm8L5i5} zBI*kSRWNACTLgj{2BuP%K=aUYtn=WFP)i?A0DV{Mmkw4+vrqNjGB>S@EZ3v;*w=X= z2g?3%ex>uul1@8=?;Fyc&4<%z==QN5Nza`okN)*b5iyq+xTfOHmAL5784SN%l^e5q zG?*!ub4OLSVvzBmocXxKTU?@CaS@p!DxQ3u!y=Rz3@>u)Czuk;UzL7hXF8CvwB{b% z4EysUq$-*Wx^-o&=mA32iWdIax$e{hlpC|fr(*Y|gml^-kW#z<-a8H}hA zN5mW(3TY-{pW^k`Uxwc54+I~W4z!A+B9mfu zawiPkgJWI>NHSthS8=_tkuG=#hFxTACew_E;9l8g#J??Jd@?V8V{GVI{DWyR2JLq)!jJSz4tyB>&6dSs3GtCS*HYWx*L3{x#=+T-2=5cOrf zFzOd>1=n0Fm?LM*V7H4_4tD!^ujZ|Y4B%^#?ml$&uv!+cVP?D%GHfZMYkEc2ZlvoG zVWvpFaG@oF)Oqee!+l3fy{TmNo8Yxl@i-AE#JiNzZUK1oV)sx`++RY40azY;;Sy+G zPO|ZuHj)8Y?`D5p^^zwv-h=~Zdu}n8EReTLC#rU-n_F3+dM^QlWnJw!zowa8XXGblSBNKJ9Ss(hz! zN~bf#jmntx%^>e`w7XfnxL;FkVdu>bPrCnWogQUX_<`|$%mET))m`sRR`&l&f7{;H#@4{02!C<20zrMu$SJKHC?b zNrJw!U!!2}EcP{`>LV6gyRAD;K;l?8zVOf zJmuyPk0r^oX;kFxSsASsKpxavnfkZZA{dFR)&_npr3^YrFaeCbhx!fY_2R|P7Q4NG zD4rc%rI*U>Q<6t%_RqQaBl2`dBg|goM2K#lP>4d}zwWtafPF(Cb17%_eA*)(pzU>E z*rCE^r;hL;*ImtEq2;htLrEuwhm{+D<$`Q`&|JB^Pnv^U!fzs!fnUUEXOQg&vPV?T zW;Sb+ve9c$HO(v9b2~H)S3b~9po!lkhCQn9)Qef$K^ek24~u7gY=WYRVF}pc=73|4 zx)4wJ^29FQiH%%>t+b2`K5xz-Vk;t9NvPqd!amkE#%r=y2$)}@ffecVtvv$ItyJ56 zw*kVk6zeuPdpU=p(3rRr z!!XUjA&53q>(o=|pXes1=2@!&U_%^TZ*`>0alMJulNBzinP(P7aGi|3tRt3;lFjl7 z&;>mOEZGav(t!$^AUY8z=j)BFh*Gu(YrNAbMTC%)w3MG$^5!yWh#HuNz7|aIk$@pb z4FTWv{}$asY?qqHbFweq6wo*&^8@6Y+mb#JOK%eLnX4l}(xz36m4>rTaQh^NnPXCi ztjql+t#%?G;xmH2-pKqC#NbQaN)T8@=v|y3#cEf7LOjVjka>onB+fusXc2GkA;`;Z zZ9q&h+epbGcQEqSR>386lYHK9z^rp+qXL=pO}hD6Y~vU`wQiO%&CFj|NW@AhLx>{( zI%_7Wj!KhQmqjCo%oZ&dBNua<th&c@27LBHN*kB(>W`Bhb-?=6=j2+QG4E(+q{O zCY?lld~uXFa4IQ)UVXpM`MtONI???kMvB;LZ)TF!*$e-K983J)L;{`974Km z535X;>q@I1FA|^z^8zcgV?qiF&p}qYm;%)I){BynNu>%Wh%6q(XBC;g@Jm69wPAK< zJ~9;{3s*d`i#dNcu%b{oX6ugC4qJ%tA#@%$q*IbXb+Qv@ z%dxxk%9Y6@U{E<9oeaN_MjDP`*P)|FPo35Lid{aO8a91ce}q0l6+`7_hcJXJIbH3`+(f{ z->oen8=nX59pH)qrfMtW1jX};AkbuyQPDLSFj4ovMqca243kw1uFB9gVeb`F@v^kN z{>X)a-6vU+mSizHym~R}TL<145?8?L&trc6MU#;uZZ;PZA?~ZaLuAQ=GnPnywCD!Q z$}8!>;z|qIS7cOjxRi)V6xUCs;8#{1VIr0x&tjwOf_0BSs|IB(d#Hv&sd$0Ogf9cu zbk6fPNQfVnScqcjT|R9zNVQ`xkaOe&A^{>DxPqvdVMVndNYg8;gF`KgdFM=Ds^kS9 zfNUzorYK;q5arFgvr^0f3xM>&I+t@ltF1{i?;3Yx$dYq!_a}-V#-oeCYEU+Gpc0=7 z2U>_`QTny^M~#rR5L9^^mB0T%n-&F9S-0b?5>ZOOARi9Z0Qdao0)Mc<`yp9MuqpP& z6>--2t5)+_K#P^&sDjJLVIk;G8!SLqgK?C?Rl#5aVR#7wB5IWpQ;ZU%5=qN|EW?fl z)$6`2fnhAVE%$;5in-t;HEN{Kla-J@5C7`>&FpM2+C7 zxI%f9qNMyMW*jXzMCkL@zdN}%j;tud=8Q7=s+XiX-x zA#W-omeJJ@OnKY6PGE-Uej)$Ma^5#VCcx4H@r)ODK7jN5@r9Ei@a(!mj!=&>@vh*? za^9OX8Dzi+@QfSxZx-wMq6e)Rf3&M`970;!{Aa1Bo2@XldSid(?2cK%da7JpLt?M~^Y7QYA=rms z4j*55Cp*$|w`8rbBhlLE*z7o$Ui5-D+lI^Z-QyqD2d!k&qZ?-Vst||>`&dfzSV=cE z5a~u>s&9JE@#bZ&ON^M7J~%J;r})1lWUQeCW-iS*x68C%?EKwazKY z3>xOypBrvSSd{-wG&Vbv%cD-~rPPKm=RXhacfi%BlqOi~*?OuW8#a5DOLOMSTeU<* zV|Jq;UaogRScyrgUY`R@CmA>a)?iQ40Rz(-wn9K`w4}gXRv%6RkDh6LWVCq%#cYE4 z`Aa#0BD3e76SA0n%BfXz)(O5ZZ+|TgV$kA4HNZhIdsg)afWaFdNG!!9kZ3net(K9X_57@*V-XSVWR$RNFjHe7#ROWdVD!Mh z^Zp>wNXXf&PmwHi#hJLN#750%EZyDVD0W0}tyrG~}sRs*;%A9c&(hH@>S z$>sMm2O`5wRn`?r@5m(_S@*vCYGL5Dn};W_4^T_n3jW3wnX+jngo=FDl?h%zvujUg zU%8m;WxaPp+Y4!)eePK2G>JB_IQ1@SnJ`=ZDzh==JOd);gUtkI+e7@OHW~Ux3H4!x zs;vfv>4N%#jDoGYo#$^z;Tc>DOB<#y-YU|;wC3Z<3CrVInYtgCxk#| znFz0rBWGifY-=TS>m=r+?4@Y=!5ZyZP&S3}XK|p+$`nB8wJJ{8xUkBp`2|KuH2bn< zIVp6RL9=G*Q9GiZL20?GD19Xy8U-?vAW8|H1@LB}X!MDY)qCl2u_6$FytZp3c-kQ@ zWfO48NePgALV#<9#gXL4ENew*lzi(!JGq-Y@vYj8(%Q}8a^kg-+Y2u5L^)fYrRLoBWs9K|&%LUP9Wbz@jdNt~ZtIW#VX;2Fj^)z0? z&@4{(#5tmu$^pO4k`bO-(hjFMj5fsHG67%DA<+8;AtvQXIug~!lIDhFWbmU&m<%PV z3}P;Lz`S)VO~nlGpC0vJy)0m3Buat9hevBmkLECUBDc;{!r^=zBnEkSOp}e`j74K~ z#On&trjCM|W7-q)s{`A#ZrL+hWRPO9TGtq&C@+r23t2Vi+JDLsJ&4lbQutfK^yQUk z^;H)4Jt;~HC#)tV!h=S&7pX+IM-~EB(eelB92L0uyggySgLOjAvi>7#xK}`s zvD)NP$b@xZW`wX_vSZ=g9Z6O)FsM{U3Jh0SY$lTx1*M$U=t4XPJ#5B+&^0H;Ouvyo zd%}b@*_ilY{SJ$TLM~h%Z(PYnuLu}h0&ef4*!OpUt&A*00R{q_r-kWoZ5C#R zqY}<9QXsCNR3)6O^a+;Zff)Y+r2-{787kA?lbuMBTMoTgmz)-H>dX+KO1h*DkXUpp zC$duUegTmI9YB3uc2Og1^4^;3AHW4#RPOo=PXihkp=f9<`owdwv7S9^nSJ48Vrc_K z^dLU<(Y=SuMP4VSD_g#aZtZvv3h|_vB6+B~To=NY(;6F*OLSn}tr#x_Oyc}i;TTB}7$~KV??F4GEzh?m`(bIVyPU=D+4TjHnB@nD z!5{V1 zkbIRvy}8R&%1iRI?2imGs1cB47R6>2DKvFd4eOvk$`{(@zmEx|%pMd1Hem>neTD1p zW>lVO5;D|>U>;vlNA_d&lPgh*ZE%Eq9U&{aW@vnpqrCu|nGiF$2-RO%c;O#Uor}ek zN?p}E*w>b*u;V*o#bvP(-EMYhT1Q;(=_cd(PZWtgi3E|!w9@w)&%>X4`VGI*k+6DBI2DXxfq}J1(X5=eat^Rboslr*7lZ2 zZOOgi7#;%@4mCXBqzhz6&*odHwbI3Wqv=uetG%Zvb}n|?Sue?DWwD6YUAs7sBS!&P zZri2eGg;BLYk${sInjEAPELuv0*?Afv;$jWe730Crn0GWJd=U~cMp5+8K=mh)@a6g z9y2AR9OcC>UtBmKF5lC`tj4ssX8pv4c^0Zew@SN5z!li$+e9hCQ2pNjJ9Yv z+%%G3zRVezX)zJ%9=5;d8B=_Y^jV>I$^`b5T1vx5PYv1i!AnW>F*+bj8eM|gm#RCJ zn{XiP4TDA1uObqel0y@uA8=pjnXUi&V%o7UT%d z00DPSAHXGf_4~uKi%}rbw8X66>H%x#n@X+;fvF4r8d%vzKCe@$t=omXcUr}(tBP!M z;_wz{()j%rn?QXVBn=wi*eDjOf8fjCq;tX+d7r4y`v=stFQyt+$~HyHEDp|UXI+^i zxPhvg@|lngtDon}qY%9G1~5($I!G$Pc_gjjalB#U#1aGqP-5uZK+Jlxb_0n)Gmzk< z1Z8ZmN7$V?Q#PY{jX()Lr`!<@>L_;>NN$i4^I@m1kOLG~7y<1II%P z^aKRv`AwxlFQ#ymS)X-;w5kh{ZO1#7b4A^NJAV!YiUcyf; zgIX%9NmtE3;)2iuQDO&MG^E{2(($FwT#^y6TUTRLtN z6BFQo^Scy6<}v2G-Z&++SRyk93GdnOZos&oDF{oOpeizw=kh%l?87rG0ijIu4=*f$ zmHuBENxrUOyuq;Vn8iq0Pt+!9nXspIX7Z=a3QN@aF1C>JJd=9-xxeIK^Q@2C7b-JpT3l|47V$H6o^Df z{ngI%ww3V|Xontookxy?KhjTfbUKC@y#W#Z)TisgL2-p`>>s?sufaRsBx!#^G!4;v zA<@#ig&7_1q&Ff^01uDbi)rk}cuC%uAETaFf@2mB-_ktZ56ty3h(@%#5zDl^Y92W8 zPa@9%B#ME1dh(%%bGKHb>akR|MEAquzi=xczyfqy)ZvVt7{M+ptWZ!x!779w6i-SA zZeMoXsgyAK4KPLc+Xm`vb17Ymdn2eS+V5wI@O|DK)v-|T^x@^`SPG8 z%OyCjY&;icJpK^}lS?aR)QY==#lpz>zJ9N4JR^J4W*-TndL~I04ST zV$+-lU)^MMN~yL1ig)z=#4aYxUbOA5$PKfWEV&c<5lj_7A2wS4SrN~^(){zbDYH20 zkcr6_H~qJUFE{`S6cy^uoh}%NWJf6!FO-4WmZ0My;qN)vXbR?PfrUHCMO2WnqT>k& z_YKZxTiVY{ba5owO?%2UV5fr&`q5<98TP0UYYWKW9?Zo%0!yTt!=-awuWXj0M!$@$ zdXXtK`2t4I7Ip)mg$ua(fr^VpBC=CLDscceZ^|}rJWlOLRVoYRCq|B#(m=Eujn;b0VLd zj#n~Yxp{OFO|BnP?W@s06qUuWWQH8X=@gH+__G@HO1Fa>%C;o9W4{CLY3UPGeo6&o zncR?;OPA|_a6D|pg=clPW!@=@G$C7@Ve^N=qp`l=4Mw5(8aNcyf zUFU+=G^DvE3FglDQwUMpgS1=+B6t;w8csr(OK&0Z9=Yd8 z@D;u7D6f8v`3ZZfS`VAU^je2xu9Ntb(m6E?0=&@&kn$|Mq<%X#+CfD{2C^p(g<)DU zizjv^*2N{>r;9m-Wl4a|4W16*ra37k`cVX9!bT>@!MKwR>NqAV6%D~FSjh&h9?J|^ z(R?d=TAHM$M=dY&ywz&_&IB+v1sQX-&ww^lzWC2=wOvf$gz<_UoqXJcz-+wJsREFF zww0*MK>_7qeEB#9sqvu&i?bvV3G_;*OgG%`6)TA~{R$=n#qu5uE_EW9RKT@_=>Z_V zsuI=OKuOw>0EK!KUIPap;MBu>7Q{j0K>&qF)1~GrH%o`n!~3LKNu;P-yeF(4Usd&W z{IJS@@!P}D&x5YlpeS)&i$r62jL8NMw@VOU*og?SLWX*+OSK~18uL_)u*oih27Id@ zkjp>TWY9S6Ad+cl<$@ZDko`HfFgmN06n8hVfc#qZgs)FYd~|t7wKtvORj8i*@|Bdt z3uK@=6~XspF>*c9t2F%&rfKJJ7_Lpq98O(cZ&%FHX1^?4Gv?r)#{2dLjNq9v;b2V` zco{&mO8;S`P4DkKs+9XmHQIuIO%(C=M7WH_+*krRc7ph{_gHXP%F`Op=6MMlcOvu>l#q!07!nY!#=77tq%x)h4r@NtuS;^Sf zkEW^rN7MBGui*F}swU~0A`zGm`mEKy&rR+YEh6D~2MM}xq63$X?KlQMKld@Crf=?$ zRqr_{kYR1>Rpmu58>p+5?dRR8NoCM$=GHQ)brhX9Y#X+%jB|Y05V6IGEVfLFm^*gsy4H^VSXWSQoxcwgbf^(?GYFN-hTn3U6i^S;2a{_ zQm|T$Ov4KKfHji)z>#Upr~nC1E2`#&IhYpt0 zk0p=o(`3@6&o#*R&X=0IVXdtcD>WWboBj-V<$=sovFG8Z0=DTg{Fp;9cUsL~PX`YP z9x`pDx^IpU;ww$JH)=B^8Z{rgoL=8EU)*y_*(SWMP}=*z?DI%v1`W`}I-WlI z`0Mgde_wG~i>?;-LUNBw>ULJ&zcH4Oz?rfw4~tCF$jpivcn4dtSn>tWQW1d~7*}M) zmV^?yo!&!im$)C|jnuVWod=Tsy6jXT;faj`_+_f-;G#2>VGYT{zc7mlJR4Dy^ot`r z5+2^i_ep5FIXyKo>*%d5{TaLFQ?$T7yy0c2wu-YbVHh!B8RZuxrb;G)<2YvV%|DJ2 zbR9pA5ipAP!U?VTpUyqjoi>{>)AlLF_SE+a+7PG1oBLe@$u$5l!>os%Ut#kegpW0ymZ^sDd=i|GJ?$c%KAIAs*?d9h{v5n`{Lyjz1 zo~@8_-Y>pMXOEvJApMu?0|8Nb{8(dk_w_-LQnTy1`k9$rg|bKqbhGXc_=c1TzO%z0-dOi%U{mRI4&mVyY+Ytz7gbQ6p}8R?g*|Bn}!)}5jVhCU@RLJ@3!yO3+t zw;rZIWg=d%y&Z!>cHkU41lbOp97DJ{0VOi+I7yPx2EelfqV=ReaVWSk#tET))Ht2K zhRp%`jCVls+>KD9)PS@I?|C=Au9g#c$IW=6wr6ow=k0jGjat{8QMPIh$H3IPjw`i@ z`(Fr4j3teO3#>i~6GsNGyX;md*#`TZC{6c};z?Hf8%a}kr#E!)BgXmun>6T(TQ_HJ z2fA$fj`Z3ZS8jdiHEg=0q7hDG-I~n$<-khE(iZIb&W$E>tHWsHh<-zszZ1?O{1Cr} zNmG)3`Oh>P@}~nZ)CA0R_#b^Pw_fE0n zBir+$cDbwB3>!CkGKVWeUgNpK54mzS4mpxhAljh@Qv%>m(S{1-jWiQrh?Bxpyyi2U z)X~C-C_o1C+~Pus@N@RWHGF0bbQoC$0#R*D0007ZJp{Pb%$0qKTjOUqM!Q zs_hvufNV1E-kW9QO|N-?Na%kGDHc#4k@8qE(`w5uew#wZjGQbr9#~=XBXL~UK^5!v z00!bg>dEa`02Yb%hqbEpw^ujSF%cJosb&I>=I)?WvDV6DW0Zk*P?sA(GX&arSavbl z{)%oj@M;2*w5p>Mz4w_p&Gn%;mP;)@h5$Vzh!pbmkEawgA2Pa5x-6U1R{g8T^mmbh zrAJ|uwyLYH&%#sAh2*lOe!@#7T6Y zzh{`H=xv?ZC;^Jg>6_=WAMv&#mdEMMw995}e(@Hu9%7K8;ZGTmu#7J|R+OwVxHIWU zh@KfG6g9%#@ylI%wG>G^Hq?VE=mmGF?)qr@iB0W!4b;lJYT0|;IzH^V1Rq>)tzIOR zoc$+e!KIy-x~A?Z-~lA1q*8JOCw(!89}(&zx}q5?jx$&x*01<8TjY50id%*^hSoPF zLp+5~Ydj`#7QEq4S(%>Z%nBU8$sk1K!o7z1%f<9?CEv_2DFy#f4g9VnI2~Db7uX$n z!EDHxvttwteo&;4!b;17s66xRX%t5a7*goFxxWA`0U$m!ZguUY0z$+$`IblTB3Mz10c<|e@ zC?8UHC+3f0xIA-2+h8DlzvxOlD)4V>MZyeJcW;snw3-e&0v(rC~_& z-2Xrht7_Np)voQ&$*ybrYck3GVgzXI|J;^f{dQbvrrKf78S!{@U-sz|)5GmvQ$Om< z9z=En#cVm|_@cENl95|1`I_eMkpbvt&FcLDsoZ@7yn}&r3xe;y(=+p=Gds5KaWlQ}9_2BL0>8n9e?-9z?qvDB7)ipffiWYE^9XjVG z74GQwiV)_C@#W|EB>{CD>_V%iyqlI~+_*jNB^NDHON}su2UJ~W@YK2?KhbQ88`{kY zIBd{}ddHzb8tUascQo;H%2wj`mF(L5)Y$XstESB0=;z&t?q{#N@K*D#YSG%R)Rx1O z=lRe?v)}!>?c4H@kSCNcd~x>5af+KYA-6L2N4fR8B70$UsPBLHKn*P9aA*8f#_0b~ z548Wdt^ap%tT+*~!HV#Ko^yz(J2|r90EqxHO4&vN5k$7Spg_T058u9fVXM+#!hDQ|{p)4FMfyxb#1Dhk2^W(QKH*&5yKLUCWufy9Y%k4 zXe=#>=72h(2^7xzzf8ImEk)fVrtC~2BKaloY6de zltC#afiUq_8Fh3v!>IF5oWE3flJ-Lgyc5NjPolfke7VDgK&erwQmNC3U}-l{O4B4b zK#cLr8^+i*wqJ>WqK&P}p*s%cwRKK2bje?@=Huc0FD(o^pY?}39?X;P?2=1A{3GtQ z))q(3ENmMmH;;$iLbD&Evurx_7{9PeJF6g)(#}167I{ro4%pe#Ae^8)BIrz1ylf49 zF64HYXLpPD&Te~i`uWZ|zkQ18_cwxSsozzozp^P0q-TkYsZrN0i-pt;@8m{3oXq6G z!m?l#VmdxCOz{oLj}DcDw0OhaduBO1(LsbHaL@&tne>Yp3m;^T5{?{oaGN{`_p?dbbUESl5ALo>=iE z-#2z|PTb!wmL9OL8Tu()iNW){jW`T z^VytiRugt)g7z99iI0zvVKa3jF#<@ zf6t8YhoZ?FWih^iUW5=-)32*zUVdMtxxDLtBO9F?su9O4tJ||$8mNh!(3Adhz z5oyS4CfQvpf=9N%+f~Y=XGmcr)P5dqp$a&E0EeDClJ=jfy{Tt@XV*&!(^PeP53gos zH^>$O|J~|bC4KjO`$z`& z-QD~B8b`iVr`B4Yhc0-gSO!1l866>Gq9RXm#-n1H5bu#o)xGx&jp<8{_Rwe%NouT3 zrL2SInp0HQAy!W9Fb})wDQtclIIy@!-`(C$`RVQW2#cDKJM+0%LnBT%?bV=_&=(B< zpcxc7s>myNOL`;dLy!;tLH@?yq{_>cD|sBwSM=c*wohR)BS$HN!Wl^*F-UrN(pF9_ zc`au@V1zkDO|A}Ik&?jICDoQ2?L=L>f;6I7kT1GEs>!S5P9O0JKI~a%O8%k;N+wrQz{rh(fjcDUNB2#Lq1G=lki*lH46m@mU=6C0Ehc zSJLYHaQF=n2+9??h%}j?z3967gCxJ??@5sYXYq`9V>o)heL8pr3gx3&c|tcCBzm%Y zWZ0__hVCW?3+t&uU06o99)PdA@U+d6CF1XUGh)JN2!@PrNt!ZYdftvc5p#3(*4M)< zb4<$>Q?{qgI7e?~B+?*Xi~bq_7-rf1qw;|zq7LpNSm;#|MER2ZSqEy~E4Om&togWB z3R*HM%EPHF(ioK~BB(Z5_|qP*6{*e<-IpQ~cOd6aAA!kS*WyYUrAgcLbrThXxFudM z`uoEn3!^C8Ue?II=l69wj8C354gS{yM|`k1Nvx zyq|Gq>KnvOQbH1iSXDl4oqZI26#CE+L;*{&6idT5XMig%P!DG`-AicX%MU*yi(}iPdVVH#Y|J} zW(M0f`=V)AhUoQ+g7fY4A#khRq(uY6d2!*inkIWsB7uhU5NQS*q9q~ys}+D3gZ!^{ zag|(5GPx@T4_fXE8$KaOt+Y?(BJ7W6z`+SYKfuJXaSc&<}+A^DkndCYQ|a$0J4r1+V9mAZ0WZnv7+o-+BD1z&^Y-k*+C zpL=IxYYcIS-+1*+D0R*Y3DCRaG92};N@lgXT=o317fNJy-465CU)1spn)nK6wcOgJ z+rD&Xnhi$iOeF3|mP#$VV}UT0(95>P7`B@&_*tS2UVK>RBzCgyS0~Z$0{rVT(2Psq zqK?LoHFd~OJs(r?HC3GN`yLB)qeX#BtoV}=i>q-?IputK+E1qO_db&@!`Q0W)N;y? zLTY#h$K-x9q6+tnS+s1aliIW{@8?+fq$z8G@7g2zM0t4@mrK0t=KqhdcZ|_3>bf?| zwr$&Xow9A)wr$(CZPzKCvTfT&*YkX7yqz!I8GlxCua)dO*~!kFYs_&)64!o6fl}gC zCR4NTA?t7)TWWPGucgH*JB>LFySO7z!gJu1VLJPk2TZe-op-O_$Bl>X|F~>-RY?+y z;S{6i9o;!?PBoiRf^u(xK;mqA&Oo%jhms`|&=j-`I>cSUUu>NtzLA;+>zl*OOEaVo{=EnGE zsb!wRQ-RbjLYQJc@ z3V~w4Tb~PAeZtbcI=j{M=R5kE_wB6wK=Zo_`|r`u1<%A4L-P_5<+F%}q+N7<2#%#W z(WIqNlV#iVuE~qAuCA-9&rdUg)p#G6IN`EEYjxu=CLgCOAX&6?!+I3RWtDiW{WjlmI9WM@d@4gfgJCv2; z`^6YpisM1=-EGN;%W$P@2Sw}G?#HwD&i=Miz}#cc$t~l=`hn9-**j1w;J;Ts{kL>| zkMPrriJL&df!Uxyi4t#tn*}=AVGN&{wGpeqm$?Uhf!QB_fOr`3kr%*m3>cIg5<3nQ z!SAqvegb9?WJye3_;~Ccv6-L&00PYT=o{cfMhuRujDXrK9!53)zb(ks8uTw^X1HbW zJ7RWGfU_Obq4^7fWkEMZ3j+x754@v1!`yIg#GYD@t*?BZS}=DEqam{3IdB4~J7Q(b zn+SG+PTdXgNFM}uKD&pAd&Wb^%k=}mR-Ly#`I{9*II6WkN54y)b{y}tTb)6-h#TP1 z0o8zmeurIxUU(e4kOMO3X9s+bB^SuuX+(80lT76LSz(zK;WZd6a9jvb6RbUL153;f zzOEf)SfL4TE_GE7c2b6mcXa9gVi?fL(s_SV0VgMORZkBbvnSk`-p7cNdx0xifzGba zGzlI;><(IMZ`kA>JZ z(oRF?zst^l6)9^t;wO=fi{nlrQ9GYYbtYM7fy@nHH7RPWLD18DSw6Tpcv!dG*8rrvc2W2QsATvk0eBC&AYCGx?ow z3EHd|bzTOqS&wVI3=D?q_mJxiB+wV{(c(i|jRnVP?WQlW{s0mf!HV9upCy)o{W>w3eyxN5d4Y*dWq7-_;(Z3XkxK=KFe zcXaNbx4vbkXdIiJIJxGTzbk>RDN@=M;!=~h;4x{#ZG9gDMm3v&Z%dxCt-wQT&VjH- zR}Du=0@%F$(z^NJHwT1Fwh}mEssr~;eD2gCEG2}hYvbM+53c_106nBkm+m(X z)ji?tXjmAJPE@S9eSr}@IPE~R$D^&qbj?Fv%6Mm-D-jTAZLu~fS#u3`PFg>*#-1%K zyeMDr>%OMv$qvSr_T_>7#rJ$>&1y!;^;>ICE}e!o!grpvQX)pyHO34`g~w2Z1{Mfl zIyJDVKBAW=gZQ{Asv=|Zj8Hd$)G;e%ftU;8cWCQm2%yz{4HgIdD3;CuZV zQsw$YR~Pb~{cOENtLw#`|I`Dn0(h1TphM#r@PmTSp!Bl*P83}MaDbNpY6H>D!TJ9i zSiLEZBG^R_hI7qx8O_I|G6|9_U+fT;lo2D;J#6CrVz+?j!_bW0L&y6^5S!Z}-!+ zHc$Qal~bbD)tg89(Ufu{))8Qd!>{oy8g(D6c_ZsViv>N`yzR#Kix^^!zjm#F$DX!n zKTN{TVKuNDj{ldbPe!R;_xTp8BDV!G|0$ue+97pUA(>g#(mBThx<%@j(S-Cq@Hw5B z**y05RgX19k#bjO?y#}8Q*Cj_2rPSI0v42A*;)={QMK5RyK~MH(a>ZLUkA$dc%)Vl z#Rjdjjzv?k5lz-aq)4^!-r{3tB1((8G)c?Phwudoclon-eR|aqN2M4%q0w&0p_F^9 zp)bfw*#c_@{CdK%l^U&RR;7*V1PmQ8tstweYaoE{GlzyHPmwn4Z_0xh)gh!(ssds$ zqd^6Qibrn`JY{dIn|z}Y2@hL^b>-l>OT}i>3lT~~pF8%etto%cu$U7LB9jUx{2rCH zN3wflihHU`@9ZCsdHFk(nKO#U13;oE1>q`V;Aa2IXy0~X)|sTdSV_dUx>%&6pDYBG z6oE*r?2<>1OF1P<0P`B4^)w;#Hq$!~i=Bj>{g1K`b*W*xqeYre*N5BfqiV1*iMw8} zK%fDi#7-ZsyJ2Q&^OXu%N5d>(E8I?EcX}@l>OJK&+wQL{_V=5tPM<3ljVt~+_{yC3 zy*H#fyg>>ymN7)zb`9=kYdRwL+3%7&x9v;x(mCOz`GWztAV~XL6UH!tXVn``0B7Ch=Oq%Uv2tzsp9x*-cOEpuq+hE`t05`L_ViqkXJes!Uh>54+WiVKp8FMB z6YLZA^NQzn0|D+YyV}m7--h&g_GkFt&Q`)AVd(nMpg@jXx&RN@eTa@Aj@Shzf>?ss zvYOqpgIC}cpcSD!_>Lf}fd6W3tR*zYAxg#Xq?wU3kM%nQZs|C&`K(fNe!0%Jb5eR| zk+7VP2_yPdefJI)gYghflt=cTKVsZO_|i7!o`ahtwqbwtsFJg_H&_Bl0!RU1Lhz;_ z$^X)xVMx>xmgy&Q40C_TogMpbjhaZ!9=+ zL-a^9!K>|n{I8Px<$t#-|1WJ|oFmBY5vc_$y$Y1wnhW6sI{tK*mI9uDo!|lD_W_=S zZVq&eT435=ODs!$9){{R4|+x*qJBANx9KPTFU51Gll)hQtI|lasvH_lmfBT(9EuO= z48X>Sir^|Zy*dIYHR;DU8i;yL{?GJqvl9{p?e%2(EJi^a$eVC9z?*Q2;BCPF6*sBv zC~T1hDrF{tPwk$HTjmd9MAdNj!aDWv$=8K2*}!V-V$cs%HmoDK6?U0P{|3#v=Y!Ox zBDI%gXxmyY_Vump&0^H5BXi|ZaxzYt%K-ra&KA^q$U^5FQ@#!d1Vot&yRjI@Jc2Zs z*oEM=KGT1f(j$Y!u-G3S_B2pzW`VhT>L_Elk1(T!yXq*j&5Us(oZy+E5M%ik;lt1! zL8XL1Oy*cb1Ve$E5BwkszuPdGU!$jTZ<1&sV z#lDl2uaTe71{wTt>Xi%86mP)g(36pLjx9D_l6`R8{ph=NOgbK5_<^l6ap0{fra;iG z=+y0$lxD1)VRUw|c>QU9$6UIgPkrc@le{j;eP1b5vXn;muj!V>O0yVIXo<5}zbAPH zA16yK&EhMYPqg4+q{D+2^cT#WzCqbTH)c;~(eC6$?qqo7APG9g1_w`4@LOchVNYl7 zvKXS6oTa;u!O(qs4^`IMf>?YPH?DD_dO_nq59YD%&Y^HuW*LpaburenM`FmhSzugM z2m3PgHIaDH#y-C4p(1q6^H?6{=T&vlO4Y)`Th5vy$SOjX&y9__UDor#x#PAjC4VRA zJ6!#K+OVdKS#&XCC__RHl~uVcsi_J@Gixr}r}o1yJ`DjKZRo`RK*yaI=0Gm3jTcEH_rXOlnNt z9y5RzTx1YcU;e%0v)w1{stOH&qjr*wI88C!S`B;pS`exxMaFj4Ey(_@f>pSTBd5DCa?t>eJ;-F4lFxV)APXZ~LRTvw@2zH181lm%H%ail|?i!-Bvi3eQ z^RH90z3u%D=cFpz(3j7%<;w6ka5N`z{OrwoFA#;BQ&}h?EFe)+1WTNyg*_9J3FI8n zpwcqV2k)!?D1NfY#N23WZT?WSH-v27gXIwC7M*WmUruUdBwLhL15hXlbxqYftb)Kj zT13M|x^doXTz?rliD$@#O+j6diI~y)2co7Y7tF3LV!gt6+Xl@@GRGMQez_}`iV3I` zojZvrsdz1nt&!TJa9AS7O13l`S<^%*>blKg(f1-lR{}E&tG2{PZ;~+-Jl&HlD(9`p zUNm%6pI6UJix25hwo>W=TlurL8pN!?Si5ebw_ey7we*KhAk5QM~b18(fiPp|= zp%!Lqihg%+(y*&%(#Y2MV#_kK0aEEtOE|t`Vui9mUe1Uec6pMrq^cW; zo9obA?4atg-sq_GkL`&W{H+8%c=XESTV}NW=mAjxps)dgR+Zq&a`O|OC1z$*>Ykow zr82Gvzz~Frgcm$It~+?CVnl^Cd4|;cs_U_0)HgXgfO7u3TMk{Jb%QJW^M~@+PT;e& zBXrZ_0!FspqYx5CwJbzZMWvdw+Lal+Ar|`1sa9(NBCn{C1Jn!IYk`^G7%DTx(SgI% z`g&MQbE`&j8L=lEra{k=D2th!pYaYXE-=rv2USHAp1JiWW}}=<{lGo{qddjKa?&Jj zI2Fuk{=KE&QW4p?fvd3VH!G< zB|ZctLIK5R$qXP0jR_!gBkp(<=(>6wnVxfxD7(dM1|olv>h^M=$&6fgb$3mwBH0WY zqw&5};t|y!NWcJ5zFt*Unug-Cvij*Uwmf*bOd1L!gXI2rTq)E|*klPj5XS)F^xerN zj_2CJ6bQL!5uHo*raFvY6rj`uic+6dpB2qOv@}Br-me`87x6&WE(l#W7MB-Swp6lg zo#;dUZw8Q$$OQ(2cUJ^zJ!<0tfdh(jx!(4Y)Z=rG|NaSLzIN+KGKxMmSqPWV^HDrX zjd3q~z^gSmANKUp5{39skbgSf8PC~)H{$}i*AO4T@~kw$3(D9Q zBgVFaKBqV2!Ye!BC3*;~yT~X!25>asFRX<$(VWFqUIt`pZpmNl8n>1Y9*r&^StWsJ zTK_a04crXiR~$y}6w(|o^)*hX8Yk9vN6JbwZ@zAHVImLMS|9RK7DFDRuEUI&Ul*)R zgN)AI(&LV|9M%t}KX*jrPX(6?KifT&@?Pa%W6aE_?w)P^y`jY)O&iR*A)JNifw28x zv!t*+hP$g=lpL<5cZCI{BV60K=5V6G{UG{p$g2~4_ zo7KP#2f_BIuo{-MsPACpUb%8~*PjNemZO9ZZrP^LdD~SXa#dp+^0gOJ*#HFWn`!XP zRreF}1a4{dyAlDADcR>b7nO4%tTnmhIIav{|L*!#l`U!e7^2OW1r!Gqj@0Rk6(b@C z*E{FJW+QXl@Kt}>&;|xH@|sL<3qwfv)krDBd8N!9)(eG^nUvYMsfwc%%M&ClR7icd z5~e?l3qh{{TCy9%- zZi#U=1TAx6dGszhIO7*JmWtuo0?cqlMP}_vYp@K{Vhus`oxO~SN8oI#O10()K)a1a zbfAOpVj%1MkWTyFxm%pos!WsDT5f8JakY*)yV_$LbCqzD4U!hj7()n_m!1F}ln=RF zxvpwPN@t8jYn9*B<``=l&g^8^#Gbvd-zX^; zv(k;*q<=25C}`2S7Xja(quH$JtZ0_G(a=$SzqNjAOnS{`S=A1*m3C$&Nl_iF=fSnBM9cufz%>;T-x0Ge9c{hTeDk!opD%0>T;hmAGaY)+tZ9zbUk zf%DY}<*4o$>8x|Y<7yP)k)nP_?U{RMRSUtws6FZ!kTGDRdN@sxM~`6TNC+Ry4}d>7 zK>ivtsN=7K0S5sLzz8&qWTN%cLk!SDHJ%9tvNVVuk58xOvNo*#ttD%`U@~8zV@@bG zFzKO$GJQS_%F*3}_w~F^fA;2s>4Zn&=PihNUOin-UGAU!(f%w=xv%?q-Vj02`+QEA zI7`{L`CLK{5dZ7<+0Rr~R(_<2WiZt$ssoF|y&+5+rOLV-O1EapqrP{?G$GS8#^+Zq z_ahK9E!(Py5b5b1IZ>H;VeyXuswwd2^R~xf{6T8871imutm|-DDm*mxj^34s;t*0M z+9lkf&X`R(QsDgY5K%~4L6T^mGA1ZjK4&=^WJgyQ*)KflV$lb#@HGmbL>EPO2JL!Z zLEDZ`uj_7q?uVxL{qx-O>3OM7mH(3$Y5Z&7@B53NAFNF;s!va;KH;LJnApygy{--! z=|hbRPRg;zQ!GC(tbQy`nb|C0hKpg8x(t|n48etjc5vXEkSg3S{@ z(ac`Kn-pDm#1nO{DiNjBTG^CZUWh?G2E1Q+-01&Ify`H2u4JGHzybj>X@1#0)FxQ; z7xHILFA5b8H%!F<$&6GRYvHQaOp@PguT=jJ7LOtyLpX8Ywfq5%9So5GF&`tLU z#fg^HetaA0#zdr?N=ZduI1&YDvGR9kAWj2LqIUDHKz=34{Vv_;y;kE|H`CGG_ey|cyb`?2=@ z1OB}3BhF&~N$s8Y`}5yz(!nD9ZzI;JOQwkTcc9beK#qBS=XJ!&&u@C>TMPNag_`>v z+wbIUE%p%4&Zhg?FY}s-FI2E|=X=8gAg`GQ@1gs)GZHGcwnwT{XyEBI6gv^P0jwEJW1hZHaf-fc0BBQVR2=8=~T8L z9ac(m9?K|F>CLHi_S)x$ep4)-fFg*H$dEfsMuD)p1rDv>(Dk}=>52KK!S-SCY}&Tf z168EYmgjLn6lv&(Ea31R5B5zJeN*7Ab0~u4zGs?M+|d^M=~e!^;dk7`@X+d;8RmOv z;?dwAc0~SzS6TN(0X^nLllPTe2FkNvR!}TtJJ-|dMtT(}!8k(@BjrZgyL$o(gqQ}xABFa*UHn_8Hz_L4$Y7SS zHFG6%pRD0zSq%F>w43O@RQ z8EIGhn?FLVo)w}w#wenq5qhb{ZD`ghQ5Xtp zpg(U#Y%a@x45Mlr(ei@@l?t2^$nxyB*g}gKgFpL=yjaPw`+Z6>E(f#Sco8bwTk{MeOC>YJ)+^ zBKwEj!mnZlH;DO(v)htF5WcMO54qzO*QapcacRV#2x@8;a1W=8!Wfc0^L-Hbt$uLm ztO}w_x?v2ZA2F6)l*;j*-A*PUHtgfjE`X$do}X;FKY(hhVHG z^%^C^4Y%f`Y1d-nl^E!%6$L+o<2524yNq~afcYY(fWu}dH??Ik{1XWN{G-QvIi5(Q z1LSl|p?hZ9SSm7;{}p8KBe#bPl)_NII-^cFfPoMpa;t7_U@*X`>xJ-gDhA3=KpY+3 za7}wpAn7>J(Y-q8VdsKK2v>P_V2L`IpT;jF`yFiqUiC6CvcpGWkx_um4JhtAc8%!@ zQtl|`33Azoxx}j9$C<rDkaY+zuK6^S zYdWq5_qDRUZ?vGCbNK4^7it(V4!XOC1CI$qA_Wq=SjgxSw;n5)QpDz!3~Y!{^fs9d zZfMdJeweie3oRSqMM$oB7n&$yglUga0qbQzDITRWf{|oDR0(AFnPeWgNMEXBvL_b< zb8_?}1c=eA8**mFzacd z*Pp4sK~66(X^2w9qUj!E1I&7Rqj#JIi{4$-CM#UfKImO_hw`dmxH+7K#dEeCt?Nh8H9N2RA@zEAQM z?GjZO%`;c1(z9#>L`)KocAE$WRyzL}*#N~D=F$9BB!3*g1A^hlE~ebS%O^GB=qy>c zh37o6xwrP31ULfXy37P<5WSGKl9>j|=j)6*c_E-E-oa z3&8l^x90;?0E$smk-v9m7jfN04z9ZU{E;cCG@c_EWZht5gg zy(MQgFP_$h=H0%1^GazkR_X96Fs3*bl8r!@+X(M{_7#L)6%t{n=Q1rz@sT}aa-=4( zm>WJ4-9~v*dcBBX@6}7Ws9FGOE{Bz$);SK_ig{(GK`nJqgvwYWgx490IaP_MZDQNw zgDvd`lt&j%bJHL>hz8ke*+#u)|H2myxiM<2Tm zZ7?1?4rTE6>^g#J8#-EEf=7@CiBFjmsc-x7%a}6d2Q<342rmapAi>i)l_b)8@UgmW zJ|YN2NC+RplLRJ(aSk)(08{A31qboB2r6(%l>J+R4vmq@o;Xjq!x4wfyx_1{$vu>r zwWqVEPgUBdb8`kQ1xW8lX=ox$+i}EGZ%_N(0it8Xoy{DK0v6VtIKY71z|5RG^h*fi ziBy6Rnk8J!M~c7Xe;)pnM$HfWNO0k@pC*lsa5&c`8?9_KO3#M5YL*k{Aev1Pltq!}bPj*|@N9Kknoyj@iG_JXZ47y7v)ue=^Ce+7Y12F5w`d zBt<=$6nH_ZF3#CQAHzocG12(o|1gvItm8y(PZ%pN;7KnU=QE{vS{7z21thInJdo9BZ)Q^&%#Om!KJyc^Z zT(GLz{DC5=`p&1|^`R`Z;($*kl6E2@SCj#;(MaMHdMPEmL|@ow30Ar)d3@wKftaDf zfa&nnjOp`RQj91gGU_ zV~m1_wHpZzdsutVfEk$bU}r+WXJg?*rzy_$5D$+62C_!y1w3_=6VsCho+;m`m@4a; z0+OSAO`gYcht_n}7J`nFdTq20%<8>o@M&VRv^ncE3&3Kni#%euh#?09TMM?YUcibJ z-NwU#vvn~1eZ$HnL<;Pz%TTX|6dc?|LvT*CM@&S}v#;v#^oN;rM1@E(nTnE~=+%fD z+OFSe+~wnJWAGX=Gw%pJc4b%{e+~vr*zfwWtqO(vU@c7*H@`meg7#52uYDl}gTjrg zij2G0rGpZ&c8t+ z2}LksSc9UVQettKzdb+cg<5pn^MCJUY`mV={EW8SsEl}CbEc5syAr@x??BQQEdJvV zN`vs2`V)9!G4v)j=`i$1#yRHwlpPdJclzisBZ5$sK{k=AJPNUeEG#WVc`~5smIjgw z){_8EyTu!zQM%go<~L(v|A^S1hPiURs!uF}4Vgequ0cY3TZj2WCYhx(9zbLsE20_( zCITXPbPem)6KC^A$+E@c=cw)84`!0C2FL3gU&#Ip=2?vJlYl}o0`}-|2|P;Pic?y+ zs#I-H3PN0_rtGi;AUS6XXfVw-o-bD%uT;EqR@}8J^_n$Wf>3aCUN_T8d6u9bz|==@ zB^-tKpwEob8dq!iX<7G?ey$kwnmFB^E^AHF@||9%-=l|a;wVi!ZC^0ue9@c_idvvN(u?3SQ$M#Nlj3#0qi9J~$p*tBF8bO@Z`DtRBy-}R9j zI~P^vytYs=^LGAMnsw;S+tws={!_@nW}m6F*A$)VPO05_{8ihirX-|LA}l7P$=Ug0 zVoSXhCVuE2y;k=0)W1F1jN7{%6SkWVB{qkQ%P{>{mo2!bgZXbmv)9_}CshsPdm!Ah z+8VRs{S}4uz5NI#hFE0zul!o3rf8o262&xSTNT|^chxW94~-Uc{4k!hAu5gaLBDm> zkelv&F*v%->s`}3z{}3oS(S4I3nNvkm2Cafdaa~z^F%#GvJf<_j+QPRCEa@ziwlHZ zL&y-O!J-_~MEasK7N7+sa1xf(e{~)`z`Lx$X{Ww&d`oZ<@Z`iW zOd*FTE56`Wz;O!$83B+czUH^ISLMbs9V?W{G+;ZFP&z+IW#0g>ui~27SoM7$u124W zDP{6i*Xl5`76-Yi#C}{R_`sJ)BmcUxr=Yq>!6c5^@8kS}H!_^#U;9ZbkX^)vQn(_+ zL@u)CjkmlJq?}b=sU>qX6S^^Qtr_gl^HKM`dDwr0{MM0<#3oy+`Lr%!GHw0|#RJ-< z>__2YshCn26^)dTpn~djmM?go|5NY@20~SCcEv`Y+VYyThUBp{l!7*9_Rej}n916S2}xK?HOC zSk+$fsyH7ko_PAlQJiO1hG!P4C!0dWuHkwHk(zz#y*E=_rs)Zevj4DoD{G->GATue zdyx$f7Oy+4#z;FoP|<5;isPe^WKmRfB84Vpu#T75bw?O`_!-Slr2({fG#G`qW1@=D z_}0HYds}EhKkc9WFFKGTgyX3)Y{-gyJujdEIlDAerqLw|Da-EITw^!JLeRGO-|ywaO@{f>Y_#YgAy zM%xD;qeNb?Y0yTUykd*jivzii{EnJDet{+I+ajr^tj!$}iFeNEMirzT0nN`NVS6Q> z?bow=Kv}F_ukM~Tp1v=ihx1XAvFp^I?}y){w7NV{{%ry0F<91nf359>EEu$+orbZD zOVg%}Yuk3b&hdNHZ_Pl*E@(5opa0if{p7|V<$rKHKEM6f|DT@H|7gJe=2Cp2FH0-k zh}|?spomORt&v1(w*GU;3I+plCg&}BdtrQ>#EpsXKjKP1I8gGogtE5p_z!HSDRuz+ zD9P1E$-P(Gpyz%)`^SNtsAjpQ$dr{}LW3h6I}XZ$0b7d(wbtzKO4 zLItQ!QZ;^#;;_e3AaLJ>j*v9ARUexOb))2Bm6rm;?P@0PbD89a!Vph8wsCG$kDf18Os7lE>Qv+2Y zS_w*%3i#q16as2+Fk%Vu<_>NSKVE`1vX=l zSYtrDu$lvLIoeq}rJw&sIv+-7Sc)(7pLnfEnt{koWKKSbd22ZE>li0kH#!{tf_rS@||0La#1fz=z?!(G%mi`-9AQ_?j?{()rMOI z6%Y$bjxJxK0t-napw6K;gj{A3?%kG$=r>B}Bx1DEq%fDiV=GBNaRQu#BV?dU%gfEP&+<3G2fCbS)7os5rudlO(wCp zZYFbBU^RK98ge_husU?#Xe3V1?JwmiLHrQhTL=s-N?^jHGVT(pbAm)1IY=X4o4tX) zy7SY%jRWLyN9-zua^(4y*+z~Za#j%kAtO*ur*X%LCn?lN@Te2pvzF#Xl6NuiFa>N~ zWa0bUcN5H7Nm+^6A}$*g$|-J}qyA{T3mW&{4{vRt2?<)NAgx1QqVG)^u1oKDFp&u8 z`pZ#PVug8@5@QL7UDQzlU;06fq-Nrn<>h*NIpdsVY~6*&LX-w{tjcH7njr{P@2oV? zI*1!xm!g%_3_0<*Q2ZwpD#OqPZq^h(1|5PK6%Go{s~9Eq6#M8t?PEKunY_;lequmd zdpJ0zQfF#g`;FBT0_opZFOcy{tfpHf-?7bA5w+5?&*oV2#FTAiGpkwNbK4P=15S}E z?^jS;BLGz%=(ih@UzPM$;Q$&;!rz|A_p(j3%y}Oft z_8UOp3h0H8UP)C*q08VaiiBgT075n6L6+U5`)!NM|L-4{dKM?)wny~)2M?`3*%X1* zGL2e(2+t!A7#}sg5Ot!IzDI^# z3`L`@2&X@f&ATina?~f{C(B?cuOU zD4Ia<{jmo8b#ST}w^5;1EFpR=hPBKvN)#>91N)6!W1N)aN%lcX@%ODc&k@!jjw|0d3E4sxLXeD&QRG8*HySy{Y=5={b&6mC*cZ*=OL|C8(mB5mS zNebucM(xBK0~^-hIfa8yCdhshPX~4?E5fQF!v6_7%R>B2)LPZRLeGZL0G89(sQ2nC z@;XZ7_s$f8gb|B&sk>N6KjgLr9w=U1Oh!cIBciAZ*rORv5wW|0fzO2o4IS~o9EV52 zatGuhhcXM3PTT=R7@n%6w+hwBV6OBLGJKw;(qSvlFPPM*;F&$DNGqcUI+Aon5#Tgx z!()Y*&ebN`;IIuF2-9xb5G303QybQ5^;8^TVkgA?DP~d9k)QBL$Q4HF+9tF*Xr8uN z*a|VJ)%&|j+A->Hy=dd4ZRw=bcpc3nFSU`PP^oc>3{w{DLNMZrTrIpjV-~rR$z++m zhS1`^Wru%%u`M?P(p$z>SZ#AqOZQDXIp@%G&lkdz2|ua!0r#VM_j{BQLEo80*xq?GjT zF{FG3LJ@7Dl|q%u!jlVF59;ED{!S)fiGs!I=V~i3u!y7W?K(bd^N65@a_!V)+=mbErqzd;9=w}G)nHLjH{w}4oyP}B z1&Hd++g!JI2b~lfO{>*dtPAerFovD6MJNVaJ6JXq9#wC2#U9j>sD?!p;IVs*!?$m6 zMuDQiI@DACP_P^LyZmVF-CqXDPPGPpUkv)2vpH=7_Y3jtELV6y@_;(N2pdW2x23KYlhAi z$h~YTEox=TF)d{aVE88#ghiitg+$RiL-jY)+%(#A#-EZjaP>r>xTt6LfMUx|3nxh% zAVMz$p3oL*VUq+8j^q4ETK0K#;diI7P4aS&u)J3O62IcW=evcQ75(D#&iT^w)lX^% z%o(Y3$}qA=`X*!cuSOk*o9CvVfoIWqVhK5bnAMeQamN5{pR4&**f#9M@wVx4O$3WN zLWha2crEI2%TjZ*cHK%xR1jk5zkha-*-VH)un65K zC4W=?oL(B#rq-|B_xtKeB1aHXCz2k+suvLi+P|SM!e(xEsnWm^FYyBmh6n5!fh97H zfPMLFDJs9hyM^qUFh#0-q_C8_`6~AAlQ)Au>6EE1q`B!+p~oTvr5rEIQK0XHzmK_C zqT_GpjX#+NEqCXjJ3!R2`$*Sbdte`si_-3{N&g(4lR}Im(FgqffK}{W?a341RxcSx zw;y2Z|AsrhFeE<$+BOJYT3{(&rLNtQ&6TMkgrV4dQm)j5IJN%G&=c+_d`xdM^mG1O zSs_AEqv8zCWig~&y2@vpYHGOHvbI6`CrftiIAAI$mRT98?GN+s+m31psdd(?1QMkVo=|aW6Qwp) zq2DlJ+8-M)8H-P=+!9D-L9^w?`8RM$u(;B`O{8kHabQ11CQt`3)jh-Dt`b%E_k8W| zc~$U)@>3gV)!*|4zvpY?p>)x-@-4xY^{IyJ_E!fN4M-@VX(d^Y0{&89e{%OBDi31PSE~gJq`%@{jn>6LVMbhuoCr8L4|YMxDm7hctX*xHMD|^hd$v% z=#MRdXJefDGo-O01~DFQjv!3+Xt*+^H{=$jLi4fr*j)G ziuTn*^SU^sbLI6pB+EE$WAd20^r)+ALU_i={Fj@O$_;mrTYkkKt=m_0zR(w!TaEWo z8#awww;h`GU2y*Oni*cJpb-nZFX$&S%<`8YuVzmDdPdEPN^)s!#ka|H3k%r0(KIhI zL*uXY+k_2+^wxS^V2f&nbp+ECgS?F;e^fhV=`uOP;qWk6o!T+JI71ch|0&08h*XXiYL7b=0s?sj>W)ddmHU5{zvd6^w!cF9!xX+lG15mh(8a{ zg|FX=62qQ-f(H{K2-|D8Jrw@2q?hLR~`rZLkT zzRMC2QbNd4Ybc~0AjfoZ9vL&2VV%H~7CENCQW*bpBII~zbEr8`*u*9)YTrl#3EA?8 zNqT9wDNU2}gvIc#0BrZ61}u7F6sEs^xPMVO8>DhcNQJ5^lST!zN+!ejj`IjZn8~qB zQw=Z>1fS7=j!x~SIw_rKSrHi3nQWm{B{flp47rkuj@)V%L_^2bShDa^iGk;D(CfFM z_zr9re&t+@g>@0u(|X3S?Iw4lLtS11Juem%Y=;-4V+XzWwMCEI@HW~AOxNBWZ1(Ak z;<{WuLTzB#ag2sj{{}`-pgAR^uvBc`T-}U6&;mu&h^ zwSYG@_HZ-0!Ok7aDn!g^RQw=j4H?@- zUB#N!hE1b3YJ->j^P1d&SOAG+fIITj(6E~B7L0_cPuiF){^#k_6dk;5;lVD~&zp;< zCu;xpE$SqzOA3d4Kv;b0q)gbou(j*onBVD*0Vc+z6o}Uw+?kTw!Ew6?5>%zhiTMR$A*7p^=L}G4`Q!?4j z{DJUNjGxrY5PDe?+hP+nYNQcwu zaqobU`7XKNgdIA=Cg5BM*dJzTyq?MhoPI@~EH>^U<~nT^U?i&H6)gNVFMHL72zb4u z=*D*2Bh{xS&_o}xndh2&WQngNXJgCs6E*1i?pmuy5ED+=#S z_vOGII55rBWsh%7CFd0zJ0soti+w%q{MCxzLq70@icKV&U}yX7J{N)MCzTkd*AjE8 zly<92#;oTkU;C8zOHB-C<$~|j?3K&sw-byGTS>7j;i}WeF7_>;-Raw*vzT~22ae#r z&s~C#xLWLASUB36*D-CFOCLnvMR1gEw~*c0XrcQgWb7bzP-sw73jR)%`sI{vu!;(m^$;jV>H;Skmb-7H3QpWRQRBUGl)uiw2tnf=mD&701=JunkUU>hHio1F<^s>yu6>g1 zoR|}iR%KLDS*A>gg+>VuuYxztCKSRK{8J9#KfgFfLE&FXSv( z@+EqL%{1G&rOUQ-k>_&Uvc5`NBapiH1XpA>P%I3?E#4ATKCTRx160RrQ}9`A3Eya0 z)rWzjR54x466+%K8NGfitH}N%nr-;ouA+d~R@#D4hxral*u6GJ0W6=0&G z5<>~_1%3*Xw35=J+@bw{$8e}jUZ62eX-aP=y9@$lGkzUsB_`-oi^_d@LcXqW<5tXx z7`3%8qY9CNrsiNHfuJ^LBmvKer+p1`B1$4$jOt=a|4dCsjn6nb){zF)W9=sr@qagX zciW>?A@CWN_XXr7NsVvH))>eADrmx6R_kbfu%sf&I5yYJ zsO@s;UO)zV#yKyt7f}4U=@%mV5R?(hk**_QfI+$=*$j;2aYLe+hmx! zGQcnYzdf@v!DBqEw<*`GFO}4rn^l3h=$ZW3_|quF{g_Qy>b)%0@! zNdV(L{OJwQV(f2C6p(zb4+AJ#Gq8jLVm9nS=5F}<+?b(l7p z{<4)*P%B%zSR2XID7O6Yq|Z0_KXl)t>F-5{uuTTKs5q1okvzz0ZUPk)}_U$6MI@3?Ea#ibAPUN|Ha$hhdRiIk?ypQt zzt1Q|7+B%*yA<@=i5$~fm_O8t$dX<+MAM?Wuh3+C@e8wi(3H@^9g7K8$jCSAi2<$P zhyQMU_($dW#|Rua$6=Eu3y20|pERh7iWPu8DK&(?kqnEBC~p`&!)cj{)o@;754jsQ z5_MXSTaMQ1dE0D3(6WH|NR`dpxrJ+nk4h3kd8QRBs!Z5J648X~*FayGe*a=kd((zZ zong=dUlp0?fJYCX7FwQ#@zW7Cd59U92(y!}{N(w9y=h^;s???j>Ja~v%aFWlWQp1En%JgX>hAr^59&8nLC}uBz%SVMe5p$ycV?h2EFw07|pj z$Xm6>$3je}`M8>5x(!Img{RxR@$Cum76UA=1yni6ak%PO6R;{;@uwDQe>G_appm;K z1waEQ4`{3vSXGq4P^bab*Bt)=TCK^scm8nRAfJpsDt4`@=BzH${J1>%D_9pM7Iguk zXl0#eQ4>ivnfM(&=VsxHzJ8L;0hwL{|16JwL*pes;w(D0ayA1&MM{xlhLAEd?iYe% zYW&5m@C;dIE$dGlC=mU(qwAY=Y}k+u%jP6`esHmmHiuV(ocbG$1B>C9#+0$F}I;9bb9`GqW}h%0>1eZG`m6l6aWSE4vANHKRaVYsdtz z1oIJRo!y>J@D%(KBkXdUqnlEvpNz-Kpy;{m*1vWWT_saJ!ff=zW zB?b|dnmsMQ9bb^C3pPQ4G*?G_X=?~1(?-dFCAjnS3w5DD{TgU#m_+L>)0eAY490ld z32I7RO)r#Or;)R~L>4{cnzV9)m{vARtF(UTvbWE#&ZgrBabxRbPDP3F3vsimJrH6R z*u+J3GV7Iub#dDi2`4NpSKDc-PnClKxeLi8Ua3|P^W4DJ+by#S#^0)ov-#{>i~@%;e%2BEnSV7)+tw{(kmZB#Z?Oa3IUc`m0SaH(8T65TpDxq< ztp{rNt^L8CvH$Y>TRcc*{ylLPzViRZV5wG#9D22=#zl06Tx+uY^WK1tOLa*D zvG0i3RSsO1u|D%gZewon4Pk)AIuj*PlXHoC1;S~SbRI04v?aXT;l5=zpMs}DKYM)@ z!zbH@1mT)<*NBPNbdiy4)*OUamA<_|fg~~3iii%)CX*e}ypvy4c~vLaVAnM{3BnTH z8XwbtMa57s+ZmEqytO~@t~t=jzY>>TIy4Amu}&W+K5l`M&!qv z;M2vxjGwu!P0=j4qNz}-pYjxz)*3V;y-TG7L`RVz#6z-w*mg_}z@>IoqToyid7(`s z*S~OzJwst4OS}+aRipnhLKN1zXS5ob#vOwF4D`bIRXh+|iR3U?aRM-&7QP%ZC0Dc& zhhJmt3L`x(es@%wUs}&yJfK7$uCEwa`@Z$qRIUYpuyQpM0b*BukP~3&jYCvmj{!Qq z_$VqgLbH8sA-Zpf5;m1tFlBibf1@wne;t&uVCY&8cY)YKCtkrPB?VAfKBW`IY^*CeDex0netQ^+!&onqQl*wGdn z16N+1PWRS_y>;h%n4t0KoJa~K_rIIGA*FAS9_9<+fx5Ow&>r$F#;}JUO@X%?R$BLQ?u?t;JpRA0P-vtNO3h{uVJ7gLd@C?3UGYRs&PH&z{C?e1P)8tUWk z?@!~Wxsg?%_UXgu5n=O!Rjgwmv~@85Riei!RhCELzXDrKQ0I^xT?bfJ86Xr77Iy;% z1~m5B#e$3qdldHO*Lqk%UR@4iJ?Xaw9aW*X5-qX_+$0LCAAwEO`}j!3J2t!P4H*0+ zCKPYK1Q<(Rb2zf{Ke2QdT{eLm4zxK(;*;Q45djM*q80!5kcj=~A-RYU4d-IHhu5En zT8@7e7jOPJ5{?4!cLo%fp>_H-rOIy%nrkK;XmHji$}hp!CN@CaOsIJim4MMaiNTB+ z5Ral=V;)@h)#tx46z{(Q%1CJR$H+b0L!_s?;`*=F!*|tW0^OUL3)sf<;Rz%1FVt=a zWR){=KAn(1RYLkj#YaoJf5}t5->N?uDmXjYZu}@N4I8tKx1@rW2>tg1qyD%I+81vz zJouRQlmy=0TlvZ=#-bnnR#0z$HWMK+vAaTs+%FU3_T(`<2U1co*|4Lz^^Q{;Ax~C$^OzYkgavZsYY~l41PhV~9HzQ>Xsx|T+hEPRjPH_PI{BI$vHkD9Q<|R4<4Sc!gpDp8yA*wr*fSn760;=&%FcUCiwrB`mwNS@E&8s$arcJxYr#t5d_{QkH`HH?-9G4%#jX;-D<9uC zX;?)Ai5WLrKK#6EyXX*6aiTFz$xQz8qV9EDTU~F@S7MfCNu_@1tFuG$YQ^b>zEN9F zMT$u4I+_Ea1w6!=+uUIBjtuI{)y;QUrhlw~o&%>$-_|a{MC=~xSb8yK5<@NZ!2}Wb z5u{DQQg}yl?iZF41rqF^4=tG02;@IyKfWYDIc285A>miIi~kV-J5nbJ(@r;evl z4ebxDQ!rfT{jtbm2e^e@O9}-Q9_JIwwDMH{%dsjIVtf`$n<<+?_q zbi6VQDA50~-;{Vo&e&To>!?FEChRe#)BspXOwRE``&%+LaFHhtnU>~J86 zI!qOqYAt+a@sppJL?-?B)|8(BozpKBC{zG>%6~jip~Q%HcWFCL-mFFrkpy#WvH+^0 z$OHTrU|Qv1kLcux2T2jc$-~GhNGp^w${#A;eiEPx@&)g|tUy$9LX_F5rE&(F`T|*9 z$dnAuR?0N%UxP$L{)Z>o!tv(Ey98okJnE8cw1o)6lDsok>eBqrf1IfOMNGt0lzjB) zvB0F``>4h3s@DfL1#S#js~oa!$7k0WZIFGQOd3w;bCAfhFqJJ?i!cFHhW=sllaf@3 zgc^EXfE^W_-xi-O&Z~Ho=KQk`S5AedK0K|f@sdv|w|*=vdn)*oMcbyY!$FXP{|EO1 z>^?2j?R!-t#w=G^A9XfUShw}_x4&t;&rAruag~Giwb3Ch#Xd4uG#$ikGCVe8wix3E zZ~IG;8)&U6lsey~tq6(L1>m;^l$hmOhckdS*k%QN`vHG|BS(}X#YkR%1L^1X|$%RaGO`JsAS`uw4`4#3Am3+r^H~bcd>Y~I}V5OcKP?9J`NniuGJ2ylTj_0Wjw%#woAd#+iYmeOo9FZ+( z!(Ox;TQ^jW9T@3+#BND=_ zkf=TAi1=gD)u{>z6awF0{_U7J9<-!=W6OtoE96qmMl8RjKE|e1qU6X|Wte}lWxly$ zSCF$mX{Xs7+;BH3J`LlChO#%>bZv#RVx}$I@UTJa5^ZQzmzM^WPgCyIRmcxMqT7;9 zn?8i}uoT}WrHGO&6Y%;-4-~q8csCG37)@qbqBA3v&@h-HQ@V}7mJS43>u|hOi@4%< zz)@UZh9^-RWor|lx`i7)a1~H}gzY0<;thzlaEiMzMRXI#F}!F@*az-pQ)VCb?u}lf zp3E(B)udE+iI{z%J;IiTzztIAOqZeSvQ$Sx(%6Pym=Xd^108qB4;Vr_vcQe*3rj0(8Pb*9-o28q|+MuAo5{T47= zqCezpfx+R{7@*x=7&=*l%R`i{^(K6#=GE*;%aB4vL22NahdJ;vT^i+*z=LM3g=V!A z5Myk@I%O6P2Lr~Ne%sa*neQgLN$S;hjn|KEzGOi)(>aEGOmzv#RjjXh1zir|bA(?u z+qU!5(0e^bxro#wYUJIlv{C~PuJf%bKwq3`jz|z54W|3x>Bp3e!LlMZXR%w+7sUP4 z46K)Vixr!9!$KW?R;@hgOjl@H-5e&Pxqbp@NdL`N1o2AX{K-wXvwnsKm`Axax9QAiPN{ z6~9TbXP=fhtZDUoMX1F__9j5~4UP882v4AhV!?CvV(6BH-h6q=p_sW+&BF#Iw`Y#; zIl{Y+A1S`TOCROmPv_84Ji#xBMyx!ng{{^G7PvBPgu*IcQZpPxl^ zij0;`C~k6_Z0XNi4$m!{hv`LeVVwk>!gWBRt=0gWw+ograbztg_z3#K#v)*Q`(j(sa2#b22vTO z=nju=x6xROO*!p>2eWB^%;MRM20ZjA%uG(niueFwrz&!KRr{2y^pmxxe!hs@NF}El zW_`~t?=iy5fO!9qYnnBsOvEV;!`m_>>z_kD_CGyopJ-etj*tn&#m(s5M~5R6CC7QE z*i+u$QBJ6lXxhjgSV~n#<6kn%*=|*qW>#3zSw)hfCfr8~6+e{>o%*0mxYQmJjK+L3M0dekdXDsgvF zhD5liVU14<(w8@Cx(L}C!iBovW^&Q=BQYF>A^SzM&4wwpbL}(V`V?rs;#e4O-!E@k z7PvEbIvTe%1}9b(sVB~kI4$Z>a;TN}c76uV)JitBt9fwVzGnHhq68XggR0i~MURQ^Sm&RnK4?0sYvEB%MMDDQxAD{?js3yW18yTY_;XnLKh<(Or25~-(V!G4KGCq-E%IEeH= zd1c{2q<(v`AH$%#tWXh%dy9QFC^DDkCECI&M=S>LjuSp|!(ec|V%F$djz` z8ty_}7lW*FXS>50q-G459XXroFqN*JJB~A*C=rED?CzTo7>~gan$%o}q4&;e>v)gU zdhRXIT)m6tzaOm{7^D7FeP{0#8oKplps=)GTVp+DdN$K4$;$dT>K;?STk~tX?dZ!4y5pT-t2b^K@WtAeU>kqFG`v~AoFV0D z>-2cI(ux3TOt%PTi=x#K64QK&hIYqbraJ+P)=B)3o}pq z(3nuq-+hl*U;?tvVbzhbT(zGirmEkQ%q{F!e)s-#0sJ*&#M?)BhkdXWXDR;FQ0&RD zZF}mT2RN%wjYD_9`FF@UVj_DFQOftG8)5Z(;FaY3x57z5X@kjpmq8!hu3Pi%g$pgH z5b#OJV#gn+QKH39$s+QKXagpS9^tZ=4M8g^N0X!#nAx>|R4%U}LZ7QV&rfJ`0UslG z{_#)jlh`G;Z>YP9tl${%(CA>w7#xAm~F^yGWu8@K6_NpC9tY&=~yT{aOZN8S{y z)aeFr2K^6AXbEIMzagPQm6M6H-mSrTFQeEFYXK*|mZOj`|IsL~YDDmGUX9QP4|}34 z?JfJ~KhjyLrbE3^wb2K*4@C!Yvm~cw)lG;aM1-VjopWfg?Auy_*FRT%PJD~d57r!q z9SO7r$QpF@7phi%`)l^~e5*uzL6PpK~Z zzjzDmn|*Nx(YBk-Ur1wsJWHi$-#;R>ompFcYWFu#S6V-{dq+dyNuZ-B@wsP5RU)|k z#vY|8JE;!#UgHI-#$$XAonrWbV}c~Mb1F9pUf90BC_+;kD^>_ z-VM-E=$nM;!_F*81YvpkDm4np5E(*I_Uh#!?^mcGsqCT2(VEJ7bLGj0HQ-y5!8y#0 zSQ}8QJSsI*;m7962xjK%vLs^)WW;fY!-3Q*h3~ilqdX{dZ{emUphPn%ZhW~h9-iKy zuiG)sdMa2^+b@sbmWQPOCfqtGwpW_=7!1YwaICjOR2J&^lWj;^zKTeXH)6lj_@faWHstfghi>Fk;5L@_`x*Q9t#k`+<8Fx)v{1(Nz1 zVeoJaF^qRs?0X4&`d={R>-lcQ_Xh=u-CGh>W_V+3Zx<>Y}9~t9Tcrf zL*`O+(TIJobCAEkM_vF%gn-D}8Vm`VHHUS+tg?kk%=F5(@=HaN<}^dz>2SuYLJiyf z7v~_8>isKnNO>ZS<|R$-7K86!dRXXC+@$TM;}2CmOJ6%4Cvi z`@L^k0}-9Z?c6dol`+Tq@cwq-!)Q!O^*RDi87sG?ip90fFxDIH=xsig`q?O~R+!|t z^oBtCOlzJDIU?qMqMjeM1FSc!6ww0MtAM*vR&#-90;+BdR0wb~oy=N;c5xD0n1WbQ zKy%_by`f+9S@mhoXh;KG*QWZ|z!%k^bC)KARLU8T!>G9(5S^3$`%Gwqgy< zMG8QjdZpR^G@>Q?qL138e87kHZ`7N>6Q_%SZyr3`6lWabd&$xo?kwMkff2B|Z5$=U zru_h#`#D2q)zG`^!Y|$;`BL&NiXTe~jZ8Xr`KdqqbMeH>pTusynW$ddTr^Xoml;1i zUY8=@XXD(NZx;8}>mY-ID@rwg>t7K-xhxmSaZ9fi=jjMnGKv&Y1t*aG9rQ|!XYAbB zHw(rOOUKX6?C2qWjGf*kBSL{7Z!9G)=rIuS#z7_Gf8zJot0ii1FGUE|Og8V-|?EQ%7Sz}t*LQifwEu>U=`GiVHKOeV|u2a zImrNYzcrOowJfbUDp?-f1kQx7 zxZy>84pPIa@CYB5XCPezXtGX{3Dyg*(=Q}LC~95bbP1pv@CSy9?cIXq0TWht(4lgw zIuxd3k+Metu-*~sK2w1n$cQ?Y7&kzcRmr3)PaxzbfTq6i_@whdUwv+RUAy50i}~DB zEaq(_sU*L|OAB!Djk?`tf`nw3p?4HVMl^o95^qv!LWoTkE#b<_Kb9KUo=UMO=m5}Z z!~p|Z*YDm`WA9`^t?oul3eCiBg*4gBS5PRrRFvqOf3QmP1XHt3d(hl*jjI>rzk$oT zOfSo-%N=o~nXM#|^I8LbZ(4oVA+e)2kA`aO0^3y#6cA(syPoUKpZ$6ui^|Uwp`B7S zR_vn{ApO%OV#4GhgnZD^c)P4rxLzXtS*`@b5I`ERQ$GSE^!s{T4c%v~j(EmM^&PiH ztPOW073&pO?Zyi`F>=>C*oL0T?Vja;mPa1^#DNP|*}CvcGain!u>F(8;tHA?zMfw@ zw=!M=Q#%Si`cA|M`a*(S+E?5M&*v?u>K*C2`M65eUiLleNn*6dbF%>*qrz}GTz97m zU$L}71I0N0B^=Kl6nNS`6m9x8V_=%M-etD!~9@n)JqPcQZncEm#rpn@juHXEcZAMFo50-1p1NTbJEP7AW|MVGQ52O$Tc zheyPTJGGbj4LfV#kAF)46I9=?zS55a?RNWXtAF1ckHVdb3qdaG=LWw2X1dEeb0FO= zV+BHE!Hxi5OKW%@9Xu-~_r`wI?$RsM|2|(N#dBtzx<^YQ^#_!6QZ61NhT(J~^tt0f z@UnNw=WJ#SA^54GIzMAc0{>v7TudO^0nzLLGZB^h`T$G?>xl%+TszQ968BB4pD6=- zM&MCv&0Jc-4(V^wckf<5f9}aXBljbgZvKg-COvB*IeRa{OiK=3PmInUGw>@cIR=R@ zbZ(omB&quZP1Xn3ktAsPIUj?<%`h*_Ku=C4kR2$Y+kXcgk^ed9uvE*o{yFF<5+wGm z;|+$}xP8@eU&ko~&fe_%y=Uw)9-CYx`yZ18S$_bt#yB#1p@nI1L;3HP)J8&A_}r)L zz@iQR0gkAwKk04_5@(b)kel4W_o6;WF{cKY5tn)fgsBD-qUlkYR5ck@{1QZuf6_Pw zny6qpwjFznT57h8Tl`*m^G?<5p+P8!3nfV6=LklctCHgrAOvxeFAaPn9OfBJa4NnU zOhStsY)%M=(VX|e=18=qXmp|W%J@}8feXV6=U9CxLf1WT)t$PY+mlK%Ky9Hm+P5v? zE~?H2Dpi$sT@@uf9JsFcxO6~8e^wf@pCXD^WG?-cz}a#e76NmJIHzX@W)uuHZ`9I z36|LcUI}RTbxaS~$}F|e@h!^h)k)?0ixb_c2-B3y(u?7@ZG{V>&rQJ~Drr{&yKc}c^;Y6?N;m0KUO**0P7b1KcQmYCDn7E1+w?%)?5Vw) zF7UGFv|%{x3y8K|;ou4h%%4>p>vh>Gu&$@f!C^RA0^;OULM?pD{o92_0kYtXj?S`L zn!ez=`CEfxDkuJ%{YPhx0av$_t0-MkIP~a;wTqq&d5q-E;Nrd8oBII#1a!M{tO(>M zGNDruhQ^Hh(i1sNy0OOQAgW5a9$kPiJ(@5n8zE2NieZ%{2*dQxV$D2bN|Ct4GSRJGxc6#h${Ds{0PzF+J?#vJ=Tw{OCL8Vpk+zUsU6UrGwp#YY>;Z%0< zY2_i~63W#;_G&9Rkfz~9c`bwH4sS5z{43qQJ}C2|Y+X>bme*`Dh6gdMqa4<54JtYA zJBd>moHU@Un<$S_i5(ZXS%N9RBd{eG2;mpmb@r2V8ejg7c%$0vE&D@LYw1JP9dXu) zq^Tv{IP&d{>(@B4llqPkU@2xa_lZ{kF$xftvhXL>aRRM&vCSa%P0)C)v=mz=mrvr_ zjsx#3mR%EjYcJlZTuvwp6sHTkwM}Wn!Kiv~{E=oB<*BZSOR2_QAe3<*EX&h~=vvxN zik%m}(qtb$UaqQmPSle4>GW!jS(v~zlnwCqZ@gF-UGR63vwaP`s)uEqMwMURw$X0b= z@%zML>fFGs{dG@7-X#lpSSJt z-XAPSEpOej8WXqA@rJ|)!wa(nMT|Dg+=c)L*K4yt#X@!-6RbqkZQl z4GYP)J`4`U=_hE}?}wA>eF}1K7K?=F+#-iaVI7+OKO$C8%%Na8^mza04H>lL(A3SK zt0Ya3AVL1`t*q!9v-&~!A3>Xq?Y4|%Oksjpb(X)7IN6eapYS|)c9ImRj>T%um6n#9 zO~^3zsaqOVgfF4k63Zat**UeY-^_pFiHHb);$_w+a~B9jtu;M0jpYow=8h!RFJaUc zfFL>G5&L8Ep31D^x>>y*LmjzdX!59u@iZ9)#;RKfW7ZHtoZwDYXr46uw2*jw+m)0h ztSOc`tSixJMo2A(D@RsrWLH{@?=5W-=wDWwSXL}A>W(eM7O0QZO;;kv(sti)&9Z$W z?#R}qg1d1~nkpMZ)I?cygFl3!h71P&2uCsVXA7C19 z&uxZS}n!F{YInF(v;xwI|Fu$nsX};j^1pgl2`S_14Pr+PLAcI3FzzaywVHAE4Bnur; zr+nQ%fN+_K63B>tShoz@-SkQwAy+=w8jqK5f!%Mv5654bX?Kw-AkvV*7EJv4vyd zW#WGtdRHsmh7|MD`5j~zAnv7L*9B;4Okm4?SpVg2nFb9=bFsD0Tw?vE5_n^PN727J zh^Kh@WQKv!AN0Ty3;gIUK7kWHx+Rbf2M=i0tdn$RkY91Mf&WyAYUU{2j2%dCgN>)b zNe9WK*|CY9KVW!@wSJ{hk_>)oAP8cWmZg67DcqSK3EnHcZ(=Pt)Zpdeov35_%;bfF z5cKJwKor5tR_>tJ(Up4JKoYlqE6k3fE*Ot6LAC08{<_j%@!xr1_p#}!fzN|?jh88X#6MIg@e^tX^Dlv#U zdu7n%5zdsk&R+7COHESKFQapOmcf*w?Y*RFGmT_g%gJNkkcc$#Gh}dlo7)!6T76+I zlrx66RzzHpG)#HyDx7!XG=RpSP<)hE5 zvuEPvPS^?DpLtD~jj@KP_z1P9yCEOaJ6H_8zV;db61jvZmB^`sFy=_TtiA`;Ph+y) zjmM+E)a+H`XExI&&}5JpQCpH=C11)n=N)>WwU z8LRu-l%YsPe;%T*mS4})I1x7=mTqWqs3|)lIwLKmG@mqrGRd@EA#opeXir{}^c7QS zQ+_DQ6<7jlFV}OHDgsx=IxhFLs%uP1^hxHTivt#`%K%Yi$1B29vxQYas%uP~XGsG{ ziJ1avd;SqZq-%GZ4yraIQBANFlA&Ci^|)E1=cVV9Dwy7%z@W5F<^!*mEAhM&ZpQ7W zWJtY2T3!kQ_WoodGPm_()NCG#V zkS5sRpizZDC*+B2i!`gS;c|GML|J)g?{DuK8EN@9%M@B}LHhD1`N*ouh*v6;#qZUN%% z^M&k!bFRQ{ilvQFy64i^j2mwDszPVm%d(0$;W11hT@&lNybC zO_Lv2l<(6AYc0%#a%b3f7abGx2IA$RoTy!Q>mYF=^{ZlXcr?i%CxO~pz|?|VO@-%w z>|q^4_Dm7Y$>0X{)gTr)t1#ggJgJbY|D>e^_+?9&PLS*H7E5GRt&(lb2P>8b<}F4A(_i`9699CK@d2%v%e8M83ys|C+M3)^WX2;{e%fv?DSg zjIw<8{S-m-j*2{nn7qDQE?|tC0uUk>PiDhNvV7yXh7T`X&`>dlN+327p+8Nk zwD_$qkgx3~Er~)4El{7|4dQ28FBS9_zleRgkPX*_wnYzhG9D_ac``-;=7gp&!i^q% z8OD%RRlMZB4q`EkEtkp+wc(ytOZK||C(c>TF|*d$s50PA*$GaR1|J(h%ue zr{@T1W1JEJ>l(AUN56H0D3%AE7g*q;I91k>5Z4Zq+&;2c?Qp-{5XR1?DhJA1d8J=L zWr@=qw{tT+)As?2PU%}uBsonhJ84gPBY!@S$1mmlTo>E-0=F(oZbh@eQyj`GSJ&eL zEGOx;izV4~2np9*0gI|ZW&?vrRL=Re5wC(vRpi?WWw`w%n<%M{nY=sIC~^^;t#7yN(aL!+103v zRFi~GHQ$r*V5sw;gXN7Wbka1C4EO+bj5bNv#<)A~PX$~9eX976|MfL0q?6InNKA8d z)Plf?5p9QBGilwL%L3HPz>nfNb(wsbN+(nRnjkb1VKBd18Uj4nh=bG8q@NRlE*K4r z6k0l*UcLd48h{ko<^<$n$*}k%chh1x6r6o)>Se!{-O1ZpS z^~PVzKJ5sxPjB><^Vv42`h$6PFC0gWAEXi6#pgC-Rw;WXv!>mqTkQ3bYsI`4HZ6*e zZrSeSQz+yXS4Y@&h~9|2)+9|i;Z|r>|2Qp&*%wG{e(~23oFy^w-o^%lsK8Dpe9=Fp-`W(3 zxF=2Fo&s?`ra;a4Izhns`93`zKPF`XJEk%;&zrt}&E8j>y3@qG%~205dmmOmGX>aw zzE6UbXyE#3Y0a+ktZ!ixkK+Uyu?OWi1$_0hX!*9S6`^Ha3~|Gp2uYM6T0jCYz{*R_!EaML+qH&-hTW{rhyFK4-kC$fPZvOJX z$mP0y-}w1`x4sUp?Dh5*WeCy1$(?*KznvdHKTRM}RPc>yN|&a>$%U1sX0`w5^N9tI znd_A04W-A=*l)1^yHz!i&?YSpM>zUCzt;sk&sv9#JGbJTZfCCgg}j^j8Bh-03~4?0PjDbKBr zSf;X@xTH&Vl0<~#;{w}{uU?%%VmK||h+w{FSdNV6T=7Q%T7x99tC^BR%LGn~kwV%G zkktdP@nkpQuq;l%s9SOu#RRk+U1jyxB7a|AR0^#$1+rl@J_tK7$yppczo4X;R{DRl z%`l7k{*!IiKzIhF6d^bU>Cyuv3`&p;vFu*e&s8faTdWu@{YUGtP&Eony-h_jE!hNL zRy%=`0;@Lg4%VsU5Tqh}mS{tr#ba_B6-(vmbWPC|nnRO=p8w$^%>95qdwp%WnX|cr zrFyzr0dedVt*L6z+uz#@x_Wwqp(<5s&`)ya07<0IpW zc3+F*uVJfiNoHOrghUP3CFEH!cx7lXB>|c%Of03bVt?zdsJa!8?g=fB3%$Wb?_}aj zF`q3utXi$sL8mMei%s*K4yXvkO`dHob&-UB!zMaD+|FY^UObMk%syGZUKd`2;FlqL zx9&IX9*pVMoj2+pNdA-O5$oZ{$!OMNT3ud;b2v!UWU{&~+Qte`ZaTLjn-|e{^#H zUxOwteY9#fMH7zo5Sr*Zfur@;Z26e$;2JiUA#j^9Q-*^C2FVx8_wV-FWb3t4Q>Llk zd+mg`#|SmLh!Z95gXXFSt0+xPjfc=uC!=2dX7p@bFBZ<;a&vqAIJv*lt`kn^a%!*X zI=6hk(&QiB&f>293Wq;_+PS4w&khK{m@soSevh?mOp3&v@n`}wdY48+p-7u~V%fp0A?bPW|IQS%c z@^<6ITgiO5SXl6VAl=AKq&s=J;F&sFHDtzm8`SZ9X}AvI1v__&0*1aZa_nI!@y7GA zb@sbGJB#bu*fE<-2s3(4%RX~5ZErp|Xy(n0J=;2(#D03|<D&SMMA%h{=x+c%k}-@cMNdGZ|u*2M!|K^uOZcpv?qJz$o< za*W)(aKLb&ko@d36;pDBV-PgYb*u%%4+4TZCjQV>+^yO)tTm7-+V;`|^y1rf$(GOw zGm8L}P4~E_1{9?F38WiZtLy!TUX;dC-w$nK=zB$c|2!pCeGgBtoUHRBQ|ePuZnL4r zVl?K=?ks29ITu$hcx`0o)aXu?0>fiY_r_J4g2Z%+Y~t#Z)xQ<(*469dB(enLNuZ{- zGdi9l$^9gzN-h=ug;+}R_(o!(F`y5-xAa>B>gkRjOj?r44ojSV9tmdtK9|J|WywaU zVWT(~Y3U}{Ns82y z^brC0?S$`4gd?#5t>Qb*y5ZnGtKQV0#`@Qr@TGj594R_nJTJ2KmMU=DaO=@uZ7S#f zN0Z7S>If7MnwzEmTAT-+l&dDcSuCC*fJQ&!5uKKnpc1;Hwg~qaibHXO4mpmDguifa zf@i)Yz2v&C(#soUZ)P36axsTA9tjglj;3rH^MkS&`w$LUk?fK-IiK~?H`0?E8o!>P ze=-*#AOI#^jKX=P{@RT(lIZ>X4*Uc?CZ=pUr+|?XG*@@pB~QKY(P@RB8z9nGU}A(Y z)~~KE+-VUxM95%g2D8Xi2ch0%PHo764ikxLiT3;FuK=A8>LApJj`XOgCf=6z0qVrZ zS5*O?V5l@;k;5cq1;@P|hg}j<6Rlg~?@O0?iDP>QlXHHT7KE1dXXH;r!86H)#S!Bn zvSYQdV|$&oJvwt&p%3qoUC8wN9@T$pBO0`1ox#oGLnE`=fCD-JOQ1H(L^>4&;q3uD z3c%xfsh5zFk=$O@*#KtuB1LV+Ekr|@&LL>K6x<7eA))>srp_rkv|#Dhv6CI!wr$(C ztsUF8ZQHhO+ctLWoAYbj@x82ve(cp zVWrq#R;gudgBlpKnxRU~^v;ayxMI)5_r6fQgq)W1^Ij&OOY@rWl-#RNeza%c$#x-t zM(p;M{jQ`+ccEuAG*8BIssm6mDp(?&7ej{eZ42(}hQ{LaV}7&j2sK=1UG(8-VQt%O zTO}aU4;`P6%P(G{B17u)#N)Gfpt7zL4LJT1xy+3bi;Auh3y@o)d;){5*r2G{W8$z& zAX1Bp*$!owR0{PS{DL4MWgok@e5;bI2$Q4LR*48Ay$uqyn6nCJ%FLVvB`Q`qyxG=< zWEOqSgv?$qj=HO$WgW{;+RCxh5zX=67Jmn{Guuc^%Kfp13C%O&r+keYoNoycO5u}J zS*jTWvtm;v)CDTa;WMj08P~fd0-nNWoXN_ED zSO@UAbe~Tj;}Ll-N*uCPIph1ncO^&X*hxw+d_la|`!?u}i^E`yr1@`!TB=m&4w%&Wud zapuMnhjX9sRJUg=thz`B%Y|m5fKrO8vfN;c&M|`a^rp-un5Gjo8EGm2h5L>2i*8Dc z&#M-pBqPlpD=o>aDFbc=2XwMpZn&Vk>M9b55wdF$D}5AV_zdjQKD_!8Sx=XgZ^o_% z>rxagY^D%U#9SqvmVuVhvsg8*onTBr|5BOxFF~*ge>yqxBPM*G5HU!2jg4M!WD=&e zLM%%Q{7Me+*-R>0JQ-sUxQpJ+V9@H0!Eu32F~dxntLths z(6``MyuZ3ICnkLStBs1xP$>ifyUFVz>wZh6%RIu5yk+IH&G<1mR|uY9DrNIT_;>dY z$v|!(CFHYha(+eJ2F0I^URA#e#P!gG<9Sop%S^9ZvibRCC12rx@mD*vwHZtdA%pGa z6YJJ1kDd$%sD}Oze5mg*|3_6hnuIr%j-YgAoV7AJQm2%5&KT+h*aVn>5{OLxP2Np` zit7y}zkz^3KUYA9-!EnIXu1jiI*ca<=c~6YNP*?0l5Hno&@Uns-XfL6AUCcX3{z>NA2(9Ev9?HxJvCBP$KAI;}rDYQ6w5{+X z6QvjEF5b$u2|`?d1FT0lt^pr{8$rsI$UetZV~UVv1)vuu^VjO5*;5@GE5dNTD?2?V~LFzSqr+oalq^pXC-qL}*_4JP)R^!2Mcvng!T z!ANC>IZX+hFzTr`N%hsi6JDTmIGt+45THi9i=jz@NCS|PLLuGXMHLLU!llCS{?vX3 z9bFPZ-O!6cb)x|^ogHYG11&mvKQ_;nuM*Uxcj7yc@C*sE@J_uTqx120f53am(XD4z zo*t#mJ)n)6XjESfJKdYtlFSuWDAIlR$hnT^yeI$ZoIDr!%FZd1g7ORphx6y~ooHr? z<&q_d^DUZ7|CgPxo6VPvmqSsP|5J2Lh6?}m`c{@c!F}@0dV9Mcq|wvqX_&#<>|~xV zq{~FVWdbm5VZZLd4&lIQ4b0t;3FnrWn^)jIs>4S^#BlQ-tu&>rluEb+#&1nO&kEao ztd;!MTVIlZTO9QV(q;{;SB$48ptODntl>|}nEjpcxU?i%M(cwdbhzX@2OLR`*a zdJVO2@Rs=7jmz1vU1yzZ?k>dIL(&E8At#oa%Wy}`u;f%tPCe^r6mv#>7Wt&+Iu|9- z`=4`{%(!@BrIA#TH+N~{7H?v2F;wBdlOIpCI!stdMo7ImGy;<9^DdR4CmD<%RRG{c zGsSIbC4-A^1e>)z8r|=jCmJZ-8L_jEAH=uxt7OW_&y_~__Ji+k+KJ4p%>Ikrb)}g9 zHjVzJ;pBe*&g;5-IAAz6@49UjRZ#(wR2y@$!2h+X3qxQD;%`PCeT&KZP?_G<$-dmK zhURITa#kNRP~g!n-s!9o>jL70y8rR*?p=HM=PmEz+z$874MPl z_?zz>PIp{VWbdk9mIIW27Sx<*P2gUFf}1j;@wrRqmw%HF$i zzRX~-Jwm(F%xXU3{P(cC>N3MC<*dX4*ETSFmbe>?>vK+4NY1qnA%NT~0L|omQ_lGX zuEw8`Hl2!AT^%&h>Pda2I|K}x-k4io;6q%#{By{1O}!^^CAwHW-QF=3k~-7>XHF9L zL#;3bT@Z!{WsqKWBt7N!%cJ+(2IOH2w?XIVL^0;s=LUL!t8G#F_MtUF}x7u?9G8`dr&f_^e+5H{@jtjL zP^Kq0aQ7@uX8+_Rc{DWlcs9Fw3kg*o3I|L+%011G3J5~b>E3gcIDFas)VlKbtMdwi zcm$y_`#+vu%p3XTGl=MK_Os^nO9)1ef=Q4N@_Srg4^2`E@0kBqQF9g9c-f7un2!)FehHBp8YXPu< z`9HXy+yW=r@Amfl2TlE^0W$Ly6I1by14cqdLVjR$ApQ^ZG0)-`N@wB96nIDFGonts zn>xZrl>czh9rhwe^J9%s3-HZNfan0X>MfN$H8nuNX-qv{d<%ds4%Se;w8@We zM2t&KfTNL%E6ye}XRJ-k#(J#~>(fxK=hjm`8hyMgJ%XQD_~fuM^Ev zT$0w6q&LUU%X0=#D=Bo=A(K%J2I@0Yv3~AUMADqYh1#STV%Z%=Pt>XTxI>MH|7e;_ zF7xfoAsgm2W9p4JO_Mcq#u*a*J&R-nO2~8{*kK;zpIUSU*ANW+9EC;a7njR{QzWK~ z9nXnG5ta)7q?a;R_!Km=6hB5_qj*H0JlE&D76ma85|##H8bAbM&t>8GQAJgGx-vXE zJeekJctb&m?@dD!{!?enb0wCj2gf*r2ei>XSfuvX;-G?q3))IEphShP-j1lnj3gRU zqe<7wo+-6K8T@I)4fuUHR}xL3NkV*nV|}DRxbcdCS&a&lgUVEsdzbh^F73nzck^ZD zP$j%myuX?KDC}6%K_XiFPbPobFc%js;}T)Xbybi@4AIhoP8cC7AgNcjAyp)|CyL*p zAPN7>rUv9T00smNaKOJpD1Y`OgdL)(UCG2vlyNi(>+|z)neqSc2FfJyg1-6B6|}BB zJ4MiHV|gIxb3jD7G0|=qR0-S zM7(DBT{58iZIimKFwAWFn`kQdhHHdU&3ACL4+%qqP{SiGWz{i1S(==OeS^sHSCllEf3@_GA$}0_b(%DP6shD*{}(C9z5C3e^kH4vBAPVAuVKsBn-JT@4`c0Hv~vg_i`EUPF&(eYF0RG~SPSk|bv@E|d zVS;t`U%Zm?f3z2TMHkgxbT?C|JS@@(Mo=ZPjH`i4utS>FF)nqCdYy>O&yh3vGIc62 zMf$YowRps77;ctuOnWi0v@cIXoJYp+q4ww!HLUzM1UI;2iz1!MbOD>`9PV({8) zdZ{ekq<_v~D6DOT%8q6)V7`+Jo>`%Jr6s{vv$eBG5K*g{t9l|1L*eXTcPQ_Nsl8>? zOYM4-+l8lcG4jAxV?La3hwf=+Exb$yRS_C+@c71ykS!O=u2$eWE3({|C2WWF-Y)7i zl=>VG-Z*&3`<9YjBpCB^*KxD^PM|Qwb~@>K5py8UmJ+{hzb(G)r@IONNLZ%0%k%i~ zmI#J(#kTiOe+;J4=^#cT@u9n*`HH1UX6<|`Ag51o$(6JZ*&`}hTrsGiA$k95m^hU6 z-XwH&BIXU3Y+#ty1;9O}AD6}v7Zx+Ro zMeatFPm2Sf!;v{mTM2t`+Z~neS#(ZYU@KKDy9@4Is^t7URu^WbEw`QSgMsY9*@3qK zZ=qZ%;*s2+pw$U)bF+WhCz%J%;zf~2pC7cNC$W%+gpu???}6TRnBcyXo3J+)1_Nm- z+`^M%Nm+DTCJk3)s^mt>r~z{m#FPU4^<(SH^m8Mcrnm2EufMG;slcl zGT7g}ZkTmw+rbQVQ9`S!lR{(C?7YE)Qal9SdzElP*EuDB)* zam>k#D>Tbi&FcuTM*a&(OEi6YcJw>GCxzN~WWE6T9JQaz_ac6#CP2w!JXU}qT*F(f zD~&sm`bhSF)v7Bm*d!OUm44==3Uc3!l`Ar+Wytj2_BVIpP58r~p<2U{6Nd6o{M|}f zK(2mVec7*#7{7B3*toVa2P(KrCU{|9F;FR}4a~Wj7U#w8!#^E0 zK0Z!!0Q-&(?3VX2I35c+I3!0DhDMCV35xG070FlHV&_ndhJC8`ECviRtu6im0+r(A zR-&?J=LQzYjLoYr%eIh~?{J5JXJ)u8$jP(z2Mjawy?UqSsjJuj3`enSh_62clOI(T5< zA!77_WeK4*bvyi2I$~y>L6`9ql?-jZoRSP(_7?H*LDf=P@UdMYVRiLhogtjfq5vtqc^>;;}m%u zftviltWblu`x7G4lP^`_ckKGJ;Gvgz>!Zvydf9(s4OF>!Ke3^V$5iIGEo(i2C%fj_ zKB{|L-`sgK?fFjzBOil=baOaIQx8{zlVrTBndi)eG*_#RjFBMat#=>qJ>TK8j6$H= z_#O<`;qq0N!8G!Q_j4qPHI#_76Gmi)%Vi*G2lIQ%jIWVpx|zq`mc`X+V42+C$^?<# zl!gX`^@J{O*}YDrbvKYh4m49c!a%R;zh{Pg;@;?k{6~sl0M*#=_kyn@-dMT6uX-4T zG?1w5(1pDAnc7)FcQhKW=JcD!VpF0W$C4CH+Yv5ck98$l)0c;u4Xq2EP|vV`FQYwN z>UDo#^mv+?EY9&#kP5h?Who}K-DMo>%FF{zI~3!C!@TVzD-l$cFJX6A@>Ggk789pqW>+ zKe^FYsX<|duCpO!l_EOUV9Y|?HA{fUsl>B7L`F_fER;`XSc3 zPV&p>X~ZuvVZWnGXL15?>TC0%YJ=QT-XrY>>HM>dYis8M$_wQ0zGYCt3 z! z+<8`IwqnJETS@kS>>2L8Tb5U4$LNaW4k1&{|$EDS7CoqsU0hNm5u_he@qI z#-hmh-o@56*S4>+2CpFGhfxq>?`aBwi3$kKAmoQkU2{APgTR=z>ac`Xs&rUOCugQF z3+bhk%~`!Gh#Fjokn!kM>(i}3SVIi!)TgM{!5UlbbgXW8TG8ue<%c}@0w5@zRbUqJW3t7x{qntM`<}!nqAVZoM%3$$4^fA3< zcM#dH%@jr&SGz?t z2J;VRQ(+5P2CMzj$z<}v7+i$30FO2Y-|Ap8P2;_tC+*%CZVmPq1a8fKfV$TXg>n(R zaUdQSnLKLAfYX$SL^?@YWWIafr3IFW+Aumul0LH{y8J+C3nxu#W=j6v;uI!82R%S? z)iMZwYRi;k_|`TE%4|HBK4cw;IwMR92t>0?qGq|WAJOf|~ek!69o zYut$wFMZK=IhOU7Qg)R?ujvZ4LR01xtc%U~rxr>r&vy7Xo_7kK%;2fgPy*}MXb%@b zJv>?fti4CbrGL*w4cnVp!Xce$2AU*`J~nxluG+JBt3edF_Pb@Qr@KM`XLn&G1v4-A zNE)&I`3h2%&ojNXO*~K8$ImmK;17te=7fKUNJ}8VOSqQ0q4++G6ST`z&-a~vR+RtJ zRE}itJTN4ishNVIA+z@Y4@VST+(W-Om7_Ei!}ueawEa-*^oPk`a;Krzz3-+^7gh}6$!F7cGwD7uRcKA> zSFJQhhNDPmQt!83dd}UKV$YYtt&G_=pz|iNA-*<3CM%kQ-+1F-B$NNdG~sKUD}6uW zsbw|W>bee}xrvcD>|9E;YMHH)|I)c#V`IWynriT#iu7ESyKRwttS_y+NeJ1<9;XUb z>&ETN2$C5Yj(z611XOIZ4yA819c{?8Nqg*$ds31qN7T7k8$4v5sB`J3EEJP;C^?Dd zZ~<%+`_<%!tnkQiwC5J9rfvC*<4pqdFqoNvE;0GR@q_1K!JVm3jrg%&a)~Jr+VI0$ z>yS+w79i=@QJt^?;rPcW35^beH5sY^g2`7r_9eV|f|2~wJWK3x{+B2EzkpT7ydJpQ zQ*}2lJ_?lQ8(Z-dr7W*B$?n&SuPpFUaa1oYOF*NSGiGSmd6=cjBnWwv;_Vd(v4_0HCRT7FMIK_S89 zNC2YW7NnCbBXAUp=nvMM9<2!igg*`_7nBR~75SP%U7@~Mf6PTzLR?0YfRJJ~ybcvL zrssFBHoiSSyeeE3Ha2`vP3|3_K&w$(9l&o8o(AIiwl?{vC-sjANR&SzP$GT`)DKj> z@{nRFyi~FVWwICO#GqE4_fdO~ABEeoHEj4(VJlOp%0nOrxZ_MF2U@iY+QAZkZ{T(R zvkep>@5Ot4pq5e!e`sxm~RHV;C~nZw975x`UUTWWUge5$!fP#Ill_dcs6>@3DmHClPfP3%-bi zrhTjMiubcOkAjMM6311bMCS{6XC_G<2`&+-uNV}fcGZEj;KiowyD~fcES|!P3@RXY ziFKJtJBuh;XD`dp%$s$P`?0(M@w3SSZ>e4Jr(~~qV^SLl)C6*!-bvoYR@U`H z18-Rkvwtd!Mv~iXd%`>{p$jeLd1Kdx^$1NqKjei1P zJ2zd*FzV_c%b|S#KGd)1bN|J)wOc(&Ltr28$3fB*`IguTIASCD{e3@#95%2Qfk~1O ziY&hk{bA{(^c0y@lOZ4DY+E5Q0{H1>>;_>A+wHjjp$b&4(^y~&JvsnEJdZ^~1dV15@*ET8FDJE?*Ar)4bF%Q8%Rdj*p4uN*BgC;^4 zB6Lyq>M))(^=EzUlTc{rA~3~3GdooP52*i;2Fw#2hb&kP(Hu~VUC*OmKZF$r%pYP)GM1yR_e| z-3r$}4{`znh=lhX@7tqKrm=s}1W+Bi4ppC~Pt&g%zzT3BK+TH4Ya$$?aTy%eJ1+_q zXp%h~IHx~+p4SBhE=HDE&M52^K$&t6ff9uqI2kILQ*vj7{-qxQTnroA-frlBKWgpm z=vFE6gZA0`)3(i$oLtmwft`DT=wgM?lzm&wC*C(vJLonVN}=j)sp#rx&= z$xmZQ3NHMndT+lOKzfdkWR7g@%Vz8N8;+i1%<4h>>-N@0z3i8;t34G8x@7~fX5l0{~A`66(vB;+&m~83{nbtC`LW`3amD(BV7K zJJ2`%*WiS++<@#4S|*Sx8`y0E*4UgVTG;bD^8SZCFx7T{5t;&=@?bZ=*3v()oCRQ@ z06+kzW}U!J0Gt5X;p#dD3#f*vn3+LsdypXmptGRl$*AFv@JGWus9Q$<7I4Vd{sr_k z2GIKudD&K0_yXbL1B`qQEKsLQ@Pfy6ga?*yQjEIpS+~L+KBa(GK|N))niGs#BQyi!rt>f4!yIviNCYQ zHHIgEIcAubPgn8Urh3nwit^*s5w_30-NO%qD|sr)L7ShQl9)z9S(djo@m3w~QCi4_ znW$ws z{v*v4Cu5PC?{eu8c^X{s9xxk=5GJc2KP<;4#dyUQk1V|f7~ z9T&7{!0~^^s(ovqJHWU8FMXbSJTBJ#WDsolTYk|NX|nX$`kVp{pP8~AYJ56>TfJK! zwN(1CiAy%;G}IEE(bt=zFmrJdKO&Xi$xo;L@kf^SRATCclmFu--tH}88=nC;bR~EO zN!YWn%Lo$!#7@VLd95Sq^WE!>`7Di=&om+LDJ+%+E3y-ls*D~JpXF;)W_+KtdDIa4 zCbfs(ghWq@Cav5N7mix?U|wjgv+85Z9}}Xm!-t25fMA9N!9263lx7AXZrL^c)e+%e z5(ZS!CmN6nLG%9?)Wm=Yb{PB6L3AblkTjI@0u=@}{Tl(zpjMT`km{5y$;dI@AUr=6 zz*UCZ>#nzGEVg*)svz{0MmeEe2%aov^zY>jYV&pB1Qh zqIPNS&!!?rfqYcgC)WM}V^ySUa2U&i#CBu>ya})^d)e|L0F53e7+m>lkipe#=tEK* zSX?=-(qn);!Gpc(&w@`~*~fFRYk!7)≫Y*sYyzW7gp%n((E)6V0<-*4KM39o9(H z><3W31o`n~;(caiHfaSup5f6sxl#%2E?Zlm9cl32|{4|2D( zW(-7_sDpj(GR#MvzDJpkNZIq;oUxt8e{+7dhVNFHIjq!?k3+Y+**2jL@2;1NRe(9` zHs&r~)hW@Qhj`nph-XSr9<{hgPa+@VU%a)gZ+aGuOMCH1h_lV@UEND8MGEryx`fQ# zlyqucip?h(sH0dVn65u{ywz~OWiQ&M0odZrDauMy#E0q0)!klqxoc^I_fU=yRKJn; zG#fhRIc>Gy($`2JC&Jn)6|2pU-=USt{sE@hGD(7rbtGjbEX`Xs)hJ8 z6SyqJ$fKMWusL88faXYG3h0SDFVXNrRG+nAc(zakOMOI#C*QPy z*%jlO$szFrjCG1g*76@R1w-EVBf98aJ&Tb|xzCV1i_g!aU1*(mEqP9JRW37kn9`Gt zt>T-Eo$4jR@h3#qLamxhpnI7dR$>xWQ zdmR7{(sGff+4dB7fv^tS3T>?UWj5gVUGK4B$za>R-Q$X~IG&sutuPup*Ac(12XtnC zguk^x^E{3xdR2`&S_+VnlzTU^>JZKCsys|+`r_8Ubv&(em7XM~7p)IY+$^(TV;=4o z%zX>*X+@R%ar5vokI)^u&^cy;X}L3?e|7L$RI0{|*f~~H?xH0tjX&Nb(R4ZO zKo18PN>}SY-uG8}-y+X1r)EPn(CpEWvspIb{GfML%w zQ)|DK`DY+8#cka3xJEb`89+FJSWRob`->k`Y=tp~2^w;YP$JcTveF+mVnaY7p%DdD zDUE4`04>laKseE$wPL9d&Tp|8Y#f;tKgUNx9p(SEsKjGc3XKVRB0&?4M8nkMUn2d5 zp8j@A>#VVya1oFZnlmyaNkgG?6GvnjjIq-Fqp#0w>|H;{J4=aBN!&@}zG(>D>5xl~ zC{6{lQggcuzRge565K+?9G={+iJ(U{FT=)N<9q84HLKTF7gm;;o!7$J<5>xB_GNu^ zby5Kdwa%gxTWl!|*LEt=eW|fpq`hdHyOr^4W^ky{V;+scj&R{|QN93B&dv*Gp}IoW zmN30HHSJ4ax0F3E6I`^7>$E$NwLf-nd+}v}tAkSquL52%1G^juXD1EK zcGD@_nP_SVTiXo{c~eaq3`NrZsUBB-wKno4d5o&ay(E0f&|8PDHpXu1NJZDbFZ=K)Ix7LK=M}#3 zX)c9{(gcBjTw+}}=Yt*ArrhzMf4_l@L~_9NppymZev?yLzkTN1m9M$ieW|U~>96*( z5g<`~ab{?p(Q%wP%jZcns1v+?78!G5^1u_b76fYB0lxi@>TZ>;H_e~ka9P-d?DN$m zF1oXBDhKQC-usjOhUjrCmk9(F{kdH?$FNlFP3M41YM!XaHW}WQD|N5ibxuk&z1B|r zO;_8{Y}UJ=cEoeIoKmj9q4ELavoxCFxHM}-8JbbtdYQs4iY6HN`shmYA=R46y5jn1 zMyu&gK6xLpu9>sQR&?o{ZmioWW>c%#PNuyR4Ex4M0t*C)RFkc4*CcO-jv{duSGB7P zqO+6ZC7IJBnZZ9JYt1cW5dreX*9*u@l;ehe8=3>kS`rqsP6LGruC=nT5R){E zM>c?9wtV^c3F>TjFzl3v7Ginmn{NpvrAkTC-`IxiE3F)KQ4S=z<3 zVd#mPfO-kVI0#jIuEe0E^BiR(;s9qjw==G);HV)RJeWe0 z?pCq2GnNR(Die8E_gJ;V4Cb>AR=UX`MiAakR-y?s@*jIFvDmfoy|*Z zWyp}S7!vv2O8VR&<{n|^&=m=V4_BoXgPu&uG2qi|(n?@XLbm>CbC2R{FY;;|-7#MH zMQgs7yRXZ@>yHRzmUOX5u7Odre9g(&!5-3i4PyIN5|5pv#k<6@sl-6SDt(jV&F;72 zG&+i!fC8a5)9Y-&N7`8EEghI$gp0j@9p}UqgK89c@eyyP2CE^LRtl2+M`{hfZ6X1+ z?AEpwk55GaUkIstb=0vSQOiii=h*zJG*O=w7vJCOC`*M)ng3p?r+0U#5EY~xX3_q@F8FKU1rgmb2Fua2CSa;UcK0DB z*b9{x$9qjK=ajjuwmv}JQ^f8~&LlZCy=ttv9mtLBnSz-Gee|g96zt%QT_A9_B}$Kw z>afZ+W;pbwVVc={pgxp6C_5I0whNtlmH84b^o-#(#|V+49I37U;xsS*au|1DRW|vML-_Y zdjlb&|01C^Npb0~$2kn!?n)ib7$oZ+{=(l2KSz`uT{F8eS)bk+c zK+G9QGmZ#F>eBU@ra_JeNYQh}pI<{H=Hx&w?f;1wgk+N0 zuE9h@Ie^%_laW~YCpa9KhCdzJdETlifr`T6WTd&HnDjTo&X_w=FB2J;#d*??x!p(H zJ}i$RR`0ueA93W&tP_w&h-;>y%*Wy?6b)VhQZE9e47iLcyYPV|hK6JVq*h~P2spE) z{MQRmZk`jeQ}nI%!(Flfgp!!O%@Y^x&PY6}iPDLS0T@hWIHId;*DKpTjpHT*%cQ%ys-78WLy2Fn*O5-;3;LoLkZm zGFD4Ou=DUnYiUBNG=C>9invLl86=2Zo^2n|IGP%pHV=>xdq9G)EY zD*h@;K*f+E5Zfe_NS9DMCK8v)K9sv|_P#>u$t|BQz?zH!8cX543sC*IguT)rBLjbl z)EUu1xree?S;@o<&zR@lKt&}_Ks3GE9L5ZNnrbxd?9*Tu@L`Knmi+oH;l|>>eav;^ z%*k+#U0vg4wv8>~NrtD`C>Et#RH_)1{Ut7-4kSk;h{|oVNFEISAca@| zRd9BsNuQx3wFuhrj|W^Y9|1bJiVk8%qm8GG0}*v!9@PL?=?=Dfqvo7~BX$Je5&DUX z!%=?mass~zPuLW3!#~;xdE=YwWMd^N;5ry;A);wZG;8Z~ti?VG7!edKj5D#RAgS_* z<}jt){V9Mw@!r>%g7&hikWi;inX!`u*Ky^; zeFw42BGq#b?%twtGXo?y^i=oM*!ylPAEfoddFmTJ*%|YcrVhpoXGdevM%)#4(Z-wZ z#QCaqV5}RI$q9|kCJ2u8X6eN;tQ&`OqZvWc^CL|D44qRiglbse==yEA9*v^)sy77F zhcMHJ&Jv8OE=>Q$$V|p;NtiweqY?*sBMjf{!{P)Acp4gl^11b3{#09Z2HA;7oaP1E zp$=k4+JU)Blk~fI1-pp@X|5q5G1ja9t_W-48O#qD8SJ*^%r>3+zedov=5c zHY^i1B-OmKk8PV+`|I&|baO`Kex6!;t9Cv&^>#iVZ)*R3*nH|XuJVXkX!}r_gnq)q zAR%W`Vf2reCeOR%rJ=-gm>*Nk8%!>!Met#*^f*+=WS47KWE^$=>1Vr4?4}fzBl#S9 zKpO!1D3e;Q9OZ#s;I?&H4+kUpb%K?*K|E36xuh-zzH{*?q=N7q_^*$=DcwY8jOujI z@h*6TpDHDM-h7^a-br7`{786?SMe?%o*GfQRAi5C?Vfx+g(6!rkn~1HPagTem)MjQ z!bLPzkzbhIidj+xOE|5XF6z207}V}$XB9Y)!g|tRuv2H2(v-UaW2z2smLSYbREWf` zO{g9jP;J_k9g?Nc-!}ajXEjQ;Gc)cdkmA6$V*leK9*sA^d7neh7vn;uI){5Aj*j7U zblrHrF1AU*i9yRaTc`~bgELC(Z0oo7BHVJQv=l)*%Vo2yOEMeO^#ZM0zTl>ez`fb< z0C3j{=o6x7x}1dypbx2L^{#yHOsh|?_E~9yrN1|}MTmdrHnpY-0}=cF4kS~=tiPiBrX$Yu3A1?q<3({2W?kt!mUE6!vU-+IG(&$l)b1-cOJ z=bIAvIBMJ)1eMWy(cXJ?WR9X88#SBLhLX^Fh6~dFP^ldSq1Jfdfwd+ldCC&D>(nAg=1Ac)2_Te0U1S zRCyKxDh2>V`ookxZj<{S6CRl}g`wr;ctpfKhNFQ)O4B?PO=I+jwjMLo0IZjq@^?6N zJ%i_I3qj}LJZLK0Q|~$byP^#}$CI`Xhq?g~=}TlEjN<`L-JoWeVvg-h1uCB(i7FW| zhvl1sqCg=}BU>Nl1BWzy-3#ob6v$|viKaP*3`1zKH4?K__8A2y3o55J%K2>k9AKl- z&;d$<$n8N|CzEAX_TSmLMXh#5fm($zUdPf8XQfsvWNsksyt$*~%YAq?R*(*60xoD5 z9YL4X^Mar_-Zg)=D}F-yb1fMvvHZ1?d1`*$&oGsmf;iRo1Qo2@YMjTsiGV*bZ2D(& zV8pL(m|oO};7uSfCrK4R^%tfaL~;zFjS+bKiAhrWF|x2n@Sz|9l!+ssu){ztJ;hpO zj^|g7C^+V+q8lV)jog7Kya6HQ$ReqVDg)|eT`Yg4|IVoNJ%y!75K+iC;Y6t3an3bD z4gzy*+9Sd>)8f)9}*;Wr`}>mp2R%q7eM6D}JB46S%#N+neG~sw$~5f<<-C1{;ghveGHLq^f-$7g zIa->$E)hoz0~3=#iq*FtG3+N9XWmr7TDoJ=b1ox|1Gt?TB83>!E zL&_FagfTXUtJf5hvXm#>@88VRUa7Vle~RBG(#1SEvW@>g#@;bVlptEOZJf4k+qP}n z-KXu-wr$(CZQHhOTd(hp_wKwqF*7m$vLfnFMP%*DtXzA20}tJo`cCce8?Lnf%XvNlhO%`!UExp1D(DIGC)QNOsy~VX4jom>l_rU{FfO(dFX;F8@z)It zFr@YiLg^kP`gpQK5Q2BxshJHOI|W+7>ol!Zvxk(!on)kDho`txE=uzJt8$ifq;WRo zB6Q?xSR^a{^^sRippne|xLB`b)mzQ!I0f)nM!^yxJ0ul3XOXPU#4MSN5oa)|j-0u6oHc^_wF5 zd;m9rO{bB5Ob(4YDSc#`@0Y6eLX3V-?miig__39y^l+F2R*^!`Bqch_wH%cc=Rf`D zW>VpGHqHE(vH+(|bPW5}IQ3VQbv%ugmB}_(f@hSgzxn!*+>Gb>yOq|M4n|)@;bGf&1l*iax z(@xTQQT)cJBm;@q2wSt#OIWe}e6weBE4GZ4Am(=nN;Iah)UE z3ehG5dQ*vNTMG&Y0GiFXB*M=-WabbM7T;}94duq{~dN!d~nBXq@($WOQslj;J-MB zd?$A1O|_{Ms*6;(n=WM>cn@mCjYJgqKG;|E%0*@11g7sYwmCscyrrf+L9Hn2C?u%s zZ7w@;U=CTMTi~kD93{lWp^r4au%ffKj~bWPYeX-qys;ry#@OE{Qh>o4fw9E+IOm6b zPAaoZ?jN5bY&XQ*>`E_+g(aB=aJ)X})jTR)o(@Xh#%9RWd|&e<{cVUB{Cov^F(FcU zFv`XwmJcP$QAF+DP`o9hatI|3M_b%+r{(5&0`HU@ZL>a+4HTcA6xX$NsXD4jDSm;W zUVN%`M)zyy|93kw8%3at(Jkik+nj>gpMaKcdZga}AH)!=-MRe0CyGABW>5Q`pygD9 zu7(+O+pl5p<6-s5`T588M%-hAwf=|XgQf0o3zQX~bAy?}pDWA^8#Jw4dgQRM)zN&y zF<9l?pDtV2>7hLiu#iI`-(Pv`aVOhNx{E*5*`8GM_n#e*L4*kR+OaP$&@f!#&D`8_ zG-Z-_;-&GVfBll(MA5DBH(1F}LTr0uUHcXPI;+ED8QU8^MB=i$Dn_In6A}r49W1Uz zj|yg=l1NN4 =Gm)2kA*CXSoH<8-O(wep!l)=8gNVRHO0Bqk3$BrS-9!0a16lvAn zBg|Q~{7K#(#bSdy{2feA%0Kg<&ov8TBEurnunj?-KDx7Vb|CrVL&WfchuYHU_GqHe zX5hpag?#hEcz)d*_^Iy{z(;d*(lXEe-N(o&-Eelif}qh3f-mD|u{j%Zq9s}s{l9^~ zuh{&(*#f88Dk2At{3ZK}R^dD}9pc~Z70aQ6o#z(nin4lour6mTy2vNTmYvEqGjllo zLLZ=_vVJwg%*s@VxbV3%9V=0>od8(O6NnKj@NJ5Ua(4)3<#0tvV=32AbHEFPVqP04myGmsih zlxCNV?`njh{C6=iApfr<*EfFMRUKS{7SxjzaY-)owv^dKeEWU{hn>ON$4fSlJ^cJJ zw6?1+ifypm#lTb&g) z-{uxu4}BA6ku!|q&!>s^sawH*2=}5&j^kuE1H-za5dB1L&tA;)ZWt+7k-$E5p*gu{ z5=nZlJinZFVQZ~lsdcGm)VUWsaxgFGUxRWX?|tL%8|!(5=AG8!Z8>0 zz%tq^(TwWkn-nJ~WeGP}K{NT_PND@f`A8lZIh=^=pk?nA9imT?^LP?f3Ht?BE3n7T zOXOll1+=gu%^4CdCph3Ce7iv4Ey}ieJw6ZXwN_LtEfF?H>51YM_Y=v-1byWSUm7F% zX49pjSCRgA1Mz0sxpr}?uIScKjWnKXsG{af6)F}rR@b`Kn*zlibTXm}(fQaS9O3sx zI!GsWY^UpGq+=U`5lmey-K_hQ#_?M5jV?V6v(WHE%(cj+4U?TybcZm9QM`T3t_&qI zy|SQX=q!U2UAA2rm-}0QH?XcQy&VJnH?Wi`1?6HYcp6I+n4O;oz*CZ${Ju(C70#UN z@3phXp@(#mSY|Bh&u9G#rl z>MW$X9Jk{PW1|jL>LaU6k`9T-fo3_au$sa0K3DDTRbdx{BM+8!c4Iz{=DYig5 zd`q|uz^;_~`;)~E=5*>zUhE1q(CD0rm;qR$HR^D;nC-T7k3r6FN6UfM_FBB5t2Ap) zQ)sjaAuuOT{kcu<+7eG`hiS?Z@8M@>%DJi$cVCK6_3fvMPURzjTC4@=lhefJmzyL5 z#*Q!?+_o)TCJ8Zrr_Tk^j`b^zbVrJL#KdP9 zNKP!5>Fd*EbtXxOqLwzB;Ra_QbK((+5f!~4;W7Wq?5Jeoj^JM?i0%;6JA#FJf&n>= z91vfVJkmZIR?00i@=wZJ)EH3gp;s(#EA;rgak60+x-w<@CK%Tj-^{aP^7~~i2|)t7 z(u#I1Xh1$^iOQppM$4zs1+hqn#j%!(BGq_MtH{B}C-wQzN(gYF3w;(6N`w>n{BuG6 z_-ilNxaY~=r?jWh_{dd)TD#~2q8@B23b38qAlNXzKRM9pa2%J^Apc%PkzEHR>3m05 z5&%MNg#pDYAAv$s!!BaXdx>B2eT!(H{UfKjr2whB5uD&{+7#htfh|w^NM?9_d>)9l zuyOdr0Rz`Ot+@AT`FhoLW@CjzkoE9CV)R;vbHLWxN9@FeRVO2CQnor$^j=(;-xVkY z7L_U4{;6UWUvInbJO@hfrISB8t zVq;ISu%_v!1rum_Mz(c!f{aN1^$RQHpQ8zE<3QoZnd~IBVLF&#N$i+t*?>0bVtg|s zGOdq!;2>fo4dCq5z1)jK=pQ^~9v$Sss!-L%|LM3BY?#iR)|Us?QtdK^i2FD|eEuHi z5<)bcWUq(J33Kc=E0VwMo8$}Mk?00Vf zEvZOIe4b57u=(35n`wjvt#F-ptJ}rSv1wd{>QR3V+bqPkl^3v<%Okn+Cu8J?pB_I5 zIdg(o1h(&bN0&y*X2Oxp5}8cc3QnJ&il;x7%c}fPnflToo=Z%rP%F8!GNqdo=5- zYCD#r$2Dj2x@z5lJ>X+OM7t(j!4kM%duGEGLY?Sn&fc87%Yuj1yFay5hp>u;EIQm( z{nZp)?lr4p!-EMYvysgX z)Q#n3Ym#hM);b%}IuXpyG+6%QNI8-_!ry(60C9TLH#3%hnHyq7T>JSQKIMb6m+yzk zzB21q=kA*&5~)zt8zYS*Epk=r)Gc}k9&WfLqqU}Kab^^ozPVfl)dg3vJb#5{6QkM1A6 zRPb`7QVf6yp@;hmMj~^l0*zidIQ2^1243%^CF)0B63;6CRJ(J27?fQB6A{L?+xJA7nU(^uq8ttTh2QUo#FmoSyxeVCwBc)_1`D97|3@_Wm zEKK0r>VqJ*w8F_IjXj!?*5WPuk-#c~`{)}Kwx+p8&DxIfZ6t!N&Ud@^Y%h#gdDZRl9feGNn`gvb-Xbwc1Di2{ zAYfuyUUZ9UyYdSNHPbci99bp_WO6Dr&0x)=cMq9G@Gw@(F=Ak0u=V0+GQWPN`UJYM z87VK~2dLMGN@?y{WO7%q z>E1mYACQI>uaSmc!&u{Ban4rZAz(yjRJVVxx9Ca1aE^syPbQ&rfnUv5({Hl0xI(LB z?S6-nP3{JDEDTK<#SbGuc)2hYRKowF?!Vh6#2(Yk__qiP4!$P@t4o8_4{e`z>iEo? zwVr+$IHv1#-!_Iq@&*27Va#&JISjbH2S~}36_P{t07B|vY%XtO-e?TKX=OekFK1T3 z+A7Xos1w6LMUcxY=oR=5{)BK|V17ZgC9 z=<6h#H{VFBSEqUM&wLXla7X*{!2~uV{&ufaa4(?W)NPrA?0Zb>WOQ(J&@79{mcUkK ziIdNG1_Qku1)tP5x55{~djZWW=|grz^oC(gSZwLC`1y2K#@#t4CqeW451yRkbl@3M zNuTW7)l~-au4c_{3wR)xzbP_OH3BZqGQ8%MGQFqkLr8Ey5k2w`4 z5K{7V8Z(LCb?-^x3CTLypn)<)rpFX8NzWCdxspU30aLXH1^!0PV-6*77cgFbDH)Qp z0NxUu#e?bimYaBi0)WiCo-I@SX?{>#IeMjGK{7ArX)}L|2w6+e_H|QR#N45DCM6dM zIwb#_@4dnHy@Bo?$f4TdO4@~kd){ASX?J6j5T@PknWysit-TbQ)p0wowLUeqA08o| z(ewK5>xhLEPoHhnkfkXT80cnqcik$v44v=HE_d%P_s{32o0uIhrC(Zxt>^pTO6~)% zd%9cW>fxp00A)S^R&Q%_yK1`Ev<6&u1+U@Z{La0zn!Mz){_5OwEq>?N%CqyB?)ww! zzYD>EnSIHoeudzkze4c;lkxcfX9S;`&J>0BM4NU^`^o7#LZk>!RI(2<&b!Xewyo3P z@AZCOzwhW+YogH-2XD8u2tUW{W(ILSt@-$ObX;k8EBaPwy@9Oz4rPV5o&K;P8n=YY zqzPpeg6Uuw4JeP!CFG=cVsbb#@88xJhOyYOEkMgey zwtN{Zc5Rxa=hj6s{`jTInmq+W^FE;s#2|5 zu7Yb`+EJL`oCG^RB^UU1=3(GQ$u@HZ^*gpRS`Tqs7hZ7YyZ-RPC{D@dyW+Oq;rgQM z#f^=Q-&F@vIegR5cx%%v9cpr05BiG@3w6gD`GGG@==UrC_VJEJj#g(q5d{)mPJ~7jlm#cV37qpVkCs+D|BbHcL|E-4hKTb*p{SKOm@eR@jZ|O^w!EnGY zL)iDi2MHFv9>D+pF3{Ian5z7~1aI{~0M!3``?sF4tD~`vgSoAZ1FeyPqXDgjgRRXK z&xZYBb6rpTd_#Vh>lNo%26YW9rH6}>=1{eRHZ7`Ya6G!wVc@`;&RgOSPZX5^A~E1? zaN3zYNd;gS>Fg<1J66;WUxzx`o{h`%OT){|#>oYH*!1*_|A$w6;hxf;si6l}Sv>z! zON62hb}5(JPj9`;2lCLIW)0mxFA}c+0N!6$pVZqR8}xMh^bpQN-XMI-Jt1%a$Gp4O_IM1s)vx zP)!eSYd3c7ex^69x8peg95M}r)P!aiffddHck!Z412YD)vwaBs#VKNY$8XT`b3y$P z_YcqzxAE(Z5#fa6z^7nxiU5wX`iZ%;{&E{;FzVgY_azN{6K??j){Ua6-{ZCn8xaf< z@y{K6##ujzA#o3;NF|4W?uW0ulLjrj#~qnxU@t(jm@ghb%SN+&h!iNy?~2#Ezklho}NYs&(9xMFi~L-Iy_=|`(O_$OG10pdE3Ag3L~GwKRR{5`LDeFx3gsItj|!P^lT^D?&TAf`HuNBN+M ztgK!m&R?6EA_c9d)FZ^aK4*wrc-dR_6BC{xje(~Z4%rw$=EbzI`#8ZgbO?dN30_cwxkk+=hv zZ{bmHY5=|Wd4_E;-AbR`1TDqW0QJXyl2aK+EEMk=@Q%#0eQ$*RnD<`~g6Y+)=p6@} z^YQPEjNNX5pMT=jX=sW|YzTmv!OiXpZ2ly`2LQ*_%kCjI`a|QX-J{4I`@@ue69mH@6PTqCZJ%Turav0ul{#TuNK<$C?IpR1)pw&ui3fxl z4Uvz5hcrO~?t6f1TDZM#!I-Y@|L?66<#Hl=v6ar5yy-W*V8 z$j6rjy#&xBZhv6%MoPSjlRvD%-Jt`nRS?*58p*n?rtIP`0vN|fqj2=A`3*XmPoP&u znuZI$Lc33Ut3*W0?Tz3DTy!F0*ZzyTEFI&>Hw&s>(^@S3Bf@4^2$@k0!}7K;Y)L(T z_^9!FGH+MV+QW!Fw+&XtMNCVN_V)L`k7svYjUny$@YQ^uVT#vczv)+hVZF3q0DJLX zG5zLLv~fqtbHxo!4)wGN8Awv0_T%#eBy3MAI0_8O_mGu{-52JM_PQ+Z)spImT%5J_esijP;rlmh>K!!huD4?R?1?+c8!hNnwlw_*`|)Gx^L9p{9T z0DA*tXWH!Fs5dnqTZ%l29TNFhPV4cO%OV$772AGdhZy12@tgL#w==TO1s3E7*C8=ZT%!(R)Z`Jvdp z0?&3c!M~VV5^kPZp+}F$EH(?i%w~}oVhV->uaC-h<{Ozq1H&NbU=;~+@K^D$N%GKC z@eo~#nQJ&WG@b#S%-PQh04?fbt}T_Qk2I9<)Q)#v)4$Q)_aaz;$L+`!yV~wWDy)oM zx!eo?eaNj)pK}_1yqg8T;bn~=9DjVja*zb8L+Y_C8xekKiV2o{I zwDSHz(V|So%U%iuWi*=lR*>u^`I4e+U(3RK96XUiRg=b4<5`8&9jmVmB&3sjO-Tr2 zh^E#+S{njZe_|SD6t89Q@Ioa4v$oz8$)r1M*AVnf2NVIJj_ywll&gHWeZ)@N@;Q9q zADg?y5{wp+Hv@!y$2?rzxq2^J+y>@XQrD;Sc@h2)!mEBD_Z4FOg(Aj!45;k!IfC;p z-tZJr38tFel=c1}Vn39>t@^0CS*TKr;&}zEQyGBO-fn%+JWlAcZtjkT9Ly1iA!b=& zTQF;1?@16K%3V?S{vGjt^keE}&@D1NajU_9kp0+6BJ!;?Id#g3p<)tb+1tgJ!z70_ zbB1~p(W%AYtp-BR3eJJbb|tYXVK2KI98gH)P1^Wxc))x0v8;PM-bj5N6hvN=C>rKE z@sQ;(R)-`|lUoB)GJ(leELMi(MGNc`O2H9d&Jfw8l!(EE?_+r-!A_{M^NY)fwO@Cc6s51m8x<6|t-T{3Y1?GfM$~Es$qJ zvxtRWnaO6wErej}DBBRu)>sgNfCFh8V{V#< z=EJmNUs^waU2;g)m&yROB)e9z7oFW3ZlCKV%~Y68w2fd|#w2_7;#$BNx(?ihx=)M( z4kTG?wznpQ7R_c%V+^I~0Gc>cYn-oErq6|dR2@)0tNjp_nhG#=k5nmCI8QC&TaW(P zf|eADY>)m0pE~l%pPU=X4J(~R6MlA10y0-8e7DRW-E=Dyq@k0n{;|M3?$o3~Csu+clP%h7~`C&BhrFCU|KS7CnB_z#k6bxh^^+(VDTC&MgtU1n2P$Z!}o=Wo0?(Tbvm#E<%&ScqP#kw4=q7x zN_iwy*?PKL)UsnpH#MW7u%zUXTek)hpp~A~-2G>o3~6a{%~~1aPL@g3Y(TI9gck1< z(z(p*{hQ0Jp+(6&M2EO8+|g(Y&%-67y=4L|Zdq~Z25`*%p5X`>rzxGL5g7?cCy>){ z-e9w@;&c(o@otZfIRZ!VW)4KPst{kG4z<$+!!sn}|cHR_V6bmRaqcVlgiprjOBc4S7>%NcI-o(a1yz6{QOgjdn`*IiXyKh{=bXBZvTH$}(&X&9Lb;Gv{LU`D*Tg3CZO>3uDFqec5vY;w ziiPm0+vdKprhTfRJFPB*8yYhOMx%(U`h-YdLV5JF*7+;QaJ?u>XLY%rFXzk_Xut9A z=+Veldh!ly*$o%$Tsi)ah%>a4r<=~DJq8?IBJ{vn zPZ`q;XWS6vpf>c#BuBC)qZznzU;88^EI1HVSTU$c zb2<&-13dF_^~$6wZ5+dj->DSd0E)gj6X3&L6<9Kpe+sXr%%{p`({^!`qHuvjH~wAv%Y#cwfOq9e2LQDvVXt4>W8E{S!Sw4E2S;F{IA4bB7gF*>$>(4`9ca9PZA0@i7YRhtTaVUXWsVh_St*N74jQh-p zYxK2J{Rp%MtTodia}38-B2xJU_LYn36U0U-W0SiCkqSgkLDKoKz6!FcRFWEQZkC1a zL6>l5%(=rdcZ3onUz8gJjJ#1^(WPX%d+_?aj27e}MWZHbUNEzfZFIemR0Gc+ltqc$ zQs?M++7fO{NfS}A4)GxUx2JIkqV2zW1gVbRBehrd%8^b>xF{VAo!$D7fBIUePV;)U zVugbJOt&;;1VP5;MKj+#u|BT*$u?H;Gp8FB;v$zz+MXa~ z2&4gOMY|bkA#I4NeF4~uH3cZ!0Vmc^KL*V&@zwv0Puhv`w_j4NP7YO4Rk9XN zTbKGo)B*L7ocvX`kSoCBA<%@k>+q8jlgrrp2nyIxjbgo4AV*km3)R`k|E&mTX-wG~ zg8^wSQEpa7SYA2V3IA!Ail&tHi0>SL+MM_T^(c6}BzGYJdZudC$qla9P0}cDp$$|N zLd`6>tnhgQa%!&n!d<|w@aIjHtiwS)7iRfe$C(y^{?JLAcH-Wx$zHD0kt0Y`uj?-8 zFh|D7c{gqtsp(b2Au6$_ToJ|c3UCvZnTqpC_s`xQT7klvkx%Sa#h0QFAt!XPIOHhj z(?(H^p8%(fNq>8eIlnYI3VL3H8 zEb>!L{6rQ54hRim2r3xIq}cKlEcmL3MQnIHz3y9I^f09-z)2fNgcIZ=u*7|GsahOG zo3va@X=g{s0Q7~^dISXGsW1n;6WN}#F?fXMYeJLO-LSNIlSu`G0jh+KmD;n43PR1|dWXutjN286!L&+*&(lExYcjy8BR|MwH)|zvx${Oj z2~y6C_;igoUxk#B))|N|3ZShVX$FZN^W-iiK~9Hx*9)+1410F$c5E{!MZB-eMZdch zpq4kl^(~9shUzHH)5q3W9^qvfxTyI|ltHr*0DFrnPfy62rpER>rpYO$_p695bfo(W)05v298+axZI6adn4>%R_|6Tg30I3i^l`+ zHa-8Kx%>)zwXTFh@CqDU{f!!q9ZW`t-#}d?YRM64F5c(ZDG`C@!e3h~(LG|eBHZEc zx`_DwIByGaX_VCo?%LDK_Su&61=}=RHGj27@d_C|CQ;ZrA3$y?6R)AUT4wQB_ld}< zX1e0vqx0G}3)k8qA}LcJ*N+X-@jrF2Kd#r(y;7*7!_ESx*~_ofI;5}dtYxe%=IZA= zAlQMbm)z$Rm5+Pn`0<0zKx{8%ZCyV-h+4K)^@IqWhm`!^fb1Ft69 z>)@InHHO}}Q|60(N80^9D=BNnO^+~p@Op4&uOsJ2O6$Ary@lvVL*`qJBAn1eB{(R( z*Tw5&SaVD7zC;C$K~_=l!gp69GtpU?`A87(MMK~Sywk{}hO?2EjJYi7T-7q;QCoEP z1`^ZEJpA`+Cxce^kD-SY^fK1|w_tlq{s-p1^1LlKX23g)S@+$R@mMzlb`H5-)vbZ; zmFh(RbgkR3dcnIxsn!4Usu{xQ{O$U@l)4&$Enu5@+z%&=q!tLLfXJ4V2|`I zJW8Oo1o^{~?Mb0Xrsy7D0=aQddsKmJjy2DNT0x{tW(wNP0L@zpZk>Hyr^TIv3zGDd zdi-HoV^q^o>>FqoYU;Zao+9M=seFnYtG2NO(8bIrSSL(mb7l$ZXiKiHLuNY}d1V5$ zeb(`$e6COYX%u2OgPx%M#_vCR_aLP;ll`*=mGx_K8*5csE$!pPe|dHmPbgFo%K3Tr zvu#$7`HT7EVJi3HzaP>_C!&t#2HJWC$?i8F1)B{=otD#ri4Guz{ZtP{8AO!Hg0gGb$ zrNrv}ttH6^Mc(h*Yvr|XLi9eEn^(uR^8FR5h0hf;4~a4+x0(wphx-<^QCL(0p(9;W z8}pi0K?XyyCig4@u>fI4hnTYGZ-qJ^#5)_()a8JLyg$6_)W66q4kL1Wii4HX$ZO>` z^IIG28nZ{^M1LV@@V#&!?=@~0hJaWS#$gNB9C!@>b@5+}$4n;WoONS##xDQz!w<(d4JKviK!l@JJZWLm?%$EA3x z)EYR>#(9)iHVTJF6%Y4V>wt6`>D+j_r9Hg%PtiBS1wlezZjU)$($fOi_{kMSHTrGe zX6Do$SqcQz-y@0Pkeuac4~B{$HcWOI=0`&f2#P5FpV&!YkIArZ4Ccm1#F;C*6@1(U4P-jrkyIe zkt#S3k7NiTk)`{rB$|kxqoqtgylU(B4t-O`72bd3*>4YcXvurU0=N>3CxaaA=K5+0yYk zL%o)?1&!uqJlStnsuzB%K(%K-M}L!cLA*V7u$dUxoiP9+`GN1 z!M3Aex^)Y+SKnlN(HmT-OE`=Lt;h{-u{L;Zh+b&QbYQ4hOsZ9REZY9T%En7-Y*RmE zVIzDL1h*|ygPqJM)HG)i|L80?V=_8YC@t6AI76fV#N65|xxX~z!6|FM#F0g{%7gmy z6yFk}JrA`L8oC*YkF@=rzAJfiPA|C%BVh}i?rg)a8<;aQ!N43 z44QifGQ~UkO~r{nMMuF!2_=i+-e&*eG}i9rI}tU!A9f%z3~56=qPjlZI@&5{rp#;R z;KJ}RX{Ls%HoWINR~zK#Bb>6IoI5%-6m{iqb+;c|);=zV1h`n!I|mXrv>8fQ++XEQ zmgZhth)$mqt25X%{LjQd^~~q{>aE4vKXx@h-C9tAec) z3XNg#%vS8*JeGAG`gW=pOKGBPMl4c&=NfpQS&^bny?*RpynIQRw87&&Nnp5aq}xdV z*aXolK4XhSV$wNq>^FbmHd?T0zc5H}1k_vD2RuO^yVV{H7A^*XR+RishgIFx3>o6! zMmb3!IgHgJqc1Qlv@9gH)gO<X$d#Z|YXty8Bxi_T!rt?j_3Cb1LWVh%|*NJ2!3ZhMUd!nbaFRE|aJC&nU0p7j_UM zQVNNKh_GJabk6s)8UDos5}Wy)YBsoACh+HN8i)DES{nq@rkB0s&x^yYfwzvCo{}0- zu745HbqZH&d|{z+7sb*|GFNQS7zOvu-nKuCy?iWU|Ftq*=$8hOn|@F4bJgZXg%esD#X(!#g-OSvM|EDbhD(R51h z|5LM&BUzI&B33AS179;sxsPSmK&O?rEbrjDLCLZpH*>{orC3&rES`KH;L5yP&bOW!7Qyo^nDSnu^O%ATMjRR3rqn2F^6 z$Do+QAbp{)m_q}L1^ro|05l3y`JCN{*@FHuz#vCrfVYzUf=2>Mx3cl27pw6j-gWix z5Hl?2-Rd;YxD%_z&_iADRZ~eIwb2O;9@W*73OiCYS50;IH5Gd-oV!!<%KyHrdNx`* zO8SaG3C|M4+lbAG4bO?3AjdE=QkojGw z8BX`I(GJjZ#;UXs%&H=ZBM8wd+`h&)^Yxqw{Mv^LAM zGAA$_BGCd}b+C5daewlDkr9#@S^ShzMD1*>RnoeGGuKo2Ep$VEGgy`;B@y@+j}3}; zMB6Z*ah!lqrSNSsBVwS>_Y><_%jBGPhATjg!HCFPiY-Xi&+}JZMQ4DPJefKjZCOzA zRBph@#LQ?42{t_XM1f4QZm8p}F;rfM(sigEl)FH14)`nkgn?%PP@do$9Gn$ve_WU; zajzo3>lAy{0ZvIjWLdrh7j2Ic;I{{GgofB38tA)k7FMMu#?VpEyF;=uu$bXvbg_1z z&rQ94U8NXG2p4u08wxW->E3v}xg?FfYyWSyU{+L7R56=t4c3c;>D^u$_`Fr%32?*= zfzBL3u0FbyI7V7x891%hOFigzD%XtLFPoTgLl}R0dFs-hw#VAn08*8D!qMFvaR^Ss3#1Z;bihOJy{AlQ+c^-o_D?N za&NVlS@yt+eR3Z=b?B@~zncDZ3>Uqy6_NAYYzo6EIv%_{%B*wIwJ;nvODlJB5jP4^ zVg`o9&hnvazQdra1jF$4$xs;H8me=>3#%Qxo!`}L)diV9iY}SfaYK$AkPv2)yV|aa zL3+IbkST^&tXCNZml3GK*+y`?L`0h0DRh!Cf;lsXC%BhJ52FL*@>V5h65${LT7@+d z$5nlx4Cgn)vAtt{Q%w-7gN}O7)sS1j7T2n$bMQ-cR;UA^{2&`%`<4L8yRroYhx|if zB9|k$2o}Xofh()5T*yVmPZ`&IN|jMunW9wDO2!*UQ+^;fm;(@|!J|10J+9?K^EgbG z3=X$wiqs$2gSsJ%&E|zlUyE0~?%OIRr3eCl#o1IDjNX-=gS>Jmv3yfrrbKl@LULX? zqtPM}#xj>M$zkEcm)*;~0#TXx%#*)Ta!{NWAAi_dog3&A?Ppz}@=^qWmrUNDNY*)n z+RLsE*6&d7lCKB+;mPomn_e?paT6bd9Be^I6W zUsUP;63_TAYROo6qPkEN`U`DYT8VC@z-3K1oX|MQ3Q=VA$bO474-n8RVcP7QCpbHj z6w|4~_ukGR$6<1$csKgX3-5YU+v!#xS=f4`_;!BhU&r0DwvQ#Hhe;`gzbXG!!b&!_ z1=epk2`NSnptbqBXp$hmmCzChIY)-IJPP<~^5CIT?P-CSA8CPhkTf`L+)H^q#1L2r zLimch5=5U_Gtg9~^%zEHG)o7fr{bJ&t*VG=#ge`QWq3Y6!8)`oh!&-aq5X0HIGCFY zJvO%h$-q;fYBK^pvHnedVTv|11{5V>X*_bVl`=1szOC^HW(vv@QK*11GpANgJkSlW z3K;62^G0Su7~+2dnlq|miH^To)I2H$X%&$i5Xn;*Rid+0NWKSOGK=+NbY$S`$>^W3o0o(-~_ zp-kfUb*bcHI<%WlNG*R*%6*1DzS?SAZ~h@qElm5|9Q>niuBSG=b;2ob8NiJ(*bFh} z;@o%2TA||^8#XvNUJoUpGN_s}))KL|SH+kqSB-{^h^@#i%sgz(1OIk%rL#RY=aS?C zvR&x3k2zS?etGJvCu*uf37avGYgFbZSUIn?pfQn(7P+E?HB~WEB_EEhvK&pgtes7{Yu$IpY@mY*yPVm1_-q&P9GO-6X(tZ^BmZ2e}D zo+4Wt{3@;aFg|d)aQ2LD2pq1ORB|CG#BzywfmLgVx%u4FaeBE~#Cy3}nB16owEVm) zxdR(1L&9i3YTG#;(``I!HaLNUn$LM1)^hQoow?>aYqzEYd&eTAn{Z&S@a%+9^6~Ub zzj#C!yBoNTf&W`M*~HDAYg~Iw29`IXHc=f2vL49iT)_mfCpw=fO?R+L$ZrMCqGf3#occ^L7~i2Y6#SDZ8T!2$ z3?Et?42oVS0L+AO{uIaUM{`!Gzv;N?aq*w6OH&Bl?T8xAv6}&eRg8BznkQVSSfAh!Y?HH&w+0U73Cz)<{QKr5AVF9Bl-xXk%AA z@j{BNyn8wXbrgg+8QiYb6Y`Oh<(=FI&6!R>e&uEc^6p#*o#kj(09xD)V$|w+`hrcE zJ~rI*ZIk?XKUDb*Oob@Wc|Dm z=yadz%jM0;VcZ4w!gnL}*QJ6jwRgsdgfhq!AV_DxVEy`^5V%%9)Qy7QSz`TLXaBEe z$$wQ?6-9yQO?>!IsFO87D?_Xu`!HmYaf&wL@Ji5h7KmZ+Tj6PoA6#yvsz$c*Q$dxb zPIzFDS#@7O0lsp;by2g82w8d(G2I(9U~hSF5RuMax}$_MQlzavQS3lv|11}`MtVM;c4gm z?(zwI)Wu35SB2We*~%4@VSX)YqW=6asi2G^+Ws37T)dzS{n3Iwto;1Q8YLHnqq-;0>W6G zV)0N;37i$|4vzyEubzf4~CSbQ7-x!05+FX4NqF&uB~-D`0_YFGp6vY3UM{}LrQ z8``=(8$Nya`_kkahj~!@^7{N7@;R5E*ZdPDrqMjB>n7Ad^@=R;6Kp!^$g?jnflz0& zKC3;+@nse>-2hcR`M`pEeQoFCY{1J0oz{Q-6=d|%vfOuW5W`?FtvUIr8z)s0M`Qe?IeVj zwGt~b#g9m;5xI(Y$1+65#%U`oMBkrEPe9_@Ii6xn`fyN*67~=h74%#7u^hcihE70C zJ}yh|+`W6m{Y{~ZESbHPl4Nq-Cx_?N0?AX7WM)trrYBcRixsJqOkX4_MAjscIq+{s%SeuF zVfK#t>1J4Vq#o6$kVe(P)_1D_CEllq8=l0m{x}}ag8pD(``h(#kKVa>KGFv^}m6A%v5yZL#Kj8o!tXGAL z$B%E+Dwbo5S0wy+6)hW?^HL&FefPbtEdu}amcO_R0-SvyJrcv!iaQH9EY@n09XgAl ztk>ZPeLagHSuhVTj$4eG0SI3_1Hhkd|4)QKZh%cTN-g7@1bR&Qe8rzVW>bg-na0IN zdL>6^zLVNvZaYvKx0WyP@&!wEp@V(i!&Gs6KTcBw8t@6Q>EphjhA+_fB?BDg2{HI3 ztyS7`1m^C2CR6>s#J`@}Comnks$e9PY5b>q4aus64f$xL{T54Id6)!IHnm~}RtuSa zIqZ5<_?08ImEtoEOdy!7o#yuI;>xEPQNBPVk|220*wqHneR9Ijg#mQ& z7HEHf@pqtX%7E-<(Rr&wA8AdACyA8oCcExZYG`2ax0xJ)upVvobJLfwhO&D?%OHVD z#Gw3WFR>2UhYdgS{X`YxrOyMRQA+m7DE{fgHuyHqtUMPz`FeiK)+l7ynrR zx`-#Wq&347wzb-}ZQHhO+gfegwr!q% zzjyC@_c?Ln#EtQ1#>}euRA%L@F*55xWbnjEJ4YVm_YMbnKVu%XpZZjn$L}|a(Ta9k z#;xFUC+BeVLqWDsLU-uQ$H!DJDMc`~PB+N7ug9~q7;pvfcS=d1LjRvVGTmQNP?hX7!8G30aK8{v0ZNRdhuDPz55hDL5gsNv+ z-<_6o!-izjwcY_&hbyCGs=w_L+Z3IBjUXM=+^t~ZnAgMa@(n)UAb*x%Z@<6LW0c1# zd=|AJ8xVdXFJtdnPr`^+H@9(gxT1j|v0m=?NjEC-O7^q>z>15mdRSa4_X|AJd(S)8$3~XEkygju@o>u4Ul@MHTTL>z3P$UZ7Ns(Zl z!iKOYi=Vfrq8og8(}XI&!J!phY*U}V-)TtRvXM4k`EwI~XCxDe-dRR*gL?rM<;^); zX9f0Ny_G|HcGYoi6D#o7=6p*SeqDuiRMYF3Ne~3AGVzm9SNyt}t)iAvO9A;j*3wq6 z%n#^6w&O>$M71`#_$$q#3OVlIwdC#^cHK|MoJgT|I}iD?BYSYyQw9M!j_Ok9Nm zr>{)2GQ^YlwfyK%d_$Qs80!FdipO606me*ttF9j8JIP{s2sF?0We?=0MfaOB9#|cy zLK?t}MwLi*K+en0JH#MSk*s!1a)}YT120xhCaag4PHmj}8R-(ZzH*kFo))>4s-s zE4T$cC+DYTrw2u8yTsS)Y0LBLtHYZ;L_6$w zS~kSj{?01A5a{Bn;xG^k_0Bg1dOFQ=V6jk!O8$SPm*RNzIz7w>T2@!xR?LPwg&gupSyv-DkVHPK`KSM3p%~ixby30~P!Qcf zEOtE89rSo@pV`J)=mmKuOFR~x<1iy_2~#5ztMf-+W&(#E0|}`Hqb_6{ge^n)K`Wc7 z7Oi-JE{9&LoK9l4&kC#kY^(O2v==)^`EchyU8ttp%!N)l|R{7~}ACzj$-#_Z+A z2$hCNKzA0c=e}+J2F=vDtx{?YN7GquO|@ok0U85|Pz#quMB)<@?@Wyy7$7<}DNPL{ z?8|h*)F!#%{A8JbE0MWK;$M0Ce6xXFUb3;pGM0d)nO5`>i=A50sphFX)vS4OJp6p| z`^Mx8##mo|*!1~g^qfO?k1x=%UU$o05E$uRgv~(}>lrOvifK7qN}v&|;d-_j_jnZg z`>G*S7fXpK{EZ^~4v5Q!-BPSh?6!_}ezSf4n|+5_Z&YvxAC8iZJskIy3+Rd`kQK z|B(Hw6r5g@_y7R*vB3XVEoiG}?_l&lw4jzo!bWr57gd>8vdiIsbJmM=GTk9(@}6ov zo($#S-juDgncc`wK@ew$v2UJD00{r(U9a9Wx^h**5dyy&tW7*j*qv`1Yc{_OVm6)a zPB&g|pKI`~$p|bH7a0GUO%El{$bjCS9-daa{ubxq2Xv3<9zJ2avkRbSKkxS%pPtrR zq>yZJFE-ow2Ul6N4EQVHF1=&0FtRI$*U4ow&q7IWuFtubua4tSFXT>_>)$BaLDSMM z+aem?`beVUdjnh1SH;ude4ed`cwMcpJ0TjB?|PCg+8Y-Imkc+VqCOpu{TJuuH@2Pd zA5rCdIeP`ybFMQkuPz3>pH(dpn;|-#2k3=llyVp37fN{F^19pYja%1@ zX^RT%SSj#9-PhwO(y0sy@Szk}1J}XT@;JyBB)U{bsX&0j&h4Yr~ssDqrleMxMkU?jF z!4WmclO<`7_h>D+e7c8HwMG=eQQ2KQh_AZ3ZONT|$K$Bw*wY==YhAxhPEdpvmQg#} zVs-+vxbBohh9Qc1o1(LY08op@y;?sZ86 zp3KedAHVDD{InuWIWme5S1Fpt@vA7m-HQ;PFS5B4*U*~=yy({#tmoeAv+vuB5Ch(? zWT{mZ$EUAtHgB$CC_abfuKbzW*{wH$DGfT<{K>Zczs0JJz|^Qy+^rPe7oLC9^?BBU z+^%k=s;)9U#guX6kK}=ggwSXiM!1jafx}olRx!cke^=N}?wToHSB1*Zm6aXCTgq;& zftJZW&!)Z3Q$dA2lSP<>NBlWZ9L7Kk4`242g?duOLu$zuT>8_&7AEZ3#TM0ha`T1^ z`q}yDPWZqgvfmE;kR+og?fGSCZYdh-(|klXR_=Z?!!A)E&5W;7>BB*)T28@#J&kps zH*hR2Jx3M3hp<)qE!WJCVpGM(wYjA9^KIq;Eja0XGPO=(ItLatQ4E#>tv55KN zFbkvUV7sLB@Ca)1UVb!|Uk4qcLuFaab;;u6{(E=%>*HE#cRAei%ydU^cR8I-T>#>I zKXs`=Fi>aur(CP|B~Edu z3G;4OJavudxrX@7Fq(lsmL2QLdBRCA;Ny2&?I=cPZS5Mx`q}Gy$u7&f`)_U4U9_x| z=cC1Fkv2hR7$Q}6s^wO`X7|Bxh%4X@pDob~F{o?G%jvuYV6S=xwfMg4W7l(wQ_nzs z%`Xl9j;-(>bi`ld$)(S={lQ6*z^R@0PoM0VHSS|Bvf$Ny9IfJ@ZkW(rZ0=pH=)VPZ z(K)e#(R#}}eqByZOkGcFxYEy)x!a)6l4nP%?6L1tLzls282xp4$(uHP{U-R{4T<)Y zi#*q#-sFA0BW*csKLRVTC%RRr?*Q#l*nKV`Y%m^HRd=3f6n7NlUpF=A(lx|^^)m#G z@+a4)(P(#>pDaK>(b(P*RE}~^8S6OF(+Md?r7qW505vY8K9V;sI`a&susTS#8pUqiPh=hE2vG6lMVZ2d>8YFslVQ6Z5hcnc|99K_zo% zJ0r()JC?hadcr%FB@WFA6GMNg`~CxIljMQIJpy(G<^i`I={ZAy;Dlf7xd)_#A|BO& zoLRtZ+^!3B`h{y5Sae!)cns-;VKQv>B;1a9$=U_blJGy^t0`)6mZPoWuMuebY)IdE zzXAFO=X>vjuQT%MGj^97ZS_bJPLr^5heM!;qa)2_^8TN5aVRtqq*PtZZIuGVC}xf* z0~~Oy^&~aM*WM?sd->wc+wK{mmR7{XQi&d}W70y+VI$6a8>h2ntij2%h!E8*Jj=54 zAlhx&W#ftyVa}!D> zGAz2;go+#tV;pag?CUgyi;lEA&ok_qn1sJO2foO_9Z5Lnq)fVzZwAoxV%GK2Wn^B({gTIV^0LQRMax`o z{clHhF0Rb1rfiD&*=V@YnJ68ha#v$mI+BIPTKhEQ6D#B~-03mjBTp7*D$NIl`1q{3BCV76uyvMF^uIO?$ua#gYk8(zWQnv@EE$q}@fkbb`=K=Klh|cp?P6S5uOMt?jqa=#X>lUwW^W1yPswamvgGaVQV$ zd;@tzRXnUz9X6)VCf8Fc85r*@6--AltQ!3WCDMJ(wcu;F zz%`)iaDX*H@3g>m0O7Jg^|0fbHO24TH1pngB2)jGGj=*@|GY@h7giSm*xHWVd9nOR zVlR?24Rr?JhTGV3A!RDvL{|mc_wvRsIvsGI9u$EO35M$nB?m~!4_aEyD+V-MkOJ_} zXMn{fcgKHZBnrz5fupujn*KZ`m|M%#I|_I`%_C!Fp0PF; z(rFPBEUx&+_KyJn$2dxEyR)GArBdYql~=L;rH)_F3B?E{Ho!ll{*R&9MT&wFeH%*7 zAaQbm;i!S>#)A%}#Y}Q#Yt5M@;Cd^{Bl9j>L!R;YQ3*O}YN}YV-{@M0GiH@DrmDl* z)h=q^Tc5h4r(RK1%Y(+yox58ni|@hyN5?OZ3JQRKs`Jn6`<*^;=zG&YtOW&C0hMrm zO#U+_C@Kf*%FlP%3#ij`3_1Pw;s}!H7XNAZxiI`>RcgpPkN}YXnjKIFX1ew8L0K=3 z3;6c;smMHzgwOmNc}y3$M7+UHs$&A5mewhbz*2kuwEZCe-EAPP0b*KGI{|7vKix{e z|I9L|0mOt>d|7;1da?h$QM8VTEl%UXD_kHz*G18A-;CdiJxmvOCLz!1$x{I1X8$ly zaoc^LqgBV%(HtOMa1G#p-=Ir@-(t8~ApdsA7OYiyRnB}QI*>e&PFLXHSq&ve;OUo1 z{u9+s{NG*Mk!8X$Bs$lB48txD=U|!xjnXhgCYgdb8OROOa1SM#>rZru0c(<3mSmeI zKXu%LaCgwKVKw?zyZFT`$VPFtUJa4Kp8vtQy3Q#%vw-_nJ*LtiweJX*<*u$Hv`#w9 z8g|UxUTma1$D9OimR5XvA@$hU9b7D(Z-zMS#w8W&v@ z<6xE=t0>-MNZ4Wom28w!5z-)8xZ(n40mVd1wG@1LhlwBjpy<;Ix8x_ zEE6|`XIN;5XB8t%12^oU5C0mTXa5nm{Z4tKm0gf01)ahgAg^o@!EvNK#;fEm9LOmd zk!0$Fkv5(e0XOZqBYyHR`T9|F=ZGK=?8Q~I?eW{|o2AfJLnN}CyOi=)BuZgy*-7z& z?aJ@FxYlNPT0AQ2Gk~m?V>2&0Oq9Xo`et#QF7tpxYLhH$u>oBr-x??9QZ2;=7xem7 zIje;akMcBW(MM!ZjSkUkSWD8e?Xj)(3sEb|7gg@kBGv_KDEwZy>^b9l>bILWjLWZ2 ze=?2d2NH3Y2W`&{xbLr#HGCTov#&zSO&*4I(zKi{)tIZfk31pp?osf2u8uv!J&P}m zE94cyA!HRd^9p&x`bi(8Mh&{$8pOaP71rzLlH#IyQzXzThuI+)yfBII3%ps!L9-+= z#|~u%f}S?7$H{Xxr78QLO#>l8f6B^2^?M#~=IB=MsHW1m=rz<@?V6teIM_$-VGOpK zZ+IuL+?L5_ckJdo{iSpbEe5lmLhTw}d4g0TRqNgpM zX4l1N5{dfr(7?@}EfMw|q^bbwJi7@57xuj+6Z^mTp8jT`CA{YFhYjOhL2h;tZqnQ= zQ)CHOd}-Ebu!BlYPbtANOKoJ$=+SPj2oWt?_e1_Ff>;2Eu{ECw^aN1AqXeh%l_^UF zxENj>Nv8>&JUv)Lf}13=QE3OnP!qYl7oQz+Q|CDR9P+vQYZItX==|g9uhvI>jnC3j z9BR~jTbnNX1Zgk^9~Gs&q@*v0tZ4eULT~)dgArc&2k3-HTv4LFfR~&R*8c>Frzz~I z(R&+7n@R#0Hx|!-yXX$)=39X_;MP`a##X!Mfyp#z~)yX|tPD;$Se1S>& zYgjWkj^uaberP&9C;g1uh0ZJ%XoL!;7tiKP4;doOMA;-^W!SbvEa-ljS$v--W(J+& zG%2R|>O=q~6xT1j^1e@EI>bT~D3yUZV6wBnVRuU7h(wH>{fwSs-Zzz&Vk&@|rKlM6 zd5ThEu3GeY`Ag(-jbkSk<8E@JsFh)$;-TZzVyvam;FK`IHNl{D^)l0b#Ai%Qxh{p$ zWp%jy&QOMJ3_97dNua4*D`z;06e>OOC2O5+DtD1$QU_-?IAz#Y*)?ma@|G&S%}F^$ z6@d(FYjrj=1ae(;< z#iOoKLXfga{Jpy(rq-Q<0 zlw_%IOM;kQZhC+rjc%f!3Hc8A4|7cE)Rq&)?v;E?bkSrolGG1yJwEN2h)7vQ?0+F!Dw+um0|N)}FU$4zat^c&Xd^hv1j+eFuLI{TvwB{bh{fGrl0*aZB3F2DC zYz!eFa3Dx9|H_LU{#2oUY{V7BNS1#&%a2A64l)t|4D!D^4RMqyWUiHm=@G)e)3p$0 zt7GhQ#zw7iA=av`sD~lJu{$DJyxwo6Z&_`%-=ayJ>vTi;U$->R5BI+%LQuhG^2m=f zA5|feZZ0G=(7(1v$oWM8#Q-f4|Fx{QY!Ro3|Dz6`y2``bI+j7B&NU(sO+X$D-v?-T z27a08jorTnsF$1>836#c`&kw(_$|5l}hxhtJ z;;mY2$BFZsr>Z8;D#0{kzyVilzMj%7eT2GRYFeo|!Cgg5V3>54LBs(UR?(jFG(}3S zL8`BwoAc4u84@K!cn~D>{zR3uEIz(0P<*_RT6RBC_T1uRmDrv1HdA!Au@WkM2(Jiv zQ(4NtJE3iWT5xPiC}e|-t8R1eUotEsuKSP zefG^7s)Bf2e-%zCPlZ&s2n)?op}HZ86*Y{1X~>tC>V%=O)xvx0+kcAP`1og=r&xTA-e*dR2`858>n4{71gipKl0m}8f?YP}j5SQ5OfTC~uFHXTIrVyB+pSrELHKhV z%ogwSa;ZQKW~#1SR8wjfQDF)XM^@$!9(q&8`0;DbT{ejl>|5e7PUd?rET3385uBK; z0jaU3nmGrG>n`b9xzCWDu&yHXCK(y`)uh4VDfk}tbz8I7>5lW(J5nCV; zkg$C!>L-vdq0jizrUru9)DAbS4;<4jhCApAi?3jjMcl0R2{=bGDfw6wnCmKOmM|t1 zv$7Pu>+&sk%W0Fr)FluozVust?!tscz*i~-ts+CdM zm^qvd6^;Sr%8V*QD8|`Wgl;G{r3qW1MHe6jJ1|%rG^9TAZr+N(%$PU;j?BWCD&TxI z4tDax+u~HYxRj%b5~~cd((BPymxIOj&2p!`G4QL)_JO1K#Qns*;?f;7u;hO7a&uR~ z>tgX>B{gjSyr3>pTSyUra0P*|pXgG*s+d=e$4=}B8kAsQDqGoRnsAR-m*dI1hb7)SK3 zK#*&eX?ck$LY?vZM$;>al>&Q}qXhP09ExDg2P((^tdw!!v(NUA?nz>^w%kiWm7+c- z0MG51$DY$9DD_bu19wlS0Dvda8^BvHqc$WHsIJWkSt_kB4R1^IsBhs0`^qYJ_++@{ zx^|vi$K23Dd*g67#^`;SNO}hT&Ab6CFM?oRdpvPIJ$qAy=j0RgAzEbF1Vbi%Pp3(2 z%PhMz_PW(U-L_2TnW=1WV zXZYq{Y-AVV82rzyI<8PKtlo-q^~K}#$OVtZRO1M%IvGmSAB^p~(v0S?kF2BFA3!ImCpLJxf8qBx3yRl>ile2O1P5rZ3AkjT{eJE~xvCPK zBQ)&1$W;0J6n&PvZI$Ti(|DL_D9O+JcI6p|2OCMDt@Tsby2!k@7nZK1zaTH<=@0L5 zjwbLb0o6Lg6#H)T-38Wc1r&L_t#?9w-IA@@q&BJNv&TUWXRDiFf?~^w98Md_kTG%M z2~w4T1KyLim?*`Rsgm$t=^bT(&oc?NQST>1egh5ap|>Z@2?i})hv|q7JXd*;kV`nr z1`6dB7Dj$@VP%3^$umTHi)X5Clj(!B%Bg&dl5>$3HPYjSNL|-T4q(!rtRyKEGu+_8 z*hs?jOJ}f4VWH=o*Pe>3M2n?PiX~8?eU$<`rYgT6(CliyOfr~tp2)fP2IEuNhwc^F zh4J(D`isZA&chlTS`%!B94FOt%i>8H$Sj1co6IcRI_16{Yl~g$%|E^UFMTgnD=(+5 zDuTYq`<^CI7NckQ)r$~P-dt1ynihB3nF3t$bl$@?8djPWYWE1s?UYd+cdbj9)V4bA zYwcRDXHW&T(Z}Ok?YZN zAET3o;QQdyRp1PxC*iiO)vbJh2`#t>di9+>Y^Tkxb!SE7HoN4ox~nzvvUybOe_fVy zJdSCc>_Iu%#d#s>7K%DYN^0%IMf9e#;Qldk%Vk%Ewzn=62-SVo`wwcSu5yfQ@L&{| z*`#pVH4wPz|3z%|bMMeVypj00T2QsSepz})LW-XLsbz4N)7py!Y4Vy95|ZT1;CY&= zdQ)8Y#;!Ce5RA=~-j4zFtxPO41i}@s|KPKt;XPHgpB@EC@}~EuR}R~Chsjl!rEY+S zQG=3g*sp6bZ$0|2mHi2+sGZe*v*6<$uL|WAqkBdA+nAgDC8-!6&I@Ff$HGaFHm2~P zYZ=c<*%(UPe2~dtl=*4BY!f1zavPy_`%Pu*=)U(wAPraeh%gNm7nNxpT5H*N-FExL zq3!%4m7oc74mgkh^-gh&`t-MZ3PRpjBv0G>4vEYA z@!$Kb+~b(7CtC5Ak4E8BnS4jrC!4Piy8qo3XGiveEcwS32ks}>#jD|8&Kv z-e>)ZGpS;#(_SFzu!cegZ8vA53HL=T!)l4PTA8w%@B8pDGdThR#z^MJl@~ph%R$)< zUVdNNR@0J#(PbgtmkoFA-m>kwEvNM<9rD9amq9?E3B;Bv#f_3I6`EvSmjl2lOQ6)m zP6FyHRN)?sb*hKVoFDBtf@zHxUnZUj@6&HU06J0u3}Zm4i7+6R6gbvrwwnQJLN}jT z0`R2?614mkRNkT*4_QVy7O#M8N;)%AuwqoGHVg6Lz zT8gElE1G68GCS%AxR^ti7UIpV)J*1JPv!%ZX5=|JCCPWG7G^Hq6+C~}1;C9M)8zB2 zRHC=j{y5*7U8h9Q!29RbW?Btkcq7IJFM{eP`F`uw&1Rb@pXFy=CoYzCCszA%T!E`v z@nJv($kUY^T9Uf$3=mtKfTv5Zw0c0rPERASgyOGvwj!RsSLV-#7tPWlBUX&9uewk^&Um@U2Z|ZR524hdJI#I>e#t zQ#J2ahensIo^T0;!AZq+Tz@Lru5hn$Xdjn)?n_)YH+^P&Hhm_LYI>Ho=cjvM_}r$l zMrJ3cFIl6mEG=7}uzb?%zM^b8ou7|CF1p)htFSIlQi3=0cHO+6!25c|JRbfZj^AHf)Ic<(005z*KmZi~ z7sqdVBReM}2gm>E?qij(&KmzkRsNcpbcu0Idj4rRoUy(!c$=tmRc?JQu`8B|QO#1A zTsV~7HRiJo#=jQ@pd$c~C|5>IBu9t||C8fZq~QIVZfV_12^p=1c8dom+gsx4cF+Ya zV}0K{M+BaM8Snm+w@vN%mM_LmzW?vKf!oBs^quXV`eFGhSl zoz6cxE#7jSqe6w94TQ5Ao(hW}lGE5T~Qo5CP7SGpAh|$0!-bOe6mvri$dRw?R8sLF1ZmFe7zf7%uX+D$?A0iDHL%X>G9^Zt4 zJ`|oR4U>;s^j1p~*%WQ(a;`X&-)4zJ zO3P|-4M`ogfne)Sgfy*cdYagRh1~DbHWHaW3gVT99Hpu2P^gAnDniU|WKJ-1p$)AW zwVD6Biu_JYmoof-qG(bCt!nEeH7Vx^@xkwW++vo{@F|7nk{?zQZS5rxrIbJp233#n z^{5ss4GlTG#wG%Rkh)TkCs3pb2J%u5$DT@?msWU1vKc`~=Qq`0lK0ZvbGzp#1Rwyn z&zi0VpiFK&%~p;2(}8KA_ze$${W~}vax^9bCr$fsuoqhZJ=1>G>{;RORiQTN`MuIOSvD9iTKXcaU zlWSfR+yr>kJ6A8*={;^xL-$8Zp5);@J4j_~MkAZ-@h4#ICsBOn>`oZ%r2cLmB5?~j%0HW7-k+WhzcXln3x*U2sf~Ct^Jov9kCW!X(QM1 zR`u~nLPo^7G>Ra@*7Sihcue<}0k8yNg6W6`Fd?YBI0mz+`3Mp>+CZJQ-g<+*uS`^% zSU-_DIUNduyf?XzFpD)Q4D@ z1272?e{EdVgYr~`sT&?`S(FnNPnCX+i&~WHHznfzYhlHj{2 z>-!5-p_{CzsoT}FR7*hcf~#d^YMu$YVRaHbJHqKbg@(Ps=}oW(@kU7YrGcKxv(7*2 zq&;GUghb1n|2mS&eTINRrjHwvB8L7(49k2MRJr*S(5a3?l!_^9C>)kZ87$^$JsqJG zV6+y|*hx-n4Hk(}K!!Nn6slTS{E`MIqEP2wWlC%~6cr{^PN)nwj1@~b^m#8rCjKIT z@MnUiwHheN6#|PuVo%7TPh7u6=5M^Pf_ky-?yN+|%aLnK44wfQEeo8{a-9Srf)@;c z-*5MKJ;X*p_RCgcdFP9?y$;KO3*ZjkqLlV=ppo$M{W>Z%2*I4vr+A>V9sR_f|V8m|5~ z^C|;|qSPCtVHWka22BG7HTET?Te{uF5d2NOkim1q_c}&zrWS1HHnE>9uPz6};Y2+r2?&brs@9I!pfLbG+ce!@ zaGlTXzE}ZW$0qgzgo7=ywx=)QH?Ei>(y}zcOKvt~L}W?|zD}4gggKw@66_chXx*N& zAKLt|CcLaP$yNnjt&z-(^(++{c$4C5&q>M(ifhz@6-94>lhF|0+FY8+#p67>Jr0Mh z&6g>mRyH~o@d_@zKIU6<0Xud9vE$&H;}~TegEQcbw`Ev69P5iL5I zg#WIpG9vC--~u-*Ku5{YV5JFtG+Cn?j|`dFH%qT`N8dRdTfhNN0Ucri>pVBCQ5aXh zVp8|I%(5Zd>Myh;9 zKtYoF?yfZIz8vo0+$41$unpaOwT{z)d`)D9WVY zT4S`u2D|8d+bcY)J&}ZcJIH+#3&MZj;K_Fz*Q5%LXv`FSaY$Kup4YpF(nd-*w%mB<_r06Lk~uPg5Rwn|Ml3&b;>iJc z2{w5&b=}p>>{`rhWE5&TZKX%O$KHE8R?G=i>jAa%1)!__&D)k%-$nz!5Fu2x$+=x39OZqoVo~Gm z844Y%zCoK$fbzi6sj2&IoH7QlUeqrs_>bSy+0zE}7FYX<+TOL%V1?mboOf=SpY}+f$~R>>k^? ziltGAx+Uw`CFGNJM5vvbkiF>fSalLR=MoKK0A)9E@ptTZ3&j=b8Fn5;g>7yEtO6U< zvz+Y$rjw`7Hd@81LC+0Iv2f^#Wu2UF~=Jr!W^wX6SZ%iCK2|L zoJ-|L!!wt(RVu``zMr5Jau&sC27g9hDm1;2C#zv9yO2k@vm7mvNj#!`(u2u`(oko@ zbkbKglNCRw;eS+?nkV$G+6E4A8vroer$ug^gP`a#R|ODa1fz%yX-AsR!p1$|yT4kj`U(E| zIKJvr{~Kp3>g_AaVX?ShT-j9H)$+JJ9+Sf1Pon>qoUH8(K|CCwQUKyn+?PEd=iA(O}O59f?)OnXok z#>2`}bUUaiXA{ZShT^416L>xP%M7m#sw}7PGVJl|3>5|UKQLs1t~Lst5XI=T_8(BU zyo))IF{&+sfh0eMSzhBr>Z0I3rVCX6hM}qHkuMRE%YBuow6V9H8FSY(%tAxfospI( zPr44>aC)KRDq~SSb8}SnqXZ8^p(BF=>(g6M7-ZX#6j$+?pT9k|EKg3)=)U7fgDJ#h zcr_39FRN)CH~ulifabiM1lM!QuAC#5%c`06wh`oDWoZURnaTEe4XS(fbx8G`XS(T4dAt!3WCZ5=^bLW2RI>Nah>F#? zLBoX9I01Ryc^@ppB_oPQNF0rszXEEVHHbKN85wabQZs_%yaWwS>q$C5pk0vY0W?#V zj5R;IdX3RcBCd+_>cdifJ?U{HWYlt+{LuWMj`R~%YFxpQOQnJn54*1kc)lq37yqwG z^Cj~^9L)j-)oJP}mWhIOQ3liJu;6)}g)b94+8(B(0;N|13KWJr!#QKDmhHtIbfnB; zydB`>sAb0ABNrq}YYdpERE(f^`BS%1f`1ptK)!Jxz5!I}swALBaDILtQ`FsQ>rbT2^SPbv-Z z2&2kC6wigPk0Bqp7hVv?ZUHxv)HRDQ+{lU?bGd>U9=)lC38g1`EWK7YbnPV(L#xhbIPFOJs^k%pQYI=XwZA+_%$EF9E_ydq2GD~ zyrGCTaP)dPM?CBCu89bhFyv-XZK--R-nKufcs?Sh!l_Bqp~hod?d&eC(PKEI&pSR~ zh+REEiHekPE3I9~Yjzl5G|;jfHad{U9@@k6exu=ci{`XwuuF(PxYk_ql}0hNyI*7Q|E*IOMam>3?(W&elsRt0B(rszptp0B1$#tM|d0{SBC^h~dsWgY%&4{w`AG*hll4_jd)(52#{`6C*vnPHkS-wBD& zi#&z!E5^^yrD{WCT_H7q*;D%^FZovRx&9z{-2PDY{?M~U(DK=_icS=)7}M)rPB&^Y z&uWwpQa5^Udw(rYGLat4)|S~y4wx^gy8FT~o5Q{=k+9sYimeI+Ik7E?YVL{0XDyr-5ps-}CELyCvg8o<dfa{A_dNZus5xANOlk29J2h*G? zWC6HS7e8OUGs4l*K}{r{e|HZL`E+Ay zq@BJTAbuE03-3*6`NG9J&;i0;4HMSl(aLOC3nD9WpN9f2^7Cl&jv%FAB3WCc1`}G+LLiDTq5v;fC!;WNwfb_RwZzm#8#tB*$Z*G+iX}nERCL?tiw+ zPWZE5eAWh4ZW4LcHdl(ffU6`c$Ac{jvREL`vkkt;B=YE4BhORHQ<|OY&K?a>E?VI3 z4e`&>uL1?`avpL#0*|)Xzm(w77~;3A5hC8S*u|4S(#=sd__4qeCEY{20Rayt=?B$q zI!o}GM@me|vmbmx!#D#D5kyZmto%X|#z^STh5*?YAUMU~INh=Uf%a;AQ_o4-{Rn)- zb-Wq>GFpN3B5Bv9Aaap9MEH?(P7?)4iBsu#Fx+{~IjZch`6lEu;KN^&x z(c=pS<&y*!+g7RQE3|%Pf)>~^vzKAi_Bn4F8%*=N`+dz;Ui|l}EAa|Sr5q7e{$be* z2I_^EN&=?VsG~GHpJg!Io@QBp3V;zvE@kuYOSKC-6|2mIjtxcl=iC0Uj&~(E(>`c= z-0k6(x(dttA}UIW)WxO-!DG@?^995r95$T7CB-&n%*k@*?*%P<-{!l{rahvOYVPYZ zBE)a1^O&1SZ4`X9PGT_)^CtRCFIdE}0@CMB>?H>q=RU~=pNMg8NyVo|zJL-Gn_s~$ zn&0*k&)50D?yEOj=Xf$_p0<4{F9Z@#7CU$k9Zg~Obl1_Y9bCBJx6g`VuPgxt)dpKe zTNI(WJ(+W#9;z4nA2d&NugbL>0qtyv>`EmpYbGwG%GpZRrFH`P6CgtR#&{mzI7*^NUfJq+H(wqJs;0_N< zgB{9zkq1{wdsuIs8J);ETCQ~kzjBe^}iJ`JeFcz0VJJN zE&bKySaM+>n=mdNDKc&Yj%W}Tp)6j;;X~p+L0-g@9&&&9tvgd*Guf*rn4taUf$K|F z+5;mU!-?o=sZPmW8O$|r*!HiVFdoy=S^h_!x> zZi{Tv69lLtDPd!7^Q^{vG%=@vqJURLEgj%`8IU}%nuylUAnNd*SEEOS0B;X+bld(l zi;TC!QV-T_>&mwRD(=Hk_iiJ*!zI>CemHAS*o8^#!Xa$gLAThekT%1Uy_r^az7i&@ zC>j|jj(1ED)M69rGq|q-Cb6-^eL0~;%na8W+^;3$jzNnHp9%)azGy`^*PZ(A!!yXd z0g!1AjNl5SR>E01eF>MltWu+XBaP9+iJH0zCi2<+voREGgdVVGUc-Qy4s34(%S!QU zQee@6>nXR~#04xb9FvL3=ZsX(V4#oGQ6Gu*cvm>8A2%PfQczJP59&e}_01TS)o8m^ zt5hF|Dp+qsc58U(e=+usL81iRwqSRkwr$(CZQJH)+qP}nwr$(CZJg0r$iG-H`UIx_b_%I^u{%wMwKb=J+jFp2p=tzS0CGJZZtiyzGM z@5GKS@(GV8fh&oG@*Wl-*tx6RjJo;zfln`bO>Nvb;O9L4KH4Pf4k4-r|A!KkQah+z z$(H~;Oao9M?@CgW(s3)da+sx4)l}!X_(Cbya|kJ#5g4+-Ca`K!qOS|A5Q(<*>HudE z_gWA}OV$oJi3g!T88M|=ugvbPE4oz!Hy#U89O$d#N=htRgSb+jC|Cb9ihOi=Ld=2( zw&)^KJ95#of9JHd!hfNhRO4U9yaXYV@(uyA0ki69*i|qyxJzi*v@pXyUp_j}5OW;j zGt#@1yqXe{o&MV{V9HCg*t43ZXHRj~nb!~|1~8CG;?dS%qAm)Kck+a7ZdseN z3=@Zh=Ho47CaiXP1ZO4rNge9Qc!$1vVFYE;+ja&TJ~vh@LreW_)s^GEi~4Cu64}yJ zb{5)iAC(o9;6{Sf!^rcQ1X8Xz$GYa1k7lE;%-t8OjR;mALI|k`m|J5G~q1l6(dzh~|pI z!7I2^LEo-9WcD?qR+PR>O52X5Ha}q=1Gm_kh3P*Ik~Jr>CO^EsffIxR{pX(w>4n8= z(gmQTAa4cy<7K1s1HJl?B+LZsckTv!*^&T?(1jgjm?mDQxqM#%4c7-Uq%Lm8$z5FZ^063%Easl^bfw7+H2)p_v6MpWMU$qWhN@ zITyjW-xZ#EefOya7a@{o469b8oTY0`IYUPP;p86gCSp#~iN#}3V1s4)VYV7Ekmr<_ zWQA|3`Kgw?>e#RKJ5SndBt!_umuH*^9}MH~F(x8k@xF}5RcdB`jhv8e!(@k9=ciqC z)P#7mgF9#?de{lJq-#A)MG?K@1c5^6%bgQp+@TxHrBUyj&Os9PHLwA~(A}2^PO;v+ zqj$=OlD0^dh}NaGgZPOY$<)yQ*auXUyw~z76XB7_`aH4+%pu{@UANzQo7nsDXW(uj zqI&@arqaOrWPU}vX!na(u;T<0$SN-*M-`c1NSJS=v|9=!ZAvNC1BV~gxPn^?kfxF) zvipxb1UckdGvo$;!-fX2cMb&VW5%E%ffnJR9x!|-nCb<}Wu^vTBH5p)Ti)5z*}7Ml z+NMJ(i*yANgKGX%A%VV;HaR%_Ic91!n;j&;B%2Uu*d)#*(X>M<2jw}I_%dG%3YsLp zlk;5qgN~`P^2RL+c=%f>9WJj^%xV*6ZyG+E;KaysSKTTXf%wov!%WKsnWg)V<@V8` zjyNc+{Rp71qokmi*0$-|3rv^b0W;C9^!Fj%2)kszxmh2EQ)mc{@@40>)ETvi-b)nO zAz*A96KqHTVkmD0rh1c84yd;6Kxvs+*lekVKQ970yyGfok{G?uZ0TUcut&kYFZInrGB0R8&~{#F)%4!IZ)X zF>%ht&x4YvWT$Q`QW3oTx;cXkTKVP&({jrN%LYTq)PL}EQYl^dJOi~V%ihOn-j&K7 zD;Zhg!+2~*>z~$&vy57fdzMQkm29M3Nw4i|12WNJjL@u5R>-)>QJ&zKCZ+|A7N%z3 zaO$IoWmU^WOLberk_PtH9=Pq-*i2Vym|dCd8-|lvVKhk8zw?1u%XRCF8KkP-NYN`)BLvylHkj{O-*Fetaws(X9z*`w(=Lu!OXHk9!D!NOX=dQE`Ms#Uf;zRX*NYQJYo}hCQY**RvO_Hx z9yxhu9y_RD;+<+FN|T74^hdhXppP~CY3M!1nHYGmpT$923{Ruro~^)9z}ydxQ#vie zShjcthLmv}jwV<&Kdl5qJ<=`I0%e-E47kH>tO!gr;f|{bTOHST>@vf;I|En{F4wK) zFS`7@K_HQAH>RoxJR9u>qq+XssV+y{-&kEaFWWD0|V z3!{qxZHA$<*d;mrFRy*f34=RGzMn%=64hNKQHc+!OBGK^@&_UAQkr1noje^|Xx5{d z-{SG)@gI89b?TQKwei(Ygo7=_xGR5NaD9(yXm^8@;4vd14@?~Ny^+_r05r*|!J_zI zGAJlvXnlRB^E+<%gI#J$39z-3N`qR~4<&V>vW(3q&~{w8ffp_VDDaP z-6~}-cvWdV$__(TnJ>yi=L2H^Ew08xOkb)~n+T6SW+N%AE>ZK~rz_4`6G< zVqkdE&#Hbq6q==Q@1nM~1x8|M!lt9I)18j>xNSL6R>=9AaH*s2AH`|(SNHPiMNdZO zbc&8QgS=dKIcXLiCxuULIlNF@tE5s6BjL7DmDJ3^+kj@u@hqbO5#1oy+Ul&XmvyZ7 z>hW8Ajvr6mH=KHlY+R~K%Z|we>p@a;xek#4FD#4r(BP=B#mbs-=AMLzmI|3GQRvF# zt&Zb>M2eH69Bw72Ph((NQD?GyOFvM1^Phq;VXJsdpA77wAkpx^UDXPj>=yvg(J0*2 zZr>G#tdC3j%?v#+u(x-2F4Z*EoG(gXiL#EddTK@>+^<~{5SZCErU_tTunXhhUNn^S zW+83^wXpDqQMK=byZrWXI)bqkB99Z5PR)7c?G($tv@;i(VQ5W}@)oW{=c`dcPheyG zV%OYQUdHdCpnaaSoM`O~W4B`~8t|nbNrmWNgVT;rD&8_OWImKgs0IM7tJ_6=91YHa0o(ml-CJ*g^m^V^yCc zE6^d;XB-g39QJU9%YK@836Zd*j`bkd=Y=9j+6aPG-?`ROE3Al8S_4FnKwG(U7@>yl zSin%~ZC8^iHLK(!G-(3RqN{|z`$)Q%LG}$z4u+QMmzs=s;qn^lmwBVtyfF(#vxNRo zoO632BY&ngW;P0`@C{~NLAGCJ|NiBNI=}?D$WrjSX8WNWX03?%*NTuJ@+Ic23F&KQ z0pVHV-dvV+mBdQ!OjZf8%=cs8=seTm3uhIbWKJ+Fo~ z%R3l+exQNDbKUr5*+A!_@nO{+-530L_4YGU;aC#@Gc(4NMnD>f&x~c(5#+JsIb-`T+WIhgkVB35(kZ4qFUAuaApK+-c)#z z?Hs2x)x6Y4!X?yIdR#C*BS=r^yd2;U6$l5!Vh*c&LoofU4 zMC1@J*FP5Qlj=8TS+xH*(}*w z@hc#1l&J~Qz}{mE`?w(28Hf~myK4h{y8@c?Z!vgufarOaG~WU}t;Gj(70DEF$ZODt zecMgv-lR6_vSpeK(*w7)b(z`w;+=@L!Co&Uf0j%)<$T*=-#I0&^Ir$-=bO4omsM*3_)(ZD}z?i#L)ZqnQDy64fS2iVi&@J9|@K-ULY0NbbWkso;G(~<%? z?7*;``Wu~GLx}=KKofR}(9p9aX%bbvp&uH5-n{s#eFOC^*-`PJ=y+5p9h8)g<;%s~^m&_T73)>sE zUk0Kd#2^hWt)pLOJ(nBRTI1FAi4qt1e9bt1fKV(m)=kjQPLRg^ku5XeK2wfvpP)$2>i6+{7S0 z=+MO|;u+cDXwlvec%&@oAqR@Y7o(;q;-KP7tT<7XPq-7=GmtCbnT;Q_pioFD+C$EB zd!v9z2eZJ3#W5vSr1uFwL~9Jg)d}0mEk*D0XiVARk;HLys{{lIQvePwGp>N$+$2e$ zY;9vOnPR93GsM;DIodR-$k6YaO8wDUvpL#^(%2YqoGA*@R=gn<1sW815EMo>_P6e1 zVwQtGKi2WI9in#F33F$rrbGAVzDx}gtz3n%T$0qk#7)svC6r?jTbE?Y;-#Lh63#J= zX-GD0;m1sINS4p>;kBniF_8sBwfP;zA!e~cEe-rnfGM-YZdi)uSS+oAg~XnlL>||+ z@Qf#!o=n#mVib`Z&1(iF%E&~fL|*}sBKv%;NnV}((qCD{6*F7`8rwQOqZCTp%7{|M zDaYt7W{~PF5{l5N@h%qM9rhXajRz^&UzYDQXTb5P|9pb=SSS|M!!7+=N(K5*MS&2wJM#pQpNARVNyh9dyvTW2oG zCF3DO2GcMQLzsh7_%Q<@ou(MnY4ijHn!_wnm&0nv(k6rERQWnIey(Q=zBJ<8J-Li! zm()t2YZ{WM?20j%Ya!;#6_KG7J~h67F8{e;&0|jv@6DV(`J0(iNW*~?71Ni#PbN0X z(=v+K$ox~tte0#dRR!B?Y6#xQfB}N=illlAk;Dgx+5@~IeWX9Y0AdRCO}0NRS-*$y z(<3FYS9vR&Q7Y-`%c}%O{>qhl8zla4c&s9N#*oWVITw;QX;~|9a^jL0+H}^2)@vngC3B&Js@%EQ1~)9<}2We5eDx(>@@_A&qU;w9TgPAjjK+ zWj;EiuVGhp@^gKOi2MX$xcCEZ!k2eus9|oi6k@JKnVJXEo)!JH`k4S` z@s=(Gwc~j}Kf%S8lo~3Bp}FmXLFAVvqf|!mT#t}0YUGK|xfZr+Y6bZ!DW6i_P<%!> z!?K!WGR@-ZYtCwj4Kp9&y^Tk5eYfT^;aX2r1L)hyti~&iaRaK+e#)*zToshlIScjP zcVm_fw=WohY&Cc9|A=ujpXvWS?-%J`10V8YDzyX-tP&5L3i5JVjI=_m`h7HrD`8%U z|1={vhfAS`QWk+dgOJi{;tFj@Ys3xpYSR#^rZ7R0Z4VoVH3gwsC=%nero2AJXa7*% zjm`BA9qamXBxOHUSiZw0`Nm;=Q@a;mb&LB}tfV>1Rh5|E8sn%|X|>_e?GAQH`<1+E z?#{_+$r-1mSacYD+C*x=_UjxlR<;95X`-VNw3#)hz;Is%)A6D{2siXI!`ebk=fQnY zrg$82y-n84@$5E%%O~=}FWf0()#m3y$jR-Qv<-U?>tB>|dv|sN8Vdxr3#y8H)`0fp zr~n5;&=m|;{7;E)ZO_OWw-=@*! zUq&X|Uykm3Yot}N)vMq2dGZvlSl6l-+T=0o5`edFiwV zYqv2;!Xv6*X4CLo))2H*Y*cx{wCXE#q6z3GyZYgt^(VDhh!)d)@W8{7Yz9HG3k7QM;XNBek6YvpRlk(p>ZOp@|UNF5}X2Mmv zt!tA0T+M&>l!!gQKJu)N5D<%vrATS)m)Asr)}zsWHXqP#K39VECu~|_QTfEW0(>Dk zi&Atwjg6oW0|g;6tBbd>UZ$Oc^WTA00zF&kaPjeCkf(~+epUi*3Im9wku!_3}q z243NH^A;l_-$hx~!dY`r*AK}-{D3g&B?Y|3pQzv?t7lrM2Ac7{`G7djjI)t`zEkR6 zN!pczhjUSmo)q*4Im%Zsx2KLfs~TZYj#FH%c!{mN@9d@iQBdBYkl7 ziu8E3om6qyt(D<*D4k2MZtv#*MOy)5r)|spWn>}$GP3?p))fCwQt@9HLTR#Q^Vj?* zRfB7Vo0;ym<39q)728eZ(6Hm}IjVHx4=`Ey|8OT{fq;RSf!)VAI72_Gz+Kv6JhA@6 z$=dmwEA6W|H2Dm=8O`SWE~WxI)pK(6$CI7_45<$65b?}!-4rVVOzfh_uqjB}n+BZ{ za8-JlpL*C}Y1}bPx!#Ou4%07dTwCXcB^|e5wPOXbfF?)LWjR191&+M)$a`vGmux{i zm|FAH8K{=Ra%}iwKsFD&U$B`McnpOHUWl@rHpZVCBs+3`NC0X{urIIrerHpWd3mWSWplEf~ZRavU>6e?NbPvk`p^TI-=2DOOkmjkTy|i3t zCYH`{bY?6G@O&$Ek}o5dyj{+hp2+jA6u0PANo$!ITanr||KPzR+k3%{Ah)K=y3Y+A z<(XM}4Gj_u6+0L5IJOCD8ydqED`h>1p!+&HV0dS+!Q_=~?U9(bwOQACK86!`M60{% z{@t&FIOJ~{HI8r(oHUD?!0G6Xizkuw{~!D!u{A9Ap{-r z&j5YNMDLb5-Qu(v=)n;rR?0ZqYIpRYdEZKMIAa)2DeKa)rb9HdAO?TYXXiNWQDS;h z3DNnz>g0Rm@>xvqeJa?}c}q>JPn~~`L0uXo+N;f(Az6EZ!3{&d_K{N(t^cE5<|2~V zfbY%O*SgDQ8#dM;Wub1K+u01b%ih)ZR)1K_^Azs31Iu^*Uv%$DdMn@k?b9dTpe@g- zEb@8Es81%DD{IT<#ta^6-@oCeYAn3T!cQC3)@!FcJw}&Z6K=ZLmm@b{%f;un@@10_ zxIqc)>qUZ4In9I52JunS96lN^v<1SUXNxF~Os|!TNwxo(HwVJuP(MHd05qlo0O0*^ znOV*j#{Zp)?jVWRJdavus|}tC;lfnI0slzrki3w zqS&6(dU`4ys6r>~H>B!=%l^FqIE%RgWroq~M6 zZwF_e&)dXBM+X=0PQiFX3IfP$ET2+_0}&$NL%wC7fLw;c9WQ`73IZ#2W&VBOhlztA z2>Nut?>MXhCfGT&6N=Z@RI|X<0G?g`g-V_>{$L)-t(~0D%-xS>FqE=7vGC2!$K2Y` z&~Hp)ma;nWNvzBvTXZ|(rKXnUwXJqpm>!N^p5FKGuPE)U-R<41*ZqYC$dS@WjAvP# zQXl*;dJIy+6dx8gJ)N8#ug-55`_)S8cGSp|b$8qPyN@Qc8=9nE^W=n<`qP6XY&`Fu ztw&>0qF|U-rwQX_-N_Ixw+|EcT zQu{#rQWAmPcr#3?<$g0KM(nhJGI5gx_$>7ofy9D(gY zX3)R(H>Ff#0+_*kXSDQ~O-o-IS35I?F%wCV&7G;BgkE1@nc&K9DrT(yWGsK?R1PKg zLU~AvRqx3iM15br|Kz>M-w>07tezmR#%?C4wk1{YeP*dSUjw`(74jPl> zu1IONo2ODeV@@Kuwqa-dBQ5M8keH|a7$;%&*Y=8)wfQH7t_3?cHzwr|vI7#Tbn`(m zDKK~Sq`&+eGkh?W>_GcW_PYcS&V*G`jaQ+Sgd3)uU)o^%HR?dAq!aY-yz#OO5N*`12qvIFs*t%RX7Up^ zUdHSf;S+wxKJlFe4cY2z|D)FGd62DEY<|J?Mai!G6_Zt6ue<=H?HOU2S<4mLnZwQ; z9DdNZ#F&SbXM70Kv1K5zK0~}9uCnAFLjW8<8V)WtAvJ%&^9SwpurKDYuZ9}Tz!P=p zN8QWnkYU+0%RZY0?kC(DJijCSpXPvwwR(Bs27a$n{_->2Y3pekVGdrIi!MS3l`b9- zIjCHtLC#cS4~X@=9af(gmzxP`SLDM>FS>qYod>G@t_gk6}vW^N*w}@X2vJA^l?P45mtUe^|We)EAfA?zy{8If&@Mn;j8>*AXq74jQ zD;Vm3rfEV7AEqJsE9MVC@52J`X(1lRK5i{d?($-1eDwLOVsN~_Q^#;0mdpUT!Me&p zr>SL?=j`6m#a?s>v^=LHa^kIG+;_KCoS&>I-0%i;G`Z3y(ojYt*Gbq1#s#DU|dQMYK z16tB5Rjg}|E+iY-q}O#AT|{1Mxl(lS*I^v3n(aGRUE^ZTJ1zkh^*@;x5>7OES1s5U zG-2eNR8EO#((ZPs+)~;UOaJrwjj`bX>H$N~QL+pj@Pp703n=T9#%f{HJo>`}s5bDO z(KNPQ)OAWwAS0EP9aVF+aCqnF9Zx%4Puj;H*BxQDjrn=^v_RVUFmF)WEc=aNka(+D z98noYcoBL52G;S{mPCIWCih^~>Kpx+fFLWxwxkmCEL#?N#SRy&VX$_H@0eg8C|@L$ ztu5m$-#m;0F(Yb0qM2Ct8Bnx=fGf6LrZWZ}xU4+kF=OijVmc)GS&yfza>UGxCD_t6 z3AxYHRA@R}c1+J?E`q!e>@qGP(7Mw*BFxJP8&b$E21H@8ZvkNkZP-~Z!EI3b%OR|| zG$jwrYaqw>GQUkbINR8JE)ht=5+(ZfJA%H?*=;dSk8KzU8bxBBCEZwO&h2_UGsSH| zbg{3W!4iQk`*rQMMxZxWBeCvr09ODvUe*k;Bimz8FYj#LEp{;)VDhkSZ9rlokRg>C zVz@Hxe)31M2Z!UU%tu@tXb68l5pbvjy6g8pR@)P&1f`N5cv@DO{yz(N07)e6o_UOVho>@Iaq6z`M>3!s93QelruZzH865w z>XmGDugOx4BRwEIMZiCmFPh5)V$WgC>Li$6W>VfS#J(05u2aj9r=(c@5p*CKt9o-c zAXwU-Dh^G|Yi=5)+L0=RWKWYhGY-l?LoAxzdqD97UexOfnmol4aOv>pNrg>_PV;o0 zk`~DT_DL&Hw2YF8l_nn~o})HYYtt^O1a`yFqwXkcE!s_^Ppj|(G02~wG5cbzNxMPl zP*n9PLkb2c1+T8xJ{0QMm(L*xLat0-unmQsC{Cfelp#_Q*xM=s7+#IDPHJGNVy?fU zX1IuT5A=mRW}MEZN~9?eqqZbh0a`Ck65T*JF>@aPAOi#4wTphUs5@nLw#Q3?ZR=3T zc*&b7_`~+vdI0gc~gciQeg>($argKpzP51qcOZ+{TYc%HEBeb9%jkKJvs*e9#c zIc#?KE#2v&c>70u-@Cb4m<+u0ex}8`wnO2&RFLXk-fbX1Vxg1NT5BA8GQVF2Hb%Ql zMyCM>`^=z3Y&<#aT*}k`z9X0r&@B_1%b-|bC@`bNaue%~&6}OW5=Jtw1KpU3K(~Ck zZDfxhJ5*!|QZnaR_7^NQRA) zWpipIn{luqHL;Cw9w8iY>I1A#I(9rw9(EfZvE$;hSl1qV_Vc|cCu-y zHMp8OPE6CKyoYw~=fj$`TrG<~_MQ&)FDUre{hcJMYAbt7;;!UUO0#+IG|gqx=3Ezx z?|#9I?=)CXan742(N4sf@k-j>Q9#AMI~FH9iIM1cNF%jYESW{b zeSz#XOF|P|2iGTAdh5P$mj@2#P#K;xh%Wn46*p79rNxhZrwoxMMj9R4x$*H)j%o=^ z1y+W!koTzE-NsI`Fpg&A{dIaow2HjfS!zv%^l(1v`&Ja|H&4Y>s$p`!v2${LlE!UW zx@0G-qldx!pEfQiTWBG)JKi7sZ448s{@}E&OIVqcAX>Yp=fcIKRl+viWNcK$O2T;2{j{rJDcXWV=Dk$A+oaAJcH+1w2ZAKV4^NAtd>xjArRjfsPNrf9u( zDO)l>pRv9=V5y^5sR+{1QABJL8 z2K=A5P7RZiql4bosCrJQ|UKG@_LP1}Vn(=l{k`&+#{K#rg zTSCOlnjaKcqhKhlN!l14v$+XHQM8VMi;T)bFRK+jk3Esaw0ft%mBjYR+FD_`ENaCa=xgPO2|YnzJ_)T2QXgrR(?Ygbb3^61%J#6@u5jD zw6SE-RRJ4yjL`kHMrRKe?74Cw*5Vo{=4x5$2D9s-LLyA>-2?i&c1iwRT&LOo;>xon z8#Q;o!)|ZtGcUrXB72EJ=lwvsjhh<`?UQA(!TE?0T1i)|@FVw3t&qLpt7;aA=V8*r zx)O`I$umKs(G6^5EwV$hIdQ;&td7A~%l*LpApML}e-GNJ^G+Dkprqrl^k}(35ttdg zkrM=r`rr+^_<~798bb!P^YNp+i}gP~AKnqss6`KW_J0V69>wnu8SwBF^=2Vf%Mi&Y zE(=`QLK6||e-DKsCATow27R&Rxz`J(vuB{Nb9K zT0fm^|8XqSTCUG`x|E*p-~?L3ZQHHW<+8(R?KXCA+phGHk6)!)XP9cDy1dh0#*>-6 zqz(c(E8Y_iFY5izIlAYBa->TH01zYq0zmk`gLJMY29^#crvDAqW!uJXiYAQoP8jOS zjo{WIY~66#I#{CnHws)$1Gch*V}~Ca2*zuMH}G&Ab=tCYO0beB5)PA!Nb&)9;wt#A z=~Q{-R9swK{OfJc4Batxd|b_Zovl3W1^*4DPh8_>52c^kc+cGX2MdA0RE{mP`Z`M1dV^YL}>?rep9 z%q4PB@D1_|bz3m1&NI2psXG$hRg0VaS|_ox!=3If(Ey9v67GR`j%A?e#h5Va>-o56 zz2It#+aiz#Jbyy#^R}Ez#`bU@oa%NyZTjFif5M$a)aakbx=JWY7D-^DlJ(E|IKhq z8#pbRV_9DHO^$s|Wn)|0S9=d+ZmI~iD&Hb9MaCiec!kGtv38@!-Zt`l~R zo%!R^{IJ>>>Q<@Thid$y^*Ygv|sc_ zEv(XyhUn-Z057EfFOl!q=y4)Qo{5pprv#$o_5*n{{cXQPEhj<*;Dy!le*XMO4 zZVpE4nou4x5sUMC`mAo-^cAl+o#HR48t!>#0B+duc+eBP{w}#pIOBbq2~s}59h#6% zd2DVjB)2PxnHvU6)Y!C(!!&L3!{(wQ04KgGYH{xjSAu4>9&fr7@@Duq`fpB zfEWORANS7~-%{I^bSi0k{KpEc6^gzB!>hN@ILTRq&l$wS>Wf-zLLe_0<_ zK5uJ477OQE>_)nymwSl^00sfiqFo^;^$;`KYgrq z)zy!#o`=3D=n~&OUu=t7XLhZ+RL5#mUKRD+q>rKywLi#wKjjl05n$?3?*uV})D2gZ zJ~H^ZLy0H^dMuI1jCHut-Uz(@a(juDr$~S;42cpk>Z2GYRk8Ap2}edbAyc+Rz7zbi zlP+oVns9`@3&flEM%WhmiQ|)3D3w=yMU4YrKSg}z7fNw-S2nyhv>W=q0E-4J>M*KY zM3u|m6;sqvi3g`GR1(&=gz7C-CR#yXF&>un;6v|pJ$d{zI;u)xTRs9zpQfoG)2%*7 zwadXRuCqO7dndL7eU;M}YO#I0#((DYZWtq62ZTy#C{A~;ygrF}1ppsxUE06lej0n< zG`_7OgRd%fFFf5nF@e(&4l4uDFToj7zhL2RNG!1XvbIL_J|Adc?1fZ8)fbs>)zw5b z!0v>CT@!UL&nY&1hE*(X!!OsC??YMg{86i`xl}jT!!6e+F|nA>%YHWPD;kk?zw+A1 z9uMn3>|>{&K^S46 zs*nG7d?nx!0mcJ-?m?B7D(>)V-c@s~pReYU{p{F`Tf|+VsCgjf`3;D%R%jt1`7kNhl2Ce~ zwpDRI7{ZFmM8)PM4Ws`m)-^6hjHe{$2kCzZb2COKlaH#D85F)WdJx90V@qlq!Zn_> zwf`<92&e#>aTN263Oq<0?{J$IO0R^K+bLvV)@Kw6>Yl}!q%ax|Ba++Xy zuFeQ?XP*?rp5`-V>sGZq5U=jK`+~Wi)G)H`r>J9TC?mF9sUlH|CCyC|=;NuhgC{+c z@37e9?)mz#%PsoIl1#gkdYXWUBF%!7`v*Of>-VKBr3V z-twTf+vCNv$t@QCszPVj1~b?|!(_xkT?jcp_2%M4+q*I3+!Y1+-Czg4CJHo@RQ;Ms zxj>s_+=Z;CEjqIj3yzMzF+6p@XNQ~y^v`+K)6??8>0`En* zvNMDexROc}ayOVHWsq)lFnzD#4G{j!-ePOGV+C1B4or1IoG0GnVtAoBgr(Ve+_jVC zxnHYyYxO=CYl3g>!en)4^VySwVuzh~H`4r8P}R&(BOBqSid|{oL!2?Uit0sT#d%>` zFoQ-yCVyPCa9P3+G|vK-8Ex4I=sFW~I=ZC{T_poMuAPcrHrMJhdRP=sc6Z=Ypok*Y zi72y!&9-1zi!a`BenR1A-~X)ba9nR|`Aa!<9s|dkF7+x>$L5Ex0G2WJ_E;mF>7*Kq z^%lM8;0;KN?JDvU(S!XJ0B}YsHGu8SD4ECMTf?)=61jRW8{BWFXOfo5*j+b)?bc-r^Ap5AXr z9W*@&irO*C+}cdcg3DQhob9*^XRnnH6-n_H@vFgbR==}nk2)e8mn7-gkM4~Q0YwfT z3^ux1eBP*s%V-^@7v?zSaeSn~uv~P-k%r#%XDW1G|jKj(UAu}mAY8(OAdFOx2y;?56o!D6rJ;hgTOILSL@mUD76 z_K#Rpn9va4UxEVHGcaX)z?>_1>cQJS!Q1W$1mwNMo8a&e@epx_LWe@H_{1oI7=1FQ zM$zh**=>d|JU?ZZtBL?DtBR!2NUZV^`V3Bi?ac8u!~jBJ5&IDOvX@yVp{fp_%`Yn# z^I_CnA7lhh27JFftzE$|8ZZ{!J>dXG7;s5im~AE&IL;f!RBitwLFoN;)ag@fwUgGRG3 zPcgKqxV`1i;5 z7YCY<^fd)-1#S1RLjhng$7@n6VECh7yUe>fJaYZMcqM%nQdTB6q}^mG5(oQD27e=P z+HT!$UEV>v1)|MLa$&>(@$ywUr2cA&`|UK(l<(F4ij5FX%^9oSr6PYaesSEpO9@5Ur4YkSfxnUMoq zoAp1j2Av9%3PUn#lIb_eD*xy}Nq=8(S5Vexj1l%yxyQ~98&fDCkD&~j3@9v*fho)y zGDI7$gV;&zf>;RkwvpXwC z6QeLR-%~Ou3DriF=`hd55bKZ~n88a98xeSr$NqaUf;!>|ltDF64>CXig(=7Zp!4T< zyW>ay0O8KpV+Q{S_7TG=f@@?6AEFO8KpZ3v*~exNm2~jZFCb?QBZn02gce5SL1-4Tv#^&te)q8mM8Ib^))h{`zDY>yQAYc??1*KBpG|BjXb7EIv2 z1eFkaMDX52H6X#d7s`)}b{uM@)-HSd=Wy);OMbMm;&4KX4AKYc#Ja+imiXb?K61<~ zk-WJ_BC-b7SLi2cek)wBS@kT&HrQ4K2xJtbq|aUe$6nj+H^d=vBq6sGkt^=2F(pBF zAsMlDS{wF2@&~ZgknVsc{223~$>ny^@yxjx%po)t^@2SV^guobdal`22Xg_iCoRi} zCM~IJ_mTFH;{1Vw#`yzW6GyqWBZ!c6QdO~ODqtu(m7Qy7*~|U zovU+STSjJ;V()aG&?~uWg4hg2(Efh4ZR(>!Gb;F$tqSi`|T%Vwc9fh4euz5Zu--HlJa8AfLgXQOXQ|vz; zYq=MmA2OtNQHl6A7TfZwcRivfFROo73iV+s&d~ICsdbnNVV1<68+}Ae>+NX0<)fE+ zxy5?Ood;)JUL}(8{OfKm1?(3egQgP;Oi(MRq2xypn5av+H>5J6+!Y*m#G$G!e#lwo zCxIP|5h5LAQ&qU0B)>IgMT^dyt|6)}CUHxZ>*y;#faIXL!}02sU!74Ly*K@0q@Qr` zY5!K{El|K)_~YP*T__s!63WrOSwJ0f^Usri)$;ZTYZE`@xp%=`%g;=g>q>ET=f%Fy zPeI>_AFPT|AE#2P1pdNLNEQ0!KY(1}G=!LV_|C~0p(DNN8Tf%p_zWHxSRlwpmUB*j zDGWY9oFa2u?KGbfA$56% z>|M;HX_0Ixon;9=z@4c_o{*de);m9e;mK~Z1=)u85_3tZH`CiX!QB9B-MrY6J({ukn`N-2}Ey^xP|#&5N)vrCYfc7TnPl8Grc zUs7nCv)9mWaIMAhID3o5}G*|A+@1-|W0_TcdHjsM2b ziWYs@#Wop5aQdF=xarCt`B~d=T7g&3-R*wgbQO%qq3z|Y9u_VBP(!!2_kJC4wMUP* zYQk&U)Y$$=s)7KH*Nd>ybiSVBo8th;Rwj9ySyb@mhGbaVJiV?=3jLFx=!6El?V@Qi z7tCWi8To05T|()BTbm=6-e?KtaZW4=#H!YjgW);s-R9ql(7y=`rBnxEl}}dTD((lh ztJjX-Pirr9;!XE$vSrODkG0ZdYQtc5{?|8u;3jEQ^SG0WRdXOP@^E;VbfCM9q;`9+ zg1?TmA4UJq8UU+o|H(oHDudLN!-uH`Fn2yBd*_35f*>TxY<}AqCB&Vb|D7Lh^qaXF zl)q*O?Tnn;viAMB+#aM9asQUzxPhO@y^_tn2F~@=7)3gMC4v)Wv;bg$XJ^OC6_@}k_yS0C;ba!<=c0v2 zs?n^pn5RO=tyT_FW7RX_I0z+HMwqX2Vy=%I{t}k!2%J;8;To>Sz#VYo-wJB#I0p^!o)^T-rRGEbhbn> zLtEDpeM&V~mG0OQMmEGS@&NjpE7)Af{i-&S!6nrQ_%G0}z!Lwj{}kh6P*m6mL)XLz z#w8%{-5^%@7*ro%L^>)>dXBl{b{Rtp@GEUEsOYI zpXonmF&PVYAuKl@10QB{z3@vhCKsN$(JU}BOc+ybpy_<$U=uA~veOy@AM)M57v+TS z6S`a82%i2x+xlGR{AXc|{AJJi*O*e&47VHjyJwHv?XPZ-jN5JBm44Y~c84p2EbwCn zjD>JS8*WCO0kHzI+XCXI+RgKk{gQeizCMxB!7QE9V7PWBXv>H zXgFkow&_F#tU2rHi0pi){Vy-3rbd>w->)5HHzTO;K5i9sG-^Kr5h-OT)XIldg1u8a zCl;jZ({XfOFT5TU=HvIAXjsz4erMbjdvbCS1`bS1&G{+y`mkym1C6y5Jpx(OQs{GZ zs*{|W8mAfMFNAQgO2LE$KWscP4hv&0;&cBY_#}Yk<3~Vp*EP{laWmP0mag{%fK-o@ zbQL^b_8l{S3i?jeWCSS$)?7<*BW*08C!xntO@Q#0^V`E3}y=g zUf?{imU%cYn1SZkVAl!5PhL8(KE=K{$Kaxq*`)JXwf-U8Xw8rq{-HPsgB0+DJ2z6q zYo5jUbeE~Yq|=GJVFR59Wy&-;G%>Kz(j*p$U!A;SOfj?;Q_uGay2-AVT(lZC=mQ$l zzLTf2@Y3r}k2q+Yg07}|TU)A3xzgWV0M6f&TQLLDy4fdKW(sMDXBcz^M?>+wsz4Co z+0&)JM8Bu~*(G-XMHqv1^5c-Y-77~U)*R!UDrh9jAMaEx@HT<#J8a*FaiKk*zVS0K z>Rn^lQGs502GppZwH)ic`z<;t0XrB+;Eeh@_fSW{?&KpKK}SJHK->r*2z@6m%SzU) z7PZY{#29=a=fI_}r<&&UmT!CRhtk=Q>ct;POhPyc{ov&ttq#a0}2qHEy4$IPH{qf8Qk)``|~O<13^}qoMLY z)x{CTMZve7*Pqev>Vo^bx=?x})26m@TAAinmz90WZn+qsAZw5Q@OFognwS_fw1cF%~jD$ti)(=n}B?X{EU&^j)Opj0#~GG(ShrB`*!>C&KoV^ZT6sz z1tn!ax?#E_%4Cyc%<^E115fq1JiuxNt&nj1{y7*8iGw77i7iTiipYlFLM^pT2RV7PFe6jjmD~g>LXM1M9o@;%_NrWTiRvu+B<9&&2M|40;yB6c> z`>zV1(j&$2w$PsFDNB6PjTUcY59v$7b*9wV7xImo!223=?$;=Rw>bVBev>=oDAYR& z#XivoN=&yXCFds_Kcs}+PhD8uNHEwKxJ-ZIK*hb0op1eNvB+R)BR5w%w`5I(??P{I zq0=1?zygB})lz4G69An7Vgd3Nl>KDDTkDarOQ51njH04#!KC}UAq&L@exepHOsO7k zD1m?ojOfopswbU|a?q}JFkdO4xEp?PxF*0mK$T&VRj#YX`0;+al9IVdxFCbw=0_J& z7&&WTVB<`yf0J9JCzh(9${gSD&&&s1Bb0i-UP22E_JpZ@BWmaP^dIKZ?;w(#l1;;c zH;*4wYTW&6Mq-VH#}`vkWP*0!J3~;F{YO6GC7{)eKY#NWNE~#oll*((iP>h5z2bYW zM)L}FRMsfFBDpZX1FPu^*29zmp=&K)^@VeCJME-bck|kl=1^qihG(NUk|ITq*D^5+ z_Vu=|YMGjw89YU?ikY8xhQs6MN)~PB zqx}!?vEwcDKOVCZLe+HgLe*B#01Rys5`@`72?|#$NBZW-Ad@|`2@`K|z-m?PdIq_i zR#5fYFjA;Wh(^Nn_>jf~U-yjB{)!KhYM7*A>RE9mQ=OQX0;<&BZ?DqH8iR@?1#^L> z3YRTALgwWdiws3Nv|S@PedOt3LT_Fqsk7B!4f7Q6vXVQ@kr^>Yd}b6&p_e2TW<_26 z;iw7m3amp_jY)BE_^pyS=`zeHR{A%CasWU%)qY|;Y9L+7r^rW)8QR$Qi5Qe$|Jv?} z4=)P_Z4QerV6xgCiL@Tl*70H|{e>$Tjfy*i%7_&CR&2La@6U%6tB^YkIxPPf$TpP( zWwC0cT;;GsN%zvrQ12By;x!|CsZ;otXl(76P$fwMQaM<_E&J`EVDNEoKieoSGQD(! z+}UwSklGREejkelZKCU7UMj8kUGHAjuQ>m#JB6>a!Euk&9AsdclN7A*=uOc~8kl3KVT9=eW?M^=Bhf~7V3Tut`Sw+~fu zT^PAo)_;$oemg2m)4sE6D~(?{uS$^}f<(ZfDnSR6Ui^+xckR|vR!V;I323e|$&jFv zK=}n)7yNqFvzNO%n1HaZJ>22KV<2JxkoFloOspCMgTaz?P#*;Rw}^ z+|^0fW}jHWAXFRQoNI!~+>Z;-*F7bc!cgbp5v&(yQqgr`df?YXPX#yeS9f7pB>DVX zaNe!~{=2bP+i>e+kb{*Tn#q_C_QkW9KNY6X{xn2m1$v+U7U15YCDH0HsaFose=cC- z%VIZ4W>Wopn3AqU!*Zt0GMdbKHX51A$F2n^_dKgYfH^VM%&CBa*&z{IT*HU^Qy!nJ zR3)}Lu3&RV+%D*{zp)xUTW4+2+Nh)%bx#HSE^p#Q=~$U}wJcsNH#AOw zxKdH@%;uft`xq-tCTXQ3|6aX; z=sAlvy!1_~8<{|0`T95@Kl8S)RMGQFCes)IV@yKgy>h zJi-&BH#(k=#r$qX_jp5ZUZs$1(CzQ{Z0OGGJ|i!WF}6HDNm#kJy?kB_UXBi{$Gg8s zAKCGbzut8D-!Z#9FTW0h8FXSab*gwwAheg1^yyuS=Uz^bNKd;IVvy31lltg+=74$-PyPCPf%VD<6PxI@tC>r3{S8N)> z&++r)$sNA8BN+An-i{y^Ju#esmn^Gc8bKNvPgur@j^x+Y5K-+xz5)(8F^775P-5f6 zH!a2pfkCV=8`qvoq?}Me9!!PIbsFi+c?OEzc}K}nz%XjS@?1uMSwSW#F+t}RrA`(? z?*saQW__RH@@r0oH$#c zbQqbHE$KmbT<9k@xpi(qx z&Udr@t*KkJNx2f|tmP+AyM6JL78uLGxVg7JtX{P^H9&gcaxXGx& zqE4}U5%7?L*3*OFMw*KTH9^*_v0XH&B9x9vxakZ|5;VYQDpOF&OV3(pg3@$na2(1rYngjnS?^?Sy)2LJtan@ywHC-j)(&TM0h=K%w(L*dKqQvZ8-Q4wNwUH2EkSKC*r$u}smy+7R@ z#jh^;vEtCp^R&*B7CHB^3V*zAuF0N2c3dZo29_aHtTa**gR<)#_TPgYw=L%f!gsJELH{2t z&D^Z5=yi3?ZOol?b^pbqO{$FBtp9^eX5le<=bAZ;lZwidq_prhP(Vp)s9RAe?$BwM z2Y+=+NhP1=Ls;a7Is6EHOT|Kw`WU;NqZqj0YQJj#RKXcRPpb%IRB7zFE@q8d@DG3l ztWUYeH!4uezPwt;nI~r57AD1s=k18T6slyZQ3uwn$GW-5@Le;ZA5Iir8g8Rc(I_)I zC68fJ%8Bx{P>J!HYu*fn!dU1uR>43RVZaoO18Sr5%+sdFei0Hsh))ETc?>=OUdQeT zjZlpig3gGsG>^A51~IC*TZ!|Uc9b930Tv(tp$A-HHfJ`Cgj@xUXNf8{MQJ4BKcfob z%%MAy;N$d#bi_xD+&$*OB~-QcpjxviHf`FtE~%f3$GM>AEcbQ4I*O1zoW@3f?Jg6A zch-I|9o17smtI=#gR zhNNVT4R~pcqCsK&;gFo3%GaG9Cz@{}G^NOlu=)ay7e>L)m0i{ncery>*09WVwV zq6T|7E>^v3;7z%fF@AM??AM&|pVkxSNsc|{bF{AD9z z6a=pX0*IKckQDcMd&x_2Up}{b!}<4+66&eZz1)sBpN?iHP3() zyghinHN;U@Xe}oGQKj&p=nnozl-o{^nu_NG_oZcqZFg!;y$`nha70U7j7#co{OZwbFvf1D`M2(UIQF=#s3B~5TVox zMFH}A^A{R9E;FTBeTFG22S$M+6ln%6n99a`KI{ydnU_v5ekf)zAdR8kx~ppU;trm@ zFi_sFhROry<$FPU4_##~B09oulc!~##ttPeB(GfCc3XGOt75e=jV&vQ6>byq5Zt7o zps_onED1$y(!|TRgT$yU>{AcoK*D-lz~>Xs<@jA4I<9{D3LuF(zK(?k8iY??F^|a{ zQzTv`k|Z(N{*NqXFe7$~kwYo5{}FHN2mg=r4gX5bacWiv4-?CFJ*;i8VmuFN3H_Wx zNJ2uOl2rX`LHT9H)n4u^*Rypn+DhYMZRD})jv5SW_*odo7>HhbCNxQY9Vf)5mZTvxj82&pP_Md<;c5*N_rWds`wl=nL zQu;psS2DWP{x1u_7pzA`)J}5`R3X8qWy_Fmh)MSRkAyxkuj-QV7|F~L=rRk!x(BG^8; za*w9ZPsm2%30mBLfS>CnY|d{xP4CmKllo&+HQUF#$Hsk$(7!By>F5RnnjaEq?uqFq zT@amWpBH`eRe4_(u*0@nKhDN!R%c0lL$X=8Zg<^3kbXUV2A+(oW*i%KcSWXn4HJdW zFGvOadS&(83dtzJ@i}=tTehn&Y`RLhpMUss|xXaoVK=vjQfQUZecF3J zOqMTSeWnGe|JisO{rxBFRPaR@$A$&J#dizBHDfrHvR!7+Idk?(n(B*i62&*p-6xrJ zx1Y{XG7LYzqQy}-;rkLhC40-|q1u?nyJVf=_&(6+t7w~D_sqIp69voGbHg0!o~LZN zz@w%qz#r*gQnq-dIFY(P!_mcbyLWxN(e^op!Pp^{;_F9kIp2d#$gcTb!!Zft+^|Fz zu=00%cT#%UpwWHMkz|bN;s0991Cr>~=2Ap%TFBFJSVfiU-wB$T6v9~+lm|&n!3?qn zcz7Eq5rvntFV`p0WGH z9s3e(-7{tHIs9OTXa>*0n6+_tqx}gO{2a!N=nZ>)R`5fMErJi-JTaerw`~}Zct*83 z`H0nzk1r&byKm3WFyj3E`cAPIZZxp-lD4H~m*S?DFXe2UM+W{VHu1alOO#fLMT;@* zz70}Q;NIk8p7_QWeF!Inqfd=!E+oxR_=`Gm!9xm$kYtA*J1n#>QB21PFk^;z6JuOu z29-~-QG5@%sk1@3DtxPlVg%!ekSmaPI6o!FG9HmE5Bg-&@3HunL^kZkziTG0ngqasF{Bv zwmXtN9{4l&=|cRQuIiGV|SKu%h$nwISt*zf{# z6?`k4#v^@Tn++7H5uoS)QbGi~Zs(*x&?OVXav>*#|3S3D18F2UCpxXsf*+KmLk_LKAr zGHwd2JY=?Qr<(Ceo`P7M;`tsbEKq60h>yukDlXZ?ZvlX5Mf?x5lxlmJct$_pmv{i$ zV1rmftZ{SEpqL|TU4s)9eaiTso&1(?^Xze{yF-T$qY~CTvYSCeslx}xo5ikYS48Ti zzV?q?{aJZv8;jmb=oUP@v~VNS{JUY9(D2ZCSO?Yo)ja(MNPb<>yc~29b`i;Nk%tD) zGa)IM-zN<+${$1z&B~4>zU1*rvPfEv^0Rt-XYRC(wkVk&b*}VYKuZ+W5r+h;W)_K# zA9R3>B?sG#mson-pUO_w^FElU$@;HSxHe3&>J`bx8b~w#?>YI*m2*CB?p>B^)6Km^ zvhOs>9qojzmdBAw>6`EmRVP5y@fhTzqG_xS2lRdSqBrj;^nU{r&7SZ3wmS%X_B6j} zy#+T?Ds`ZasJDIliP_vG6a%=3?qv*fOuJo8wqk8+kIrxlo{%kfDj>ib9)PX!DuF}i zF%rSzrPL=hmH);|Qq~zRhQ$ox2a-g54wnf_dJ@TSq!mx@-yV&DV$beS=%9gq zCnX1u%=j4?L<8^HmkojC?g1%_V}#3ufW?nSerwjUehkcR(gYzkf*69 z1f*jEOdnqm+Y8HKyO(Lon0OfHh;9YB1Pv)Akw0zW*=rWjOAtgTS+w-&kR}ZyAT=SO zLQCJ|y<4|M8B~Wl`Br=j0d}Scux@d?Q5(=G0ZFV2I$QpH3Z@^lV_~eOHU~crj)<$ z5h_p4<@JB&oFBgTBqsrmhr_^n8vu zsjkw^p?&?q7q+%?U!}l~0jHGG!6}*>Zc-M`Ai*o6m~+^Dzp14hg6x5P7&9@qwqK}i z@11kQRvn$?RW4>b^=oTxd5M*SX{CjYqzlR?y93-NySgV0#6^$+hAk!1-{Bf!`~Y4+ zfM5p>NxtUpr4L9cZB$1a2m(!`+m1J{^JLEt`f_-`-R9ULZD4M~nC(#=`_WHKVEUz6 z4jvajrxszxfGeVaS3_)0edH`V4y+JF5g2e3pu~H4*na@}2ShyXO^@>_j}5*gFCIIa z0hScP5k~{$ztp!#3jt&`@Cyk)4ccsByAP-~Gz)?o-Ua)Dg*t=eL`j$L?K7TwkP79* zeRi1w8Pxf(sV%*dlgikx;=sF+9PopJjo-Hn+2pXj*ump)*%>5{*A`hHhAv-^p2kZM z@@b^^ymge`-UyLp6fRf2=1y%ynxxaoQ8`XvIAIvju zjY(hJE089?W?Dd2jM*G?`pJues>(PC3n2)Dn+mMvuV1R7k-*Kbsvwgj)B5`UmB1x< zr`Er5mWf-87fM<|ukk_n6N&~T>wvEjpCRAnz&`_nM>IDl~7P_9XKBzhA3$YJVuO= z86`zQf1IOBT6?wrd-hzKq)4JPcx)RIhf62Cp2)hu z;mI)kbzQsQ?YIv;<7dqD3Ay7!ZaN_=hZgi}3C71AsACE~pjQb1&ncDvy&;bVT&MX^ z`Z42btawynhbzjd#|8qJA7~Ho4juH)>smgT;SV)9njr<7A0Vm&Dtd^Zf&RTcdU`eU zHCyfhxe0XVsoG+ng z>%uiGzm^H)iTj&IaT0LTLC2z31favt{}!uf6h5s|iASU{x$5EYFK&lvn>?;gY@TkG zselqpSuhz|w2QIybp4J*M^N+vx!YcThc%>yUP^>z%FS|_;K7;}_)RjSm0wMpZcHu) z?`c*1h)Jc^&`ezj7o*%8pvb(_YQioJ!ApD#l1=qzc53g|U-*Cw33)v>SV#*u?*|MX z*-c0W12F}ZFJkh2Gr29Q$e+_rnHm4~l2H=+v%(u80Z$b9Q6|Dm5!W=+QeTMquyQV~ z`Co+4f;Cg-pu9gS8Y-%*hKN&ZqVtD^ft*SmjAYvo=cx4k2r{I2^RZ?8IQ)TYSc=Wf zarp%}qxUM@4sCG2Aq@^@e*}mbAT0Ex7yv3-l1O`>-FTs1KB0mb09S0Wj2}nVqZ8q) zZM$8T$NrSad>3_I@;-@Zu47gF&myOD3uP!Jjqi}JNo@CS=K`B*@3$?K>-X=~PS$6V z`2>HCE*uIZuEp%;7zt~0?px;HeSSjwL>Sr|Iby*mSz6P`7glONyO7k$*gzoel}w}| zOTaO!z^8Xz82qhk)R;LSeZYlR z=7)JWpxRFWn!ymsW7{L-`HOiqQt#7HJPU`mhbWkjT$=D&G;$Ee3%*pJ@h`{#gh6}Y zN){IZ832bjGxDn$DdKhiYERPZm;C(?_wo9*w>Z#tWO=1{el7W0&+jF+kt0ru{fBd4 z%w@=+@Nn=v^zc#I5uKK0-yT^d4GUm`gJn!bFM)iOOC{3jC{m*kX3GDe#mI%Sh1&E8 z=Y`9!2UALz- zTC%`QC#sqe+B2lny|u|-U+)P;q)v-jXT!dvnK zK>UU;#+GJHwP0A(E9+ItXalgVC}`DW=&|?O^f@3!fj1`yV&g_bY-3;|z=dAHZeyIm zbbPxXU4sSd+&iIriQS?nmn z{=30brqib!?Sv0Oqf)O$Nc6+nkCDPu9H|Ucs8P7Fy?V`8Pyc~fG>$5o219V5<3@r& zP4jg8#7tg(#%FaMx&B6u3JFIPBA5Mt>Xjk|VHv6lSqWDOSF%IM^dYYv$td^wm`+y7gFiAWZs9*H%*j|x2udYBAwj`&BPqUmo;`hO5^<;DwfP6q^9GZaek z&*D;pkP{9`)mfenQ-oIw)Ljz+Jc?6h%m`u%T`Ic8hoF&UMT&AeiFb5`MwEz zIUoWa+k2{CABgtSCD~8U{yq>hgmW_W^R!A`V{Ki63qz;(A%o@187@Go+3u$B9C_xy zCMuKguEDZG>zid$f#@)W;&Bf-$JS}mMNCV9G{Ku-tdjh#l5BN4eU9w_1t0-K^Jw>E zVx??JNf6W*rk@{zeUyP9<^&I^7A%bVx(EJ^JsNtIty7*Isb<)NsA{9S?dytAru=B@ z1>|7HB(W4~I=70I_Vfj?fj7;9ZQ^~jmCv1WJ_k`z^J1q&;iHm?!f``$RBBsFUE-QG z+fuMDc;9JYQ@KS8!su-*%4M--lNTwKngo@XII>z$b>XbYt71Gf@?m-ipZ@3N2C$9U zgS$t~`0&^M=OyA+Ml=7`mn-jf;d}VpIu%cH|0Ybs9Nm$WtybwiFJ{()oti~?y=Wfu zA5>S!ARHPQS2$Jv(B6_GM^&CRr^AFS{%uKz`vh&iY9hSJ?ri>*!+qNi z+V1j~;XBB7ulM10u{jN4x^fp}ZQV*QrgVO(@{B|{$f&asYG=7|z;pW@dVXMHNK}f9?B@kZq0=QHx`oTf;Absqi zz&`tXH5_g>#%6$cFjR(hai@et?|ow!m%sowrgL+fO;Icp!Yo$@y+rP_x=h1QUud0{ zBnk1LTv;ki-UmUk9g-s~z--Q#1atbfmkyXF>3iFaW%}(Hej>G)lESshWLAajqkFAA z+zZl6zf7tE4f}hR8|8-E?NA7WhF~Vntmq`y*%`$rh6t4U8mBdbEP!8xpg9!Ku6_`X zO6$C^$?NIRO2#D*3RxCDYFGoOs~9Um<|eO`=X(~=5p6fCJ6+KJx5iFu)goIYYCQF% ziLi=`!C>%|fA{h$b)aE10D7?F&qX2coWzTBnVPp=L!)kR8EI8gKmEd5Tn=?gPDE9| z%_TRAua$w`n8k0l@On4F$iLB@avw$*Pj^>W10tMeA{fOB;*a$t(I$uQ4ix2_U)i7# zj$29SCRNAa-R@g%qbLDH@4o%yl2B!v6dObB=Q7du%# zGlP&6#JU3&P{}ajP?e*;JTcpe02{3Pp0R|BONMDli>!SOEUku-23#vV)TLN_6frt( zTlY26T{F|7!-!Qp&Z|R}8$&G4e`{;@2(79ruGI-KI_wC_U-Wwvf() z^vvr4I9qy!#OuC8L%HEn%aKsWTgCu>LX|9BE%lU%a)eh^*Rpc#R?rEsc{R1<{f3U> zOlJA*@lmaTAgsglO=4rHCP|!aPtg?7Qp8?c?lPSjqHm&f_4KYi)XUhm2Hd&o`r2c= zt;G1}-=Kz*Q}){Ifh0V)%DOL6mp}w?>J00oh>{bheH4sI=oyog5_pm4zP|Vy1@^Kl z5MRjHc7T{v1=~Y94?fgo`0yOJI&nK=RnpHZ2%B4(d@E&~O#03qF*&2gNILB}+mktr z?5FB*=Va&DkM4#8$OHimq48)o6v6>$-S2*cw7pd9sQmZ<%~QWWShz!|Mv(*@o@vpX z;%zrEp_npc#H|8GLu=?$0?poo&Nv$jommesrLuF19gfj>@{FS!{#t5$-d&Ds!Q3NE7+h6hg5OYQe5LD0Zg(BE+Y%)2H@qqw6R!%n|1$g6dor8QW z2_96usL43@K;}qC8Je%vVz*LvbJ}pnMsl@OWw3)fQgtN3`p7ni-y!5EL#Eg<=I!8~ z%BOU3b(ZJ=^LppQ)_E`MR*O^`CWzcOH^=e#;!3rF+(*agK=+6g;jf$HJi z#BaBV^WAEuuYxlwFE3Ec&!nR~8boJRlw-cx# zWn>vYNMRd%ySV0lK6OdGbIMkz&cP9Hd#uaK45sPzb*v{U`BQ3X5OC(){sE0sG=YeZsbG`NDdv0 zmTYZKEcd&U&}Kv?QI+Mb5-DhTd(5{B4zZldYXNHl(!}jY?@dWMQ7jL*6en-Z+{D{Q z`Gv<-FkgOdsftFS&_@=TAZ{1|uwmNl6RUFm8tQuqtlq-S_862oj_qKglz;0f{|L;4 z*o-lFT=AczFmWZTMLT?sfz~!q&#~t75?4=fEVU+qADx}m==u1S@E|>4~?Sa)yxRI%o4{` z#f9d!m&FSXbA_Jap)r+65QJCo%;I@L^(3Eibr)S|Z|aO2-T!7{Z`dp)n>*2C$fOq6 zhY{lp#e{LfU|BCf-H|6#UMfvi)3+>E6vn~Fn`hVLO`4qUP5lN%f}=Py>V|_?56^lc z((Pz@7}AR?e-QKwlLS!kD*&B%ni~+R!!l78WX`zPZ$Pm zsKCxC)rO5PlGV)rtS_DYs=21zyDp+G3 zbGpoATv;<*K*g)k&mj)p= z))SBkZ2$(6DPqbHd+p(9Vf;&FOBdQ#fQiEop>w&3&#h;)$96MV@g5VTl+yNUNr0;2 z2P~6NXD3PByT1cLzv@l}vS3#tzK2G>#Bun>1z)5j$ncx&#_8vzp zK`=mJ%V(PwL&qF3N@#to`A4CPqZ#&%$U?_G2IZc@n~<7na}o1W2l4JITU=*l!1D-d z^@532p4~d9Ub*Au^P=-r$S#@Oqu94l`X;F}bf{Ps; zCNmP3<>|IWeRbTxs9&Xe-aQil--xNtjI;rhoQEnbkqCabDoLkxgcrcAk} zV|UjJr!bOaBi{d+?`3vc0^zDB`&bO|3YX!Ru>mKfuZ zaj~AbdbzRF<^J%heYHU_y5ff?QjKc@J#9GOww^?5|w z$jra873mtKRAf%qJVN17F5@Y32mExQ40aqMH^NE6a5;aNVA@?cph ztno1LiYl%3>h7xJX*FUjZRItnEBs8sF`2 z+~o>=V{YV@0o zDo{HtPgAJ&vdrxBJb}8RAs(}f#bh$^!9n7qRBQ=?2eEgTqinkGAntSlT>>jk-3&I`S> z03WRWdD`fq;n;Q#zD0Uws+49HZ2ZvBcXIMjHK$3;qSY0%+&)x$yTmfxAtdMVn;Uo3 zw-4SG3Z%%s(2VB%YW$T8)ZmtPnGR?SBIZ0zwACjia;}IE;~JV|khY{Rng#5x*?`z|T)q6Ka^IYIoF3OCep@ z=4`G;E@(dcThP_H45VKLTrjyNzyzcWV+AN3!WW z2cZn4r%UMdiOUgG%_>z{m`+x8K1{P%XSk_9`}x;G(dxW}XOg*8|Kv~f;yu*aL}MCJRoLau_}%4?s| zPAPAfEUZ6D|HP^S>{p$Vn2vBhY;B8?q{7OZozP7LFC7V0uRhn`yXB$$hYV-5O9#+n zSZ0L*1ZO@1N?iGY5f8^_t)fck3hF1QP8eK=1_gCwyE}C~#{M3_ItA@^>KAP^b!8!~ zWzI~ME^z}5EGARn)=Y#{MQ}sxLzXc{c-4J}Ou3u-OloRL5fh!e@T}bs)5lYZOx%vS zs1Yen=aT<9N7Fp!Tp)8Qll9u5Y1?kh?@Hk-YY9{i&odJfJYe4l2_CQW_^E z(?qB?)3oDn-R_geQ?8e6Iaus_pLx%F4>5Qjl*W-++!ESapVD|-Xo)q`c%u#mrQKBG zMg^^4=>^AvW`yTtLaJY-gm!WAimUiWn2(26Ru+ABP0ajwVVH&U&VJ@U1{AYGoYDog z?a592a7R!u-Gj+LcvK%4Byr0>L89%!6lDz+0)S!&9;Mq$Op{#T=Tn7Ly_DQGOJ2`; z+d$};4(nVJE91|e6~n2_*>P0!StpA0xwUZ4*N{p-o6JfuQN zxdL{(qThso3BjE)F*C{`*(n-b=)C`FkCPQ?9}!~>x>}^DLE$}dCcG6G8&M{_QBXB}6zEXz z@=p}9#lMt3cC|1kf8Bm4_@-x{G=Bv=9)E@wMOI{Q8-uGTVQ0cUju z1oe6crHWfp?v%3kTu#;`UgibVUgq4#QA=H13mk<%qa@$&L!|yDQ^^hx)rONX0diF7 zP)lVeR*l$JvW1eU6e1bD06jpmh4hqkg_pQi!0rz0fL>^x&Umj4_sa~N0X0Os z$_8+wWuRxdL=eGzM}A8JfSpYNxA{fguAyg^BIf%_rxhHQ; z+dp7Sk9CK)SOX}W(WIo#m_%5or~<84buvdz3hu*=gD7)DB4=VWd*^`k)x}$QQi}hQ z;!lN&gJ*Ze>ob2I4@zv2Z`;KsTc)X&ip~;q_hic@xNLuq@p*|ouOVGs9iK|8>o#ed zY5w|8=14{l}&UlIJW;TiCmoVSig5n;g=ugl{t}yU_j_ zt)|1NIwqSAB^ocd9IGTO6k>(~83|4-Tth~oian*Npm!AC=SWH7zfH zHZ_-TUNcI^W2>YOJ!;ixwRUzlwIo`8jpgVld%g*NIKNTKbokJ959|8i`M6}Rjc-5qS$cX-eEa*BU$)K0pfj!uPdsunPTv*qQ+0OV8#encq>sDd zL)1!rUC~8E&W`tcXJhE7-i*vns{*v|Ph#j!>t#(By*?2C{SO!N(z|i+O-b5A0s+zf z-=Vkv9lsq^o|N5SK=Q%JNlE=IrSjYt3Z`nQxy(~G<{UNJ-&=_@ts||x{fk%KZ=j)K zK-#deH_iO%vnTx-mS2ahKr^_w5{oeupa!)Fx5Tvx-0zx2>jxszfn^4jE4}7R!3e0jY1AP0uzsD6;}BICwm8?C|5;1jrf`}4Qbgn$7*A1Jh%WX5Bq zE{U1Y@aSGV@Jbei9mD$k9%NelXUpGlsl}9JI&Wvs66A9Nd_#&wVwhQ|4 zcJz^?s@GGvjC^7M(sz6VGfE0sjqMN5d;8#<$+#Ki=n{32KqTi19(Ge|)jY`~B~@uh z^ogBIHR}BnCIJ^ya=tDYra$eJz2BZ+)^xf;nMZ$(`xGv_Q6v$sBwB{5C?Q55CJX8ibH+|zRJg6c}Y!^QC%s{MZrmwzQ^MVabF0;CW6 ziYw~-z$T~Tp@Gzvx#%GlP`GM}BCwOi*W1tbhUJa{czpy%GSjKuV6zd1>eVXoVviH| z{%18;_I$<(*h26hXTeSv{h4R)?)xHB&0($T18z_Hf#PO2C-Vk}Cbs;|%G!N1YUUiUf(>rq2`@7(9$X ze;kNa(0z2Ps_MstkzZdlhcR>fD#mrC37f`*r|4j#x$IF|N3=5YTuj%g#}*q**J0DT z&(Eyr#hwH*Ui(sk7upy>uiK3HJPo#T@8#$Q4uVZIla9lNzHGVvJnnI;Auv+H_e!cS&CBZtTPI8Lbtv4#Ybxg)X}bhz+F$~i+3XGuHGXmV3r^cDsDVj`x@ef1cN74>)1LCAS7Vn_I&P|hB2@Xn?*d}E$nK%p z@>7C>Qo18kzpp`M+o9ry<{ZgQo8Nf&(7iBYlsnFFBHoy!y zaL1a!)rqZ38T5 zXv!w^z@vTP9IKH^^kq2m{w8jRjz|ownQBZX-Ij(GI&wO$qzt#+EB9l5)!80nY^oPD z>@5`DU+3M9Pk_vyTGjlYM-FWJzAKkja=i zGjpdNIBEI{WpU?R^5-xH}d)#7FvIBUvibF7e)^K-K6KC z$Dm<3KM8Whz-)|m3KRu3jxO~$nNG9x*3>Cw%;e%g85P0({QZAk0_CgHnf(vSzA;F) zX6>?V+qP}nwr$(CyHDG;ZM*xlZQIuAGkw2_!F}f&{m6=45mgnLxp(EmwH7IAlUegX zIAD+J;J$?4PV}^6&+;DlcV#h7Th<`l1>OFIqwD?7)FP8Rq|<;7myeW!wxV~ObFB4x zLHc9rZoTZ<7=id;4r6WsRHyiDwxZ6XqC=n*;7cImp$i75b^Dq2m%ppqx#KpI9K-C9 z#v^7{quMke&W?{2qztSOyV#)y#9*+h6claCvXa$6%R{620?UT?5V$d%i8QO7ZO|d} z`UT|U6B&Z(oDl?&=^5=E9!|aBt6g(kuxk&7n00k1m>ly`{c(w@=eYlYlwKx;>utQ> zrg;Hs?c+-C$w6C+8{8J;`MVHdmvZ;sQ0V+I@Mnt;3wCPV&5x;=`jRXxaBgPNy|mO8 z9rM;Q8VLuvVdpU_m}1qn>>;xuKeVjyi?>5tP=`Ke8DU+qyjWn}!Q$l~b={ba$60;k z!Iy~i&mu4MRGgtSr2*Zpsg0ZH(yce5WqAApr#>)TR=Nz~b(5(Qoft zij346j?`^{8}h6%Z_ixfTZs#81hXJ4{8kB z8!9Wq-g4U-%ze|6X4267w!OXGzjQ!$5NBF)pp30@h6%m}ZF{BpgADBP4|^rctT2oq z8ZQDQvKdqj`4vNq?;gnXfg1h0@Lpssr>D%3q(8D&Ceb;&yTI254?0NpnwD%^t1uZ9l6OsF|s5G1Yl6{fHlXf#8RTx4hHVPQ!m21pI4s* zFIWD=JVjG`Qjj_6*0#38%emP^&aVK{Im5KVl7?fQ>mMpRNNS0y+hQ&D2xb_o6FE2V z=v|17X-2!Ky>9>49;v?s_QntwBuz%B&|fPES3rI~KAxZ=3aHuNDCy_jLcI7dkkqiq z!o{ughyYywe*A=@mNhw1nk>84qfr5GJ$x0~bhb*xmYMvl@zVI<@I@w3%Q-&2Hkt5o0`SdOYl5_+4twl3`Hyn9hHnorGp5lON62wV z7T>Xv^#9`eOUXgMm5<#coFKRVh=B&J?0W#*p055PA)Fw}?Tf10ogtTpG&}PFh!KF5 zG^NB-3QNH60)+isYPV~K9#lVZFtS&^7)X~g)&(4dm2Ok;84JqNaJV0aJ$v;g7{A#Q zJF(-*vJx+GtMKuuzHT)|N~*f@!L$#4W$HnTvG`0MP89--Pk3}ZD}0skm{6pd`|_db zJmW>vb3%!}0d(gh!Uo`_^W9H$xq8Uf(RrdJ$~mIcntKZ)Y`=#c;&q=%O``LnC)I28 zHx`!K$HahRUHIY~ntMK7ZCsS*JV#cD~Sgt3kVlFN4ZsUrXa({gK@C-dNP4syh z-*;m4k=+wvuq&o&oF?^+_WfXO(r!B2Icipj#5XG1zWqDtII)4#N*GX77f=+=Qe>+r zHlg0i!{IOPzgw-K)iw{@AB)BRlal!VJ(T{3?NXkK+amY@Db6&3Ju)z~=@5w~I!Vz* z5>*A7g%8tXnl;W?{pDTb)+A*Er^vjypz9CPj5#X~!rW-Alf+ zcu}ZB324~z#3zACN@c-C1q9qv)zcK6BwQ;t0j@{luQvSQWXvv6zVdES%&HuqfSz}{9gWWj;7vB z$N4c$!Ylg()f)rMiS81Wnh8~h*n@|sa!dd-WMy&$bSL!LQQn zbJxc9I&xZC(%JUuOXkFBpv50pqGiCa+Q#$HQ9 zJ}(R&i554hXiAOlkt8#9F&m1HX3u|+*o@0=N^Gjtpr+Jp*uZ=iv3*Bm;hH|^+?RQ~-onZ;*>3R<%xpz6(|c;XFMQSS?_YLt!;_jnEbxEE zryhR8(4Fl5@%HdM7uOWHG5(yV_v{Rm{$l8^aPjTZ{hf3+lb7f>)2sXavGch)vbs5y zMejQ6D~a}Y^~#{vAL~yOM5OAio6JMG4o|Z?%1p^3Tk2&fGVtdC7 z`!(_cQBSqFrx6}w1k@J&TQlKQ@Zxxrbt`tC**@;H(*N`o%Q6>Itr55R$<21>{F>T|F>$Yz-ZlOIz75{H$| zs2T|}0ki?5BMs#Om2ARfimzINo+S@3oa&B*nLo%QPDitL7eFOVL@L&tX7W*x>bn^% z0lKI)QNfGqxBua2wkRO9T%kOQoS!{JBeBMxRhf|k2= zenkQd>ebCJ!pI)@ovv39@^}|&yVEWk%9zuO!g~2O~A2-J>>`yD;OYgFc2PpjupOz;Yy*gJUYI-Z#P%-k^;Ix zvT2F;u)v0I87_7+X5SjkQRpJ@6oz6W6$UQ-O+!y ze0h1>7Maxs$U(Y4N&`9HQG+5^1R*>%%fLU6RNuCT5lJ!M(LAf5>HTGY@nn$Gl1O&1 zSA5olhGjIx1hbW@rh*_MRk6XE$1m%NF^yWKiDtIdxAuF}#|&*fWp4XTZ4HOCNu#goW}B3$i1H{_=$u$7QX4tY1X8@MmRs8IBTf5I?M z`lwc~3&5))CSvExyb*#3FKUXGqU`xa?`y*_IRsGieglzN%j=K!-+Ii<&E@6TDh}np z?qQ}2H_LfBtIQk#9E0<80S+D11V78$^Lwqc&HOR9Lh~>0i7E~#&PM`4>46-2z@h}h zD)Xc~c1lG6uGtpL04SUx2(I{OBaPsNc1bnB=`~vxRgdx)&#zS47_3Yqe2O4 z4hdq;!gy@ic_pvWt}h~tqG2sP+sso+h8mA+1`7-lz~sbI4=W#%K+1$9OVF*lZyOShF~ztV|AI!r z8^A#Cr7V7D`S|%bM^zF7mClgxSuD61+IN1`8t=zE z*iEl@AGqiPbIgE&5M3}SP>}fr^SZcIMZbX>B|)ZD!!GAVC7$WPVaF@4T&_`E>H}ix zj)|2nD%l)h5&?vJp9(W{x50D(WJ}dzSG0W3nd$Z;%Gjp&xvePd+K;q#Ektjo)2`j?4(s4Owh zKVbz|+dJDnxcz8CRwf+##thaX2)30dPRa>Q$g(J(CZsMoVI{ zu)zwybpG52D5F9QrZkTJ@LfD#hdxD=BLe5)P))-j23-A)p7yu0*XM)g0!|!Ee#o4# zvJIP{+hg>HRCn+9>p?yr)-#gcP%~hk&kUJvPnZRYd-MSzIp}6~!V4eV6Q=i1RLl$c z0iONnVF|bHKPaG|_G4SP8o->s;0D2YVtoyMLh`)vNaJ-6V_foDBr%05JpG~snIt3@ zY(s~UQ-!`dLFbB{Pjy(*!|6Wh*ye{N^N&-lAP?D{IrWgwc1W&d@HIz^d% zzb*atUfs|%RbbLAd;j1rmlsl|`Srfn{y9$s1K&eY~%NS+X zhrd}+po`%pC{cjC6f>8r*00j&S{oUmLz`mWu9py*Yi+@hpqK#OD<6oI|H6 zg5GQx$1_leRV&^j6AV(hLkV^KV^npHa2OzYWFn0`db@7BJ``qi?}K7i-#fOILA9KmQnlq|8#}aZ>EBv zF#1911bJ&|cft0KC>f3%c|Im1Tj6gvTn>0uSPXw8FPDsu-pSY`LW-(SWITrLWGe?- zS))Gd*ybn^92we~Jza$7q7Ro-gj45z4ksR#Dd@1pQY3+*L!l-5f=gW36Cu>)nv2$_ zj=)XUE7=kvplA%^8O4IYA`sXFO~Iz#960IjYm6V^9)cn=vQ#Qe^&6EK6U_-+>p?ep zF;dkjj(6bt;&s8eaw3XD6#r$ey)_q6K#gsC>Tc~qJmD4w3SOx52;VIs>t@f4G*YmEu>UIC6hQb5Z z-VDJ-UXg&%oAz173dj5|fprkz*$A*|Jc5@YIr%96!#xB0v?KI!oY|iF?CmHH0K+rC z(%WaW{~YX2xKY`k))r`yye&L~!|8l9JztPjRPZ9)^)@#Wu8d*wd&L*DOpa2SS|wmGpLWpse+d`Xb||OI}pNIl`gyij5-78UR_aB4uvF)5I+$Dxm?t@_jkQ7*6`$Yy~fijO)!O&{H(M;dslF!=a zxfdQ7YhnPhcvnV&#N{4N{}uWl?G-ORE5{X34=9)3A7TPm|MIB{+ zsUGJCD*@;=4?LLg3yyYou%9geUMs~~%_m7%6sv~10#ON4l9Ha1%D9#`URc!NXsPCE z5m%7rZxy#Z=QFWj=M@=mX^l~^4i$OZj5GF6ZUMPI^T8Kz*QMThadHA#aGMqgF{WFi z!WQG;4KnQF-kL+5&{7|UP{-Czi#xuY1vl*5_MIan^#dIZ`jU#4{;LK>QKk?qVF_@% zThw0HOE2V`cq?)$_!UX=oU{b&k>Bl=WM@gJ%*M%CDx{D(eu%Mtj8P_hRW|7IUIxtR z7F2Iti5R0=7VXBZ)jR~H=$CtT`A`I8eBG~fD(+56U8cazuLWaP=2SSrb;hA?sW($) zCmMf2_fz&FcvJwv2?0udh^asfp$l5Qs=OuA#)U8((UFrEPl5Mm@HFI2Y+(aEt}f!W zOr$=a&8$|As1u8wbjRJ|uN*)qpHdDmow^f;b@J(22Sr5QM>5J5^y5?p$;MU=U9}jg ziK?y8-d`d_-*(=zrG0ucSHI<0&hXl8I1K{l8|tL&U7KY&7>-4Pe}u92;LVzg=n_dryfWeR?37y(+UDO zx_e&zm3aUGJvafDmR_kMES3*O#WCy^ikI>Z2~KECUgic{uTgwd=KK;}M_pD-TN{I~ zEK5-n0&_RDGd1`sl{4C4h7Sz^@38MUwe1Y-zpFJQ9^zLy9_$iF=Dg%gaLKA9GbZIr zLLQ-Q-h3vZXi8{%-qWi0H>A7ICje(SRMSVMam9VZG4%vGXj4 z)&WEQ9>n7ZNvJ!RnZRXMvW!1~&<>fJPVLa4@VwRj{rLy(sUO{UoZlt2Kg3xuh;Bvo z-HFJ_SN<{i%=wCt9cZVijeK}M^+0|X%2yaw=}-_$ENqWK@BzfSpN)V4Pq#(IW&QgP zh3q47Qd-P?O0@8Ilkcm})=5YaBaA>KZ=&=tNQmX;f5CeKv@?7f?U#-LL)@TVf?Kll18ClN)NPhcdb+di^oSk_4GeUixW;6D3iTgUTHQ z%+CwQ=rZUzXDPnSbdTa2lO}Ajv1(376%E(EPC{6MA=@@xl-|P^iK8ISRry`I8S;sN z{HNL9xgH=LM%^K?F*L;`qWe`>a%>6!;Zi|GfI208|2OT$d9rN{?|37M2BPMQO%RO> zG#$sLnL#Iwuo|!|k+ub!IGRtWDrhDlqzIuwPP{Mb6<%FNGL)a#;<~(75#=(p8Id zgZEnMVM2T6dSOEGR*-H0iR{pf>$a#~Ks1qg3nqhnIMlynY{@~qdDv=F=1!lC{#iX@F7mh(!tQDT^u5&4H zfY}n_)~0>P`_#(JoJMmR_?1gJf^|3h7Ba+ZWm>459z`K5_{@~@6cn6%qvohOPmVwo zo|!~{)KY-|BEt!3)}^}1848px$M1*0Xa?~cvb1lt%C3>svp3trE|$f2qwUbbi67uO zae9DCns`=Lef`idi30;aOC)z@Be8)cXZ8~u8Fy86P}NHGSFRVz>jQXHsPt6I{Z zm~b@3+Hj4~0gb=rm#ouI(}F0KOLKwe%Dc=Lj0L%z-)%af-x1&b6q&iQk3T<&npc& zEjyQI-&(gjrk!pdHlZ3Xr0rH5o*16AxNqngGnL0@EDzisXx8+-qeeas6IFkdd?^n7 zK#`}7aKvI1iIuII1k5VmgaH&V!ztX!BgB(;%3`EZ7-Tp$h%zzN0^v+ucb5u0EhTqR zq~FPO2Ui_acE-g^WO0+4P`!ln5?06v=$-Ek2JdsEpS<8k!&|z-k?Un_J*$EfErSvw zgX8H8hpRF;)Xq$neH;nv+k7%L(prJ>Ne^&hm|JCIYVwoKY*JPbV`~;JQAX9ycWkBB zO>H>~Xg1>t8}9(v3eS+(^)MeznursfgsqC|;Ye22rPidi; z)r0{FA`U@h0LD!ZfAM?}8q>zI1gL1C+C2#}yS~q^alh33t3(< zLNx(=un4btWZM$+R`A*REP>eyCzSbEJs4Vk*9bsz@*jC+3YeYuWcgpq?afo$Qw|L= zrfI6}g-y)<1Msq>tVb_77F_bk5la;U{X?wXz7Xiq01Kul(^O%LUamMza!=O`Mrf@5 zT(QEH>*e`nsRqAnQ;gwCpcKm#8>2L3_cwQ@8h%nB9A<{9ny>aZT;0s1tlzOp-0~=1 zcGW3!$irD<2fyj4pjtX%yhh8qp2RFz{2>AuByj{mY@(d+fv*DrE#^7~tH}EVY3r{@ zUfUFtei}quB@TWacEE3E+u+IPpF9b_VSGK`Ur$^-UE^2H`pXAh*?nK1`uj!F@q_vE z1Lipi#Xf`|y5Gt(%{BKm8jx^n+OBRQ*9wtq_V$-$gyh^YW5%y;-kpv81^D;0cprw| z=f_VOruC;j{Qs5~{~yJdpN7yQw*P1dwf*5logx)LASgRZB4D71%2YdOh27+pyfymk zYdL!b01fOlXBt&W^$|@`Kjg5xnKP^kdfV)xm?4Lv6^3oYzA^kvuQ-9+ky-e7B=CNe zA`U$%l7RP1Zo?eBG(v7%icqVJ#q)#|I0{itLFBi5Qp~lR{@g&IdN9>VNR7 zYBkG4TF)HmDq;k!WFQ}(RiyWe7C|FSD^droDzrS}DT#uI5|~jp5mIz|WHcs)cAJ!g z#p_ueXD@Q1<68lgE?bQ&zDEFb(=alypppk#wJ4+rk*z7EG;D;BKqE&ph7I%JC|ptr zB=n1bnJpPU&v0cZPq!=yka7pRFc3%!hMNefjufi7Z?ib-_eiv+uTNhTn8PbX?1WZrFu`zvs;1c(r%V(&Iw|{?oVEj4KJR&$H3q=Ioi> zFFMybb2*m6;n1$36Pl)4i6CHVO>0?_+!C2Wlx9+{l3-(Izfn{<`1Yz4e?7%g$I{Oc zb=k`oLA)qu4O-kJicy__4Ir>OyiQ%E2Fo5DhC#G!Fz+#ASAAf^|Dq)kS=W_~6M2cw zc+0L89v|Qk$@DptP{S3x5I_*Tios0PThB(=sL$+hU>edhbD7geRsX$8cApXqKC3?e zw_(2XnVV-ULqAqKqwG51q?wDtR2C-;y0^M@@x_(x8!AI8Sgi(&f@zbe|hhvlNfm+P>r zR}AsgE+4*+13NS7rm?2l23zX@??aI&Vp@yPqSr<9$=dQ`gH>Y2_=|nkrhc(iNuOr zHs1A$lT=8dtuCE<%!o)}tcd>Lb2>S+nb*{T-Y&`H&FS40?PhNwV8+gDo&qL#W(SGl zUo;OTbt-rC0K27K!V?=Q+=dw4(FC={+WmF@nuRI5mzb{1?tqj_w?FuKP`i&z%`Oqq z9)H}F#v`|n#1C(e1>3%Bn@01tM<H4Rw#;N&VIP>l#a)*2>(rj7?8rlYgu+iv#MUn`}HWmLBaw|6S z)+W-6p|1%FdiX2IUmyCRN`svqfa-pXJ-P%(!4Xd<B> zlp1E^Tq`2BIz%irq9 z#W>dwR%6V%$?&3ef7t#l3CAlPne8)H3P2QO_IGHLN36jkhv|2AwhDy=BZY1jr=b&Y zA*GG$O59kG>0{uO$0w*2s7L^IkkSYi;RlTp4VE#fpYuD77YMZk1&gGvP`dGG2GWPRw4M@Q zvl|gc0Avae8xTAZ3un$g_NJDKwrBuS=Rsm5xSX13%zx zo`-Vz#zLsQ73*Gsa$PS*wR}V%m)Y20=L0$Q@t{+JEsMuFEb#&9mHU&;gUz9)zwvd_ zB*--yVU>+AkXLsn@>p~dF{=IKEoC51N$Aq$s6*vdp!#-sWTAagUp#I>8PG0pAwafR z06JK6Jy36!wrORNuxgurAbxa;sj$vl4(bS0tx2M@f_)nUUh#5>8<1bX=L)k`LV#f7 zWM~7XSfU2D$u~<=U%}YLnh2C2oWt>3XeDoL2uobRw+t`?+;u<~WSm1w=%5vh5pPo& zM-yRhkKTBH(S-+vP~8b5_*QE4XJMzD!X4>+;(S2Y(HiR1B#Xe_AbHYvd-8n4&6Urf zIS?cDw)otWbjP>r7KrzHpj-6RI8q$oj4Ek=NUX6rHT@0$gg&FM=xg}TFX-nhQB1)w z$q1-Y$vEdyP_u)!E`E}5^^}^n?yPwkU&hw3x9)h5*wZ-05m-P%<3$`U8r9W2uBp{k z7h`lNioD86;j!bG02S=hqotA&t1f!K(7ggMY-{rRw@?C4mtosraIB2sfI5Lm*92!1 z_pcP*XfKf%@Vr)1cDp1n%_Bn|mE0=NU40P_qh;IooZlINb#~v@Q=j=PYHt8vW#Oj%xf)z{?Q=S> zyY)Xu2gEV8US8}!1B;+ERKc6C_Ihn4u9qym`1Ikul`YSX%r(g=lY6f5&3iKRf z{$}gT<*}Q{3sp9>(5R_p5*M1Ze~1dq zBy-GdG6k1=KH;%41TcJ{M(F&UyB#ZB4r+cSR~y`e5U$=<>oxZ)5y|Gu1-ZQxDrfx+ zbrC1ZbLi>La9mDC{Ay=*cIRqw^Dn9kyPKiZPideD{ZoZ5jJC5FJ=cdwkf0D%KsSfW zFu#&hI7wjHN5v@M%W>vR7$D$pEV>7Q27(PlCoFbFtvtFxdhyCI+H!YxrMuwKTwQr; z+VNysOhF_zg9{k#rj9(F^h7n$M!#NKHnTk`2EQR-3IS^b1uWxA&tCP|_3x%AI@xKA zg_G8&VPUB&3eC9GWOl6WunN*}YZiL9#)*=c$;^&S;B8)CKd^>}^C4F-3C?>k?&-nE z5Gi1a{g7jQV7;4ODdVvL7Jw@=`T>3&jlhd)C-}Qfog8v?4VFn#7Re%FKlR!oicAiY zaGy{RjVWEcT#4y&iIs$NYixS8^Xw3dBRJSX;>%m&Y+>3--q<6dP6#p($Cj9jHC&_+ z4eNH6)fgn|4b{S&z$Fy9Wza$@y=r)2O`?RiltHDo0inj87N2)v@z3l+5jWk#RhmfO z)i+JR>|}sKh+1GUZ|NNG9{?3>5b8_QL~J9i#d5c@dKrEoYebbMi}Q(O15kb-*$|a% zc`k@IW{xXL!cCNKi;Zp0)Wj%x-oZx_<2~97Ht{SXdXh{5{Lie%FRZaC)H9eM=f`~o z%25D2+1625vc{``sB>5sB5iT?d46rPJoGg!R$zq+kyrWzmi-FxLbSD!_jU4-hf%w;%M{yZl(Q&lSqe($&KWz zCn=~2ID@9nl=J42Dkg~ti*-wrYChW6r{1oq@fE_+A0>4noCfMY%J_>MGErQDbYJ)D zkY*9pBE;u}G0ctI2f4{5b}I%iH210LUsj`KIQR1RjR=fR^Yy?M0w7dd`JJ|6~1t==W^wF3KVu*n?W-^$jW!Pn=HJst~S+ou&+;#pUB4dI{SB@uB2^fFKitumRP!*mxSB9B*m{1N8@zuFuy zMzG0oJweel+WMhT4O3c*83Z*sPzn*1$w@Kkv8d}R-$(Ruvpm2bJ`MdA)vY2*p6=yD zH0O1@ZqWqQq8VzLg@>}rNPcdJ8aPFn4Loj`b$#z5g6(a7cmnQxYrY=im$QNi+XHDxAICBp;kd;v8tAu?_nJ550`d?9EOB1ATT)yEzp zADDbk(bYawr(6IktYY_94KkM9>o_K55Aq%BuAPR-G&U8BWDR{T)Mb=oJ371jHtdz( zZB!dCY^jx(0eO`N?>%@rVGt*})FiovCuKX$ed0{iFj#n}F$p!vvsyOX5?86&xl--H zqOj8c+$T`Z`-nzP<8D4MSa7aWlQ6xW3BDTPOEDK`Zms*;Qy9Z*;l|%};R^4%Vn!`< z24?vCfknt+&gd_p=|`2*PSNJT8KZh4n_Ufo*L+%B3EEpU8}PdyML4CVt9!Ik7NXgxrsK$FbKn>|cm24@vt2wIONUg-Q|{?x4d zE`yyEZ~O#f%^VarPTFOd?oqi=s%4BoI?bgJT0aj-w~`l3x3d`ehuymCQZg}>eIkE` z=1PL#YLJuiLc|-YxFYGX19Tc&7LsHc512$(>9-HK*Zi(TIgN)v+GAT`>}xs-a@YB} ziRdcF;u^-`n*#kht0~Iw%=`00YvifQdVNOiuhaw%O$T@M`nN=|$l1Y>E$1Fs&0=_H z_7^q=*sHPd&5@Vo>(lG3JWNSG5B$IPdF}(-&JT}34mu*-y@va{C$oN9Bn-cw#7kl( zjDFno=xX6ciS~GTYBM?zX7qK>tw#%S^WDEd{~n)jx!=5g{>10)Kk@nhPjK;XQTl&} zZQ}PHBN0F(C_70aK-jQx-iV}vNahZAt@*UTnM!?g8mmv^0E zbSv^%dS6h{4xzSE+ioAn$5A49&P;$mJ0%yWeE2{ntAg8_k=Hoxv9D+wP# z5Ct$RB*8%fnrJGcSk>wQGIAT#C>A>mQ#DLUR7QcMD@`wiDX>Oi0fad_#vKD{E6xZ+ zMRIU82kJD);j8AD2b2>OBMo51<8(h(9;viD6>D{iat%ubstT$NjAolu_E$p~p=IDC z{ZNKA4uBR^YRwa`k9T@a&!AzWdOw+1lfiImaWV+Jo@UJ&XXUbO{^HB{#r>|#F`}I0 z@6_Y`uONLauNi5uElu^oyJ%j;a_|N1zXjleD~YLWQ?gKbT!5QT z4-FV+yZ;hAp;t-eb-d*_h!gM1iJ#Hwi zG!P`$h6T?1C61(VE$qMp#P(Z#7Do9n{ah&5H2UpseM?-vfEjZxthS>ylsPa26&NsO zL0V&M>|aNn0VsN6Yo2=cja7pm@>!jAKzPJDo!@P-D>b|Oj57}wTNgaf<{;fl{{BDe zC)VC2@^zJcfAYUO|D>D$OZ{Z=U+O2ElYiEv`gmS0dDffQ{-u7>4Dbz0-~8M*_Hg!> zy?OV=tA1DXDv#)8Xs)yse#q0a*}UpCjSY4zysS&+)iGJ8dA2Yr#b)ZGmxkIlYxwZ| zuZT0HyvR=4>m0FEWUkk?aW|5H}AryiNmYIaVz1&fy6n&ymxY`yE1r|QV<6Wn@ z+<251eHO_Mg|1I5Ud;3-b(m%I3{g!<&Vo)*2hwWw-3gNcJk+uk7CL|8nC0a52WJ1m z5tkkYQ4B%Ew@{Yd5pxok-Fb+$m$u>_&np*6y+sNS{TevBJ$+YsfH$!^^(R#RFx2aR zyzJ@@bvsL?sBo>e0ILD(yiD|Wz{p`t4ElOS$qZMdTTEK?7+pw?3dNGsNLHRfD!W`} z@G>5`g@$Tec^u0~N>kym;mC>$GIU!#5wie^zbPE>F8WrQJq^2XAD-?sFj`KZcWGuB zrrYo_8i^cvOHoqNEs}M{3LLxx3^@@oL88>B$dCrU9ye!N7E2r+9>y3cC8fB*YOnBy zibyhKA>C*dTTF5s3c(Bg7+|Jk9G<}#N*6L$I&NsassxjuY^KT_<#ffpOoiElK=BLw z+t)l=4Lv`P#GzU&7>~;7MaD-ynF(56nmlv_uoX2zNsA&IQ2=04qKfc(ot%!Cg(qR! zY8pBVvtveHTPi>~2sRq*6bEUIq*O~n)Mt9rA_qBKM~9?^K_*J!14pO3Nl>P5X*5@{ zsf!s9n6ky+w4^KuzCzSU=MCLp3pMjVW({s`F|wF*?YR|j zI)-;PKgIzt!yzkxw@!1|v(4SRQYK9%=K3oPrz35UwZ=p`0)TikWfNcu9HFTLs2}@N0LWy7)mQxZ zNfV`wBCo4JlbqBnUy7>b(WGUOy8>pxrQcC>Y;k}#_9f*6!2}KaK3Yh}zSgAErwK}X zlURAa)-o`V0fY~#X#JvLmJ;ouR(N$< za|KEg213GzR8=_q9<~8^d>&a89wKQYZTO?mpW31KD@YY>hT8zo=?${<27j)h?{o0N z%lsmOUKN!+KAY=m>_xv)&;fc#y#bCGD{=!-<3zxTKkoLy%)O<8IOpVSeLW3URs%fL zLkoGCda+@)r`ql?Zi@gKxmnW-I&e*&ZP&G02aZT>4iAFR;>qKUc+tajR5lfCF3mkW z^IBkPTL5hRJJvC)dBbQEfCO}*28(FqbF0JQRpEnPy$Cw~lu$N*ac}6*GHju7RIS3j zOxn1Y&Ica`5i@ND&D}-)!Z{h=<2Ge8YEN0$+ojaoTw`y#$CWPmjXS%E7ZK(Tbng4T z6L<`bv&p!P7X5kLQFxQ%;QDwlv*?Q%AJ(RZeLK+y{GPVAWeQyX3^w0z`FC=X8(#}9 z3=FjUiZ8(pq8MsK_rUznb9ewa46;39G$5BnPJ(IkYt-r1u_={396m)Apu29B^_td! zEn}BR%?lfcIso?(WYsTtkB_^3NLmm;__|ENtq)}@`5JE|72BNho$nPHr#AoU*mg@g zQ$jSR2O_@yCG1DI%Ec-6M~D{?2)Gpdxkw(I{?9IOdk%Efb5}>kfB=6yo}3>xMdp;V zg%MH0UnVS{C6_x=58&sac>Vl%Hih-LR63G=TWXNsdO=;1I$=AH$yQLe#E)T#m=6aZ ziyNcnil32kRYVLj1!RV0xb__^eH3fRzc+MZ`^?qj*FwL5yqUm01HZQ$oiaV!ziwCD ztL-kWDMNnyHDJUN&QtTUnaq@YjJC^_V8q+}=AxS&yU|+`vdDJ3tId#`T^4t` zKmE0f5(>szLTC|j(gEkgY3ZEUw(X_;mQJ#JIbbkTS?S%@4-E2QL&wh=1_7){0n~Z` z?YKcJOVXSPDu^PDn3K$`RP^18(rG!mL=7eP>R!)%yvy$P2 z6(MG6VRUdh3W^)Sk_JMAb&E>Mq(;#(RKZ3%X4Dj8_+AlUpE9Cy%(7#ERIh=XoExL& zDJ1m!6>EW0voft35&V(Rg9*#Y#T-#Fh7#?vv!n&n?bwEU13YHGEE67;nTaxyIeJfGh&W)Tj$1w|(M599HopBK+ zRM*ViJTCY@9^*E0`^9n}Ch{Ig@a3nE^p<)bmgM`r5KKP4Fg&~D@neI=H+ik+KZPJ0 z;k+64N%e_Fp5pGmbD|{srl>Z)SH^9;W@xjh8TgB*ZTP!f-`o(_H?m|I#!gR-J*v8l7Y2&E)<+8>Q_GRCoCMjKpq0^_SZ}#^0$1Mf zWrA;Bw(OXw6`Iojv*v@C(Av}clBSw$$?gU7iw4#@WAr6kRLt)7mX@~gl-u7c{N)Ar z%kh&>uKPRNAC~`{<;#Gxb>Yg*&t}z<#k^#*W%q4S=3U-Nii(6hCq9PC87) z2l_{8OyX+THu2}%eLn;1U%%qu>0;{aqHkz9tzSG-mt?vK0%RP<#kZ1uEDo?4)FSSYenUJGWWDQ;dwhXuNhXCo*!zv|!qnxcD>FM8;uUHpTo!O`=TZsuPL_`%tu|1S$s5)~AY6{WK^k&SDx z10jSF@$Ma5K$TQ$n=6~aC(%W*gtUeu>4*5VbN0vIQ!0K~%f#$MUg{!%=C}kJ#KYz) za0~yXgirX;7hOcemg?7NG{8Pd!22-Hf3GhryDCj%?iz9iv?SKnPAq@gT03EHFvVAV zn^Y-6^m>pC!tMYDuzx_mV`hcrFj?8EBE^FDp z4#J}n8xdpxVEg*SUW#3{gAYZS04k3iEJC!PGKudaI(52gMfXF^29bs?5*0wi2{`}r z4-ou&!h@sS56?yw9{>RE|91Lz_9mv5Ce9<;(#}6%_uf-=6DPY&N&IseJ5LVgn`X;V zH?MmW1_VGQ35ftufr*E@TRH$KqKY*ifJsp@!H3U)5u=7Kja@#^kEe{|(M)vE8f#-F zTC2~(!o~v90dW4+uGPBxAd^hxDab?D3OCfWmv#BYzS@?L$tKG@w5^$sT9iBIv)YVr z#(J|Y48B|CtGi};S7OPX@o6Yha^JNjn360?OZ;?r}QMM=|rv)2b#M#&u{+kg=zCiC3?`jP{osTspqOs%SP?^MsLckN2pkcuhTKgMU z83`OHp=%Bq$@^7Wh}1S2F#_j7QjYS9$4ZVip}NSE>0zQ-PtLrb5%!mQ%< zoS>;7AC$QHg9?LC39StFP$6h)H{dw3S*54v&qRvOt|Fn`UJ+GdzsQ!=kk+tNi<&U8 z`yrAbyw^-sse9N%2Dbwn8rL`aFd!Q~?MM{*+F%efCV{kMSzXP7F}kSV4UvZdzhy~7 z32u?T^gX~wR@Y%z|JFI?Hk%kkceib!-z$;9t*LeJf5n-{USCYGyvbj4uf9#5H}rqU zDJ&xe#_nl4PlnJeBuO0ik!MEvO=ASo$V9kDSt&j$J$ z8n&|x3T+|A20`|Zo&6Wq-ZCn#Ht8D1U4lb!cj#`Qp>YVV!2$$=1qqVi?hXNhCAe$Q zAi>?;Ew~4F_pdWE&->1uXYP4d*7|;&pIO;d?b@}g>u#y5q`$rN#-lem*O2DnSRV z5eJbGd_A}KcBP(;-*BiKax(8FyADk;#4)vV#f3oRTt z9njYD5<(xaHZ#0=U}O8XR;iAU>%PL3ao2x8GX|lnPjtDqBeKqZR}M5)hZKuy)9D0X z=@$8C;9{7T?U5;<*J@eJhqcG6)X_%J22)pz>Ryd~&)!I|c!fhy=%3nu>m;Kf1vG;> z!>UWc=tO*cL&I<_OV5(u8r|i9vTBA1^VXKyujmyZ&V?1`{vM{J?)`Ue#&I6`WB0v* zh31B97kgWR5eIA(4&i3ykjJg9z2pJxcp_#Cacq?vnqDVg^e{usR9X0z*otVFE0RIAll>W!EJO@ z2kkr$g>4|QTOs6{h`^{d$XO#fCzNniY0aWW{@La^LML6GVXm05UrBdo&Ek|S0@Bzs z3o&6ClAf*^GVE1G&TJffVc8wiHb#f}0_W0AlAE8`EB4od$%P}LRRqsdzB0g0QMcLH zAp3tL%l(vh$YvYzhGuJUQW`atvV9*UAe)G7y042TXe@|eVaA~JO;tK&Ydja$goS?B z^ypNzZC0}$bJL*|z%ybiOvj^16Ho63Hb&U$8x%K6l33fb$#J+vVh*~-zYC?@l>ljM z+w&(Uk1CPnp>W~mYjK}hvkazWk09n^Wy&eY^d>%234Y7(jZWL9 z`bN-Cuc$1=wPwK-pX3$w=UOsYrdrnpTF`J(Jw6F{LiOiwl$acEZE%qK%F&wIzC+jW4<;lDa>?!`5Um#ClIp+$7TSbhtIT;CMt=VW{Rk zXfhGg3t40LL83=u;fvoIOs^pOuC7RreeYWd$wfce?ggygn(g5;^^K@p;r^No4Y7Ns zGM5GX=kdeXpC#JBXqwyZng;2sGVlR0Uobz@lbd(qej@8H6+Jm3+G%}lZX0=_vuS&Z z?d7KP3gbu2Rv7ml9n<~Hh#QVYGA@501w7Fsc~`q0ISJ2P7B0Y`cY(8tzC*0of7N>m;+2kdz10IfmJ*-}ANC=WEET-5wn8IjT*G-vGZH1bQVBS5};i( z4OyACcL|w(INt9drozkokZu366W(IUfEPPmBwPs|)Hq7=Qg6 zJ4aK~f4N8i2N1-;#|Z?2fFKZ%7s$l};R0~;aszqzxTdk$mDpLPIc19n`#4!f2AP%m zMkY9AhuBAzM@E!KWFt67IQuy`WE7MlMiev|QBI0S zaYPkrF6oz~f2>lL)SyRoKwRit82tP>W%5)aq+B2Yp3Z7S_&T`IS3p>pi?fnWm?Ech z=M~ZmN1ZEIb$TQ7&@Or^`!U@z*RHo0KIhD+KkfQ|Oc z=&Lcgp&?G+T5xj)Qwtjx;u=N|qW04x>H?wURF`&23U&nN)>%3YANu8yga#Fst*C{H zikFIql#!mCk}1WHrsaMw4o|xlFX|+_vfaVP?;hXZ@v)D$R2s^^=DmD5FfxQFMfStR zpdP-UZ`?XR?VWp+R7f9 z}lDv4(-kq z00O|^z{68&nRv*0c_{&9G)$Mnv-E}T^s)iE(jlhYwXVg{gc7;C*Ut}=Fz|HXnQ=}E%LtF zWZ6${>urURz`*b@_;Nyja|7w3(#UOqUh~{dkpCZoNU?j3f_3OP}i^&?_h#AjP{}t%_8Nm_MlcmHPXZ_Ol zW|D9W4AIWkR_s>^(Mf7B-w|?(CEm7oXtjB$shcz!nEd)n3?%EWJSE=Jo4RJR#0)47 z$}1p4s@Z^yT=eC1Tq2wlEJ))Q`YW?Hk(8L8Dvl>&;YteqvWUqK@7Lzb6w_-2B{jOp zDbo|{XAWvxfg744g8C{}Kk3IfU5r<9<%;mjtlCK@QBtfc+m~_?s~D zaPjd1AUwQK;_~wFaf1Q8e=KHsn?#$0e-$&#)V!hhj@E-2EAnrtun5Y7u)|=e^uBwL693E$G>RV!pTXZYa;#02^D=sN@N~5m^@5O z|kg0^ovh^MIk4x%og`pg(qF!<=%P8weK6^6cZk>dY#s z>nh8`dF9`!>~Hg{!FkAMnQ__=@lQdN^RM%cERL&^92EuEE8W@S0dRXWgsVcF^(92}5pkDTW(N9nv8~hkK zGC05%8i~xTU1-unpl|nxSBT4z;V!4j+8XqA06o#1cWOaegBs>iSH;53)K*r7UM6*9 z_viEKJo1RIyL@u;K5eKm*nDltRhObe;_9xy@wK3tz5oA_dpL4Z019?-AE0|7ih zz#r${EGI7x|F5~f)4}IRQR5ak`AbXqznZPu=(RN8t@}bmo&Gn>uo|utnozE4|7X5( za{vLHP?H0I)))^E1odVR9xh(qKUK1SwBFNycMGo091ED~_r)tmi=U^DA|b=7K_m`U9vn_|qu?%1e_C`Qg6s5t%{xKG}b; zlNEZ&%FX#i&ye>~-uIvpLt_I2W0>FYH9Ktw{tDZ#@cjpTJRAT{AOOq_HM7tj02L+x z0BQxe0EjT1r6W_0)Ly!w$NqRa-ju1 z^`B>~f1-nM1Npf4pzydryg)t>FZUm2mIJ0AKOX-qERS((>kA!2JpSIld+)_2r&Vk` z?V6u0VON0+L;nckhEyCcfbgS!+>JJZtR7zzA1x2TP|9I%tdIgarE&Wd5P+7O4{ah@ zEMdac`Wh8ydn$D68xv|DK?AC7!{MA99CBftO#Pg)iX44|Bdka}vYfiYCwH42elk+> zVi8Dtuw3i%r)T!9{mEdvw8$9yr%2;K12;J@P~i(Uy`hFLDV0rleJ`~`SjrL+LnT9Z zBtsENPP*v7KnkeHi6-`~CWXEeQt!O<3q)8T4;AI~30MomJsWSEOL^7&9iO>=|Kr+i zr=Q>2+D6u(AMV3KXXAKhic1tK)#lQ{Q}v8UbiA}(6HA3%nIe^!pajh7(fjn%RCKnX zJSuIvPsMn%R|3XhcGn78z_UI=>UZ2!td@;#%VPTkGo!#}n@Q{m}n-2`;1#oi%_;?}Q z(6;&igMAj-@%oAEeV6^#`$4}BdUN@&BH4gv4dd(^8DU0Zo~^L8%&1>T(W}KplKKsE zBOQaLGZb&&f5FTRb#gphAgF@|LkBD{50IOua{NzR9dsrn#K$LMBr+lV3<&l4aq zTA=-!5L5sl(6ImiGtUZ@zm5hzTHL3#$Y1_XP2mR=g#R<<1@*#H z;7EaMY;gj3+TTL1+5GtOB~%c`q0isR6*nK09b8~OAcPOvoS?eO1yYP|;`A3I>$#g0 zl`o_*G6p$pzo`ExCPAy3T6VLk`%Rg%X{)YhQRNM090wurEAe-4pBgc78e)tXIifE= z66i1%lU+&`H-3(6-MK5y7R8{Lv#OiwerAe=9L|2MZ9cF$1i;aAd>off@CoxIwHFJb z5i<@B$eVzHT_qhi24$RLe24tIVdO+a&=qvZ9<;r4|pL%Kh zNtflZ1);g$xwol)mdXp#^XPjBVzjhw=C?^lDf_y-i^VB1w|slRV6S0OHOK-pnT;lT z=4Ql!RyYQB;Z6{XUi={VF#nZ2z-$_mtRq|;))g{6zg7oV(D$&>SbjjPini*P2Ai`6 z2a!k~C?8ilsCKnTuUF)vG&ma=KqDN#x=0Zs@`5%v24nh!b8*Y+7H<{!EQ& zRkWh=JZ%WP`|V4ESN0I)U$St3tau)e4T9il7DDow_lc!-77vs;x z$9mj8;r~?v;@uyhRqOU&Di_Sd!wmp}ctIej_Hc6neoaAtxY79!Lr}@xq|pzp^R3}! z0dQ>l7IdQn0G;}N>5AXj+QRq$?6HO8M%cyB>6?S_8{~nm*xmnu{2#peuQm?i0{Hy2cL{y4<{*gv8B6kLZ-rod%<__Y^ldec(F-fWw4 z@a>5;e&ZjOGW`F}KQ{@Au?_o#4gs`%XJQ!4&=v(ty1J{|%g^pP!u}sRR7A0d@~^;q zK%c(}JaiKU-J?MpD%2T6hkXbDYCivT`uq=r9FnSc;Y0vY>a}~1sp>TubVn^Eb$ER} z*fu$eK(wI^T0^>7+a4~0~iuC)wQkB zpDqSP8-{fBDP4UxqfxcE>v5+ldus%A+Oh^^ZRBami7_#;uhUSO9K*3P^~4Z8v3rv_vFcjLcE zhxBixyI)p@y%@hQS6)3055Iq@_K#j*x*=$v&IT-*Cg}Tpbfbm{um7^7zl!H?G=P^6 z0^KqJz+BvX(7J)H{ki}6@A$8qn}1WS;IIF(qy;xs#d&&9wMQ@Lczz3gR`3=6J1E*; z#_n&52Wmk9(D@zO7ob*@8vudsKqmgE+&K(z73A4CU_ooX_5G@0=V&ZX&3u$Yx81uj z`^)rq;;}z}^#wr17IabVyWv+)Ge1*OOA$=>J540WRj1xVak~6HR;V&T_xjw>^#TB@ zw7-n#AN&3Py?s;^;D0|f0x#zNv{asmFZ|0hM@JLou>5P0NAp4zdNJ(4YM%_tZ^4Cw zhw=NLHz|J;T&TeTa&bX7Owi>RH!sxeLwNq!F#l@?&R3>yr*5t2sI)oQ&+Po6U$=KL><{#cjJU(&Wn!|TjYfdA#6J>z z#FuPWaX*=&FY8B;=YFZl%!P7yH&4ElhVi`i!pBRtIL3B6;~j(rCVw%Vbe5dhaZ)?0lIFYlE+&O6)l(pP{F9Gep1oCY2PIY>Fw?z^1rk zymsra8z;k>O52A*q#J;qXKzf3A7Fkka!C?e&Q0B$v>RRRsW)2}rKp$dt% zZ3AUaCeXR_i-lHS7ROj0(@_a&+jC}**>mr4@2WUZpt7{I_qof`l+^}R@xs_!dLD85 zFTOP!2sf$a^#Cfa>xBa+Uo$|&8YJ;uFZgVj)H7YGGMe>pIM#c?hFPmTXf8#(d`%85 z89sx_wwmc#ZMDoa783IaNPW*vCEJ-Sy*`#SsY%~y3Y%@Ql0Z=ERYJ#Hu@g4yWne&R9!|BlkU+*SJiISjWCl9{RuUtiiLR%4C6WaF^XQ1(b=p} zyoeI}pfH8qAbZhru|G9&D98J1St`*3YbYN|7Nz??MR#JKct0sRC!O_w# z_;L8?XRM0g@xwv)AP36nM>YngDfO5j2R^VYzI=d0-1(1b+e*A2Mq&Js-}1*c?67#7 z!^(#rB3F<@pE@d2d4-!OG|=6ObsY-3-SQGf50R=epT)m&Hc-q6`@ypBogu}HZkMn$ zQPNUVvT!@{e%)#!r|pZ`h{P@FU+0>%K6`dAD787D)JBF`E>^p~2S<^a!{ z@0g|B=e}gUuTte~O6+8N^@0py%G?u0yvEYWwJB298rXW{d!q} zQJ|9_ce(8X+-kqiCa7^0ksqI!Q{_0a;PZ}D>)aKG1M2rr8CPnZrWrrUjKzhU4J_I&g8 z*PDIwo*Lq!Bd-*DbeHhuj~`BdbXrK|DKnJphj8=k_}GJmNGiIeD?y zt8zV@(3bCZMDQv^RlOpL|KZw)$qV zV>Xg&`3bBa?Mr*sf5b{AS%b72JK(O+0+-96S8#|$z|l0mko9$!*FsM%E!#j(l&_Tk zp_=3eh?j#8j_d^#&y_=}6Rwc85d&@3Ww0q`0b<+H0NES)N;$)i>{Z~m3GVg-u zLMqY%mhf(nSp1wv=?4>f+?Cg_@OxE@9NU?c7Fux-+M2`{UNY@MGDgByP^L(^A7BC$ zj>*c81-qwVUzP+~)p<+r=rV}YvIc1yc6Zac2kNe-vC z=4KAy57W{+8oZRcPW2s5OP0Dn{fRof=K3w(B~MyV4h?$zIMk&xud$!gNq%qAqykRp zsyU*k%zDE~@~O0*#H$zByX#)^{h{pP-DS4&>HYU)di~n57)Da}J~41@FA^pA$5Hl1 z$MB6gi0u2c>4;SJqfOOV8DiDxS+`39rp3;?&(*`jz|FE~tYDHy3t5G;_4DqR#+g9%{SI3KWt}^W1z)6bFwXV;O1ebRV zC|`)K_Fb;V+kbE?&KDNkGhVzbTk0bn`tF&;Q*4gOZmFqVldL}-Oq72n`K>W>`RpX) z%hXw$d3Mo3Fs4Jomae;Ic4cGyLJ23Q=TT7Ick>9v7WXaNl38-I+&&IY&OJ|a`q-+o z0X<&j`HmpPsv053kB=HH6SW~DHQfBky|oWwj~dg@yCT9MF^as_W6peX(y6bS(l5U4 zW*0k7Z=3lWF2P@WCso->E$DMET#aSK*33xVFda0D96CyMR2}38>)a{NVAnXOmmZOk z-(RoT?DgF(?i)MMZC72on0c;OUTv~H@Qze}?_zR?ygq|Lb=F{$t>FnLqM5&?vBVO{ z-VcJWc*V1v4nU}%RzlXEW=+>97`q=FZ0}kBz`K*1eL{7>u=w4XUrD(#=gXCpe`E4S zEqUl-O2qf%i@6;G;4GN~M@830?AiB;uKY$GT?MRR&;ru^;=y;W`v+TLz8?MHonyqu zxmFkE%esV|CZ`J9xKA--vs9GnX{-DWn=IA!ZX>?12i#v7`(z^*FNRP+3tc~@T{2etDyyW`+u zgeRAqRK!-p)nhjK;j`6VOm%pl+eNF-bq_tC;U{6rw6(M*A(xeBWQxu+J=v847n>U% z3*?R#IV62H3rAc2(StR*9bg^K1%7^^{fc?Sw6)toAHP2DKHrNK4wN*!lw2LjIg8D= zaHbq^Eo8DW5wFks?qbmo*A2$j4L)Ouvf9e{uq#zGCG0!)Y&S{b&QUr9@lEAZVY>U@ z=Wr#>l$daekGG)nlE{#KXRP~XeJy87Iqadp(K z{6-;s+LTdAm9iKH{=wZSxzqjBkhbJU;j>*^=WW>SX?*sAJb9LF)J(#C`^}|?Q(1>en3EVi`hx=*^5eININ`Rp~BR*x+(^138w;*AzxtHyRX zLcwc^uBpJl-OBD8T_5~SuT6OucYWv0oy9K#+Ld*xI?WCDU#lw|ybI!l-O5xO?!J1O z?F=spL{hME4tbp$zA$BBkK=U}y6H=}YFZ{w?|0N&E`5J79CG@;u1?Dx+%80sb!Ow? zm4;O+>~;~kyvE^q?jk71F;d$6WSAXFvJfd($lqb?ZRvT|_sT92D|SH4jJw~>?G60t zY`Z7z6r@Ar9%g8`RMgRSV7Oc@MUd)hY*4z7Uv~SMHPMdMIGJ<(fzlB^FF7p>X(qk{ z&bM|~@2FJYWU6xWtrDOw{UmFjvN7UK?fju9KyFx+%}Nq-=_{FPvgjq`3f}mU0VC8n z1Tqv&B_g~$^Du;^Gclyjn7o#X6G9?*ZlnL+1F5{@8N9H@L2+V?oneeq8YbjH^X;=! zF(-bCl_)W1T!oOHCVFgAeg+;IoTV*Tefsa6>oe4KoM7WE8W^k0wh(2-qCn>uW2+w{ zuT$|pPzQ*=fvI2YxU~^y*Paqv+B3;E4kYyylM9(>!?G3X$q4LQa0REYP#6I+5H20l z1g+SXtrzMv8Tla_FklV48uNx(i)vGmxvpnKsr%zvvx$k0Q45ViaRRCxr}LVyV=rBt zU_PpSKql>AwNLXxL`cox+lR%%-BxQa#gTcu>=m-^$Vpsxrhs0QC+Oi(P7oJhcubS$ zLWc-@J`<)?h;8NmqzzPBsv zhv+lcw)|wa->e3%cFBUna#v_!h6OS?8>x)$#E*>3Wot7JIG(R$INtFMPA7V_HDO5m zY(t)B;}#Xs{SX+aVWx^eaJ;hq)KpfhloJY+~!^xF;XJr0cR>^M>KvL1XMlfRyt8q%>Z{=w-6( zDQP{yERA`rojvf$L?Bk`$#Y)CnIo5NxOKg|9;nl)N=ByoAi5Cw zW&XKa=Y!2>aNm~1#)2c^>*gdjPIk{LWgjL^%T*$eLMiyiF(-+qrEzDoSaec!pS*{UBNRjm;$;Ng)$G6ARtJ2sq!fL?UgCkV#*b8QhT5cJ!x2^;Ie;~Q|+BT|wZE!!LSfl@b9BF6Ma^M0ps`815q!)Du z|N2(=60RGDR+`BHMGWhQ0P)oLz~_bMFzq!9EoFj!eq8TN-$?+|U-Vdmo`1MFrYR+W*PUvbMK3TVTi~rK z92pF@?qL=~yjSN26hCeQ&xPQt8E|L68U7LCg^+uKO2{& zasAbZ8M+KeqBK!wQ=Us3a;tV`4&E`h&923?q+eBL8 zqgqmKoZ6*~4Nb+>IEAxh@l7SW)y5x0)RYjyI%>Bo%2x1uM;YnTF~;*k5?sbII^;eT zopWDY4vYcS?Q2J^Zg-QH_gUkGJT8W;sZo7q>pOhktA1)oT(=E2Qkm{nV%#X!^E?YX z6_@nqw8oHuUIeH-3jO4cb1NAB_{h#-6rrs?eEr2S8zur%7EUMjCjGT-SUyc;qDQgn z2h}1}AVDN^-UUr&A5)iNvi2%knlErCoUiF6^1`N`)6Vo*RthsG=W5zzDZ|z}&WPv- zJ*{iF+t49Oa<6nZ)E0k(I{O~DGuOs_7lBc{XX^W3wvu{gEqzy2NFoILP?fC{)Ha^4 z=xw;^D$#S^Z6(I&WVE8>j<<_roiZ`fO+FCecTxGKI2-V3Z3I(N>z7I+vo`i^p) z`)d_{)Qu8n@|K__j|pO)v@dcVBiRtj+)dB-#~LFQ$LVE!Ee0Dvbe#>&J2?z%Z>+m) z)a#A$^4Rc8Ni%%QPkB+37S-(~6!|Ar`YNZ66}%MZ_9-_rT>9&_WltZy*Y-AQlh!D* zotd^7=9As1hFhsvRtw;nAZf(L39$6ibBc3ERwBZ4ULU7NE^9tvUCpFZ>>nIbmafd`aX|j& zrz~0?Q(r)g8V%Y7%SIRtU==9Re&&5XI~_TjoYES$FLx`+)+3Q$lAmrRvRIKF3PDIy zbo5@pchL3TAKv;TO2{3gz>fOk5OxI3p^O8{JhZ zu-zwg(5NJ%%&bP$Tcpu_>tbpd`>6W$j@;RY1HH?4h#&8QhhERM_uS1co)7ysunrtF z>^SaPqeQ{+CNTwHQ*7{X3!@{KmmQN8D;aL1#sxB>yy}}fE(SIyAVbpDcfR(8%nh_V zft4SKgX&!I$!SMthX!zI&=8sv#i1*ERGX}uGjIudw6{mr!u;!s!^|eR*G>lJJHjDL^lEs{QgzKVFAerEM>6IG%0K7FH zD>MT-ZgX+G#QEyANbVo6Z!Aftg>+6+8>(vKPzp zhLwlmAY#W^1%Hv?p9~0Bh$eRbNmvBQI#t=Nk`>PUP`7qU7GB|1;;I2ZKS#6%c)1?j z+OXRk?Mr)kg^d`5o+D^am!FDs3TyM4Bqzv3V0YY+t`b=q4|m`V#PWx9QE zmF0Ti`e_;wZJYR3Mm;w6ZjMhEew z)~18fZI6ics9u>szU0yCd?EK2*!crddi@}Iq9wx#OTM8d3R8*fCir1Mr#>eZZ|4$x zDQt*(cCK%n49*X9rQFv|>I)vnta$Zw&F)Uq0s*d5u_PT!SYsS?x5{<*vZrrRCeOjB z4JzlBdIjgwi7(Y*+6+h9xMg1;V&U7|!+48TDVPy5jZ@KQITFyn#1o{1t@de_C=F`Z zeqocu{$u;6rdV{$R`p`c0D-d|x*tvbNzftFfpc@@ljEG_@U+?;+LHA&SqsJ#yp@sC z1)Wm>|XxA%m2p$&67i*y(^XWW6pj}eT!1J7!HsL-XhOidf-WMV)MFwN}S zE3r`tN=W#2ZiD}%S1Qn4ykh&R{DIAt;y5%~-6=IjPtTg1TtfHaHjm*dMJn@4SWs8v z#HNt_N_~~wNF{1Ju4pcHI>9aP&Eb*U#ya${+8=l)r7ZLErBhi1x=-~M!@@#>3t^Dv znYekc6#~7N3>}zRjTxxqvvcXIh>T^sS=4vF&B&jX(GzoW0IV3H-`KrzEKDZeRE09X z>?~>WxT#pv{AR1uP+!mTYL7ca!qhpQfSR@ItK432S;|k!+qqhchm_n5Gt66DP zV1seWCyW_-O_&jD^G_r7+NsCQqtG23Jwv?lVz^~z8yrKO+(pp)@n8)J1c3qb9~1>o z)O-VX-L7b4#kSi#(B`VWA`7uqS{mW zsUqoVH&~=H8_P?(J-h$1-f#io^;}@$_Lk#gEy2Egb{f-#tGr7hl7z~>tPp>|G3FRn zIf{`GYR_vCLEYu+bJW1Oq4&V&R{ayP9l0t?_G*C9g38f%_Kr`bDv=$*;t@3^keJk~ z8bHT%cP6|*s?}=mx?<*Vd|-pmEMgka7@&R@uwz|)gc8Ksd^efe4UMzlB zls-sg0Sb?b8epye(8??&{8R*R9jekB)_hk&PiPTo-{}^u!&Yt)Xq_|rh`>T?hnQ}n z{uV-|VPOhG2(e{ytIg}~vg#naS+Nw>&bWzX4ZB6ns60BL1_O!!Jhpd}L1q8L}u^xx2RxceA z@ur{Xd0ckV+%9;lUXNtUpI#Etsfvxv4T_B-A0D%P`yOPt()A2J+o^vQtt$`ja=(s) z2EaXOOkh2A>VL=TtobbB{CM@W3Hge$bfMwQ;KFOm=vt%lsp2Vi;4Mm4W_==4#1HN;8~?9y zVsmpt?}+t0nytsac6+v6^aTb~Kz>Y6*-XP3^>rYYtA+J#AwNI-n72#ZC45ZNF$6n; z;0LjAwj<#b8;s%Wl8K6lU}3PqICvL;fTMjJ>$lh_uyVnz(S1MF665j3gS^OCq3QUe z5dueV^!mkes7+|#zHz=edfUtLU0SDHw(XlD{ilhi+aqsMIP+2j3}hzp!wX6$yLWbv z(D!a{*CwavJ^V_=Vht`@ZqOsHZ1NeXJr=Q4fagbE@H@&EtR{hqo8s`xxi3Z5v$I*I zxTw#0Rr$OkiHXC3wbiYx@R`#S0d4al-})JdgpUj1i$!0acxhD2ic@#;5JVwRyHUH+ zk} zX+$^u8MKZVijAwrt3u%AL<`j&g|%Zv{M;oMZ@TfGMFu zm`}|FpyCc%;N-`mpO7<7A7$nh!6U|2)?xl=H)KsF;+RpsN*GZNt|)%63gE56Em(0XJ$fuAsi z5`04GWYV1JoL;1yL>$|3Te!@o+9UYBD9Ym0l6cAf zI9j8gPUt?YhPoK-+`n3;>-Tg`^znAs(*tcP0jB=`O7C=+@~L{?)N5#J^l=#Z&7Il| zhu5}wGFIbct)58TnCs5{On`KZ-U{)?>?`~)=mtrS)Nm{CZ#G_*NiIMh8OEk$G26(a z&Ab;MhQCbSqJH5lx~DC8L-%A_>WzA4yu+gGG0 zRDO&_G|$P|`4d)6&E?DnewHh15=HUQD;(O5=1~6Ut z7L=(BFO}&nF2xi+U0Ro;)gKa;uM4l?)*kvbYmM(!dMj{?i}P}IEKuitI$IqrxQCvQ zGpTAp8J)d-&XJKa5d}t%LMD@ZU{Ha5?24n_3+=o+dzuedBO=n!V3QN9h$Nm6kkKdL z2U773NzVC)$YkMd7&7y{=>ECXT4oDBV`Ew9Ji%^)>)AsdDqRzJGwQxpmB@Qip4Qz6 zl={d`M@PAd4*RKiSAhDpns=+n4y6i!Dg6b@Gu&78Cu**_1$#*5&nm8%K~%aeRisBI&P>$8ylt$CJSGjtv4pbiPJW$@${*ty zX(dxq>9HG=@v(%aQmsbY$}qBkyzC8P;^-{3nb%u2#KUE-VcUqrc2?_WAqBq9Gc0Z! zuP8!PW&DFmg{HThB3l&FlHc6ROSh1}S$U=}z@h?nL&*CSlj~n-tJkn@Tw2m^Gsm&v zrnvyo$o1~^rW`B3rsbg_ZjZWVx$41qE|G3$-ymjGy|Q=$_Vy|QRqbteK&;Ge4R5JxKra;&m~$*LmJ5mkjcm67bBV*};^hg0 zH1qIwiWt7kFYcYNbKuWZNw@4tMAu)~*x_V1rs`=ZVWnO4J}2a=r~Z^N(A6mOs!O0l zdneR3V1?5wB3y*EDtB}4wCP2e+3C|$9Q2mjkh4{dBS`2TUVrR3pL*T1DF+JFSbIA1 zj~CC(A+#ZGP0PZo;@o&6V=kkc*h&LmcC-t_FuS9st8-rR-|jNP5r!~+7TeBr>ktUR zlS;?l^+O(M>6G52+}hP86-JXrd^k-3c~+jC$qo1#a2KOEi7D@BQOUZg@~MtsLgY5! z6cT0mn5ucT88EbJTD{qHtOb#4723)ehhV+NJh-9^xV;fh5qyKC@mPo?mT(8#O0uK6 zTg_BV{RI8*ZetOgnsjLqF62GpKEu5{l9PGDMZhd`qG@Ai7`UfPWd;#%9}Clv>OfG2iCx)sdo9qpZWaBk%~(p0nYq?oA=>=F#^fo+H{9 zj2jI!?OrQYnbB-0%r$Iqa-WH5Lu@zcV<={QE~5^E8w>a>0FZ20aSr&>$_<3mG6Ndg zc*W7sN;-jF7y5X063b_DUy0auUPMV|i_d+z7nxy1Jl6RFqbc(hZ~kI+;*5)?s=k@LqVc(Q6uU~PniJmvS>z5Ps zGP^rb;*$YhKvKPY{5&K7OA|E~tf{ruEi(zVzA;5LRi^K6mJ%cz-<9ptnlH*?(R7p` zYKNz!N6eFtB$n%E*`Gl~cF(P*I#mgY28YT-^b&?|YGtr#e11+PjDBqHOZ_37uiM+p zlNl-5oV_=?q4~56gEGTW4(`={Hz|}2U^>1qGKxAQiN4@EUrWkNcb)&=PKE^pY zpW9m=8Y_mC7^WmF)n7l4^^|iqE?(~I#gQ_sM443^RaH@Oc9|Vd*4fK~s{6eC3iY5^ zpIV+M@k|GcL<9oLgAt9@fzo(z;Zq;#(23 ztZ~6K>tg<7@O7Lq(q^o=4H{~UGL<7M;`}7@B;fe~!Wg2)kLs_kWUf*$*S#Smi zM!Yw9_xvKLo}lolCI34=jvIt$xXPt16~cw_)64*@S7}M@zD?f5QC-s7CGF|=^*tk4Mj4fu}uT{Wo<8@uI|Gh0QqyCtYrTd&Th<;&bE;<8aPKgfD z?G5t2{*!QKy6-;Z2RqN!b$wcFUrQw$Wm!D?VEzG*qSmtk0apw$rt5hU99yOq++0Ol z-@#7jh|8ae%H}N1@iHRA;ktYc3^OxkPO7T$J1pe9U1^Sp4l9*-G)*&y{wEdd_a2_! znEU9bL~iNz_!FckbSeGt-JC8gpAa(z)i6Wg*GSRo>?iC#z!wM{IP- z7=8L6L-u33+U~J}KHrG_X|QO z#M%ON=weVpVnA-aGB_r_x};P}`AZm2K|>kWwcc~P5LOXf(h!GRZqg0T?yx@LGVCmn zhZt!4S-oWXL3g+5cyQ9^kFO5SWX;ZZY7>iDD;pE;0ejShG0e%|N7QoH}>vtGya-1M0cPOk$cSZK@JX249EcmOxH9xCT#XecH;4@e!f=O=0~rg=ww& z#>9Esyz4Ff{qwZE{0TST0%?f~7I$m|S4SsfN>g`P(8+ZRmc5YJnZ0Za1=p7{x}!ag zeZ_RTfdon191+H7bv7xZq$#pIwz_o9{GUHwe0f2VciR{P&U3KzKf%ykA%Prjk`0?k z?#<`gf5&?|TkCuyr!DYdiYUm8eVKdhXnBe`xv=`BYMk;Qv)4_mpZ52fzGJk2T@%}d zE)=F%YS|ZzpC|;wMlIvj9XRBG0^CB*=76mO<*s+;5+kSt{Mv@z^Z>s{22L6_7Nim> z`UB-nnnE^%9*^~mc^(NLC#(&GW9J;W6jyUH4VWK%_R8Ss;J41}(qT4FZWaCtwb`#c z(?A}?4aH_QL5%jdr&!naC*uW5*_pCbDxU&z;c3p?HHANC`DBDEj5~BPU5(eOm)s!{ zB7Mt4NGzg)<0mAOediik>JtU4Azr6eCDc~6S@=CSBfa+c_RbWhWy7$zfZ)DN}I0#29`)c;ZCDF)u1f_}vW#-RMWL))gSX z=LL3(?weI-wdYt$h{l~^%O?)}QT`tcOL!y7(%(>V#+CLWi0rU8)0E-8_Fi~qbzV#x zSDw+}@+~V7YenbeGRL5@_au}vVJu`@3Ta8ggb6k~MB49F`vWjjpeunR(dMa2*;ChX zRldXhET5*BcfYUwMGu2yLuK&=&GU%4j_GK>uiQ&Wq(xphcFj7ZTEt9x=yrNCsNc@A zo_x<2al=UsHSM)ze%wgXKHic~T>L=LRGdT1)ZolSGp-PPG|s}wi*a(FDoM;gp325p z1W4Om$@vQrYJjgtEQ11^GRY$${9LzCfeC3xg(cCN+j3jl^Sk(DZr|^nrSK9p9wOQMlO~56U+=DZ#%I$+UL`HHij;Ra+v-xo{z-=HF1WY(7Q7 zluH#2pxg)mo-F~C9_3SKEMVyDOS8|(cV_{i_~W&8Jr4;&@9c!*UK@uu<5$MA-wqdc zwlC>NGYLhgC*n<1Cn4Adz!S&jv!_tg_Z27m37`-*e{FgG;k1X+>(RxsrSjZvt4QP_ z9EUkfy;K#^*&DIV0N-$aZYxC(egV}Tk-6eInWlQhC-+3)aY&qe>LQTjl%U=7N=;%) z?&Mj)gv8ASx)I$MN-<`lce-Dh3~qowxxS##x@^YKGVfXrajI_ldPryKs04{l(z*5f zxoajIq!73fr>(8*6@yPW76VJTB4S=;S6CHqu{CCwgxgx0t~_TZ2>T>IXzptxa)A3l zG}6pP5HG_0gNw>B{S{&}EI5H7B6^7KNs~7% z-JLO$@w+>7RkY!(@7$|Jy(ML3Sk5~V_(U^g7PB_KZK&{QxJPV_RrF|%1>v3|b<9fA zUBD89X~oP@F0LGeRXB}jA_M&AdfQYP!%N@d+&zp7>stlnyWBi zm!S$-q3ePrYJB~fSnDu8bbjwe9^(6u8I`cX2iG^UK`d~PPFeZ?adl2jf&fbzZQHhO z+qP}nwr$(CZQGuxKCtj`Gju+Ioej$_q?_mpcw4R{LAH{FM-Cv+#Zlry9m$X1iRztnc)(#9k?2K1v=M(~ z1K{GVDQ1=sG4UXLB_>n0NM9}wayFes%*#=eixh2hFUMI~{rxNRS_*u+r}5+)J9N_^2f z^fck=I=ntoypo(lV1eJ^p~L6`@gKnU2WcS>>xqMZRRy zawc+^UK24+$xx3{k}#ZUqwX`bbeHd-!8|x4ilG%UK0!uq$vN$8zmldaQkph$}cfLfKJNmf0qTtQ*0W7j)6BJM;oC(|`hrRNkQc zsq(tJAF{$i^uOh!P(bsv|1id%J#vmYe^e=cp6DKQ4#I(7LPyJrcQ&X7Fs{42W|cL> z)*o|9R!j@a8pM^a*PUuMMoFGT99B8Q5CJ^gQZ-?EMAa#z0bqI~U8`Y%OsNS8fZxyZ z{DfHhpmS8eXl>lYyGkXbMzN?Lw=RLW;N88k}5i1cein;97?RFp}SQl zry1g?DgF7oU;rkjW6cgr-%C5QIcbc7kV+Yyu(hAAASnscUwsB&QIAh5a#OHgqPf;A zJJCMdBDH(K`pk&b;YauR_ajM z2lqjg`13WF547m3W(+D!@nRmf<2Dp$1Vy{D-$iSA4xAP3bix`Yv5(bw-egUp(xumP zttj4PQUaOa@^C#F(eL9f7&&qo$D3179cdCWOQ`yJy1Oj7DQ9U6b4!%2T0#Ny@nlVT zdB!u6$?I%ekrJTQRI(xFl;6mWGkwBzsC*E6GP8Ki@%Pl7`6bK4-U#KSiUY+TPtnoD z9VDUf!@^!tILL~}iZkj=WK)ONgI)ydNez*0isXU%@-NmF@6!#jIjgLJn z8a7iQxH@R@`x?(KpO|PV;xE9G)l;91EV?2KZ0ek-?X_i7M6i&C823_#oOX~PaR&R; zAN&VkWpKv2cBhBYKS;dfcmR@r0k2@WKT61|?sE_S9{DB>Uv{|A4J2TJ`r0mBvH#+P zwLEaBlQ-ck_LLr!J6a$xZ}31CX|n1h?;UPJ;FIzpFi5M@4}lMcMpFPOsSh`lGgv0U z-zIgw78@w~6$cuc@E&nJuY*O81vnfOUAE}Xfh&fDeG;r$NO}u;kn^VtNe3f^BEWiO zjh3m1v~pF$=aNT-NdLotMhX%AvWOR)J}|E%Ro>c@*2M6O6gXz^E+BOif0MM^(gE