cloud-py-api-nc_py_api-d4a32c6/0000775000232200023220000000000014766056032016745 5ustar debalancedebalancecloud-py-api-nc_py_api-d4a32c6/README.md0000664000232200023220000001077614766056032020237 0ustar debalancedebalance

NcPyApi logo

# Nextcloud Python Framework [![Analysis & Coverage](https://github.com/cloud-py-api/nc_py_api/actions/workflows/analysis-coverage.yml/badge.svg)](https://github.com/cloud-py-api/nc_py_api/actions/workflows/analysis-coverage.yml) [![Docs](https://github.com/cloud-py-api/nc_py_api/actions/workflows/docs.yml/badge.svg)](https://cloud-py-api.github.io/nc_py_api/) [![codecov](https://codecov.io/github/cloud-py-api/nc_py_api/branch/main/graph/badge.svg?token=C91PL3FYDQ)](https://codecov.io/github/cloud-py-api/nc_py_api) ![NextcloudVersion](https://img.shields.io/badge/Nextcloud-%2028%20%7C%2029%20%7C%2030%20%7C%2031-blue) ![PythonVersion](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12-blue) ![impl](https://img.shields.io/pypi/implementation/nc_py_api) ![pypi](https://img.shields.io/pypi/v/nc_py_api.svg) Python library that provides a robust and well-documented API that allows developers to interact with and extend Nextcloud's functionality. ### The key features are: * **Fast**: High performance, and as low-latency as possible. * **Intuitive**: Fast to code, easy to use. * **Reliable**: Minimum number of incompatible changes. * **Robust**: All code is covered with tests as much as possible. * **Easy**: Designed to be easy to use with excellent documentation. * **Sync + Async**: Provides both sync and async APIs. ### Differences between the Nextcloud and NextcloudApp classes The **Nextcloud** class functions as a standard Nextcloud client, enabling you to make API requests using a username and password. On the other hand, the **NextcloudApp** class is designed for creating applications for Nextcloud.
It uses [AppAPI](https://github.com/cloud-py-api/app_api) to provide additional functionality allowing applications have their own graphical interface, fulfill requests from different users, and everything else that is necessary to implement full-fledged applications. Both classes offer most of the same APIs, but NextcloudApp has a broader selection since applications typically require access to more APIs. Any code written for the Nextcloud class can easily be adapted for use with the NextcloudApp class, as long as it doesn't involve calls that require user password verification. ### Nextcloud skeleton app in Python ```python3 from contextlib import asynccontextmanager from fastapi import FastAPI from nc_py_api import NextcloudApp from nc_py_api.ex_app import AppAPIAuthMiddleware, LogLvl, run_app, set_handlers @asynccontextmanager async def lifespan(app: FastAPI): set_handlers(app, enabled_handler) yield APP = FastAPI(lifespan=lifespan) APP.add_middleware(AppAPIAuthMiddleware) def enabled_handler(enabled: bool, nc: NextcloudApp) -> str: if enabled: nc.log(LogLvl.WARNING, "Hello from nc_py_api.") else: nc.log(LogLvl.WARNING, "Bye bye from nc_py_api.") return "" if __name__ == "__main__": run_app("main:APP", log_level="trace") ``` ### Support You can support us in several ways: - ⭐️ Star our work (it really motivates) - ❗️ Create an Issue or feature request (bring to us an excellent idea) - 💁 Resolve some Issue or create a Pull Request (contribute to this project) - 🙏 Write an example of its use or correct a typo in the documentation. ## More Information - [Documentation](https://cloud-py-api.github.io/nc_py_api/) - [First steps](https://cloud-py-api.github.io/nc_py_api/FirstSteps.html) - [More APIs](https://cloud-py-api.github.io/nc_py_api/MoreAPIs.html) - [Writing a simple Nextcloud Application](https://cloud-py-api.github.io/nc_py_api/NextcloudApp.html) - [Using Nextcloud Talk Bot API in Application](https://cloud-py-api.github.io/nc_py_api/NextcloudTalkBot.html) - [Using Language Models In Application](https://cloud-py-api.github.io/nc_py_api/NextcloudTalkBotTransformers.html) - [Writing a Nextcloud System Application](https://cloud-py-api.github.io/nc_py_api/NextcloudSysApp.html) - [Examples](https://github.com/cloud-py-api/nc_py_api/tree/main/examples) - [Contribute](https://github.com/cloud-py-api/nc_py_api/blob/main/.github/CONTRIBUTING.md) - [Discussions](https://github.com/cloud-py-api/nc_py_api/discussions) - [Issues](https://github.com/cloud-py-api/nc_py_api/issues) - [Setting up dev environment](https://cloud-py-api.github.io/nc_py_api/DevSetup.html) - [Changelog](https://github.com/cloud-py-api/nc_py_api/blob/main/CHANGELOG.md) cloud-py-api-nc_py_api-d4a32c6/tests/0000775000232200023220000000000014766056032020107 5ustar debalancedebalancecloud-py-api-nc_py_api-d4a32c6/tests/_install_wait.py0000664000232200023220000000122714766056032023314 0ustar debalancedebalanceimport re from sys import argv from time import sleep from requests import get def check_heartbeat(url: str, regexp: str, n_tries: int, wait_interval: float) -> int: for _ in range(n_tries): try: result = get(url) if result.text and re.search(regexp, result.text, re.IGNORECASE) is not None: return 0 except Exception as _: _ = _ sleep(wait_interval) return 2 # params: app heartbeat url, string to check, number of tries, time to sleep between retries if __name__ == "__main__": r = check_heartbeat(str(argv[1]), str(argv[2]), int(argv[3]), float(argv[4])) exit(r) cloud-py-api-nc_py_api-d4a32c6/tests/_talk_bot.py0000664000232200023220000000412614766056032022422 0ustar debalancedebalanceimport os from typing import Annotated import gfixture_set_env # noqa import pytest from fastapi import BackgroundTasks, Depends, FastAPI, Request, Response from nc_py_api import NextcloudApp, talk_bot from nc_py_api.ex_app import AppAPIAuthMiddleware, nc_app, run_app, talk_bot_msg APP = FastAPI() APP.add_middleware(AppAPIAuthMiddleware, disable_for=["reset_bot_secret"]) COVERAGE_BOT = talk_bot.TalkBot("/talk_bot_coverage", "Coverage bot", "Desc") def coverage_talk_bot_process_request(message: talk_bot.TalkBotMessage, request: Request): COVERAGE_BOT.react_to_message(message, "🥳") COVERAGE_BOT.react_to_message(message, "🫡") COVERAGE_BOT.delete_reaction(message, "🫡") COVERAGE_BOT.send_message("Hello from bot!", message) assert isinstance(message.actor_id, str) assert isinstance(message.actor_display_name, str) assert isinstance(message.object_name, str) assert isinstance(message.object_content, dict) assert message.object_media_type in ("text/markdown", "text/plain") assert isinstance(message.conversation_name, str) assert str(message).find("conversation=") != -1 with pytest.raises(ValueError): COVERAGE_BOT.react_to_message(message.object_id, "🥳") with pytest.raises(ValueError): COVERAGE_BOT.delete_reaction(message.object_id, "🥳") with pytest.raises(ValueError): COVERAGE_BOT.send_message("🥳", message.object_id) @APP.post("/talk_bot_coverage") def talk_bot_coverage( request: Request, _nc: Annotated[NextcloudApp, Depends(nc_app)], message: Annotated[talk_bot.TalkBotMessage, Depends(talk_bot_msg)], background_tasks: BackgroundTasks, ): background_tasks.add_task(coverage_talk_bot_process_request, message, request) return Response() # in real program this is not needed, as bot enabling handler is called in the bots process itself and will reset it. @APP.delete("/reset_bot_secret") def reset_bot_secret(): os.environ.pop(talk_bot.__get_bot_secret("/talk_bot_coverage"), None) return Response() if __name__ == "__main__": run_app("_talk_bot:APP", log_level="trace") cloud-py-api-nc_py_api-d4a32c6/tests/_talk_bot_async.py0000664000232200023220000000425314766056032023620 0ustar debalancedebalanceimport os from typing import Annotated import gfixture_set_env # noqa import pytest from fastapi import BackgroundTasks, Depends, FastAPI, Request, Response from nc_py_api import AsyncNextcloudApp, talk_bot from nc_py_api.ex_app import AppAPIAuthMiddleware, anc_app, atalk_bot_msg, run_app APP = FastAPI() APP.add_middleware(AppAPIAuthMiddleware, disable_for=["reset_bot_secret"]) COVERAGE_BOT = talk_bot.AsyncTalkBot("/talk_bot_coverage", "Coverage bot", "Desc") async def coverage_talk_bot_process_request(message: talk_bot.TalkBotMessage, request: Request): await COVERAGE_BOT.react_to_message(message, "🥳") await COVERAGE_BOT.react_to_message(message, "🫡") await COVERAGE_BOT.delete_reaction(message, "🫡") await COVERAGE_BOT.send_message("Hello from bot!", message) assert isinstance(message.actor_id, str) assert isinstance(message.actor_display_name, str) assert isinstance(message.object_name, str) assert isinstance(message.object_content, dict) assert message.object_media_type in ("text/markdown", "text/plain") assert isinstance(message.conversation_name, str) assert str(message).find("conversation=") != -1 with pytest.raises(ValueError): await COVERAGE_BOT.react_to_message(message.object_id, "🥳") with pytest.raises(ValueError): await COVERAGE_BOT.delete_reaction(message.object_id, "🥳") with pytest.raises(ValueError): await COVERAGE_BOT.send_message("🥳", message.object_id) @APP.post("/talk_bot_coverage") async def talk_bot_coverage( request: Request, _nc: Annotated[AsyncNextcloudApp, Depends(anc_app)], message: Annotated[talk_bot.TalkBotMessage, Depends(atalk_bot_msg)], background_tasks: BackgroundTasks, ): background_tasks.add_task(coverage_talk_bot_process_request, message, request) return Response() # in real program this is not needed, as bot enabling handler is called in the bots process itself and will reset it. @APP.delete("/reset_bot_secret") async def reset_bot_secret(): os.environ.pop(talk_bot.__get_bot_secret("/talk_bot_coverage"), None) return Response() if __name__ == "__main__": run_app("_talk_bot_async:APP", log_level="trace") cloud-py-api-nc_py_api-d4a32c6/tests/_install_only_enabled_handler_async.py0000664000232200023220000000073014766056032027673 0ustar debalancedebalancefrom contextlib import asynccontextmanager from fastapi import FastAPI from nc_py_api import AsyncNextcloudApp, ex_app @asynccontextmanager async def lifespan(_app: FastAPI): ex_app.set_handlers(APP, enabled_handler) yield APP = FastAPI(lifespan=lifespan) async def enabled_handler(_enabled: bool, _nc: AsyncNextcloudApp) -> str: return "" if __name__ == "__main__": ex_app.run_app("_install_only_enabled_handler_async:APP", log_level="warning") cloud-py-api-nc_py_api-d4a32c6/tests/__init__.py0000664000232200023220000000000014766056032022206 0ustar debalancedebalancecloud-py-api-nc_py_api-d4a32c6/tests/_tests_at_the_end.py0000664000232200023220000000465314766056032024144 0ustar debalancedebalanceimport os import sys from subprocess import Popen import pytest from ._install_wait import check_heartbeat # These tests will be run separate, and at the end of all other tests. def _test_ex_app_enable_disable(file_to_test): child_environment = os.environ.copy() child_environment["APP_PORT"] = os.environ.get("APP_PORT", "9009") r = Popen( [sys.executable, os.path.join(os.path.dirname(os.path.abspath(__file__)), file_to_test)], env=child_environment, cwd=os.getcwd(), ) url = f"http://127.0.0.1:{child_environment['APP_PORT']}/heartbeat" return r, url @pytest.mark.parametrize("file_to_test", ("_install_only_enabled_handler.py", "_install_only_enabled_handler_async.py")) def test_ex_app_enable_disable(nc_client, nc_app, file_to_test): r, url = _test_ex_app_enable_disable(file_to_test) try: if check_heartbeat(url, '"status":"ok"', 15, 0.3): raise RuntimeError(f"`{file_to_test}` can not start.") if nc_client.apps.ex_app_is_enabled("nc_py_api"): nc_client.apps.ex_app_disable("nc_py_api") assert nc_client.apps.ex_app_is_disabled("nc_py_api") is True assert nc_client.apps.ex_app_is_enabled("nc_py_api") is False nc_client.apps.ex_app_enable("nc_py_api") assert nc_client.apps.ex_app_is_disabled("nc_py_api") is False assert nc_client.apps.ex_app_is_enabled("nc_py_api") is True finally: r.terminate() r.wait(timeout=10) @pytest.mark.asyncio(scope="session") @pytest.mark.parametrize("file_to_test", ("_install_only_enabled_handler.py", "_install_only_enabled_handler_async.py")) async def test_ex_app_enable_disable_async(anc_client, anc_app, file_to_test): r, url = _test_ex_app_enable_disable(file_to_test) try: if check_heartbeat(url, '"status":"ok"', 15, 0.3): raise RuntimeError(f"`{file_to_test}` can not start.") if await anc_client.apps.ex_app_is_enabled("nc_py_api"): await anc_client.apps.ex_app_disable("nc_py_api") assert await anc_client.apps.ex_app_is_disabled("nc_py_api") is True assert await anc_client.apps.ex_app_is_enabled("nc_py_api") is False await anc_client.apps.ex_app_enable("nc_py_api") assert await anc_client.apps.ex_app_is_disabled("nc_py_api") is False assert await anc_client.apps.ex_app_is_enabled("nc_py_api") is True finally: r.terminate() r.wait(timeout=10) cloud-py-api-nc_py_api-d4a32c6/tests/_install.py0000664000232200023220000000245414766056032022273 0ustar debalancedebalanceimport typing from contextlib import asynccontextmanager from fastapi import BackgroundTasks, Depends, FastAPI from fastapi.responses import JSONResponse from nc_py_api import NextcloudApp, ex_app @asynccontextmanager async def lifespan(_app: FastAPI): ex_app.set_handlers(APP, enabled_handler, default_init=False) yield APP = FastAPI(lifespan=lifespan) @APP.put("/sec_check") def sec_check( value: int, _nc: typing.Annotated[NextcloudApp, Depends(ex_app.nc_app)], ): print(value, flush=True) return JSONResponse(content={"error": ""}, status_code=200) def init_handler_background(nc: NextcloudApp): nc.set_init_status(100) @APP.post("/init") def init_handler( background_tasks: BackgroundTasks, nc: typing.Annotated[NextcloudApp, Depends(ex_app.nc_app)], ): background_tasks.add_task(init_handler_background, nc) return JSONResponse(content={}, status_code=200) def enabled_handler(enabled: bool, nc: NextcloudApp) -> str: print(f"enabled_handler: enabled={enabled}", flush=True) if enabled: nc.log(ex_app.LogLvl.WARNING, f"Hello from {nc.app_cfg.app_name} :)") else: nc.log(ex_app.LogLvl.WARNING, f"Bye bye from {nc.app_cfg.app_name} :(") return "" if __name__ == "__main__": ex_app.run_app("_install:APP", log_level="trace") cloud-py-api-nc_py_api-d4a32c6/tests/_install_async.py0000664000232200023220000000254014766056032023464 0ustar debalancedebalanceimport typing from contextlib import asynccontextmanager from fastapi import BackgroundTasks, Depends, FastAPI from fastapi.responses import JSONResponse from nc_py_api import AsyncNextcloudApp, ex_app @asynccontextmanager async def lifespan(_app: FastAPI): ex_app.set_handlers(APP, enabled_handler, default_init=False) yield APP = FastAPI(lifespan=lifespan) APP.add_middleware(ex_app.AppAPIAuthMiddleware) @APP.put("/sec_check") async def sec_check( value: int, ): print(value, flush=True) return JSONResponse(content={"error": ""}, status_code=200) async def init_handler_background(nc: AsyncNextcloudApp): await nc.set_init_status(100) @APP.post("/init") async def init_handler( background_tasks: BackgroundTasks, nc: typing.Annotated[AsyncNextcloudApp, Depends(ex_app.anc_app)], ): background_tasks.add_task(init_handler_background, nc) return JSONResponse(content={}, status_code=200) async def enabled_handler(enabled: bool, nc: AsyncNextcloudApp) -> str: print(f"enabled_handler: enabled={enabled}", flush=True) if enabled: await nc.log(ex_app.LogLvl.WARNING, f"Hello from {nc.app_cfg.app_name} :)") else: await nc.log(ex_app.LogLvl.WARNING, f"Bye bye from {nc.app_cfg.app_name} :(") return "" if __name__ == "__main__": ex_app.run_app("_install_async:APP", log_level="trace") cloud-py-api-nc_py_api-d4a32c6/tests/_install_only_enabled_handler.py0000664000232200023220000000070214766056032026475 0ustar debalancedebalancefrom contextlib import asynccontextmanager from fastapi import FastAPI from nc_py_api import NextcloudApp, ex_app @asynccontextmanager async def lifespan(_app: FastAPI): ex_app.set_handlers(APP, enabled_handler) yield APP = FastAPI(lifespan=lifespan) def enabled_handler(_enabled: bool, _nc: NextcloudApp) -> str: return "" if __name__ == "__main__": ex_app.run_app("_install_only_enabled_handler:APP", log_level="warning") cloud-py-api-nc_py_api-d4a32c6/tests/gfixture_set_env.py0000664000232200023220000000111514766056032024037 0ustar debalancedebalancefrom os import environ if not environ.get("CI", False): # For local tests environ["NC_AUTH_USER"] = "admin" environ["NC_AUTH_PASS"] = "admin" # "MrtGY-KfY24-iiDyg-cr4n4-GLsNZ" environ["NEXTCLOUD_URL"] = environ.get("NEXTCLOUD_URL", "http://stable29.local") # environ["NEXTCLOUD_URL"] = environ.get("NEXTCLOUD_URL", "http://stable30.local") # environ["NEXTCLOUD_URL"] = environ.get("NEXTCLOUD_URL", "http://nextcloud.local") environ["APP_ID"] = "nc_py_api" environ["APP_VERSION"] = "1.0.0" environ["APP_SECRET"] = "12345" environ["APP_PORT"] = "9009" cloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/0000775000232200023220000000000014766056032022602 5ustar debalancedebalancecloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/notifications_test.py0000664000232200023220000001457414766056032027077 0ustar debalancedebalanceimport datetime import pytest from nc_py_api.notifications import Notification def test_available(nc_app): assert nc_app.notifications.available @pytest.mark.asyncio(scope="session") async def test_available_async(anc_app): assert await anc_app.notifications.available def test_create_as_client(nc_client): with pytest.raises(NotImplementedError): nc_client.notifications.create("caption") @pytest.mark.asyncio(scope="session") async def test_create_as_client_async(anc_client): with pytest.raises(NotImplementedError): await anc_client.notifications.create("caption") def _test_create(new_notification: Notification): assert isinstance(new_notification, Notification) assert new_notification.subject == "subject0123" assert new_notification.message == "message456" assert new_notification.icon assert not new_notification.link assert new_notification.time > datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) assert str(new_notification).find("app_name=") != -1 assert isinstance(new_notification.object_type, str) def test_create(nc_app): obj_id = nc_app.notifications.create("subject0123", "message456") new_notification = nc_app.notifications.by_object_id(obj_id) _test_create(new_notification) @pytest.mark.asyncio(scope="session") async def test_create_async(anc_app): obj_id = await anc_app.notifications.create("subject0123", "message456") new_notification = await anc_app.notifications.by_object_id(obj_id) _test_create(new_notification) def test_create_link_icon(nc_app): obj_id = nc_app.notifications.create("1", "", link="https://some.link/gg") new_notification = nc_app.notifications.by_object_id(obj_id) assert isinstance(new_notification, Notification) assert new_notification.subject == "1" assert not new_notification.message assert new_notification.icon assert new_notification.link == "https://some.link/gg" @pytest.mark.asyncio(scope="session") async def test_create_link_icon_async(anc_app): obj_id = await anc_app.notifications.create("1", "", link="https://some.link/gg") new_notification = await anc_app.notifications.by_object_id(obj_id) assert isinstance(new_notification, Notification) assert new_notification.subject == "1" assert not new_notification.message assert new_notification.icon assert new_notification.link == "https://some.link/gg" def test_delete_all(nc_app): nc_app.notifications.create("subject0123", "message456") obj_id1 = nc_app.notifications.create("subject0123", "message456") ntf1 = nc_app.notifications.by_object_id(obj_id1) assert ntf1 obj_id2 = nc_app.notifications.create("subject0123", "message456") ntf2 = nc_app.notifications.by_object_id(obj_id2) assert ntf2 nc_app.notifications.delete_all() assert nc_app.notifications.by_object_id(obj_id1) is None assert nc_app.notifications.by_object_id(obj_id2) is None assert not nc_app.notifications.get_all() assert not nc_app.notifications.exists([ntf1.notification_id, ntf2.notification_id]) @pytest.mark.asyncio(scope="session") async def test_delete_all_async(anc_app): await anc_app.notifications.create("subject0123", "message456") obj_id1 = await anc_app.notifications.create("subject0123", "message456") ntf1 = await anc_app.notifications.by_object_id(obj_id1) assert ntf1 obj_id2 = await anc_app.notifications.create("subject0123", "message456") ntf2 = await anc_app.notifications.by_object_id(obj_id2) assert ntf2 await anc_app.notifications.delete_all() assert await anc_app.notifications.by_object_id(obj_id1) is None assert await anc_app.notifications.by_object_id(obj_id2) is None assert not await anc_app.notifications.get_all() assert not await anc_app.notifications.exists([ntf1.notification_id, ntf2.notification_id]) def test_delete_one(nc_app): obj_id1 = nc_app.notifications.create("subject0123") obj_id2 = nc_app.notifications.create("subject0123") ntf1 = nc_app.notifications.by_object_id(obj_id1) ntf2 = nc_app.notifications.by_object_id(obj_id2) nc_app.notifications.delete(ntf1.notification_id) assert nc_app.notifications.by_object_id(obj_id1) is None assert nc_app.notifications.by_object_id(obj_id2) assert nc_app.notifications.exists([ntf1.notification_id, ntf2.notification_id]) == [ntf2.notification_id] nc_app.notifications.delete(ntf2.notification_id) @pytest.mark.asyncio(scope="session") async def test_delete_one_async(anc_app): obj_id1 = await anc_app.notifications.create("subject0123") obj_id2 = await anc_app.notifications.create("subject0123") ntf1 = await anc_app.notifications.by_object_id(obj_id1) ntf2 = await anc_app.notifications.by_object_id(obj_id2) await anc_app.notifications.delete(ntf1.notification_id) assert await anc_app.notifications.by_object_id(obj_id1) is None assert await anc_app.notifications.by_object_id(obj_id2) assert await anc_app.notifications.exists([ntf1.notification_id, ntf2.notification_id]) == [ntf2.notification_id] await anc_app.notifications.delete(ntf2.notification_id) def test_create_invalid_args(nc_app): with pytest.raises(ValueError): nc_app.notifications.create("") @pytest.mark.asyncio(scope="session") async def test_create_invalid_args_async(anc_app): with pytest.raises(ValueError): await anc_app.notifications.create("") def test_get_one(nc_app): nc_app.notifications.delete_all() obj_id1 = nc_app.notifications.create("subject0123") obj_id2 = nc_app.notifications.create("subject0123") ntf1 = nc_app.notifications.by_object_id(obj_id1) ntf2 = nc_app.notifications.by_object_id(obj_id2) ntf1_2 = nc_app.notifications.get_one(ntf1.notification_id) ntf2_2 = nc_app.notifications.get_one(ntf2.notification_id) assert ntf1 == ntf1_2 assert ntf2 == ntf2_2 @pytest.mark.asyncio(scope="session") async def test_get_one_async(anc_app): await anc_app.notifications.delete_all() obj_id1 = await anc_app.notifications.create("subject0123") obj_id2 = await anc_app.notifications.create("subject0123") ntf1 = await anc_app.notifications.by_object_id(obj_id1) ntf2 = await anc_app.notifications.by_object_id(obj_id2) ntf1_2 = await anc_app.notifications.get_one(ntf1.notification_id) ntf2_2 = await anc_app.notifications.get_one(ntf2.notification_id) assert ntf1 == ntf1_2 assert ntf2 == ntf2_2 cloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/loginflow_v2_test.py0000664000232200023220000000143114766056032026621 0ustar debalancedebalanceimport pytest from nc_py_api import NextcloudException def test_init_poll(nc_client): lf = nc_client.loginflow_v2.init() assert isinstance(lf.endpoint, str) assert isinstance(lf.login, str) assert isinstance(lf.token, str) with pytest.raises(NextcloudException) as exc_info: nc_client.loginflow_v2.poll(lf.token, 1) assert exc_info.value.status_code == 404 @pytest.mark.asyncio(scope="session") async def test_init_poll_async(anc_client): lf = await anc_client.loginflow_v2.init() assert isinstance(lf.endpoint, str) assert isinstance(lf.login, str) assert isinstance(lf.token, str) with pytest.raises(NextcloudException) as exc_info: await anc_client.loginflow_v2.poll(lf.token, 1) assert exc_info.value.status_code == 404 cloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/talk_test.py0000664000232200023220000007020514766056032025152 0ustar debalancedebalancefrom io import BytesIO from os import environ import pytest from PIL import Image from nc_py_api import AsyncNextcloud, Nextcloud, NextcloudException, files, talk def test_conversation_create_delete(nc): if nc.talk.available is False: pytest.skip("Nextcloud Talk is not installed") conversation = nc.talk.create_conversation(talk.ConversationType.GROUP, "admin") nc.talk.delete_conversation(conversation) assert isinstance(conversation.conversation_id, int) assert isinstance(conversation.token, str) and conversation.token assert isinstance(conversation.name, str) assert isinstance(conversation.conversation_type, talk.ConversationType) assert isinstance(conversation.display_name, str) assert isinstance(conversation.description, str) assert isinstance(conversation.participant_type, talk.ParticipantType) assert isinstance(conversation.attendee_id, int) assert isinstance(conversation.attendee_pin, str) assert isinstance(conversation.actor_type, str) assert isinstance(conversation.actor_id, str) assert isinstance(conversation.permissions, talk.AttendeePermissions) assert isinstance(conversation.attendee_permissions, talk.AttendeePermissions) assert isinstance(conversation.call_permissions, talk.AttendeePermissions) assert isinstance(conversation.default_permissions, talk.AttendeePermissions) assert isinstance(conversation.participant_flags, talk.InCallFlags) assert isinstance(conversation.read_only, bool) assert isinstance(conversation.listable, talk.ListableScope) assert isinstance(conversation.message_expiration, int) assert isinstance(conversation.has_password, bool) assert isinstance(conversation.has_call, bool) assert isinstance(conversation.call_flag, talk.InCallFlags) assert isinstance(conversation.can_start_call, bool) assert isinstance(conversation.can_delete_conversation, bool) assert isinstance(conversation.can_leave_conversation, bool) assert isinstance(conversation.last_activity, int) assert isinstance(conversation.is_favorite, bool) assert isinstance(conversation.notification_level, talk.NotificationLevel) assert isinstance(conversation.lobby_state, talk.WebinarLobbyStates) assert isinstance(conversation.lobby_timer, int) assert isinstance(conversation.sip_enabled, talk.SipEnabledStatus) assert isinstance(conversation.can_enable_sip, bool) assert isinstance(conversation.unread_messages_count, int) assert isinstance(conversation.unread_mention, bool) assert isinstance(conversation.unread_mention_direct, bool) assert isinstance(conversation.last_read_message, int) assert isinstance(conversation.last_message, talk.TalkMessage) or conversation.last_message is None assert isinstance(conversation.last_common_read_message, int) assert isinstance(conversation.breakout_room_mode, talk.BreakoutRoomMode) assert isinstance(conversation.breakout_room_status, talk.BreakoutRoomStatus) assert isinstance(conversation.avatar_version, str) assert isinstance(conversation.is_custom_avatar, bool) assert isinstance(conversation.call_start_time, int) assert isinstance(conversation.recording_status, talk.CallRecordingStatus) assert isinstance(conversation.status_type, str) assert isinstance(conversation.status_message, str) assert isinstance(conversation.status_icon, str) assert isinstance(conversation.status_clear_at, int) or conversation.status_clear_at is None if conversation.last_message is None: return talk_msg = conversation.last_message assert isinstance(talk_msg.message_id, int) assert isinstance(talk_msg.token, str) assert talk_msg.actor_type in ("users", "guests", "bots", "bridged") assert isinstance(talk_msg.actor_id, str) assert isinstance(talk_msg.actor_display_name, str) assert isinstance(talk_msg.timestamp, int) assert isinstance(talk_msg.system_message, str) assert talk_msg.message_type in ("comment", "comment_deleted", "system", "command") assert talk_msg.is_replyable is False assert isinstance(talk_msg.reference_id, str) assert isinstance(talk_msg.message, str) assert isinstance(talk_msg.message_parameters, dict) assert isinstance(talk_msg.expiration_timestamp, int) assert isinstance(talk_msg.parent, list) assert isinstance(talk_msg.reactions, dict) assert isinstance(talk_msg.reactions_self, list) assert isinstance(talk_msg.markdown, bool) def test_get_conversations_modified_since(nc): if nc.talk.available is False: pytest.skip("Nextcloud Talk is not installed") conversation = nc.talk.create_conversation(talk.ConversationType.GROUP, "admin") try: conversations = nc.talk.get_user_conversations() assert conversations nc.talk.modified_since += 2 # read notes for ``modified_since`` param in docs. conversations = nc.talk.get_user_conversations(modified_since=True) assert not conversations conversations = nc.talk.get_user_conversations(modified_since=9992708529, no_status_update=False) assert not conversations finally: nc.talk.delete_conversation(conversation.token) @pytest.mark.asyncio(scope="session") async def test_get_conversations_modified_since_async(anc): if await anc.talk.available is False: pytest.skip("Nextcloud Talk is not installed") conversation = await anc.talk.create_conversation(talk.ConversationType.GROUP, "admin") try: conversations = await anc.talk.get_user_conversations() assert conversations anc.talk.modified_since += 2 # read notes for ``modified_since`` param in docs. conversations = await anc.talk.get_user_conversations(modified_since=True) assert not conversations conversations = await anc.talk.get_user_conversations(modified_since=9992708529, no_status_update=False) assert not conversations finally: await anc.talk.delete_conversation(conversation.token) def _test_get_conversations_include_status(participants: list[talk.Participant]): assert len(participants) == 2 second_participant = next(i for i in participants if i.actor_id == environ["TEST_USER_ID"]) assert second_participant.actor_type == "users" assert isinstance(second_participant.attendee_id, int) assert isinstance(second_participant.display_name, str) assert isinstance(second_participant.participant_type, talk.ParticipantType) assert isinstance(second_participant.last_ping, int) assert second_participant.participant_flags == talk.InCallFlags.DISCONNECTED assert isinstance(second_participant.permissions, talk.AttendeePermissions) assert isinstance(second_participant.attendee_permissions, talk.AttendeePermissions) assert isinstance(second_participant.session_ids, list) assert isinstance(second_participant.breakout_token, str) assert second_participant.status_message == "" assert str(second_participant).find("last_ping=") != -1 def test_get_conversations_include_status(nc, nc_client): if nc.talk.available is False: pytest.skip("Nextcloud Talk is not installed") nc_second_user = Nextcloud(nc_auth_user=environ["TEST_USER_ID"], nc_auth_pass=environ["TEST_USER_PASS"]) nc_second_user.user_status.set_status_type("away") nc_second_user.user_status.set_status("my status message", status_icon="😇") conversation = nc.talk.create_conversation(talk.ConversationType.ONE_TO_ONE, environ["TEST_USER_ID"]) try: conversations = nc.talk.get_user_conversations(include_status=False) assert conversations first_conv = next(i for i in conversations if i.conversation_id == conversation.conversation_id) assert not first_conv.status_type conversations = nc.talk.get_user_conversations(include_status=True) assert conversations first_conv = next(i for i in conversations if i.conversation_id == conversation.conversation_id) assert first_conv.status_type == "away" assert first_conv.status_message == "my status message" assert first_conv.status_icon == "😇" participants = nc.talk.list_participants(first_conv) _test_get_conversations_include_status(participants) participants = nc.talk.list_participants(first_conv, include_status=True) assert len(participants) == 2 second_participant = next(i for i in participants if i.actor_id == environ["TEST_USER_ID"]) assert second_participant.status_message == "my status message" assert str(conversation).find("type=") != -1 finally: nc.talk.leave_conversation(conversation.token) @pytest.mark.asyncio(scope="session") async def test_get_conversations_include_status_async(anc, anc_client): if await anc.talk.available is False: pytest.skip("Nextcloud Talk is not installed") nc_second_user = Nextcloud(nc_auth_user=environ["TEST_USER_ID"], nc_auth_pass=environ["TEST_USER_PASS"]) nc_second_user.user_status.set_status_type("away") nc_second_user.user_status.set_status("my status message-async", status_icon="😇") conversation = await anc.talk.create_conversation(talk.ConversationType.ONE_TO_ONE, environ["TEST_USER_ID"]) try: conversations = await anc.talk.get_user_conversations(include_status=False) assert conversations first_conv = next(i for i in conversations if i.conversation_id == conversation.conversation_id) assert not first_conv.status_type conversations = await anc.talk.get_user_conversations(include_status=True) assert conversations first_conv = next(i for i in conversations if i.conversation_id == conversation.conversation_id) assert first_conv.status_type == "away" assert first_conv.status_message == "my status message-async" assert first_conv.status_icon == "😇" participants = await anc.talk.list_participants(first_conv) _test_get_conversations_include_status(participants) participants = await anc.talk.list_participants(first_conv, include_status=True) assert len(participants) == 2 second_participant = next(i for i in participants if i.actor_id == environ["TEST_USER_ID"]) assert second_participant.status_message == "my status message-async" assert str(conversation).find("type=") != -1 finally: await anc.talk.leave_conversation(conversation.token) def test_rename_description_favorite_get_conversation(nc_any): if nc_any.talk.available is False: pytest.skip("Nextcloud Talk is not installed") conversation = nc_any.talk.create_conversation(talk.ConversationType.GROUP, "admin") try: nc_any.talk.rename_conversation(conversation, "new era") assert conversation.is_favorite is False nc_any.talk.set_conversation_description(conversation, "the description") nc_any.talk.set_conversation_fav(conversation, True) nc_any.talk.set_conversation_readonly(conversation, True) nc_any.talk.set_conversation_public(conversation, True) nc_any.talk.set_conversation_notify_lvl(conversation, talk.NotificationLevel.NEVER_NOTIFY) nc_any.talk.set_conversation_password(conversation, "zJf4aLafv8941nvs") conversation = nc_any.talk.get_conversation_by_token(conversation) assert conversation.display_name == "new era" assert conversation.description == "the description" assert conversation.is_favorite is True assert conversation.read_only is True assert conversation.notification_level == talk.NotificationLevel.NEVER_NOTIFY assert conversation.has_password is True nc_any.talk.set_conversation_fav(conversation, False) nc_any.talk.set_conversation_readonly(conversation, False) nc_any.talk.set_conversation_password(conversation, "") nc_any.talk.set_conversation_public(conversation, False) conversation = nc_any.talk.get_conversation_by_token(conversation) assert conversation.is_favorite is False assert conversation.read_only is False assert conversation.has_password is False finally: nc_any.talk.delete_conversation(conversation) @pytest.mark.asyncio(scope="session") async def test_rename_description_favorite_get_conversation_async(anc_any): if await anc_any.talk.available is False: pytest.skip("Nextcloud Talk is not installed") conversation = await anc_any.talk.create_conversation(talk.ConversationType.GROUP, "admin") try: await anc_any.talk.rename_conversation(conversation, "new era") assert conversation.is_favorite is False await anc_any.talk.set_conversation_description(conversation, "the description") await anc_any.talk.set_conversation_fav(conversation, True) await anc_any.talk.set_conversation_readonly(conversation, True) await anc_any.talk.set_conversation_public(conversation, True) await anc_any.talk.set_conversation_notify_lvl(conversation, talk.NotificationLevel.NEVER_NOTIFY) await anc_any.talk.set_conversation_password(conversation, "zJf4aLafv8941nvs") conversation = await anc_any.talk.get_conversation_by_token(conversation) assert conversation.display_name == "new era" assert conversation.description == "the description" assert conversation.is_favorite is True assert conversation.read_only is True assert conversation.notification_level == talk.NotificationLevel.NEVER_NOTIFY assert conversation.has_password is True await anc_any.talk.set_conversation_fav(conversation, False) await anc_any.talk.set_conversation_readonly(conversation, False) await anc_any.talk.set_conversation_password(conversation, "") await anc_any.talk.set_conversation_public(conversation, False) conversation = await anc_any.talk.get_conversation_by_token(conversation) assert conversation.is_favorite is False assert conversation.read_only is False assert conversation.has_password is False finally: await anc_any.talk.delete_conversation(conversation) def test_message_send_delete_reactions(nc_any): if nc_any.talk.available is False: pytest.skip("Nextcloud Talk is not installed") conversation = nc_any.talk.create_conversation(talk.ConversationType.GROUP, "admin") try: msg = nc_any.talk.send_message("yo yo yo!", conversation) reactions = nc_any.talk.react_to_message(msg, "❤️") assert "❤️" in reactions assert len(reactions["❤️"]) == 1 reaction = reactions["❤️"][0] assert reaction.actor_id == nc_any.user assert reaction.actor_type == "users" assert reaction.actor_display_name assert isinstance(reaction.timestamp, int) reactions2 = nc_any.talk.get_message_reactions(msg) assert reactions == reactions2 nc_any.talk.react_to_message(msg, "☝️️") assert nc_any.talk.delete_reaction(msg, "❤️") assert not nc_any.talk.delete_reaction(msg, "☝️️") assert not nc_any.talk.get_message_reactions(msg) result = nc_any.talk.delete_message(msg) assert result.system_message == "message_deleted" messages = nc_any.talk.receive_messages(conversation) deleted = [i for i in messages if i.system_message == "message_deleted"] assert deleted assert str(deleted[0]).find("time=") != -1 finally: nc_any.talk.delete_conversation(conversation) @pytest.mark.asyncio(scope="session") async def test_message_send_delete_reactions_async(anc_any): if await anc_any.talk.available is False: pytest.skip("Nextcloud Talk is not installed") conversation = await anc_any.talk.create_conversation(talk.ConversationType.GROUP, "admin") try: msg = await anc_any.talk.send_message("yo yo yo!", conversation) reactions = await anc_any.talk.react_to_message(msg, "❤️") assert "❤️" in reactions assert len(reactions["❤️"]) == 1 reaction = reactions["❤️"][0] assert reaction.actor_id == await anc_any.user assert reaction.actor_type == "users" assert reaction.actor_display_name assert isinstance(reaction.timestamp, int) reactions2 = await anc_any.talk.get_message_reactions(msg) assert reactions == reactions2 await anc_any.talk.react_to_message(msg, "☝️️") assert await anc_any.talk.delete_reaction(msg, "❤️") assert not await anc_any.talk.delete_reaction(msg, "☝️️") assert not await anc_any.talk.get_message_reactions(msg) result = await anc_any.talk.delete_message(msg) assert result.system_message == "message_deleted" messages = await anc_any.talk.receive_messages(conversation) deleted = [i for i in messages if i.system_message == "message_deleted"] assert deleted assert str(deleted[0]).find("time=") != -1 finally: await anc_any.talk.delete_conversation(conversation) def _test_create_close_poll(poll: talk.Poll, closed: bool, user: str, conversation_token: str): assert isinstance(poll.poll_id, int) assert poll.question == "When was this test written?" assert poll.options == ["2000", "2023", "2030"] assert poll.max_votes == 1 assert poll.num_voters == 0 assert poll.hidden_results is True assert poll.details == [] assert poll.closed is closed assert poll.conversation_token == conversation_token assert poll.actor_type == "users" assert poll.actor_id == user assert isinstance(poll.actor_display_name, str) assert poll.voted_self == [] assert poll.votes == [] def test_create_close_poll(nc_any): if nc_any.talk.available is False: pytest.skip("Nextcloud Talk is not installed") conversation = nc_any.talk.create_conversation(talk.ConversationType.GROUP, "admin") try: poll = nc_any.talk.create_poll(conversation, "When was this test written?", ["2000", "2023", "2030"]) assert str(poll).find("author=") != -1 _test_create_close_poll(poll, False, nc_any.user, conversation.token) poll = nc_any.talk.get_poll(poll) _test_create_close_poll(poll, False, nc_any.user, conversation.token) poll = nc_any.talk.get_poll(poll.poll_id, conversation.token) _test_create_close_poll(poll, False, nc_any.user, conversation.token) poll = nc_any.talk.close_poll(poll.poll_id, conversation.token) _test_create_close_poll(poll, True, nc_any.user, conversation.token) finally: nc_any.talk.delete_conversation(conversation) @pytest.mark.asyncio(scope="session") async def test_create_close_poll_async(anc_any): if await anc_any.talk.available is False: pytest.skip("Nextcloud Talk is not installed") conversation = await anc_any.talk.create_conversation(talk.ConversationType.GROUP, "admin") try: poll = await anc_any.talk.create_poll(conversation, "When was this test written?", ["2000", "2023", "2030"]) assert str(poll).find("author=") != -1 _test_create_close_poll(poll, False, await anc_any.user, conversation.token) poll = await anc_any.talk.get_poll(poll) _test_create_close_poll(poll, False, await anc_any.user, conversation.token) poll = await anc_any.talk.get_poll(poll.poll_id, conversation.token) _test_create_close_poll(poll, False, await anc_any.user, conversation.token) poll = await anc_any.talk.close_poll(poll.poll_id, conversation.token) _test_create_close_poll(poll, True, await anc_any.user, conversation.token) finally: await anc_any.talk.delete_conversation(conversation) def test_vote_poll(nc_any): if nc_any.talk.available is False: pytest.skip("Nextcloud Talk is not installed") conversation = nc_any.talk.create_conversation(talk.ConversationType.GROUP, "admin") try: poll = nc_any.talk.create_poll( conversation, "what color is the grass", ["red", "green", "blue"], hidden_results=False, max_votes=3 ) assert poll.hidden_results is False assert not poll.voted_self poll = nc_any.talk.vote_poll([0, 2], poll) assert poll.voted_self == [0, 2] assert poll.votes == { "option-0": 1, "option-2": 1, } assert poll.num_voters == 1 poll = nc_any.talk.vote_poll([1], poll.poll_id, conversation) assert poll.voted_self == [1] assert poll.votes == { "option-1": 1, } poll = nc_any.talk.close_poll(poll) assert poll.closed is True assert len(poll.details) == 1 assert poll.details[0].actor_id == nc_any.user assert poll.details[0].actor_type == "users" assert poll.details[0].option == 1 assert isinstance(poll.details[0].actor_display_name, str) assert str(poll.details[0]).find("actor=") != -1 finally: nc_any.talk.delete_conversation(conversation) @pytest.mark.asyncio(scope="session") async def test_vote_poll_async(anc_any): if await anc_any.talk.available is False: pytest.skip("Nextcloud Talk is not installed") conversation = await anc_any.talk.create_conversation(talk.ConversationType.GROUP, "admin") try: poll = await anc_any.talk.create_poll( conversation, "what color is the grass", ["red", "green", "blue"], hidden_results=False, max_votes=3 ) assert poll.hidden_results is False assert not poll.voted_self poll = await anc_any.talk.vote_poll([0, 2], poll) assert poll.voted_self == [0, 2] assert poll.votes == { "option-0": 1, "option-2": 1, } assert poll.num_voters == 1 poll = await anc_any.talk.vote_poll([1], poll.poll_id, conversation) assert poll.voted_self == [1] assert poll.votes == { "option-1": 1, } poll = await anc_any.talk.close_poll(poll) assert poll.closed is True assert len(poll.details) == 1 assert poll.details[0].actor_id == await anc_any.user assert poll.details[0].actor_type == "users" assert poll.details[0].option == 1 assert isinstance(poll.details[0].actor_display_name, str) assert str(poll.details[0]).find("actor=") != -1 finally: await anc_any.talk.delete_conversation(conversation) def test_conversation_avatar(nc_any): if nc_any.talk.available is False: pytest.skip("Nextcloud Talk is not installed") conversation = nc_any.talk.create_conversation(talk.ConversationType.GROUP, "admin") try: assert conversation.is_custom_avatar is False r = nc_any.talk.get_conversation_avatar(conversation) assert isinstance(r, bytes) im = Image.effect_mandelbrot((512, 512), (-3, -2.5, 2, 2.5), 100) buffer = BytesIO() im.save(buffer, format="PNG") buffer.seek(0) r = nc_any.talk.set_conversation_avatar(conversation, buffer.read()) assert r.is_custom_avatar is True r = nc_any.talk.get_conversation_avatar(conversation) assert isinstance(r, bytes) r = nc_any.talk.delete_conversation_avatar(conversation) assert r.is_custom_avatar is False r = nc_any.talk.set_conversation_avatar(conversation, ("🫡", None)) assert r.is_custom_avatar is True r = nc_any.talk.get_conversation_avatar(conversation, dark=True) assert isinstance(r, bytes) with pytest.raises(NextcloudException): nc_any.talk.get_conversation_avatar("not_exist_conversation") finally: nc_any.talk.delete_conversation(conversation) @pytest.mark.asyncio(scope="session") async def test_conversation_avatar_async(anc_any): if await anc_any.talk.available is False: pytest.skip("Nextcloud Talk is not installed") conversation = await anc_any.talk.create_conversation(talk.ConversationType.GROUP, "admin") try: assert conversation.is_custom_avatar is False r = await anc_any.talk.get_conversation_avatar(conversation) assert isinstance(r, bytes) im = Image.effect_mandelbrot((512, 512), (-3, -2.5, 2, 2.5), 100) buffer = BytesIO() im.save(buffer, format="PNG") buffer.seek(0) r = await anc_any.talk.set_conversation_avatar(conversation, buffer.read()) assert r.is_custom_avatar is True r = await anc_any.talk.get_conversation_avatar(conversation) assert isinstance(r, bytes) r = await anc_any.talk.delete_conversation_avatar(conversation) assert r.is_custom_avatar is False r = await anc_any.talk.set_conversation_avatar(conversation, ("🫡", None)) assert r.is_custom_avatar is True r = await anc_any.talk.get_conversation_avatar(conversation, dark=True) assert isinstance(r, bytes) with pytest.raises(NextcloudException): await anc_any.talk.get_conversation_avatar("not_exist_conversation") finally: await anc_any.talk.delete_conversation(conversation) def test_send_receive_file(nc_client): if nc_client.talk.available is False: pytest.skip("Nextcloud Talk is not installed") nc_second_user = Nextcloud(nc_auth_user=environ["TEST_USER_ID"], nc_auth_pass=environ["TEST_USER_PASS"]) conversation = nc_client.talk.create_conversation(talk.ConversationType.ONE_TO_ONE, environ["TEST_USER_ID"]) try: r, reference_id = nc_client.talk.send_file("/test_dir/subdir/test_12345_text.txt", conversation) assert isinstance(reference_id, str) assert isinstance(r, files.Share) for _ in range(10): m = nc_second_user.talk.receive_messages(conversation, limit=1) if m and isinstance(m[0], talk.TalkFileMessage): break m_t: talk.TalkFileMessage = m[0] # noqa fs_node = m_t.to_fs_node() assert nc_second_user.files.download(fs_node) == b"12345" assert m_t.reference_id == reference_id assert fs_node.is_dir is False # test with directory directory = nc_client.files.by_path("/test_dir/subdir/") r, reference_id = nc_client.talk.send_file(directory, conversation) assert isinstance(reference_id, str) assert isinstance(r, files.Share) for _ in range(10): m = nc_second_user.talk.receive_messages(conversation, limit=1) if m and m[0].reference_id == reference_id: break m_t: talk.TalkFileMessage = m[0] # noqa assert m_t.reference_id == reference_id fs_node = m_t.to_fs_node() assert fs_node.is_dir is True finally: nc_client.talk.leave_conversation(conversation.token) @pytest.mark.asyncio(scope="session") async def test_send_receive_file_async(anc_client): if await anc_client.talk.available is False: pytest.skip("Nextcloud Talk is not installed") nc_second_user = AsyncNextcloud(nc_auth_user=environ["TEST_USER_ID"], nc_auth_pass=environ["TEST_USER_PASS"]) conversation = await anc_client.talk.create_conversation(talk.ConversationType.ONE_TO_ONE, environ["TEST_USER_ID"]) try: r, reference_id = await anc_client.talk.send_file("/test_dir/test_12345_text.txt", conversation) assert isinstance(reference_id, str) assert isinstance(r, files.Share) for _ in range(10): m = await nc_second_user.talk.receive_messages(conversation, limit=1) if m and isinstance(m[0], talk.TalkFileMessage): break m_t: talk.TalkFileMessage = m[0] # noqa fs_node = m_t.to_fs_node() assert await nc_second_user.files.download(fs_node) == b"12345" assert m_t.reference_id == reference_id assert fs_node.is_dir is False # test with directory directory = await anc_client.files.by_path("/test_dir/") r, reference_id = await anc_client.talk.send_file(directory, conversation) assert isinstance(reference_id, str) assert isinstance(r, files.Share) for _ in range(10): m = await nc_second_user.talk.receive_messages(conversation, limit=1) if m and m[0].reference_id == reference_id: break m_t: talk.TalkFileMessage = m[0] # noqa assert m_t.reference_id == reference_id fs_node = m_t.to_fs_node() assert fs_node.is_dir is True finally: await anc_client.talk.leave_conversation(conversation.token) cloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/user_status_test.py0000664000232200023220000001727014766056032026603 0ustar debalancedebalancefrom time import time import pytest from nc_py_api.user_status import ( ClearAt, CurrentUserStatus, PredefinedStatus, UserStatus, ) def test_available(nc): assert nc.user_status.available @pytest.mark.asyncio(scope="session") async def test_available_async(anc): assert await anc.user_status.available def compare_user_statuses(p1: UserStatus, p2: UserStatus): assert p1.user_id == p2.user_id assert p1.status_message == p2.status_message assert p1.status_icon == p2.status_icon assert p1.status_clear_at == p2.status_clear_at assert p1.status_type == p2.status_type def _test_get_status(r1: CurrentUserStatus, message): assert r1.user_id == "admin" assert r1.status_icon is None assert r1.status_clear_at is None if message == "": message = None assert r1.status_message == message assert r1.status_id is None assert not r1.message_predefined assert str(r1).find("status_id=") != -1 @pytest.mark.parametrize("message", ("1 2 3", None, "")) def test_get_status(nc, message): nc.user_status.set_status(message) r1 = nc.user_status.get_current() r2 = nc.user_status.get(nc.user) compare_user_statuses(r1, r2) _test_get_status(r1, message) @pytest.mark.asyncio(scope="session") @pytest.mark.parametrize("message", ("1 2 3", None, "")) async def test_get_status_async(anc, message): await anc.user_status.set_status(message) r1 = await anc.user_status.get_current() r2 = await anc.user_status.get(await anc.user) compare_user_statuses(r1, r2) _test_get_status(r1, message) def test_get_status_non_existent_user(nc): assert nc.user_status.get("no such user") is None @pytest.mark.asyncio(scope="session") async def test_get_status_non_existent_user_async(anc): assert await anc.user_status.get("no such user") is None def _test_get_predefined(r: list[PredefinedStatus]): assert isinstance(r, list) assert r for i in r: assert isinstance(i.status_id, str) assert isinstance(i.message, str) assert isinstance(i.icon, str) assert isinstance(i.clear_at, ClearAt) or i.clear_at is None def test_get_predefined(nc): r = nc.user_status.get_predefined() if nc.srv_version["major"] < 27: assert r == [] else: _test_get_predefined(r) @pytest.mark.asyncio(scope="session") async def test_get_predefined_async(anc): r = await anc.user_status.get_predefined() if (await anc.srv_version)["major"] < 27: assert r == [] else: _test_get_predefined(r) def test_get_list(nc): r_all = nc.user_status.get_list() assert r_all assert isinstance(r_all, list) r_current = nc.user_status.get_current() for i in r_all: if i.user_id == nc.user: compare_user_statuses(i, r_current) assert str(i).find("status_type=") != -1 @pytest.mark.asyncio(scope="session") async def test_get_list_async(anc): r_all = await anc.user_status.get_list() assert r_all assert isinstance(r_all, list) r_current = await anc.user_status.get_current() for i in r_all: if i.user_id == await anc.user: compare_user_statuses(i, r_current) assert str(i).find("status_type=") != -1 def test_set_status(nc): time_clear = int(time()) + 60 nc.user_status.set_status("cool status", time_clear) r = nc.user_status.get_current() assert r.status_message == "cool status" assert r.status_clear_at == time_clear assert r.status_icon is None nc.user_status.set_status("Sick!", status_icon="🤒") r = nc.user_status.get_current() assert r.status_message == "Sick!" assert r.status_clear_at is None assert r.status_icon == "🤒" nc.user_status.set_status(None) r = nc.user_status.get_current() assert r.status_message is None assert r.status_clear_at is None assert r.status_icon is None @pytest.mark.asyncio(scope="session") async def test_set_status_async(anc): time_clear = int(time()) + 60 await anc.user_status.set_status("cool status", time_clear) r = await anc.user_status.get_current() assert r.status_message == "cool status" assert r.status_clear_at == time_clear assert r.status_icon is None await anc.user_status.set_status("Sick!", status_icon="🤒") r = await anc.user_status.get_current() assert r.status_message == "Sick!" assert r.status_clear_at is None assert r.status_icon == "🤒" await anc.user_status.set_status(None) r = await anc.user_status.get_current() assert r.status_message is None assert r.status_clear_at is None assert r.status_icon is None @pytest.mark.parametrize("value", ("online", "away", "dnd", "invisible", "offline")) def test_set_status_type(nc, value): nc.user_status.set_status_type(value) r = nc.user_status.get_current() assert r.status_type == value assert r.status_type_defined @pytest.mark.asyncio(scope="session") @pytest.mark.parametrize("value", ("online", "away", "dnd", "invisible", "offline")) async def test_set_status_type_async(anc, value): await anc.user_status.set_status_type(value) r = await anc.user_status.get_current() assert r.status_type == value assert r.status_type_defined @pytest.mark.parametrize("clear_at", (None, int(time()) + 60 * 60 * 9)) def test_set_predefined(nc, clear_at): if nc.srv_version["major"] < 27: nc.user_status.set_predefined("meeting") else: predefined_statuses = nc.user_status.get_predefined() for i in predefined_statuses: nc.user_status.set_predefined(i.status_id, clear_at) r = nc.user_status.get_current() assert r.status_message == i.message assert r.status_id == i.status_id assert r.message_predefined assert r.status_clear_at == clear_at @pytest.mark.asyncio(scope="session") @pytest.mark.parametrize("clear_at", (None, int(time()) + 60 * 60 * 9)) async def test_set_predefined_async(anc, clear_at): if (await anc.srv_version)["major"] < 27: await anc.user_status.set_predefined("meeting") else: predefined_statuses = await anc.user_status.get_predefined() for i in predefined_statuses: await anc.user_status.set_predefined(i.status_id, clear_at) r = await anc.user_status.get_current() assert r.status_message == i.message assert r.status_id == i.status_id assert r.message_predefined assert r.status_clear_at == clear_at def test_get_back_status_from_from_empty_user(nc_app): orig_user = nc_app._session.user nc_app._session.set_user("") try: with pytest.raises(ValueError): nc_app.user_status.get_backup_status("") finally: nc_app._session.set_user(orig_user) @pytest.mark.asyncio(scope="session") async def test_get_back_status_from_from_empty_user_async(anc_app): orig_user = await anc_app._session.user anc_app._session.set_user("") try: with pytest.raises(ValueError): await anc_app.user_status.get_backup_status("") finally: anc_app._session.set_user(orig_user) def test_get_back_status_from_from_non_exist_user(nc): assert nc.user_status.get_backup_status("mёm_m-m.l") is None @pytest.mark.asyncio(scope="session") async def test_get_back_status_from_from_non_exist_user_async(anc): assert await anc.user_status.get_backup_status("mёm_m-m.l") is None def test_restore_from_non_existing_back_status(nc): assert nc.user_status.restore_backup_status("no such backup status") is None @pytest.mark.asyncio(scope="session") async def test_restore_from_non_existing_back_status_async(anc): assert await anc.user_status.restore_backup_status("no such backup status") is None cloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/calendar_test.py0000664000232200023220000000145514766056032025771 0ustar debalancedebalanceimport datetime import pytest def test_create_delete(nc): if nc.cal.available is False: pytest.skip("caldav package is not installed") principal = nc.cal.principal() calendar = principal.make_calendar("test_nc_py_api") try: calendars = principal.calendars() assert calendars all_events_before = calendar.events() event = calendar.save_event( dtstart=datetime.datetime.now(), dtend=datetime.datetime.now() + datetime.timedelta(hours=1), summary="NcPyApi + CalDAV test", ) all_events_after = calendar.events() assert len(all_events_after) == len(all_events_before) + 1 event.delete() assert len(calendar.events()) == len(all_events_before) finally: calendar.delete() cloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/misc_test.py0000664000232200023220000001756214766056032025161 0ustar debalancedebalanceimport datetime import io import os import pytest from httpx import Request, Response from nc_py_api import ( AsyncNextcloud, AsyncNextcloudApp, Nextcloud, NextcloudApp, NextcloudException, ex_app, ) from nc_py_api._deffered_error import DeferredError # noqa from nc_py_api._exceptions import check_error # noqa from nc_py_api._misc import nc_iso_time_to_datetime, require_capabilities # noqa from nc_py_api._session import BasicConfig # noqa @pytest.mark.parametrize("code", (995, 996, 997, 998, 999, 1000)) def test_check_error(code): if 996 <= code <= 999: with pytest.raises(NextcloudException): check_error(Response(code, request=Request(method="GET", url="https://example"))) else: check_error(Response(code, request=Request(method="GET", url="https://example"))) def test_nc_exception_to_str(): reason = "this is a reason" info = "some info" try: raise NextcloudException(status_code=666, reason=reason, info=info) except NextcloudException as e: assert str(e) == f"[666] {reason} <{info}>" def test_require_capabilities(nc_app): require_capabilities("app_api", nc_app.capabilities) require_capabilities(["app_api", "theming"], nc_app.capabilities) with pytest.raises(NextcloudException): require_capabilities("non_exist_capability", nc_app.capabilities) with pytest.raises(NextcloudException): require_capabilities(["non_exist_capability", "app_api"], nc_app.capabilities) with pytest.raises(NextcloudException): require_capabilities(["non_exist_capability", "non_exist_capability2", "app_api"], nc_app.capabilities) with pytest.raises(NextcloudException): require_capabilities("app_api.non_exist_capability", nc_app.capabilities) def test_config_get_value(): BasicConfig()._get_config_value("non_exist_value", raise_not_found=False) with pytest.raises(ValueError): BasicConfig()._get_config_value("non_exist_value") assert BasicConfig()._get_config_value("non_exist_value", non_exist_value=123) == 123 def test_deffered_error(): try: import unknown_non_exist_module except ImportError as ex: unknown_non_exist_module = DeferredError(ex) with pytest.raises(ModuleNotFoundError): unknown_non_exist_module.some_class_or_func() def test_response_headers(nc): old_headers = nc.response_headers nc.users.get_user(nc.user) # do not remove "nc.user" arguments, it helps to trigger response header updates. assert old_headers != nc.response_headers @pytest.mark.asyncio(scope="session") async def test_response_headers_async(anc): old_headers = anc.response_headers await anc.users.get_user(await anc.user) assert old_headers != anc.response_headers def test_nc_iso_time_to_datetime(): parsed_time = nc_iso_time_to_datetime("invalid") assert parsed_time == datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) def test_persist_transformers_cache(nc_app): assert "TRANSFORMERS_CACHE" not in os.environ from nc_py_api.ex_app import persist_transformers_cache # noqa assert os.environ["TRANSFORMERS_CACHE"] os.environ.pop("TRANSFORMERS_CACHE") def test_verify_version(nc_app): version_file_path = os.path.join(ex_app.persistent_storage(), "_version.info") if os.path.exists(version_file_path): os.remove(version_file_path) r = ex_app.verify_version(False) assert not os.path.getsize(version_file_path) assert isinstance(r, tuple) assert r[0] == "" assert r[1] == os.environ["APP_VERSION"] r = ex_app.verify_version(True) assert os.path.getsize(version_file_path) assert r[0] == "" assert r[1] == os.environ["APP_VERSION"] assert ex_app.verify_version() is None def test_init_adapter_dav(nc_any): new_nc = Nextcloud() if isinstance(nc_any, Nextcloud) else NextcloudApp() new_nc._session.init_adapter_dav() old_adapter = getattr(new_nc._session, "adapter_dav", None) assert old_adapter is not None new_nc._session.init_adapter_dav() assert old_adapter == getattr(new_nc._session, "adapter_dav", None) new_nc._session.init_adapter_dav(restart=True) assert old_adapter != getattr(new_nc._session, "adapter_dav", None) @pytest.mark.asyncio(scope="session") async def test_init_adapter_dav_async(anc_any): new_nc = AsyncNextcloud() if isinstance(anc_any, AsyncNextcloud) else AsyncNextcloudApp() new_nc._session.init_adapter_dav() old_adapter = getattr(new_nc._session, "adapter_dav", None) assert old_adapter is not None new_nc._session.init_adapter_dav() assert old_adapter == getattr(new_nc._session, "adapter_dav", None) new_nc._session.init_adapter_dav(restart=True) assert old_adapter != getattr(new_nc._session, "adapter_dav", None) def test_no_initial_connection(nc_any): new_nc = Nextcloud() if isinstance(nc_any, Nextcloud) else NextcloudApp() assert not new_nc._session._capabilities _ = new_nc.srv_version assert new_nc._session._capabilities @pytest.mark.asyncio(scope="session") async def test_no_initial_connection_async(anc_any): new_nc = AsyncNextcloud() if isinstance(anc_any, AsyncNextcloud) else AsyncNextcloudApp() assert not new_nc._session._capabilities _ = await new_nc.srv_version assert new_nc._session._capabilities def test_ocs_timeout(nc_any): new_nc = Nextcloud(npa_timeout=0.01) if isinstance(nc_any, Nextcloud) else NextcloudApp(npa_timeout=0.01) with pytest.raises(NextcloudException) as e: if new_nc.weather_status.set_location(latitude=41.896655, longitude=12.488776): new_nc.weather_status.get_forecast() if e.value.status_code in (500, 996): pytest.skip("Some network problem on the host") assert e.value.status_code == 408 @pytest.mark.asyncio(scope="session") async def test_ocs_timeout_async(anc_any): new_nc = ( AsyncNextcloud(npa_timeout=0.01) if isinstance(anc_any, AsyncNextcloud) else AsyncNextcloudApp(npa_timeout=0.01) ) with pytest.raises(NextcloudException) as e: if await new_nc.weather_status.set_location(latitude=41.896655, longitude=12.488776): await new_nc.weather_status.get_forecast() if e.value.status_code in (500, 996): pytest.skip("Some network problem on the host") assert e.value.status_code == 408 def test_public_ocs(nc_any): r = nc_any.ocs("GET", "/ocs/v1.php/cloud/capabilities") assert r == nc_any.ocs("GET", "ocs/v1.php/cloud/capabilities") assert r == nc_any._session.ocs("GET", "ocs/v1.php/cloud/capabilities") # noqa @pytest.mark.asyncio(scope="session") async def test_public_ocs_async(anc_any): r = await anc_any.ocs("GET", "/ocs/v1.php/cloud/capabilities") assert r == await anc_any.ocs("GET", "ocs/v1.php/cloud/capabilities") assert r == await anc_any._session.ocs("GET", "ocs/v1.php/cloud/capabilities") # noqa def test_all_scope(nc_any): r = nc_any.ocs("GET", "/ocs/v2.php/core/whatsnew") assert isinstance(r, list) @pytest.mark.asyncio(scope="session") async def test_all_scope_async(anc_any): r = await anc_any.ocs("GET", "/ocs/v2.php/core/whatsnew") assert isinstance(r, list) def test_perform_login(nc_any): new_nc = Nextcloud() if isinstance(nc_any, Nextcloud) else NextcloudApp() assert not new_nc._session._capabilities new_nc.perform_login() assert new_nc._session._capabilities @pytest.mark.asyncio(scope="session") async def test_perform_login_async(anc_any): new_nc = AsyncNextcloud() if isinstance(anc_any, Nextcloud) else AsyncNextcloudApp() assert not new_nc._session._capabilities await new_nc.perform_login() assert new_nc._session._capabilities def test_download_log(nc_any): buf = io.BytesIO() nc_any.download_log(buf) assert buf.tell() > 0 @pytest.mark.asyncio(scope="session") async def test_download_log_async(anc_any): buf = io.BytesIO() await anc_any.download_log(buf) assert buf.tell() > 0 cloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/weather_status_test.py0000664000232200023220000001444014766056032027260 0ustar debalancedebalanceimport pytest from nc_py_api import NextcloudException, weather_status def test_available(nc): assert nc.weather_status.available @pytest.mark.asyncio(scope="session") async def test_available_async(anc): assert await anc.weather_status.available def test_get_set_location(nc_any): try: nc_any.weather_status.set_location(longitude=0.0, latitude=0.0) except NextcloudException as e: if e.status_code in (408, 500, 996): pytest.skip("Some network problem on the host") raise e from None loc = nc_any.weather_status.get_location() assert loc.latitude == 0.0 assert loc.longitude == 0.0 assert isinstance(loc.address, str) assert isinstance(loc.mode, int) try: assert nc_any.weather_status.set_location(address="Paris, 75007, France") except NextcloudException as e: if e.status_code in (500, 996): pytest.skip("Some network problem on the host") raise e from None loc = nc_any.weather_status.get_location() assert loc.latitude assert loc.longitude if loc.address.find("Unknown") != -1: pytest.skip("Some network problem on the host") assert loc.address.find("Paris") != -1 assert nc_any.weather_status.set_location(latitude=41.896655, longitude=12.488776) loc = nc_any.weather_status.get_location() assert loc.latitude == 41.896655 assert loc.longitude == 12.488776 if loc.address.find("Unknown") != -1: pytest.skip("Some network problem on the host") assert loc.address.find("Rom") != -1 @pytest.mark.asyncio(scope="session") async def test_get_set_location_async(anc_any): try: await anc_any.weather_status.set_location(longitude=0.0, latitude=0.0) except NextcloudException as e: if e.status_code in (408, 500, 996): pytest.skip("Some network problem on the host") raise e from None loc = await anc_any.weather_status.get_location() assert loc.latitude == 0.0 assert loc.longitude == 0.0 assert isinstance(loc.address, str) assert isinstance(loc.mode, int) try: assert await anc_any.weather_status.set_location(address="Paris, 75007, France") except NextcloudException as e: if e.status_code in (500, 996): pytest.skip("Some network problem on the host") raise e from None loc = await anc_any.weather_status.get_location() assert loc.latitude assert loc.longitude if loc.address.find("Unknown") != -1: pytest.skip("Some network problem on the host") assert loc.address.find("Paris") != -1 assert await anc_any.weather_status.set_location(latitude=41.896655, longitude=12.488776) loc = await anc_any.weather_status.get_location() assert loc.latitude == 41.896655 assert loc.longitude == 12.488776 if loc.address.find("Unknown") != -1: pytest.skip("Some network problem on the host") assert loc.address.find("Rom") != -1 def test_get_set_location_no_lat_lon_address(nc): with pytest.raises(ValueError): nc.weather_status.set_location() @pytest.mark.asyncio(scope="session") async def test_get_set_location_no_lat_lon_address_async(anc): with pytest.raises(ValueError): await anc.weather_status.set_location() def test_get_forecast(nc_any): nc_any.weather_status.set_location(latitude=41.896655, longitude=12.488776) if nc_any.weather_status.get_location().address.find("Unknown") != -1: pytest.skip("Some network problem on the host") forecast = nc_any.weather_status.get_forecast() assert isinstance(forecast, list) assert forecast assert isinstance(forecast[0], dict) @pytest.mark.asyncio(scope="session") async def test_get_forecast_async(anc_any): await anc_any.weather_status.set_location(latitude=41.896655, longitude=12.488776) if (await anc_any.weather_status.get_location()).address.find("Unknown") != -1: pytest.skip("Some network problem on the host") forecast = await anc_any.weather_status.get_forecast() assert isinstance(forecast, list) assert forecast assert isinstance(forecast[0], dict) def test_get_set_favorites(nc): nc.weather_status.set_favorites([]) r = nc.weather_status.get_favorites() assert isinstance(r, list) assert not r nc.weather_status.set_favorites(["Paris, France", "Madrid, Spain"]) r = nc.weather_status.get_favorites() assert any("Paris" in x for x in r) assert any("Madrid" in x for x in r) @pytest.mark.asyncio(scope="session") async def test_get_set_favorites_async(anc): await anc.weather_status.set_favorites([]) r = await anc.weather_status.get_favorites() assert isinstance(r, list) assert not r await anc.weather_status.set_favorites(["Paris, France", "Madrid, Spain"]) r = await anc.weather_status.get_favorites() assert any("Paris" in x for x in r) assert any("Madrid" in x for x in r) def test_set_mode(nc): nc.weather_status.set_mode(weather_status.WeatherLocationMode.MODE_BROWSER_LOCATION) assert nc.weather_status.get_location().mode == weather_status.WeatherLocationMode.MODE_BROWSER_LOCATION.value nc.weather_status.set_mode(weather_status.WeatherLocationMode.MODE_MANUAL_LOCATION) assert nc.weather_status.get_location().mode == weather_status.WeatherLocationMode.MODE_MANUAL_LOCATION.value @pytest.mark.asyncio(scope="session") async def test_set_mode_async(anc): await anc.weather_status.set_mode(weather_status.WeatherLocationMode.MODE_BROWSER_LOCATION) assert ( await anc.weather_status.get_location() ).mode == weather_status.WeatherLocationMode.MODE_BROWSER_LOCATION.value await anc.weather_status.set_mode(weather_status.WeatherLocationMode.MODE_MANUAL_LOCATION) assert ( await anc.weather_status.get_location() ).mode == weather_status.WeatherLocationMode.MODE_MANUAL_LOCATION.value def test_set_mode_invalid(nc): with pytest.raises(ValueError): nc.weather_status.set_mode(weather_status.WeatherLocationMode.UNKNOWN) with pytest.raises(ValueError): nc.weather_status.set_mode(0) @pytest.mark.asyncio(scope="session") async def test_set_mode_invalid_async(anc): with pytest.raises(ValueError): await anc.weather_status.set_mode(weather_status.WeatherLocationMode.UNKNOWN) with pytest.raises(ValueError): await anc.weather_status.set_mode(0) cloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/files_test.py0000664000232200023220000015056114766056032025325 0ustar debalancedebalanceimport contextlib import math import os import zipfile from datetime import datetime from io import BytesIO from pathlib import Path from random import choice, randbytes from string import ascii_lowercase from tempfile import NamedTemporaryFile from zlib import adler32 import pytest from nc_py_api import ( FsNode, LockType, NextcloudException, NextcloudExceptionNotFound, SystemTag, ) class MyBytesIO(BytesIO): def __init__(self): self.n_read_calls = 0 self.n_write_calls = 0 super().__init__() def read(self, *args, **kwargs): self.n_read_calls += 1 return super().read(*args, **kwargs) def write(self, *args, **kwargs): self.n_write_calls += 1 return super().write(*args, **kwargs) def _test_list_user_root(user_root: list[FsNode], user: str): assert user_root for obj in user_root: assert obj.user == user assert obj.has_extra assert obj.name assert obj.user_path assert obj.file_id assert obj.etag def test_list_user_root(nc): user_root = nc.files.listdir() _test_list_user_root(user_root, nc.user) root_node = FsNode(full_path=f"files/{nc.user}/") user_root2 = nc.files.listdir(root_node) assert user_root == user_root2 @pytest.mark.asyncio(scope="session") async def test_list_user_root_async(anc): user_root = await anc.files.listdir() _test_list_user_root(user_root, await anc.user) root_node = FsNode(full_path=f"files/{await anc.user}/") user_root2 = await anc.files.listdir(root_node) assert user_root == user_root2 def _test_list_user_root_self_exclude(user_root: list[FsNode], user_root_with_self: list[FsNode], user: str): assert len(user_root_with_self) == 1 + len(user_root) self_res = next(i for i in user_root_with_self if not i.user_path) for i in user_root: assert self_res != i assert self_res.has_extra assert self_res.file_id assert self_res.user == user assert self_res.name assert self_res.etag assert self_res.full_path == f"files/{user}/" def test_list_user_root_self_exclude(nc): user_root = nc.files.listdir() user_root_with_self = nc.files.listdir(exclude_self=False) _test_list_user_root_self_exclude(user_root, user_root_with_self, nc.user) @pytest.mark.asyncio(scope="session") async def test_list_user_root_self_exclude_async(anc): user_root = await anc.files.listdir() user_root_with_self = await anc.files.listdir(exclude_self=False) _test_list_user_root_self_exclude(user_root, user_root_with_self, await anc.user) def _test_list_empty_dir(result: list[FsNode], user: str): assert len(result) result = result[0] assert result.file_id assert result.user == user assert result.name == "test_empty_dir" assert result.etag assert result.full_path == f"files/{user}/test_empty_dir/" def test_list_empty_dir(nc_any): assert not len(nc_any.files.listdir("test_empty_dir")) result = nc_any.files.listdir("test_empty_dir", exclude_self=False) _test_list_empty_dir(result, nc_any.user) @pytest.mark.asyncio(scope="session") async def test_list_empty_dir_async(anc_any): assert not len(await anc_any.files.listdir("test_empty_dir")) result = await anc_any.files.listdir("test_empty_dir", exclude_self=False) _test_list_empty_dir(result, await anc_any.user) def test_list_spec_dir(nc_any): r = nc_any.files.listdir("test_###_dir", exclude_self=False) assert r[0].full_path.find("test_###_dir") != -1 @pytest.mark.asyncio(scope="session") async def test_list_spec_dir_async(anc_any): r = await anc_any.files.listdir("test_###_dir", exclude_self=False) assert r[0].full_path.find("test_###_dir") != -1 def test_list_dir_wrong_args(nc_any): with pytest.raises(ValueError): nc_any.files.listdir(depth=0, exclude_self=True) @pytest.mark.asyncio(scope="session") async def test_list_dir_wrong_args_async(anc_any): with pytest.raises(ValueError): await anc_any.files.listdir(depth=0, exclude_self=True) def _test_by_path(result: FsNode, result2: FsNode, user: str): assert isinstance(result, FsNode) assert isinstance(result2, FsNode) assert result == result2 assert result.is_dir == result2.is_dir assert result.is_dir assert result.user == result2.user assert result.user == user def test_by_path(nc_any): result = nc_any.files.by_path("") result2 = nc_any.files.by_path("/") _test_by_path(result, result2, nc_any.user) @pytest.mark.asyncio(scope="session") async def test_by_path_async(anc_any): result = await anc_any.files.by_path("") result2 = await anc_any.files.by_path("/") _test_by_path(result, result2, await anc_any.user) def test_file_download(nc_any): assert nc_any.files.download("test_empty_text.txt") == b"" assert nc_any.files.download("/test_12345_text.txt") == b"12345" @pytest.mark.asyncio(scope="session") async def test_file_download_async(anc_any): assert await anc_any.files.download("test_empty_text.txt") == b"" assert await anc_any.files.download("/test_12345_text.txt") == b"12345" @pytest.mark.parametrize("data_type", ("str", "bytes")) @pytest.mark.parametrize("chunk_size", (15, 32, 64, None)) def test_file_download2stream(nc, data_type, chunk_size): bytes_io_fp = MyBytesIO() content = "".join(choice(ascii_lowercase) for _ in range(64)) if data_type == "str" else randbytes(64) nc.files.upload("/test_dir_tmp/download2stream", content=content) old_headers = nc.response_headers if chunk_size is not None: nc.files.download2stream("/test_dir_tmp/download2stream", bytes_io_fp, chunk_size=chunk_size) else: nc.files.download2stream("/test_dir_tmp/download2stream", bytes_io_fp) assert nc.response_headers != old_headers assert nc.files.download("/test_dir_tmp/download2stream") == bytes_io_fp.getbuffer() if chunk_size is None: assert bytes_io_fp.n_write_calls == 1 else: assert bytes_io_fp.n_write_calls == math.ceil(64 / chunk_size) @pytest.mark.asyncio(scope="session") @pytest.mark.parametrize("data_type", ("str", "bytes")) @pytest.mark.parametrize("chunk_size", (15, 32, 64, None)) async def test_file_download2stream_async(anc, data_type, chunk_size): bytes_io_fp = MyBytesIO() content = "".join(choice(ascii_lowercase) for _ in range(64)) if data_type == "str" else randbytes(64) await anc.files.upload("/test_dir_tmp/download2stream_async", content=content) old_headers = anc.response_headers if chunk_size is not None: await anc.files.download2stream("/test_dir_tmp/download2stream_async", bytes_io_fp, chunk_size=chunk_size) else: await anc.files.download2stream("/test_dir_tmp/download2stream_async", bytes_io_fp) assert anc.response_headers != old_headers assert await anc.files.download("/test_dir_tmp/download2stream_async") == bytes_io_fp.getbuffer() if chunk_size is None: assert bytes_io_fp.n_write_calls == 1 else: assert bytes_io_fp.n_write_calls == math.ceil(64 / chunk_size) def test_file_download2file(nc_any, rand_bytes): with NamedTemporaryFile() as tmp_file: nc_any.files.download2stream("test_64_bytes.bin", tmp_file.name) assert tmp_file.read() == rand_bytes @pytest.mark.asyncio(scope="session") async def test_file_download2file_async(anc_any, rand_bytes): with NamedTemporaryFile() as tmp_file: await anc_any.files.download2stream("test_64_bytes.bin", tmp_file.name) assert tmp_file.read() == rand_bytes def test_file_download2stream_invalid_type(nc_any): for test_type in ( b"13", int(55), ): with pytest.raises(TypeError): nc_any.files.download2stream("xxx", test_type) @pytest.mark.asyncio(scope="session") async def test_file_download2stream_invalid_type_async(anc_any): for test_type in ( b"13", int(55), ): with pytest.raises(TypeError): await anc_any.files.download2stream("xxx", test_type) def test_file_upload_stream_invalid_type(nc_any): for test_type in ( b"13", int(55), ): with pytest.raises(TypeError): nc_any.files.upload_stream("xxx", test_type) @pytest.mark.asyncio(scope="session") async def test_file_upload_stream_invalid_type_async(anc_any): for test_type in ( b"13", int(55), ): with pytest.raises(TypeError): await anc_any.files.upload_stream("xxx", test_type) def test_file_download_not_found(nc_any): with pytest.raises(NextcloudException): nc_any.files.download("file that does not exist on the server") with pytest.raises(NextcloudException): nc_any.files.listdir("non existing path") @pytest.mark.asyncio(scope="session") async def test_file_download_not_found_async(anc_any): with pytest.raises(NextcloudException): await anc_any.files.download("file that does not exist on the server") with pytest.raises(NextcloudException): await anc_any.files.listdir("non existing path") def test_file_download2stream_not_found(nc_any): buf = BytesIO() with pytest.raises(NextcloudException): nc_any.files.download2stream("file that does not exist on the server", buf) with pytest.raises(NextcloudException): nc_any.files.download2stream("non existing path", buf) @pytest.mark.asyncio(scope="session") async def test_file_download2stream_not_found_async(anc_any): buf = BytesIO() with pytest.raises(NextcloudException): await anc_any.files.download2stream("file that does not exist on the server", buf) with pytest.raises(NextcloudException): await anc_any.files.download2stream("non existing path", buf) def test_file_upload(nc_any): file_name = "test_dir_tmp/12345.txt" result = nc_any.files.upload(file_name, content=b"\x31\x32") assert nc_any.files.by_id(result).info.size == 2 assert nc_any.files.download(file_name) == b"\x31\x32" result = nc_any.files.upload(f"/{file_name}", content=b"\x31\x32\x33") assert not result.has_extra result = nc_any.files.by_path(result) assert result.info.size == 3 assert result.is_updatable assert not result.is_creatable assert nc_any.files.download(file_name) == b"\x31\x32\x33" nc_any.files.upload(file_name, content="life is good") assert nc_any.files.download(file_name).decode("utf-8") == "life is good" @pytest.mark.asyncio(scope="session") async def test_file_upload_async(anc_any): file_name = "test_dir_tmp/12345_async.txt" result = await anc_any.files.upload(file_name, content=b"\x31\x32") assert (await anc_any.files.by_id(result)).info.size == 2 assert await anc_any.files.download(file_name) == b"\x31\x32" result = await anc_any.files.upload(f"/{file_name}", content=b"\x31\x32\x33") assert not result.has_extra result = await anc_any.files.by_path(result) assert result.info.size == 3 assert result.is_updatable assert not result.is_creatable assert await anc_any.files.download(file_name) == b"\x31\x32\x33" await anc_any.files.upload(file_name, content="life is good") assert (await anc_any.files.download(file_name)).decode("utf-8") == "life is good" @pytest.mark.parametrize("chunk_size", (63, 64, 65, None)) def test_file_upload_chunked(nc, chunk_size): file_name = "/test_dir_tmp/chunked.bin" buf_upload = MyBytesIO() random_bytes = randbytes(64) buf_upload.write(random_bytes) buf_upload.seek(0) if chunk_size is None: result = nc.files.upload_stream(file_name, fp=buf_upload) else: result = nc.files.upload_stream(file_name, fp=buf_upload, chunk_size=chunk_size) if chunk_size is None: assert buf_upload.n_read_calls == 2 else: assert buf_upload.n_read_calls == 1 + math.ceil(64 / chunk_size) assert nc.files.by_id(result.file_id).info.size == 64 buf_download = BytesIO() nc.files.download2stream(file_name, fp=buf_download) buf_upload.seek(0) buf_download.seek(0) upload_crc = adler32(buf_upload.read()) download_crc = adler32(buf_download.read()) assert upload_crc == download_crc @pytest.mark.asyncio(scope="session") @pytest.mark.parametrize("chunk_size", (63, 64, 65, None)) async def test_file_upload_chunked_async(anc, chunk_size): file_name = "/test_dir_tmp/chunked_async.bin" buf_upload = MyBytesIO() random_bytes = randbytes(64) buf_upload.write(random_bytes) buf_upload.seek(0) if chunk_size is None: result = await anc.files.upload_stream(file_name, fp=buf_upload) else: result = await anc.files.upload_stream(file_name, fp=buf_upload, chunk_size=chunk_size) if chunk_size is None: assert buf_upload.n_read_calls == 2 else: assert buf_upload.n_read_calls == 1 + math.ceil(64 / chunk_size) assert (await anc.files.by_id(result.file_id)).info.size == 64 buf_download = BytesIO() await anc.files.download2stream(file_name, fp=buf_download) buf_upload.seek(0) buf_download.seek(0) upload_crc = adler32(buf_upload.read()) download_crc = adler32(buf_download.read()) assert upload_crc == download_crc @pytest.mark.parametrize("dest_path", ("test_dir_tmp/test_file_upload_file", "test_###_dir/test_file_upload_file")) def test_file_upload_file(nc_any, dest_path): content = randbytes(113) with NamedTemporaryFile() as tmp_file: tmp_file.write(content) tmp_file.flush() r = nc_any.files.upload_stream(dest_path, tmp_file.name) assert nc_any.files.download(dest_path) == content assert nc_any.files.by_id(r.file_id).full_path.find(dest_path) != -1 @pytest.mark.asyncio(scope="session") @pytest.mark.parametrize("dest_path", ("test_dir_tmp/test_file_upload_file", "test_###_dir/test_file_upload_file")) async def test_file_upload_file_async(anc_any, dest_path): content = randbytes(113) with NamedTemporaryFile() as tmp_file: tmp_file.write(content) tmp_file.flush() r = await anc_any.files.upload_stream(dest_path, tmp_file.name) assert await anc_any.files.download(dest_path) == content assert (await anc_any.files.by_id(r.file_id)).full_path.find(dest_path) != -1 @pytest.mark.parametrize( "dest_path", ("test_dir_tmp/upl_chunk_v2", "test_dir_tmp/upl_chunk_v2_ü", "test_dir_tmp/upl_chunk_v2_11###33") ) def test_file_upload_chunked_v2(nc_any, dest_path): with NamedTemporaryFile() as tmp_file: tmp_file.seek(7 * 1024 * 1024) tmp_file.write(b"\0") tmp_file.flush() r = nc_any.files.upload_stream(dest_path, tmp_file.name) assert len(nc_any.files.download(dest_path)) == 7 * 1024 * 1024 + 1 assert nc_any.files.by_id(r.file_id).full_path.find(dest_path) != -1 @pytest.mark.asyncio(scope="session") @pytest.mark.parametrize( "dest_path", ("test_dir_tmp/upl_chunk_v2_async", "test_dir_tmp/upl_chunk_v2_ü_async", "test_dir_tmp/upl_chunk_v2_11###33"), ) async def test_file_upload_chunked_v2_async(anc_any, dest_path): with NamedTemporaryFile() as tmp_file: tmp_file.seek(7 * 1024 * 1024) tmp_file.write(b"\0") tmp_file.flush() r = await anc_any.files.upload_stream(dest_path, tmp_file.name) assert len(await anc_any.files.download(dest_path)) == 7 * 1024 * 1024 + 1 assert (await anc_any.files.by_id(r.file_id)).full_path.find(dest_path) != -1 @pytest.mark.parametrize("file_name", ("test_file_upload_del", "test_file_upload_del/", "test_file_upload_del//")) def test_file_upload_zero_size(nc_any, file_name): nc_any.files.delete(f"/test_dir_tmp/{file_name}", not_fail=True) with pytest.raises(NextcloudException): nc_any.files.delete(f"/test_dir_tmp/{file_name}") result = nc_any.files.upload(f"/test_dir_tmp/{file_name}", content="") assert nc_any.files.download(f"/test_dir_tmp/{file_name}") == b"" assert result.is_dir is False assert result.name == "test_file_upload_del" assert result.full_path.startswith("files/") nc_any.files.delete(f"/test_dir_tmp/{file_name}", not_fail=True) @pytest.mark.asyncio(scope="session") @pytest.mark.parametrize("file_name", ("test_file_upload_del", "test_file_upload_del/", "test_file_upload_del//")) async def test_file_upload_zero_size_async(anc_any, file_name): await anc_any.files.delete(f"/test_dir_tmp/{file_name}", not_fail=True) with pytest.raises(NextcloudException): await anc_any.files.delete(f"/test_dir_tmp/{file_name}") result = await anc_any.files.upload(f"/test_dir_tmp/{file_name}", content="") assert await anc_any.files.download(f"/test_dir_tmp/{file_name}") == b"" assert result.is_dir is False assert result.name == "test_file_upload_del" assert result.full_path.startswith("files/") await anc_any.files.delete(f"/test_dir_tmp/{file_name}", not_fail=True) @pytest.mark.parametrize("file_name", ("chunked_zero", "chunked_zero/", "chunked_zero//")) def test_file_upload_chunked_zero_size(nc_any, file_name): nc_any.files.delete(f"/test_dir_tmp/{file_name}", not_fail=True) buf_upload = MyBytesIO() result = nc_any.files.upload_stream(f"test_dir_tmp/{file_name}", fp=buf_upload) assert nc_any.files.download(f"test_dir_tmp/{file_name}") == b"" assert not nc_any.files.by_path(result.user_path).info.size assert result.is_dir is False assert result.full_path.startswith("files/") assert result.name == "chunked_zero" nc_any.files.delete(f"/test_dir_tmp/{file_name}", not_fail=True) @pytest.mark.asyncio(scope="session") @pytest.mark.parametrize("file_name", ("chunked_zero", "chunked_zero/", "chunked_zero//")) async def test_file_upload_chunked_zero_size_async(anc_any, file_name): await anc_any.files.delete(f"/test_dir_tmp/{file_name}", not_fail=True) buf_upload = MyBytesIO() result = await anc_any.files.upload_stream(f"test_dir_tmp/{file_name}", fp=buf_upload) assert await anc_any.files.download(f"test_dir_tmp/{file_name}") == b"" assert not (await anc_any.files.by_path(result.user_path)).info.size assert result.is_dir is False assert result.full_path.startswith("files/") assert result.name == "chunked_zero" await anc_any.files.delete(f"/test_dir_tmp/{file_name}", not_fail=True) @pytest.mark.parametrize("dir_name", ("1 2", "Яё", "відео та картинки", "复杂 目录 Í", "Björn", "João", "1##3")) def test_mkdir(nc_any, dir_name): nc_any.files.delete(dir_name, not_fail=True) result = nc_any.files.mkdir(dir_name) assert result.is_dir assert not result.has_extra result_by_id = nc_any.files.by_id(result.file_id) with pytest.raises(NextcloudException): nc_any.files.mkdir(dir_name) nc_any.files.delete(dir_name) with pytest.raises(NextcloudException): nc_any.files.delete(dir_name) assert result_by_id.full_path == result.full_path @pytest.mark.asyncio(scope="session") @pytest.mark.parametrize("dir_name", ("1 2", "Яё", "відео та картинки", "复杂 目录 Í", "Björn", "João", "1##3")) async def test_mkdir_async(anc_any, dir_name): await anc_any.files.delete(dir_name, not_fail=True) result = await anc_any.files.mkdir(dir_name) assert result.is_dir assert not result.has_extra result_by_id = await anc_any.files.by_id(result.file_id) with pytest.raises(NextcloudException): await anc_any.files.mkdir(dir_name) await anc_any.files.delete(dir_name) with pytest.raises(NextcloudException): await anc_any.files.delete(dir_name) assert result_by_id.full_path == result.full_path def test_mkdir_invalid_args(nc_any): with pytest.raises(NextcloudException) as exc_info: nc_any.files.makedirs("test_dir_tmp/ /zzzzzzzz", exist_ok=True) assert exc_info.value.status_code != 405 @pytest.mark.asyncio(scope="session") async def test_mkdir_invalid_args_async(anc_any): with pytest.raises(NextcloudException) as exc_info: await anc_any.files.makedirs("test_dir_tmp/ /zzzzzzzz", exist_ok=True) assert exc_info.value.status_code != 405 def test_mkdir_delete_with_end_slash(nc_any): nc_any.files.delete("dir_with_slash", not_fail=True) result = nc_any.files.mkdir("dir_with_slash/") assert result.is_dir assert result.name == "dir_with_slash" assert result.full_path.startswith("files/") nc_any.files.delete("dir_with_slash/") with pytest.raises(NextcloudException): nc_any.files.delete("dir_with_slash") @pytest.mark.asyncio(scope="session") async def test_mkdir_delete_with_end_slash_async(anc_any): await anc_any.files.delete("dir_with_slash", not_fail=True) result = await anc_any.files.mkdir("dir_with_slash/") assert result.is_dir assert result.name == "dir_with_slash" assert result.full_path.startswith("files/") await anc_any.files.delete("dir_with_slash/") with pytest.raises(NextcloudException): await anc_any.files.delete("dir_with_slash") def test_favorites(nc_any): favorites = nc_any.files.list_by_criteria(["favorite"]) favorites = [i for i in favorites if i.name != "test_generated_image.png"] for favorite in favorites: nc_any.files.setfav(favorite.user_path, False) favorites = nc_any.files.list_by_criteria(["favorite"]) favorites = [i for i in favorites if i.name != "test_generated_image.png"] assert not favorites files = ("test_dir_tmp/fav1.txt", "test_dir_tmp/fav2.txt", "test_dir_tmp/fav##3.txt") for n in files: nc_any.files.upload(n, content=n) nc_any.files.setfav(n, True) favorites = nc_any.files.list_by_criteria(["favorite"]) favorites = [i for i in favorites if i.name != "test_generated_image.png"] assert len(favorites) == 3 for favorite in favorites: assert isinstance(favorite, FsNode) nc_any.files.setfav(favorite, False) favorites = nc_any.files.list_by_criteria(["favorite"]) favorites = [i for i in favorites if i.name != "test_generated_image.png"] assert not favorites @pytest.mark.asyncio(scope="session") async def test_favorites_async(anc_any): favorites = await anc_any.files.list_by_criteria(["favorite"]) favorites = [i for i in favorites if i.name != "test_generated_image.png"] for favorite in favorites: await anc_any.files.setfav(favorite.user_path, False) favorites = await anc_any.files.list_by_criteria(["favorite"]) favorites = [i for i in favorites if i.name != "test_generated_image.png"] assert not favorites files = ("test_dir_tmp/fav1.txt", "test_dir_tmp/fav2.txt", "test_dir_tmp/fav##3.txt") for n in files: await anc_any.files.upload(n, content=n) await anc_any.files.setfav(n, True) favorites = await anc_any.files.list_by_criteria(["favorite"]) favorites = [i for i in favorites if i.name != "test_generated_image.png"] assert len(favorites) == 3 for favorite in favorites: assert isinstance(favorite, FsNode) await anc_any.files.setfav(favorite, False) favorites = await anc_any.files.list_by_criteria(["favorite"]) favorites = [i for i in favorites if i.name != "test_generated_image.png"] assert not favorites @pytest.mark.parametrize( "dest_path", ("test_dir_tmp/test_64_bytes.bin", "test_dir_tmp/test_64_bytes_ü.bin", "test_###_dir/test_64_bytes_ü.bin"), ) def test_copy_file(nc_any, rand_bytes, dest_path): copied_file = nc_any.files.copy("test_64_bytes.bin", dest_path) assert copied_file.file_id assert copied_file.is_dir is False assert nc_any.files.download(dest_path) == rand_bytes with pytest.raises(NextcloudException): nc_any.files.copy("test_64_bytes.bin", dest_path) copied_file = nc_any.files.copy("test_12345_text.txt", dest_path, overwrite=True) assert copied_file.file_id assert copied_file.is_dir is False assert nc_any.files.download(dest_path) == b"12345" nc_any.files.delete(copied_file) @pytest.mark.asyncio(scope="session") @pytest.mark.parametrize( "dest_path", ("test_dir_tmp/test_64_bytes.bin", "test_dir_tmp/test_64_bytes_ü.bin", "test_###_dir/test_64_bytes_ü.bin"), ) async def test_copy_file_async(anc_any, rand_bytes, dest_path): copied_file = await anc_any.files.copy("test_64_bytes.bin", dest_path) assert copied_file.file_id assert copied_file.is_dir is False assert await anc_any.files.download(dest_path) == rand_bytes with pytest.raises(NextcloudException): await anc_any.files.copy("test_64_bytes.bin", dest_path) copied_file = await anc_any.files.copy("test_12345_text.txt", dest_path, overwrite=True) assert copied_file.file_id assert copied_file.is_dir is False assert await anc_any.files.download(dest_path) == b"12345" await anc_any.files.delete(copied_file) @pytest.mark.parametrize( "dest_path", ("test_dir_tmp/dest move test file", "test_dir_tmp/dest move test file-ä", "test_###_dir/dest move test file-ä"), ) def test_move_file(nc_any, dest_path): src = "test_dir_tmp/src move test file" content = b"content of the file" content2 = b"content of the file-second part" nc_any.files.upload(src, content=content) nc_any.files.delete(dest_path, not_fail=True) result = nc_any.files.move(src, dest_path) assert result.etag assert result.file_id assert result.is_dir is False assert nc_any.files.download(dest_path) == content with pytest.raises(NextcloudException): nc_any.files.download(src) nc_any.files.upload(src, content=content2) with pytest.raises(NextcloudException): nc_any.files.move(src, dest_path) result = nc_any.files.move(src, dest_path, overwrite=True) assert result.etag assert result.file_id assert result.is_dir is False with pytest.raises(NextcloudException): nc_any.files.download(src) assert nc_any.files.download(dest_path) == content2 nc_any.files.delete(dest_path) @pytest.mark.asyncio(scope="session") @pytest.mark.parametrize( "dest_path", ("test_dir_tmp/dest move test file", "test_dir_tmp/dest move test file-ä", "test_###_dir/dest move test file-ä"), ) async def test_move_file_async(anc_any, dest_path): src = "test_dir_tmp/src move test file" content = b"content of the file" content2 = b"content of the file-second part" await anc_any.files.upload(src, content=content) await anc_any.files.delete(dest_path, not_fail=True) result = await anc_any.files.move(src, dest_path) assert result.etag assert result.file_id assert result.is_dir is False assert await anc_any.files.download(dest_path) == content with pytest.raises(NextcloudException): await anc_any.files.download(src) await anc_any.files.upload(src, content=content2) with pytest.raises(NextcloudException): await anc_any.files.move(src, dest_path) result = await anc_any.files.move(src, dest_path, overwrite=True) assert result.etag assert result.file_id assert result.is_dir is False with pytest.raises(NextcloudException): await anc_any.files.download(src) assert await anc_any.files.download(dest_path) == content2 await anc_any.files.delete(dest_path) def test_move_copy_dir(nc_any): result = nc_any.files.copy("/test_dir/subdir", "test_dir_tmp/test_copy_dir") assert result.file_id assert result.is_dir assert nc_any.files.by_path(result).is_dir assert len(nc_any.files.listdir("test_dir_tmp/test_copy_dir")) == len(nc_any.files.listdir("test_dir/subdir")) result = nc_any.files.move("test_dir_tmp/test_copy_dir", "test_dir_tmp/test_move_dir") with pytest.raises(NextcloudException): nc_any.files.listdir("test_dir_tmp/test_copy_dir") assert result.file_id assert result.is_dir assert nc_any.files.by_path(result).is_dir assert len(nc_any.files.listdir("test_dir_tmp/test_move_dir")) == 4 nc_any.files.delete("test_dir_tmp/test_move_dir") @pytest.mark.asyncio(scope="session") async def test_move_copy_dir_async(anc_any): result = await anc_any.files.copy("/test_dir/subdir", "test_dir_tmp/test_copy_dir") assert result.file_id assert result.is_dir assert (await anc_any.files.by_path(result)).is_dir assert len(await anc_any.files.listdir("test_dir_tmp/test_copy_dir")) == len( await anc_any.files.listdir("test_dir/subdir") ) result = await anc_any.files.move("test_dir_tmp/test_copy_dir", "test_dir_tmp/test_move_dir") with pytest.raises(NextcloudException): await anc_any.files.listdir("test_dir_tmp/test_copy_dir") assert result.file_id assert result.is_dir assert (await anc_any.files.by_path(result)).is_dir assert len(await anc_any.files.listdir("test_dir_tmp/test_move_dir")) == 4 await anc_any.files.delete("test_dir_tmp/test_move_dir") def test_find_files_listdir_depth(nc_any): result = nc_any.files.find(["and", "gt", "size", 0, "like", "mime", "image/%"], path="test_dir") assert len(result) == 2 result2 = nc_any.files.find(["and", "gt", "size", 0, "like", "mime", "image/%"], path="/test_dir") assert len(result2) == 2 assert result == result2 result = nc_any.files.find(["and", "gt", "size", 0, "like", "mime", "image/%"], path="test_dir/subdir/") assert len(result) == 1 result = nc_any.files.find(["and", "gt", "size", 0, "like", "mime", "image/%"], path="test_dir/subdir") assert len(result) == 1 result = nc_any.files.find(["and", "gt", "size", 1024 * 1024, "like", "mime", "image/%"], path="test_dir") assert len(result) == 0 result = nc_any.files.find( ["or", "and", "gt", "size", 0, "like", "mime", "image/%", "like", "mime", "text/%"], path="test_dir" ) assert len(result) == 6 result = nc_any.files.find(["eq", "name", "test_12345_text.txt"], path="test_dir") assert len(result) == 2 result = nc_any.files.find(["like", "name", "test_%"], path="/test_dir") assert len(result) == 9 assert not nc_any.files.find(["eq", "name", "no such file"], path="test_dir") assert not nc_any.files.find(["like", "name", "no%such%file"], path="test_dir") result = nc_any.files.find(["like", "mime", "text/%"], path="test_dir") assert len(result) == 4 def test_listdir_depth(nc_any): result = nc_any.files.listdir("test_dir/", depth=1) result2 = nc_any.files.listdir("test_dir") assert result == result2 assert len(result) == 6 result = nc_any.files.listdir("test_dir/", depth=2) result2 = nc_any.files.listdir("test_dir", depth=-1) assert result == result2 assert len(result) == 10 @pytest.mark.asyncio(scope="session") async def test_listdir_depth_async(anc_any): result = await anc_any.files.listdir("test_dir/", depth=1) result2 = await anc_any.files.listdir("test_dir") assert result == result2 assert len(result) == 6 result = await anc_any.files.listdir("test_dir/", depth=2) result2 = await anc_any.files.listdir("test_dir", depth=-1) assert result == result2 assert len(result) == 10 def test_fs_node_fields(nc_any): results = nc_any.files.listdir("/test_dir") assert len(results) == 6 for _, result in enumerate(results): assert result.user == "admin" if result.name == "subdir": assert result.user_path == "test_dir/subdir/" assert result.is_dir assert result.full_path == "files/admin/test_dir/subdir/" assert result.info.size == 2364 assert result.info.content_length == 0 assert result.info.permissions == "RGDNVCK" assert result.info.favorite is False assert not result.info.mimetype elif result.name == "test_empty_child_dir": assert result.user_path == "test_dir/test_empty_child_dir/" assert result.is_dir assert result.full_path == "files/admin/test_dir/test_empty_child_dir/" assert result.info.size == 0 assert result.info.content_length == 0 assert result.info.permissions == "RGDNVCK" assert result.info.favorite is False assert not result.info.mimetype elif result.name == "test_generated_image.png": assert result.user_path == "test_dir/test_generated_image.png" assert not result.is_dir assert result.full_path == "files/admin/test_dir/test_generated_image.png" assert result.info.size > 900 assert result.info.size == result.info.content_length assert result.info.permissions == "RGDNVW" assert result.info.favorite is True assert result.info.mimetype == "image/png" elif result.name == "test_empty_text.txt": assert result.user_path == "test_dir/test_empty_text.txt" assert not result.is_dir assert result.full_path == "files/admin/test_dir/test_empty_text.txt" assert not result.info.size assert not result.info.content_length assert result.info.permissions == "RGDNVW" assert result.info.favorite is False assert result.info.mimetype == "text/plain" res_by_id = nc_any.files.by_id(result.file_id) assert res_by_id res_by_path = nc_any.files.by_path(result.user_path) assert res_by_path assert res_by_id.info == res_by_path.info == result.info assert res_by_id.full_path == res_by_path.full_path == result.full_path assert res_by_id.user == res_by_path.user == result.user assert res_by_id.etag == res_by_path.etag == result.etag assert res_by_id.info.last_modified == res_by_path.info.last_modified == result.info.last_modified assert res_by_id.info.creation_date == res_by_path.info.creation_date == result.info.creation_date def test_makedirs(nc_any): nc_any.files.delete("/test_dir_tmp/abc", not_fail=True) result = nc_any.files.makedirs("/test_dir_tmp/abc/def", exist_ok=True) assert result.is_dir with pytest.raises(NextcloudException) as exc_info: nc_any.files.makedirs("/test_dir_tmp/abc/def") assert exc_info.value.status_code == 405 result = nc_any.files.makedirs("/test_dir_tmp/abc/def", exist_ok=True) assert result is None @pytest.mark.asyncio(scope="session") async def test_makedirs_async(anc_any): await anc_any.files.delete("/test_dir_tmp/abc", not_fail=True) result = await anc_any.files.makedirs("/test_dir_tmp/abc/def", exist_ok=True) assert result.is_dir with pytest.raises(NextcloudException) as exc_info: await anc_any.files.makedirs("/test_dir_tmp/abc/def") assert exc_info.value.status_code == 405 result = await anc_any.files.makedirs("/test_dir_tmp/abc/def", exist_ok=True) assert result is None def test_fs_node_str(nc_any): fs_node1 = nc_any.files.by_path("test_empty_dir_in_dir") str_fs_node1 = str(fs_node1) assert str_fs_node1.find("Dir") != -1 assert str_fs_node1.find("test_empty_dir_in_dir") != -1 assert str_fs_node1.find(f"id={fs_node1.file_id}") != -1 fs_node2 = nc_any.files.by_path("test_12345_text.txt") str_fs_node2 = str(fs_node2) assert str_fs_node2.find("File") != -1 assert str_fs_node2.find("test_12345_text.txt") != -1 assert str_fs_node2.find(f"id={fs_node2.file_id}") != -1 def _test_download_as_zip(result: Path, n: int): if n == 1: with zipfile.ZipFile(result, "r") as zip_ref: assert zip_ref.filelist[0].filename == "test_dir/" assert not zip_ref.filelist[0].file_size assert zip_ref.filelist[1].filename == "test_dir/subdir/" assert not zip_ref.filelist[1].file_size assert zip_ref.filelist[2].filename == "test_dir/subdir/test_12345_text.txt" assert zip_ref.filelist[2].file_size == 5 assert zip_ref.filelist[3].filename == "test_dir/subdir/test_64_bytes.bin" assert zip_ref.filelist[3].file_size == 64 assert len(zip_ref.filelist) == 11 elif n == 2: with zipfile.ZipFile(result, "r") as zip_ref: assert zip_ref.filelist[0].filename == "test_empty_dir_in_dir/" assert not zip_ref.filelist[0].file_size assert zip_ref.filelist[1].filename == "test_empty_dir_in_dir/test_empty_child_dir/" assert not zip_ref.filelist[1].file_size assert len(zip_ref.filelist) == 2 else: with zipfile.ZipFile(result, "r") as zip_ref: assert zip_ref.filelist[0].filename == "test_empty_dir/" assert not zip_ref.filelist[0].file_size assert len(zip_ref.filelist) == 1 def test_download_as_zip(nc): old_headers = nc.response_headers result = nc.files.download_directory_as_zip("test_dir") assert nc.response_headers != old_headers try: _test_download_as_zip(result, 1) finally: os.remove(result) old_headers = nc.response_headers result = nc.files.download_directory_as_zip("test_empty_dir_in_dir", "2.zip") assert nc.response_headers != old_headers try: assert str(result) == "2.zip" _test_download_as_zip(result, 2) finally: os.remove("2.zip") result = nc.files.download_directory_as_zip("/test_empty_dir", "empty_folder.zip") try: assert str(result) == "empty_folder.zip" _test_download_as_zip(result, 3) finally: os.remove("empty_folder.zip") @pytest.mark.asyncio(scope="session") async def test_download_as_zip_async(anc): old_headers = anc.response_headers result = await anc.files.download_directory_as_zip("test_dir") assert anc.response_headers != old_headers try: _test_download_as_zip(result, 1) finally: os.remove(result) old_headers = anc.response_headers result = await anc.files.download_directory_as_zip("test_empty_dir_in_dir", "2.zip") assert anc.response_headers != old_headers try: assert str(result) == "2.zip" _test_download_as_zip(result, 2) finally: os.remove("2.zip") result = await anc.files.download_directory_as_zip("/test_empty_dir", "empty_folder.zip") try: assert str(result) == "empty_folder.zip" _test_download_as_zip(result, 3) finally: os.remove("empty_folder.zip") def test_fs_node_is_xx(nc_any): folder = nc_any.files.listdir("test_empty_dir", exclude_self=False)[0] assert folder.is_dir assert folder.is_creatable assert folder.is_readable assert folder.is_deletable assert folder.is_shareable assert folder.is_updatable assert not folder.is_mounted assert not folder.is_shared def test_fs_node_last_modified_time(): fs_node = FsNode("", last_modified="wrong time") assert fs_node.info.last_modified == datetime(1970, 1, 1) fs_node = FsNode("", last_modified="Sat, 29 Jul 2023 11:56:31") assert fs_node.info.last_modified == datetime(2023, 7, 29, 11, 56, 31) fs_node = FsNode("", last_modified=datetime(2022, 4, 5, 1, 2, 3)) assert fs_node.info.last_modified == datetime(2022, 4, 5, 1, 2, 3) def test_fs_node_creation_date_time(): fs_node = FsNode("", creation_date="wrong time") assert fs_node.info.creation_date == datetime(1970, 1, 1) fs_node = FsNode("", creation_date="Sat, 29 Jul 2023 11:56:31") assert fs_node.info.creation_date == datetime(2023, 7, 29, 11, 56, 31) fs_node = FsNode("", creation_date=datetime(2022, 4, 5, 1, 2, 3)) assert fs_node.info.creation_date == datetime(2022, 4, 5, 1, 2, 3) @pytest.mark.parametrize( "file_path", ("test_dir_tmp/trashbin_test", "test_dir_tmp/trashbin_test-ä", "test_dir_tmp/trashbin_test-1##3") ) def test_trashbin(nc_any, file_path): r = nc_any.files.trashbin_list() assert isinstance(r, list) new_file = nc_any.files.upload(file_path, content=b"") nc_any.files.delete(new_file) # minimum one object now in a trashbin r = nc_any.files.trashbin_list() assert r # clean up trashbin nc_any.files.trashbin_cleanup() # no objects should be in trashbin r = nc_any.files.trashbin_list() assert not r new_file = nc_any.files.upload(file_path, content=b"") nc_any.files.delete(new_file) # one object now in a trashbin r = nc_any.files.trashbin_list() assert len(r) == 1 # check types of FsNode properties i: FsNode = r[0] assert i.info.in_trash is True assert i.info.trashbin_filename.find("trashbin_test") != -1 assert i.info.trashbin_original_location == file_path assert isinstance(i.info.trashbin_deletion_time, int) # restore that object nc_any.files.trashbin_restore(r[0]) # no files in trashbin r = nc_any.files.trashbin_list() assert not r # move a restored object to trashbin again nc_any.files.delete(new_file) # one object now in a trashbin r = nc_any.files.trashbin_list() assert len(r) == 1 # remove one object from a trashbin nc_any.files.trashbin_delete(r[0]) # NextcloudException with status_code 404 with pytest.raises(NextcloudException) as e: nc_any.files.trashbin_delete(r[0]) assert e.value.status_code == 404 nc_any.files.trashbin_delete(r[0], not_fail=True) # no files in trashbin r = nc_any.files.trashbin_list() assert not r @pytest.mark.asyncio(scope="session") @pytest.mark.parametrize( "file_path", ("test_dir_tmp/trashbin_test", "test_dir_tmp/trashbin_test-ä", "test_dir_tmp/trashbin_test-1##3") ) async def test_trashbin_async(anc_any, file_path): r = await anc_any.files.trashbin_list() assert isinstance(r, list) new_file = await anc_any.files.upload(file_path, content=b"") await anc_any.files.delete(new_file) # minimum one object now in a trashbin r = await anc_any.files.trashbin_list() assert r # clean up trashbin await anc_any.files.trashbin_cleanup() # no objects should be in trashbin r = await anc_any.files.trashbin_list() assert not r new_file = await anc_any.files.upload(file_path, content=b"") await anc_any.files.delete(new_file) # one object now in a trashbin r = await anc_any.files.trashbin_list() assert len(r) == 1 # check types of FsNode properties i: FsNode = r[0] assert i.info.in_trash is True assert i.info.trashbin_filename.find("trashbin_test") != -1 assert i.info.trashbin_original_location == file_path assert isinstance(i.info.trashbin_deletion_time, int) # restore that object await anc_any.files.trashbin_restore(r[0]) # no files in trashbin r = await anc_any.files.trashbin_list() assert not r # move a restored object to trashbin again await anc_any.files.delete(new_file) # one object now in a trashbin r = await anc_any.files.trashbin_list() assert len(r) == 1 # remove one object from a trashbin await anc_any.files.trashbin_delete(r[0]) # NextcloudException with status_code 404 with pytest.raises(NextcloudException) as e: await anc_any.files.trashbin_delete(r[0]) assert e.value.status_code == 404 await anc_any.files.trashbin_delete(r[0], not_fail=True) # no files in trashbin r = await anc_any.files.trashbin_list() assert not r @pytest.mark.skipif(os.environ.get("DATABASE_PGSQL", "0") == "1", reason="Fails on the PGSQL") @pytest.mark.parametrize( "dest_path", ("/test_dir_tmp/file_versions.txt", "/test_dir_tmp/file_versions-ä.txt", "test_dir_tmp/file_versions-1##3"), ) def test_file_versions(nc_any, dest_path): if nc_any.check_capabilities("files.versioning"): pytest.skip("Need 'Versions' App to be enabled.") for i in (0, 1): nc_any.files.delete(dest_path, not_fail=True) nc_any.files.upload(dest_path, content=b"22") new_file = nc_any.files.upload(dest_path, content=b"333") if i: new_file = nc_any.files.by_id(new_file) versions = nc_any.files.get_versions(new_file) assert versions version_str = str(versions[0]) assert version_str.find("File version") != -1 assert version_str.find("bytes size") != -1 nc_any.files.restore_version(versions[0]) assert nc_any.files.download(new_file) == b"22" @pytest.mark.skipif(os.environ.get("DATABASE_PGSQL", "0") == "1", reason="Fails on the PGSQL") @pytest.mark.asyncio(scope="session") @pytest.mark.parametrize( "dest_path", ("/test_dir_tmp/file_versions.txt", "/test_dir_tmp/file_versions-ä.txt", "test_dir_tmp/file_versions-1##3"), ) async def test_file_versions_async(anc_any, dest_path): if await anc_any.check_capabilities("files.versioning"): pytest.skip("Need 'Versions' App to be enabled.") for i in (0, 1): await anc_any.files.delete(dest_path, not_fail=True) await anc_any.files.upload(dest_path, content=b"22") new_file = await anc_any.files.upload(dest_path, content=b"333") if i: new_file = await anc_any.files.by_id(new_file) versions = await anc_any.files.get_versions(new_file) assert versions version_str = str(versions[0]) assert version_str.find("File version") != -1 assert version_str.find("bytes size") != -1 await anc_any.files.restore_version(versions[0]) assert await anc_any.files.download(new_file) == b"22" def test_create_update_delete_tag(nc_any): with contextlib.suppress(NextcloudExceptionNotFound): nc_any.files.delete_tag(nc_any.files.tag_by_name("test_nc_py_api")) with contextlib.suppress(NextcloudExceptionNotFound): nc_any.files.delete_tag(nc_any.files.tag_by_name("test_nc_py_api2")) nc_any.files.create_tag("test_nc_py_api", True, True) tag = nc_any.files.tag_by_name("test_nc_py_api") assert isinstance(tag.tag_id, int) assert tag.display_name == "test_nc_py_api" assert tag.user_visible is True assert tag.user_assignable is True nc_any.files.update_tag(tag, "test_nc_py_api2", False, False) with pytest.raises(NextcloudExceptionNotFound): nc_any.files.tag_by_name("test_nc_py_api") tag = nc_any.files.tag_by_name("test_nc_py_api2") assert tag.display_name == "test_nc_py_api2" assert tag.user_visible is False assert tag.user_assignable is False for i in nc_any.files.list_tags(): assert str(i).find("name=") != -1 nc_any.files.delete_tag(tag) with pytest.raises(ValueError): nc_any.files.update_tag(tag) @pytest.mark.asyncio(scope="session") async def test_create_update_delete_tag_async(anc_any): with contextlib.suppress(NextcloudExceptionNotFound): await anc_any.files.delete_tag(await anc_any.files.tag_by_name("test_nc_py_api")) with contextlib.suppress(NextcloudExceptionNotFound): await anc_any.files.delete_tag(await anc_any.files.tag_by_name("test_nc_py_api2")) await anc_any.files.create_tag("test_nc_py_api", True, True) tag = await anc_any.files.tag_by_name("test_nc_py_api") assert isinstance(tag.tag_id, int) assert tag.display_name == "test_nc_py_api" assert tag.user_visible is True assert tag.user_assignable is True await anc_any.files.update_tag(tag, "test_nc_py_api2", False, False) with pytest.raises(NextcloudExceptionNotFound): await anc_any.files.tag_by_name("test_nc_py_api") tag = await anc_any.files.tag_by_name("test_nc_py_api2") assert tag.display_name == "test_nc_py_api2" assert tag.user_visible is False assert tag.user_assignable is False for i in await anc_any.files.list_tags(): assert str(i).find("name=") != -1 await anc_any.files.delete_tag(tag) with pytest.raises(ValueError): await anc_any.files.update_tag(tag) def test_get_assign_unassign_tag(nc_any): with contextlib.suppress(NextcloudExceptionNotFound): nc_any.files.delete_tag(nc_any.files.tag_by_name("test_nc_py_api")) with contextlib.suppress(NextcloudExceptionNotFound): nc_any.files.delete_tag(nc_any.files.tag_by_name("test_nc_py_api2")) nc_any.files.create_tag("test_nc_py_api", True, False) nc_any.files.create_tag("test_nc_py_api2", False, False) tag1 = nc_any.files.tag_by_name("test_nc_py_api") assert tag1.user_visible is True assert tag1.user_assignable is False tag2 = nc_any.files.tag_by_name("test_nc_py_api2") assert tag2.user_visible is False assert tag2.user_assignable is False new_file = nc_any.files.upload("/test_dir_tmp/tag_test.txt", content=b"") new_file = nc_any.files.by_id(new_file) assert nc_any.files.get_tags(new_file) == [] if nc_any.srv_version["major"] > 30: pytest.skip("Skip temporary on master branch") assert len(nc_any.files.list_by_criteria(tags=[tag1])) == 0 nc_any.files.assign_tag(new_file, tag1) assert isinstance(nc_any.files.get_tags(new_file)[0], SystemTag) assert len(nc_any.files.list_by_criteria(tags=[tag1])) == 1 assert len(nc_any.files.list_by_criteria(["favorite"], tags=[tag1])) == 0 assert len(nc_any.files.list_by_criteria(tags=[tag1, tag2.tag_id])) == 0 nc_any.files.assign_tag(new_file, tag2.tag_id) assert len(nc_any.files.list_by_criteria(tags=[tag1, tag2.tag_id])) == 1 nc_any.files.unassign_tag(new_file, tag1) assert len(nc_any.files.list_by_criteria(tags=[tag1])) == 0 nc_any.files.assign_tag(new_file, tag1) with pytest.raises(ValueError): nc_any.files.list_by_criteria() @pytest.mark.asyncio(scope="session") async def test_get_assign_unassign_tag_async(anc_any): with contextlib.suppress(NextcloudExceptionNotFound): await anc_any.files.delete_tag(await anc_any.files.tag_by_name("test_nc_py_api")) with contextlib.suppress(NextcloudExceptionNotFound): await anc_any.files.delete_tag(await anc_any.files.tag_by_name("test_nc_py_api2")) await anc_any.files.create_tag("test_nc_py_api", True, False) await anc_any.files.create_tag("test_nc_py_api2", False, False) tag1 = await anc_any.files.tag_by_name("test_nc_py_api") assert tag1.user_visible is True assert tag1.user_assignable is False tag2 = await anc_any.files.tag_by_name("test_nc_py_api2") assert tag2.user_visible is False assert tag2.user_assignable is False new_file = await anc_any.files.upload("/test_dir_tmp/tag_test.txt", content=b"") new_file = await anc_any.files.by_id(new_file) assert await anc_any.files.get_tags(new_file) == [] if (await anc_any.srv_version)["major"] > 30: pytest.skip("Skip temporary on master branch") assert len(await anc_any.files.list_by_criteria(tags=[tag1])) == 0 await anc_any.files.assign_tag(new_file, tag1) assert isinstance((await anc_any.files.get_tags(new_file))[0], SystemTag) assert len(await anc_any.files.list_by_criteria(tags=[tag1])) == 1 assert len(await anc_any.files.list_by_criteria(["favorite"], tags=[tag1])) == 0 assert len(await anc_any.files.list_by_criteria(tags=[tag1, tag2.tag_id])) == 0 await anc_any.files.assign_tag(new_file, tag2.tag_id) assert len(await anc_any.files.list_by_criteria(tags=[tag1, tag2.tag_id])) == 1 await anc_any.files.unassign_tag(new_file, tag1) assert len(await anc_any.files.list_by_criteria(tags=[tag1])) == 0 await anc_any.files.assign_tag(new_file, tag1) with pytest.raises(ValueError): await anc_any.files.list_by_criteria() def __check_lock_info(fs_node: FsNode): assert isinstance(fs_node.lock_info.owner, str) assert isinstance(fs_node.lock_info.owner_display_name, str) assert isinstance(fs_node.lock_info.type, LockType) assert isinstance(fs_node.lock_info.lock_creation_time, datetime) assert isinstance(fs_node.lock_info.lock_ttl, int) assert isinstance(fs_node.lock_info.owner_editor, str) def test_file_locking(nc_any): if nc_any.check_capabilities("files.locking"): pytest.skip("Files Locking App is not installed") test_file = nc_any.files.upload("/test_dir/test_lock", content="") assert nc_any.files.by_id(test_file).lock_info.is_locked is False with pytest.raises(NextcloudException) as e: nc_any.files.unlock(test_file) assert e.value.status_code == 412 nc_any.files.lock(test_file) locked_file = nc_any.files.by_id(test_file) assert locked_file.lock_info.is_locked is True __check_lock_info(locked_file) nc_any.files.unlock(test_file) with pytest.raises(NextcloudException) as e: nc_any.files.unlock(test_file) assert e.value.status_code == 412 @pytest.mark.asyncio(scope="session") async def test_file_locking_async(anc_any): if await anc_any.check_capabilities("files.locking"): pytest.skip("Files Locking App is not installed") test_file = await anc_any.files.upload("/test_dir/test_lock_async", content="") assert (await anc_any.files.by_id(test_file)).lock_info.is_locked is False with pytest.raises(NextcloudException) as e: await anc_any.files.unlock(test_file) assert e.value.status_code == 412 await anc_any.files.lock(test_file) locked_file = await anc_any.files.by_id(test_file) assert locked_file.lock_info.is_locked is True __check_lock_info(locked_file) await anc_any.files.unlock(test_file) with pytest.raises(NextcloudException) as e: await anc_any.files.unlock(test_file) assert e.value.status_code == 412 cloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/ui_resources_test.py0000664000232200023220000002237214766056032026730 0ustar debalancedebalanceimport pytest from nc_py_api import NextcloudExceptionNotFound def test_initial_state(nc_app): nc_app.ui.resources.delete_initial_state("top_menu", "some_page", "some_key") assert nc_app.ui.resources.get_initial_state("top_menu", "some_page", "some_key") is None nc_app.ui.resources.set_initial_state("top_menu", "some_page", "some_key", {"k1": 1, "k2": 2}) r = nc_app.ui.resources.get_initial_state("top_menu", "some_page", "some_key") assert r.appid == nc_app.app_cfg.app_name assert r.name == "some_page" assert r.key == "some_key" assert r.value == {"k1": 1, "k2": 2} nc_app.ui.resources.set_initial_state("top_menu", "some_page", "some_key", {"k1": "v1"}) r = nc_app.ui.resources.get_initial_state("top_menu", "some_page", "some_key") assert r.value == {"k1": "v1"} assert str(r).find("key=some_key") nc_app.ui.resources.delete_initial_state("top_menu", "some_page", "some_key", not_fail=False) with pytest.raises(NextcloudExceptionNotFound): nc_app.ui.resources.delete_initial_state("top_menu", "some_page", "some_key", not_fail=False) @pytest.mark.asyncio(scope="session") async def test_initial_state_async(anc_app): await anc_app.ui.resources.delete_initial_state("top_menu", "some_page", "some_key") assert await anc_app.ui.resources.get_initial_state("top_menu", "some_page", "some_key") is None await anc_app.ui.resources.set_initial_state("top_menu", "some_page", "some_key", {"k1": 1, "k2": 2}) r = await anc_app.ui.resources.get_initial_state("top_menu", "some_page", "some_key") assert r.appid == anc_app.app_cfg.app_name assert r.name == "some_page" assert r.key == "some_key" assert r.value == {"k1": 1, "k2": 2} await anc_app.ui.resources.set_initial_state("top_menu", "some_page", "some_key", {"k1": "v1"}) r = await anc_app.ui.resources.get_initial_state("top_menu", "some_page", "some_key") assert r.value == {"k1": "v1"} assert str(r).find("key=some_key") await anc_app.ui.resources.delete_initial_state("top_menu", "some_page", "some_key", not_fail=False) with pytest.raises(NextcloudExceptionNotFound): await anc_app.ui.resources.delete_initial_state("top_menu", "some_page", "some_key", not_fail=False) def test_initial_states(nc_app): nc_app.ui.resources.set_initial_state("top_menu", "some_page", "key1", []) nc_app.ui.resources.set_initial_state("top_menu", "some_page", "key2", {"k2": "v2"}) r1 = nc_app.ui.resources.get_initial_state("top_menu", "some_page", "key1") r2 = nc_app.ui.resources.get_initial_state("top_menu", "some_page", "key2") assert r1.key == "key1" assert r1.value == [] assert r2.key == "key2" assert r2.value == {"k2": "v2"} nc_app.ui.resources.delete_initial_state("top_menu", "some_page", "key1", not_fail=False) nc_app.ui.resources.delete_initial_state("top_menu", "some_page", "key2", not_fail=False) def test_script(nc_app): nc_app.ui.resources.delete_script("top_menu", "some_page", "js/some_script") assert nc_app.ui.resources.get_script("top_menu", "some_page", "js/some_script") is None nc_app.ui.resources.set_script("top_menu", "some_page", "js/some_script") r = nc_app.ui.resources.get_script("top_menu", "some_page", "js/some_script") assert r.appid == nc_app.app_cfg.app_name assert r.name == "some_page" assert r.path == "js/some_script" assert r.after_app_id == "" nc_app.ui.resources.set_script("top_menu", "some_page", "js/some_script", "core") r = nc_app.ui.resources.get_script("top_menu", "some_page", "js/some_script") assert r.path == "js/some_script" assert r.after_app_id == "core" assert str(r).find("path=js/some_script") nc_app.ui.resources.delete_script("top_menu", "some_page", "js/some_script", not_fail=False) with pytest.raises(NextcloudExceptionNotFound): nc_app.ui.resources.delete_script("top_menu", "some_page", "js/some_script", not_fail=False) @pytest.mark.asyncio(scope="session") async def test_script_async(anc_app): await anc_app.ui.resources.delete_script("top_menu", "some_page", "js/some_script") assert await anc_app.ui.resources.get_script("top_menu", "some_page", "js/some_script") is None await anc_app.ui.resources.set_script("top_menu", "some_page", "js/some_script") r = await anc_app.ui.resources.get_script("top_menu", "some_page", "js/some_script") assert r.appid == anc_app.app_cfg.app_name assert r.name == "some_page" assert r.path == "js/some_script" assert r.after_app_id == "" await anc_app.ui.resources.set_script("top_menu", "some_page", "js/some_script", "core") r = await anc_app.ui.resources.get_script("top_menu", "some_page", "js/some_script") assert r.path == "js/some_script" assert r.after_app_id == "core" assert str(r).find("path=js/some_script") await anc_app.ui.resources.delete_script("top_menu", "some_page", "js/some_script", not_fail=False) with pytest.raises(NextcloudExceptionNotFound): await anc_app.ui.resources.delete_script("top_menu", "some_page", "js/some_script", not_fail=False) def test_scripts(nc_app): nc_app.ui.resources.set_script("top_menu", "some_page", "js/script1") nc_app.ui.resources.set_script("top_menu", "some_page", "js/script2", "core") r1 = nc_app.ui.resources.get_script("top_menu", "some_page", "js/script1") r2 = nc_app.ui.resources.get_script("top_menu", "some_page", "js/script2") assert r1.path == "js/script1" assert r1.after_app_id == "" assert r2.path == "js/script2" assert r2.after_app_id == "core" nc_app.ui.resources.delete_script("top_menu", "some_page", "js/script1", not_fail=False) nc_app.ui.resources.delete_script("top_menu", "some_page", "js/script2", not_fail=False) def test_scripts_slash(nc_app): nc_app.ui.resources.set_script("top_menu", "test_slash", "/js/script1") r = nc_app.ui.resources.get_script("top_menu", "test_slash", "/js/script1") assert r == nc_app.ui.resources.get_script("top_menu", "test_slash", "js/script1") assert r.path == "js/script1" nc_app.ui.resources.delete_script("top_menu", "test_slash", "/js/script1", not_fail=False) assert nc_app.ui.resources.get_script("top_menu", "test_slash", "js/script1") is None assert nc_app.ui.resources.get_script("top_menu", "test_slash", "/js/script1") is None with pytest.raises(NextcloudExceptionNotFound): nc_app.ui.resources.delete_script("top_menu", "test_slash", "/js/script1", not_fail=False) def test_style(nc_app): nc_app.ui.resources.delete_style("top_menu", "some_page", "css/some_path") assert nc_app.ui.resources.get_style("top_menu", "some_page", "css/some_path") is None nc_app.ui.resources.set_style("top_menu", "some_page", "css/some_path") r = nc_app.ui.resources.get_style("top_menu", "some_page", "css/some_path") assert r.appid == nc_app.app_cfg.app_name assert r.name == "some_page" assert r.path == "css/some_path" assert str(r).find("path=css/some_path") nc_app.ui.resources.delete_style("top_menu", "some_page", "css/some_path", not_fail=False) with pytest.raises(NextcloudExceptionNotFound): nc_app.ui.resources.delete_style("top_menu", "some_page", "css/some_path", not_fail=False) @pytest.mark.asyncio(scope="session") async def test_style_async(anc_app): await anc_app.ui.resources.delete_style("top_menu", "some_page", "css/some_path") assert await anc_app.ui.resources.get_style("top_menu", "some_page", "css/some_path") is None await anc_app.ui.resources.set_style("top_menu", "some_page", "css/some_path") r = await anc_app.ui.resources.get_style("top_menu", "some_page", "css/some_path") assert r.appid == anc_app.app_cfg.app_name assert r.name == "some_page" assert r.path == "css/some_path" assert str(r).find("path=css/some_path") await anc_app.ui.resources.delete_style("top_menu", "some_page", "css/some_path", not_fail=False) with pytest.raises(NextcloudExceptionNotFound): await anc_app.ui.resources.delete_style("top_menu", "some_page", "css/some_path", not_fail=False) def test_styles(nc_app): nc_app.ui.resources.set_style("top_menu", "some_page", "css/style1") nc_app.ui.resources.set_style("top_menu", "some_page", "css/style2") r1 = nc_app.ui.resources.get_style("top_menu", "some_page", "css/style1") r2 = nc_app.ui.resources.get_style("top_menu", "some_page", "css/style2") assert r1.path == "css/style1" assert r2.path == "css/style2" nc_app.ui.resources.delete_style("top_menu", "some_page", "css/style1", not_fail=False) nc_app.ui.resources.delete_style("top_menu", "some_page", "css/style2", not_fail=False) def test_styles_slash(nc_app): nc_app.ui.resources.set_style("top_menu", "test_slash", "/js/script1") r = nc_app.ui.resources.get_style("top_menu", "test_slash", "/js/script1") assert r == nc_app.ui.resources.get_style("top_menu", "test_slash", "js/script1") assert r.path == "js/script1" nc_app.ui.resources.delete_style("top_menu", "test_slash", "/js/script1", not_fail=False) assert nc_app.ui.resources.get_style("top_menu", "test_slash", "js/script1") is None assert nc_app.ui.resources.get_style("top_menu", "test_slash", "/js/script1") is None with pytest.raises(NextcloudExceptionNotFound): nc_app.ui.resources.delete_style("top_menu", "test_slash", "/js/script1", not_fail=False) cloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/users_groups_test.py0000664000232200023220000001655614766056032026770 0ustar debalancedebalanceimport contextlib from datetime import datetime, timezone from os import environ import pytest from nc_py_api import NextcloudException from nc_py_api.users_groups import GroupDetails def test_group_get_list(nc, nc_client): groups = nc.users_groups.get_list() assert isinstance(groups, list) assert len(groups) >= 3 assert environ["TEST_GROUP_BOTH"] in groups assert environ["TEST_GROUP_USER"] in groups groups = nc.users_groups.get_list(mask="test_nc_py_api_group") assert len(groups) == 2 groups = nc.users_groups.get_list(limit=1) assert len(groups) == 1 assert groups[0] != nc.users_groups.get_list(limit=1, offset=1)[0] @pytest.mark.asyncio(scope="session") async def test_group_get_list_async(anc, anc_client): groups = await anc.users_groups.get_list() assert isinstance(groups, list) assert len(groups) >= 3 assert environ["TEST_GROUP_BOTH"] in groups assert environ["TEST_GROUP_USER"] in groups groups = await anc.users_groups.get_list(mask="test_nc_py_api_group") assert len(groups) == 2 groups = await anc.users_groups.get_list(limit=1) assert len(groups) == 1 assert groups[0] != (await anc.users_groups.get_list(limit=1, offset=1))[0] def _test_group_get_details(groups: list[GroupDetails]): assert len(groups) == 1 group = groups[0] assert group.group_id == environ["TEST_GROUP_BOTH"] assert group.display_name == environ["TEST_GROUP_BOTH"] assert group.disabled is False assert isinstance(group.user_count, int) assert isinstance(group.can_add, bool) assert isinstance(group.can_remove, bool) assert str(group).find("user_count=") != -1 def test_group_get_details(nc, nc_client): groups = nc.users_groups.get_details(mask=environ["TEST_GROUP_BOTH"]) _test_group_get_details(groups) @pytest.mark.asyncio(scope="session") async def test_group_get_details_async(anc, anc_client): groups = await anc.users_groups.get_details(mask=environ["TEST_GROUP_BOTH"]) _test_group_get_details(groups) def test_get_non_existing_group(nc_client): groups = nc_client.users_groups.get_list(mask="Such group should not be present") assert isinstance(groups, list) assert not groups @pytest.mark.asyncio(scope="session") async def test_get_non_existing_group_async(anc_client): groups = await anc_client.users_groups.get_list(mask="Such group should not be present") assert isinstance(groups, list) assert not groups def test_group_edit(nc_client): display_name = str(int(datetime.now(timezone.utc).timestamp())) nc_client.users_groups.edit(environ["TEST_GROUP_USER"], display_name=display_name) assert nc_client.users_groups.get_details(mask=environ["TEST_GROUP_USER"])[0].display_name == display_name with pytest.raises(NextcloudException) as exc_info: nc_client.users_groups.edit("non_existing_group", display_name="earth people") # remove 996 in the future, PR was already accepted in Nextcloud Server assert exc_info.value.status_code in ( 404, 996, ) @pytest.mark.asyncio(scope="session") async def test_group_edit_async(anc_client): display_name = str(int(datetime.now(timezone.utc).timestamp())) await anc_client.users_groups.edit(environ["TEST_GROUP_USER"], display_name=display_name) assert (await anc_client.users_groups.get_details(mask=environ["TEST_GROUP_USER"]))[0].display_name == display_name with pytest.raises(NextcloudException) as exc_info: await anc_client.users_groups.edit("non_existing_group", display_name="earth people") # remove 996 in the future, PR was already accepted in Nextcloud Server assert exc_info.value.status_code in ( 404, 996, ) def test_group_display_name_promote_demote(nc_client): group_id = "test_group_display_name_promote_demote" with contextlib.suppress(NextcloudException): nc_client.users_groups.delete(group_id) nc_client.users_groups.create(group_id, display_name="12345") try: group_details = nc_client.users_groups.get_details(mask=group_id) assert len(group_details) == 1 assert group_details[0].display_name == "12345" group_members = nc_client.users_groups.get_members(group_id) assert isinstance(group_members, list) assert not group_members group_subadmins = nc_client.users_groups.get_subadmins(group_id) assert isinstance(group_subadmins, list) assert not group_subadmins nc_client.users.add_to_group(environ["TEST_USER_ID"], group_id) group_members = nc_client.users_groups.get_members(group_id) assert group_members[0] == environ["TEST_USER_ID"] group_subadmins = nc_client.users_groups.get_subadmins(group_id) assert not group_subadmins nc_client.users.promote_to_subadmin(environ["TEST_USER_ID"], group_id) group_subadmins = nc_client.users_groups.get_subadmins(group_id) assert group_subadmins[0] == environ["TEST_USER_ID"] nc_client.users.demote_from_subadmin(environ["TEST_USER_ID"], group_id) group_subadmins = nc_client.users_groups.get_subadmins(group_id) assert not group_subadmins nc_client.users.remove_from_group(environ["TEST_USER_ID"], group_id) group_members = nc_client.users_groups.get_members(group_id) assert not group_members finally: nc_client.users_groups.delete(group_id) with pytest.raises(NextcloudException): nc_client.users_groups.delete(group_id) @pytest.mark.asyncio(scope="session") async def test_group_display_name_promote_demote_async(anc_client): group_id = "test_group_display_name_promote_demote" with contextlib.suppress(NextcloudException): await anc_client.users_groups.delete(group_id) await anc_client.users_groups.create(group_id, display_name="12345") try: group_details = await anc_client.users_groups.get_details(mask=group_id) assert len(group_details) == 1 assert group_details[0].display_name == "12345" group_members = await anc_client.users_groups.get_members(group_id) assert isinstance(group_members, list) assert not group_members group_subadmins = await anc_client.users_groups.get_subadmins(group_id) assert isinstance(group_subadmins, list) assert not group_subadmins await anc_client.users.add_to_group(environ["TEST_USER_ID"], group_id) group_members = await anc_client.users_groups.get_members(group_id) assert group_members[0] == environ["TEST_USER_ID"] group_subadmins = await anc_client.users_groups.get_subadmins(group_id) assert not group_subadmins await anc_client.users.promote_to_subadmin(environ["TEST_USER_ID"], group_id) group_subadmins = await anc_client.users_groups.get_subadmins(group_id) assert group_subadmins[0] == environ["TEST_USER_ID"] await anc_client.users.demote_from_subadmin(environ["TEST_USER_ID"], group_id) group_subadmins = await anc_client.users_groups.get_subadmins(group_id) assert not group_subadmins await anc_client.users.remove_from_group(environ["TEST_USER_ID"], group_id) group_members = await anc_client.users_groups.get_members(group_id) assert not group_members finally: await anc_client.users_groups.delete(group_id) with pytest.raises(NextcloudException): await anc_client.users_groups.delete(group_id) cloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/ui_settings_test.py0000664000232200023220000000562114766056032026554 0ustar debalancedebalanceimport copy import pytest from nc_py_api import NextcloudExceptionNotFound, ex_app SETTINGS_EXAMPLE = ex_app.SettingsForm( id="test_id", section_type="admin", section_id="test_section_id", title="Some title", description="Some description", fields=[ ex_app.SettingsField( id="field1", title="Multi-selection", description="Select some option setting", type=ex_app.SettingsFieldType.MULTI_SELECT, default=["foo", "bar"], placeholder="Select some multiple options", options=["foo", "bar", "baz"], ), ], ) @pytest.mark.require_nc(major=29) def test_register_ui_settings(nc_app): nc_app.ui.settings.register_form(SETTINGS_EXAMPLE) result = nc_app.ui.settings.get_entry(SETTINGS_EXAMPLE.id) assert result == SETTINGS_EXAMPLE nc_app.ui.settings.unregister_form(SETTINGS_EXAMPLE.id) assert nc_app.ui.settings.get_entry(SETTINGS_EXAMPLE.id) is None nc_app.ui.settings.unregister_form(SETTINGS_EXAMPLE.id) with pytest.raises(NextcloudExceptionNotFound): nc_app.ui.settings.unregister_form(SETTINGS_EXAMPLE.id, not_fail=False) nc_app.ui.settings.register_form(SETTINGS_EXAMPLE) result = nc_app.ui.settings.get_entry(SETTINGS_EXAMPLE.id) assert result.description == SETTINGS_EXAMPLE.description new_settings = copy.copy(SETTINGS_EXAMPLE) new_settings.description = "new desc" nc_app.ui.settings.register_form(new_settings) result = nc_app.ui.settings.get_entry(new_settings.id) assert result.description == "new desc" nc_app.ui.settings.unregister_form(new_settings.id) assert nc_app.ui.settings.get_entry(new_settings.id) is None @pytest.mark.require_nc(major=29) @pytest.mark.asyncio(scope="session") async def test_register_ui_settings_async(anc_app): await anc_app.ui.settings.register_form(SETTINGS_EXAMPLE) result = await anc_app.ui.settings.get_entry(SETTINGS_EXAMPLE.id) assert result == SETTINGS_EXAMPLE await anc_app.ui.settings.unregister_form(SETTINGS_EXAMPLE.id) assert await anc_app.ui.settings.get_entry(SETTINGS_EXAMPLE.id) is None await anc_app.ui.settings.unregister_form(SETTINGS_EXAMPLE.id) with pytest.raises(NextcloudExceptionNotFound): await anc_app.ui.settings.unregister_form(SETTINGS_EXAMPLE.id, not_fail=False) await anc_app.ui.settings.register_form(SETTINGS_EXAMPLE) result = await anc_app.ui.settings.get_entry(SETTINGS_EXAMPLE.id) assert result.description == SETTINGS_EXAMPLE.description new_settings = copy.copy(SETTINGS_EXAMPLE) new_settings.description = "new desc" await anc_app.ui.settings.register_form(new_settings) result = await anc_app.ui.settings.get_entry(new_settings.id) assert result.description == "new desc" await anc_app.ui.settings.unregister_form(new_settings.id) assert await anc_app.ui.settings.get_entry(new_settings.id) is None cloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/events_listener_test.py0000664000232200023220000000434714766056032027434 0ustar debalancedebalanceimport pytest from nc_py_api import NextcloudExceptionNotFound def test_events_registration(nc_app): nc_app.events_listener.register( "node_event", "/some_url", ) result = nc_app.events_listener.get_entry("node_event") assert result.event_type == "node_event" assert result.action_handler == "some_url" assert result.event_subtypes == [] nc_app.events_listener.register( "node_event", callback_url="/new_url", event_subtypes=["NodeCreatedEvent", "NodeRenamedEvent"] ) result = nc_app.events_listener.get_entry("node_event") assert result.event_type == "node_event" assert result.action_handler == "new_url" assert result.event_subtypes == ["NodeCreatedEvent", "NodeRenamedEvent"] nc_app.events_listener.unregister(result.event_type) with pytest.raises(NextcloudExceptionNotFound): nc_app.events_listener.unregister(result.event_type, not_fail=False) nc_app.events_listener.unregister(result.event_type) assert nc_app.events_listener.get_entry(result.event_type) is None assert str(result).find("event_type=") != -1 @pytest.mark.asyncio(scope="session") async def test_events_registration_async(anc_app): await anc_app.events_listener.register( "node_event", "/some_url", ) result = await anc_app.events_listener.get_entry("node_event") assert result.event_type == "node_event" assert result.action_handler == "some_url" assert result.event_subtypes == [] await anc_app.events_listener.register( "node_event", callback_url="/new_url", event_subtypes=["NodeCreatedEvent", "NodeRenamedEvent"] ) result = await anc_app.events_listener.get_entry("node_event") assert result.event_type == "node_event" assert result.action_handler == "new_url" assert result.event_subtypes == ["NodeCreatedEvent", "NodeRenamedEvent"] await anc_app.events_listener.unregister(result.event_type) with pytest.raises(NextcloudExceptionNotFound): await anc_app.events_listener.unregister(result.event_type, not_fail=False) await anc_app.events_listener.unregister(result.event_type) assert await anc_app.events_listener.get_entry(result.event_type) is None assert str(result).find("event_type=") != -1 cloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/__init__.py0000664000232200023220000000000014766056032024701 0ustar debalancedebalancecloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/talk_bot_test.py0000664000232200023220000002054514766056032026020 0ustar debalancedebalancefrom os import environ import httpx import pytest from nc_py_api import talk, talk_bot @pytest.mark.require_nc(major=27, minor=1) def test_register_unregister_talk_bot(nc_app): if nc_app.talk.bots_available is False: pytest.skip("Need Talk bots support") nc_app.unregister_talk_bot("/talk_bot_coverage") list_of_bots = nc_app.talk.list_bots() nc_app.register_talk_bot("/talk_bot_coverage", "Coverage bot", "Desc") assert len(list_of_bots) + 1 == len(nc_app.talk.list_bots()) nc_app.register_talk_bot("/talk_bot_coverage", "Coverage bot", "Desc") assert len(list_of_bots) + 1 == len(nc_app.talk.list_bots()) assert nc_app.unregister_talk_bot("/talk_bot_coverage") is True assert len(list_of_bots) == len(nc_app.talk.list_bots()) assert nc_app.unregister_talk_bot("/talk_bot_coverage") is False assert len(list_of_bots) == len(nc_app.talk.list_bots()) @pytest.mark.asyncio(scope="session") @pytest.mark.require_nc(major=27, minor=1) async def test_register_unregister_talk_bot_async(anc_app): if await anc_app.talk.bots_available is False: pytest.skip("Need Talk bots support") await anc_app.unregister_talk_bot("/talk_bot_coverage") list_of_bots = await anc_app.talk.list_bots() await anc_app.register_talk_bot("/talk_bot_coverage", "Coverage bot", "Desc") assert len(list_of_bots) + 1 == len(await anc_app.talk.list_bots()) await anc_app.register_talk_bot("/talk_bot_coverage", "Coverage bot", "Desc") assert len(list_of_bots) + 1 == len(await anc_app.talk.list_bots()) assert await anc_app.unregister_talk_bot("/talk_bot_coverage") is True assert len(list_of_bots) == len(await anc_app.talk.list_bots()) assert await anc_app.unregister_talk_bot("/talk_bot_coverage") is False assert len(list_of_bots) == len(await anc_app.talk.list_bots()) def _test_list_bots(registered_bot: talk.BotInfo): assert isinstance(registered_bot.bot_id, int) assert registered_bot.url.find("/some_url") != -1 assert registered_bot.description == "some desc" assert registered_bot.state == 1 assert not registered_bot.error_count assert registered_bot.last_error_date == 0 assert registered_bot.last_error_message is None assert isinstance(registered_bot.url_hash, str) @pytest.mark.require_nc(major=27, minor=1) def test_list_bots(nc, nc_app): if nc_app.talk.bots_available is False: pytest.skip("Need Talk bots support") nc_app.register_talk_bot("/some_url", "some bot name", "some desc") registered_bot = next(i for i in nc.talk.list_bots() if i.bot_name == "some bot name") _test_list_bots(registered_bot) conversation = nc.talk.create_conversation(talk.ConversationType.GROUP, "admin") try: conversation_bots = nc.talk.conversation_list_bots(conversation) assert conversation_bots assert str(conversation_bots[0]).find("name=") != -1 finally: nc.talk.delete_conversation(conversation.token) @pytest.mark.asyncio(scope="session") @pytest.mark.require_nc(major=27, minor=1) async def test_list_bots_async(anc, anc_app): if await anc_app.talk.bots_available is False: pytest.skip("Need Talk bots support") await anc_app.register_talk_bot("/some_url", "some bot name", "some desc") registered_bot = next(i for i in await anc.talk.list_bots() if i.bot_name == "some bot name") _test_list_bots(registered_bot) conversation = await anc.talk.create_conversation(talk.ConversationType.GROUP, "admin") try: conversation_bots = await anc.talk.conversation_list_bots(conversation) assert conversation_bots assert str(conversation_bots[0]).find("name=") != -1 finally: await anc.talk.delete_conversation(conversation.token) @pytest.mark.skipif(environ.get("CI", None) is None, reason="run only on GitHub") @pytest.mark.require_nc(major=27, minor=1) def test_chat_bot_receive_message(nc_app): if nc_app.talk.bots_available is False: pytest.skip("Need Talk bots support") httpx.delete(f"{'http'}://{environ.get('APP_HOST', '127.0.0.1')}:{environ['APP_PORT']}/reset_bot_secret") talk_bot_inst = talk_bot.TalkBot("/talk_bot_coverage", "Coverage bot", "Desc") talk_bot_inst.enabled_handler(True, nc_app) conversation = nc_app.talk.create_conversation(talk.ConversationType.GROUP, "admin") try: coverage_bot = next(i for i in nc_app.talk.list_bots() if i.url.endswith("/talk_bot_coverage")) c_bot_info = next( i for i in nc_app.talk.conversation_list_bots(conversation) if i.bot_id == coverage_bot.bot_id ) assert c_bot_info.state == 0 nc_app.talk.enable_bot(conversation, coverage_bot) c_bot_info = next( i for i in nc_app.talk.conversation_list_bots(conversation) if i.bot_id == coverage_bot.bot_id ) assert c_bot_info.state == 1 with pytest.raises(ValueError): nc_app.talk.send_message("Here are the msg!") nc_app.talk.send_message("Here are the msg!", conversation) msg_from_bot = None for _ in range(40): messages = nc_app.talk.receive_messages(conversation, look_in_future=True, timeout=1) if messages[-1].message == "Hello from bot!": msg_from_bot = messages[-1] break assert msg_from_bot c_bot_info = next( i for i in nc_app.talk.conversation_list_bots(conversation) if i.bot_id == coverage_bot.bot_id ) assert c_bot_info.state == 1 nc_app.talk.disable_bot(conversation, coverage_bot) c_bot_info = next( i for i in nc_app.talk.conversation_list_bots(conversation) if i.bot_id == coverage_bot.bot_id ) assert c_bot_info.state == 0 finally: nc_app.talk.delete_conversation(conversation.token) talk_bot_inst.enabled_handler(False, nc_app) talk_bot_inst.callback_url = "invalid_url" with pytest.raises(RuntimeError): talk_bot_inst.send_message("message", 999999, token="sometoken") @pytest.mark.asyncio(scope="session") @pytest.mark.skipif(environ.get("CI", None) is None, reason="run only on GitHub") @pytest.mark.require_nc(major=27, minor=1) async def test_chat_bot_receive_message_async(anc_app): if await anc_app.talk.bots_available is False: pytest.skip("Need Talk bots support") httpx.delete(f"{'http'}://{environ.get('APP_HOST', '127.0.0.1')}:{environ['APP_PORT']}/reset_bot_secret") talk_bot_inst = talk_bot.AsyncTalkBot("/talk_bot_coverage", "Coverage bot", "Desc") await talk_bot_inst.enabled_handler(True, anc_app) conversation = await anc_app.talk.create_conversation(talk.ConversationType.GROUP, "admin") try: coverage_bot = next(i for i in await anc_app.talk.list_bots() if i.url.endswith("/talk_bot_coverage")) c_bot_info = next( i for i in await anc_app.talk.conversation_list_bots(conversation) if i.bot_id == coverage_bot.bot_id ) assert c_bot_info.state == 0 await anc_app.talk.enable_bot(conversation, coverage_bot) c_bot_info = next( i for i in await anc_app.talk.conversation_list_bots(conversation) if i.bot_id == coverage_bot.bot_id ) assert c_bot_info.state == 1 with pytest.raises(ValueError): await anc_app.talk.send_message("Here are the msg!") await anc_app.talk.send_message("Here are the msg!", conversation) msg_from_bot = None for _ in range(40): messages = await anc_app.talk.receive_messages(conversation, look_in_future=True, timeout=1) if messages[-1].message == "Hello from bot!": msg_from_bot = messages[-1] break assert msg_from_bot c_bot_info = next( i for i in await anc_app.talk.conversation_list_bots(conversation) if i.bot_id == coverage_bot.bot_id ) assert c_bot_info.state == 1 await anc_app.talk.disable_bot(conversation, coverage_bot) c_bot_info = next( i for i in await anc_app.talk.conversation_list_bots(conversation) if i.bot_id == coverage_bot.bot_id ) assert c_bot_info.state == 0 finally: await anc_app.talk.delete_conversation(conversation.token) await talk_bot_inst.enabled_handler(False, anc_app) talk_bot_inst.callback_url = "invalid_url" with pytest.raises(RuntimeError): await talk_bot_inst.send_message("message", 999999, token="sometoken") cloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/ui_files_actions_test.py0000664000232200023220000001612714766056032027541 0ustar debalancedebalanceimport pytest from nc_py_api import FilePermissions, FsNode, NextcloudExceptionNotFound, ex_app def test_register_ui_file_actions(nc_app): nc_app.ui.files_dropdown_menu.register_ex("test_ui_action_im", "UI TEST Image", "/ui_action_test", mime="image") result = nc_app.ui.files_dropdown_menu.get_entry("test_ui_action_im") assert result.name == "test_ui_action_im" assert result.display_name == "UI TEST Image" assert result.action_handler == "ui_action_test" assert result.mime == "image" assert result.permissions == 31 assert result.order == 0 assert result.icon == "" assert result.appid == "nc_py_api" assert result.version == "2.0" nc_app.ui.files_dropdown_menu.unregister(result.name) nc_app.ui.files_dropdown_menu.register("test_ui_action_any", "UI TEST", "ui_action", permissions=1, order=1) result = nc_app.ui.files_dropdown_menu.get_entry("test_ui_action_any") assert result.name == "test_ui_action_any" assert result.display_name == "UI TEST" assert result.action_handler == "ui_action" assert result.mime == "file" assert result.permissions == 1 assert result.order == 1 assert result.icon == "" assert result.version == "1.0" nc_app.ui.files_dropdown_menu.register_ex("test_ui_action_any", "UI", "/ui_action2", icon="/img/icon.svg") result = nc_app.ui.files_dropdown_menu.get_entry("test_ui_action_any") assert result.name == "test_ui_action_any" assert result.display_name == "UI" assert result.action_handler == "ui_action2" assert result.mime == "file" assert result.permissions == 31 assert result.order == 0 assert result.icon == "img/icon.svg" assert result.version == "2.0" nc_app.ui.files_dropdown_menu.unregister(result.name) assert str(result).find("name=test_ui_action") @pytest.mark.asyncio(scope="session") async def test_register_ui_file_actions_async(anc_app): await anc_app.ui.files_dropdown_menu.register_ex( "test_ui_action_im", "UI TEST Image", "/ui_action_test", mime="image" ) result = await anc_app.ui.files_dropdown_menu.get_entry("test_ui_action_im") assert result.name == "test_ui_action_im" assert result.display_name == "UI TEST Image" assert result.action_handler == "ui_action_test" assert result.mime == "image" assert result.permissions == 31 assert result.order == 0 assert result.icon == "" assert result.appid == "nc_py_api" assert result.version == "2.0" await anc_app.ui.files_dropdown_menu.unregister(result.name) await anc_app.ui.files_dropdown_menu.register("test_ui_action_any", "UI TEST", "ui_action", permissions=1, order=1) result = await anc_app.ui.files_dropdown_menu.get_entry("test_ui_action_any") assert result.name == "test_ui_action_any" assert result.display_name == "UI TEST" assert result.action_handler == "ui_action" assert result.mime == "file" assert result.permissions == 1 assert result.order == 1 assert result.icon == "" assert result.version == "1.0" await anc_app.ui.files_dropdown_menu.register_ex("test_ui_action_any", "UI", "/ui_action2", icon="/img/icon.svg") result = await anc_app.ui.files_dropdown_menu.get_entry("test_ui_action_any") assert result.name == "test_ui_action_any" assert result.display_name == "UI" assert result.action_handler == "ui_action2" assert result.mime == "file" assert result.permissions == 31 assert result.order == 0 assert result.icon == "img/icon.svg" assert result.version == "2.0" await anc_app.ui.files_dropdown_menu.unregister(result.name) assert str(result).find("name=test_ui_action") def test_unregister_ui_file_actions(nc_app): nc_app.ui.files_dropdown_menu.register_ex("test_ui_action", "NcPyApi UI TEST", "/any_rel_url") nc_app.ui.files_dropdown_menu.unregister("test_ui_action") assert nc_app.ui.files_dropdown_menu.get_entry("test_ui_action") is None nc_app.ui.files_dropdown_menu.unregister("test_ui_action") with pytest.raises(NextcloudExceptionNotFound): nc_app.ui.files_dropdown_menu.unregister("test_ui_action", not_fail=False) @pytest.mark.asyncio(scope="session") async def test_unregister_ui_file_actions_async(anc_app): await anc_app.ui.files_dropdown_menu.register_ex("test_ui_action", "NcPyApi UI TEST", "/any_rel_url") await anc_app.ui.files_dropdown_menu.unregister("test_ui_action") assert await anc_app.ui.files_dropdown_menu.get_entry("test_ui_action") is None await anc_app.ui.files_dropdown_menu.unregister("test_ui_action") with pytest.raises(NextcloudExceptionNotFound): await anc_app.ui.files_dropdown_menu.unregister("test_ui_action", not_fail=False) def test_ui_file_to_fs_node(nc_app): def ui_action_check(directory: str, fs_object: FsNode): permissions = 0 if fs_object.is_readable: permissions += FilePermissions.PERMISSION_READ if fs_object.is_updatable: permissions += FilePermissions.PERMISSION_UPDATE if fs_object.is_creatable: permissions += FilePermissions.PERMISSION_CREATE if fs_object.is_deletable: permissions += FilePermissions.PERMISSION_DELETE if fs_object.is_shareable: permissions += FilePermissions.PERMISSION_SHARE fileid_str = str(fs_object.info.fileid) i = fs_object.file_id.find(fileid_str) file_info = ex_app.UiActionFileInfo( fileId=fs_object.info.fileid, name=fs_object.name, directory=directory, etag=fs_object.etag, mime=fs_object.info.mimetype, fileType="dir" if fs_object.is_dir else "file", mtime=int(fs_object.info.last_modified.timestamp()), size=fs_object.info.size, favorite=str(fs_object.info.favorite), permissions=permissions, userId=fs_object.user, shareOwner="some_user" if fs_object.is_shared else None, shareOwnerId="some_user_id" if fs_object.is_shared else None, instanceId=fs_object.file_id[i + len(fileid_str) :], ) fs_node = file_info.to_fs_node() assert isinstance(fs_node, FsNode) assert fs_node.etag == fs_object.etag assert fs_node.name == fs_object.name assert fs_node.user_path == fs_object.user_path assert fs_node.full_path == fs_object.full_path assert fs_node.file_id == fs_object.file_id assert fs_node.is_dir == fs_object.is_dir assert fs_node.info.mimetype == fs_object.info.mimetype assert fs_node.info.permissions == fs_object.info.permissions assert fs_node.info.last_modified == fs_object.info.last_modified assert fs_node.info.favorite == fs_object.info.favorite assert fs_node.info.content_length == fs_object.info.content_length assert fs_node.info.size == fs_object.info.size assert fs_node.info.fileid == fs_object.info.fileid for each_file in nc_app.files.listdir(): ui_action_check(directory="/", fs_object=each_file) for each_file in nc_app.files.listdir("test_dir"): ui_action_check(directory="/test_dir", fs_object=each_file) cloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/ui_top_menu_test.py0000664000232200023220000000570214766056032026542 0ustar debalancedebalanceimport pytest from nc_py_api import NextcloudExceptionNotFound def test_register_ui_top_menu(nc_app): nc_app.ui.top_menu.register("test_name", "Disp name", "") result = nc_app.ui.top_menu.get_entry("test_name") assert result.name == "test_name" assert result.display_name == "Disp name" assert result.icon == "" assert result.admin_required is False assert result.appid == nc_app.app_cfg.app_name nc_app.ui.top_menu.unregister(result.name) assert nc_app.ui.top_menu.get_entry("test_name") is None nc_app.ui.top_menu.unregister(result.name) with pytest.raises(NextcloudExceptionNotFound): nc_app.ui.top_menu.unregister(result.name, not_fail=False) nc_app.ui.top_menu.register("test_name", "display", "/img/test.svg", admin_required=True) result = nc_app.ui.top_menu.get_entry("test_name") assert result.name == "test_name" assert result.display_name == "display" assert result.icon == "img/test.svg" assert result.admin_required is True nc_app.ui.top_menu.register("test_name", "Display name", "", admin_required=False) result = nc_app.ui.top_menu.get_entry("test_name") assert result.name == "test_name" assert result.display_name == "Display name" assert result.icon == "" assert result.admin_required is False nc_app.ui.top_menu.unregister(result.name) assert nc_app.ui.top_menu.get_entry("test_name") is None assert str(result).find("name=test_name") @pytest.mark.asyncio(scope="session") async def test_register_ui_top_menu_async(anc_app): await anc_app.ui.top_menu.register("test_name", "Disp name", "") result = await anc_app.ui.top_menu.get_entry("test_name") assert result.name == "test_name" assert result.display_name == "Disp name" assert result.icon == "" assert result.admin_required is False assert result.appid == anc_app.app_cfg.app_name await anc_app.ui.top_menu.unregister(result.name) assert await anc_app.ui.top_menu.get_entry("test_name") is None await anc_app.ui.top_menu.unregister(result.name) with pytest.raises(NextcloudExceptionNotFound): await anc_app.ui.top_menu.unregister(result.name, not_fail=False) await anc_app.ui.top_menu.register("test_name", "display", "/img/test.svg", admin_required=True) result = await anc_app.ui.top_menu.get_entry("test_name") assert result.name == "test_name" assert result.display_name == "display" assert result.icon == "img/test.svg" assert result.admin_required is True await anc_app.ui.top_menu.register("test_name", "Display name", "", admin_required=False) result = await anc_app.ui.top_menu.get_entry("test_name") assert result.name == "test_name" assert result.display_name == "Display name" assert result.icon == "" assert result.admin_required is False await anc_app.ui.top_menu.unregister(result.name) assert await anc_app.ui.top_menu.get_entry("test_name") is None assert str(result).find("name=test_name") cloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/activity_test.py0000664000232200023220000000612214766056032026050 0ustar debalancedebalanceimport datetime import pytest from nc_py_api.activity import Activity def test_get_filters(nc_any): if nc_any.activity.available is False: pytest.skip("Activity App is not installed") r = nc_any.activity.get_filters() assert r for i in r: assert i.filter_id assert isinstance(i.icon, str) assert i.name assert isinstance(i.priority, int) assert str(i).find("name=") != -1 @pytest.mark.asyncio(scope="session") async def test_get_filters_async(anc_any): if await anc_any.activity.available is False: pytest.skip("Activity App is not installed") r = await anc_any.activity.get_filters() assert r for i in r: assert i.filter_id assert isinstance(i.icon, str) assert i.name assert isinstance(i.priority, int) assert str(i).find("name=") != -1 def _test_get_activities(r: list[Activity]): assert r for i in r: assert i.activity_id assert isinstance(i.app, str) assert isinstance(i.activity_type, str) assert isinstance(i.actor_id, str) assert isinstance(i.subject, str) assert isinstance(i.subject_rich, list) assert isinstance(i.message, str) assert isinstance(i.message_rich, list) assert isinstance(i.object_type, str) assert isinstance(i.object_id, int) assert isinstance(i.object_name, str) assert isinstance(i.objects, dict) assert isinstance(i.link, str) assert isinstance(i.icon, str) assert i.time > datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) assert str(i).find("app=") != -1 def test_get_activities(nc_any): if nc_any.activity.available is False: pytest.skip("Activity App is not installed") with pytest.raises(ValueError): nc_any.activity.get_activities(object_id=4) r = nc_any.activity.get_activities(since=True) _test_get_activities(r) r2 = nc_any.activity.get_activities(since=True) if r2: old_activities_id = [i.activity_id for i in r] assert r2[0].activity_id not in old_activities_id assert r2[-1].activity_id not in old_activities_id assert len(nc_any.activity.get_activities(since=0, limit=1)) == 1 while True: if not nc_any.activity.get_activities(since=True): break @pytest.mark.asyncio(scope="session") async def test_get_activities_async(anc_any): if await anc_any.activity.available is False: pytest.skip("Activity App is not installed") with pytest.raises(ValueError): await anc_any.activity.get_activities(object_id=4) r = await anc_any.activity.get_activities(since=True) _test_get_activities(r) r2 = await anc_any.activity.get_activities(since=True) if r2: old_activities_id = [i.activity_id for i in r] assert r2[0].activity_id not in old_activities_id assert r2[-1].activity_id not in old_activities_id assert len(await anc_any.activity.get_activities(since=0, limit=1)) == 1 while True: if not await anc_any.activity.get_activities(since=True): break cloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/nc_app_test.py0000664000232200023220000000465414766056032025464 0ustar debalancedebalancefrom os import environ from unittest import mock import pytest from nc_py_api.ex_app import set_handlers def test_get_users_list(nc_app): users = nc_app.users_list() assert users assert nc_app.user in users @pytest.mark.asyncio(scope="session") async def test_get_users_list_async(anc_app): users = await anc_app.users_list() assert users assert await anc_app.user in users def test_app_cfg(nc_app): app_cfg = nc_app.app_cfg assert app_cfg.app_name == environ["APP_ID"] assert app_cfg.app_version == environ["APP_VERSION"] assert app_cfg.app_secret == environ["APP_SECRET"] @pytest.mark.asyncio(scope="session") async def test_app_cfg_async(anc_app): app_cfg = anc_app.app_cfg assert app_cfg.app_name == environ["APP_ID"] assert app_cfg.app_version == environ["APP_VERSION"] assert app_cfg.app_secret == environ["APP_SECRET"] def test_change_user(nc_app): orig_user = nc_app.user try: orig_capabilities = nc_app.capabilities assert nc_app.user_status.available nc_app.set_user("") assert not nc_app.user_status.available assert orig_capabilities != nc_app.capabilities finally: nc_app.set_user(orig_user) assert orig_capabilities == nc_app.capabilities @pytest.mark.asyncio(scope="session") async def test_change_user_async(anc_app): orig_user = await anc_app.user try: orig_capabilities = await anc_app.capabilities assert await anc_app.user_status.available await anc_app.set_user("") assert not await anc_app.user_status.available assert orig_capabilities != await anc_app.capabilities finally: await anc_app.set_user(orig_user) assert orig_capabilities == await anc_app.capabilities def test_set_user_same_value(nc_app): with (mock.patch("tests.conftest.NC_APP._session.update_server_info") as update_server_info,): nc_app.set_user(nc_app.user) update_server_info.assert_not_called() @pytest.mark.asyncio(scope="session") async def test_set_user_same_value_async(anc_app): with (mock.patch("tests.conftest.NC_APP_ASYNC._session.update_server_info") as update_server_info,): await anc_app.set_user(await anc_app.user) update_server_info.assert_not_called() def test_set_handlers_invalid_param(nc_any): with pytest.raises(ValueError): set_handlers(None, None, default_init=False, models_to_fetch={"some": {}}) # noqa cloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/files_sharing_test.py0000664000232200023220000004576114766056032027045 0ustar debalancedebalanceimport datetime from os import environ import pytest from nc_py_api import ( FilePermissions, FsNode, Nextcloud, NextcloudException, NextcloudExceptionNotFound, ShareType, ) from nc_py_api.files.sharing import Share def test_available(nc_any): assert nc_any.files.sharing.available @pytest.mark.asyncio(scope="session") async def test_available_async(anc_any): assert await anc_any.files.sharing.available def test_create_delete(nc_any): new_share = nc_any.files.sharing.create("test_12345_text.txt", ShareType.TYPE_LINK) nc_any.files.sharing.delete(new_share) with pytest.raises(NextcloudExceptionNotFound): nc_any.files.sharing.delete(new_share) @pytest.mark.asyncio(scope="session") async def test_create_delete_async(anc_any): new_share = await anc_any.files.sharing.create("test_12345_text.txt", ShareType.TYPE_LINK) await anc_any.files.sharing.delete(new_share) with pytest.raises(NextcloudExceptionNotFound): await anc_any.files.sharing.delete(new_share) def _test_share_fields(new_share: Share, get_by_id: Share, shared_file: FsNode): assert new_share.share_type == ShareType.TYPE_LINK assert not new_share.label assert not new_share.note assert new_share.mimetype.find("text") != -1 assert new_share.permissions & FilePermissions.PERMISSION_READ assert new_share.url assert new_share.path == shared_file.user_path assert get_by_id.share_id == new_share.share_id assert get_by_id.path == new_share.path assert get_by_id.mimetype == new_share.mimetype assert get_by_id.share_type == new_share.share_type assert get_by_id.file_owner == new_share.file_owner assert get_by_id.share_owner == new_share.share_owner assert not get_by_id.share_with assert str(get_by_id) == str(new_share) assert get_by_id.file_source_id == shared_file.info.fileid assert get_by_id.can_delete is True assert get_by_id.can_edit is True def test_share_fields(nc_any): shared_file = nc_any.files.by_path("test_12345_text.txt") new_share = nc_any.files.sharing.create(shared_file, ShareType.TYPE_LINK, FilePermissions.PERMISSION_READ) try: get_by_id = nc_any.files.sharing.get_by_id(new_share.share_id) _test_share_fields(new_share, get_by_id, shared_file) finally: nc_any.files.sharing.delete(new_share) @pytest.mark.asyncio(scope="session") async def test_share_fields_async(anc_any): shared_file = await anc_any.files.by_path("test_12345_text.txt") new_share = await anc_any.files.sharing.create(shared_file, ShareType.TYPE_LINK, FilePermissions.PERMISSION_READ) try: get_by_id = await anc_any.files.sharing.get_by_id(new_share.share_id) _test_share_fields(new_share, get_by_id, shared_file) finally: await anc_any.files.sharing.delete(new_share) def test_create_permissions(nc_any): new_share = nc_any.files.sharing.create("test_empty_dir", ShareType.TYPE_LINK, FilePermissions.PERMISSION_CREATE) nc_any.files.sharing.delete(new_share) assert (new_share.permissions & FilePermissions.PERMISSION_CREATE) == FilePermissions.PERMISSION_CREATE new_share = nc_any.files.sharing.create( "test_empty_dir", ShareType.TYPE_LINK, FilePermissions.PERMISSION_CREATE + FilePermissions.PERMISSION_READ + FilePermissions.PERMISSION_DELETE, ) nc_any.files.sharing.delete(new_share) assert (new_share.permissions & FilePermissions.PERMISSION_DELETE) == FilePermissions.PERMISSION_DELETE new_share = nc_any.files.sharing.create( "test_empty_dir", ShareType.TYPE_LINK, FilePermissions.PERMISSION_CREATE + FilePermissions.PERMISSION_READ + FilePermissions.PERMISSION_UPDATE, ) nc_any.files.sharing.delete(new_share) assert (new_share.permissions & FilePermissions.PERMISSION_UPDATE) == FilePermissions.PERMISSION_UPDATE @pytest.mark.asyncio(scope="session") async def test_create_permissions_async(anc_any): new_share = await anc_any.files.sharing.create( "test_empty_dir", ShareType.TYPE_LINK, FilePermissions.PERMISSION_CREATE ) await anc_any.files.sharing.delete(new_share) assert (new_share.permissions & FilePermissions.PERMISSION_CREATE) == FilePermissions.PERMISSION_CREATE new_share = await anc_any.files.sharing.create( "test_empty_dir", ShareType.TYPE_LINK, FilePermissions.PERMISSION_CREATE + FilePermissions.PERMISSION_READ + FilePermissions.PERMISSION_DELETE, ) await anc_any.files.sharing.delete(new_share) assert (new_share.permissions & FilePermissions.PERMISSION_DELETE) == FilePermissions.PERMISSION_DELETE new_share = await anc_any.files.sharing.create( "test_empty_dir", ShareType.TYPE_LINK, FilePermissions.PERMISSION_CREATE + FilePermissions.PERMISSION_READ + FilePermissions.PERMISSION_UPDATE, ) await anc_any.files.sharing.delete(new_share) assert (new_share.permissions & FilePermissions.PERMISSION_UPDATE) == FilePermissions.PERMISSION_UPDATE def test_create_public_upload(nc_any): new_share = nc_any.files.sharing.create("test_empty_dir", ShareType.TYPE_LINK, public_upload=True) nc_any.files.sharing.delete(new_share) assert ( new_share.permissions == FilePermissions.PERMISSION_READ | FilePermissions.PERMISSION_UPDATE | FilePermissions.PERMISSION_SHARE | FilePermissions.PERMISSION_DELETE | FilePermissions.PERMISSION_CREATE ) @pytest.mark.asyncio(scope="session") async def test_create_public_upload_async(anc_any): new_share = await anc_any.files.sharing.create("test_empty_dir", ShareType.TYPE_LINK, public_upload=True) await anc_any.files.sharing.delete(new_share) assert ( new_share.permissions == FilePermissions.PERMISSION_READ | FilePermissions.PERMISSION_UPDATE | FilePermissions.PERMISSION_SHARE | FilePermissions.PERMISSION_DELETE | FilePermissions.PERMISSION_CREATE ) def test_create_password(nc): if nc.check_capabilities("spreed"): pytest.skip(reason="Talk is not installed.") new_share = nc.files.sharing.create("test_generated_image.png", ShareType.TYPE_LINK, password="s2dDS_z44ad1") nc.files.sharing.delete(new_share) assert new_share.password assert new_share.send_password_by_talk is False new_share = nc.files.sharing.create( "test_generated_image.png", ShareType.TYPE_LINK, password="s2dDS_z44ad1", send_password_by_talk=True ) nc.files.sharing.delete(new_share) assert new_share.password assert new_share.send_password_by_talk is True @pytest.mark.asyncio(scope="session") async def test_create_password_async(anc): if await anc.check_capabilities("spreed"): pytest.skip(reason="Talk is not installed.") new_share = await anc.files.sharing.create("test_generated_image.png", ShareType.TYPE_LINK, password="s2dDS_z44ad1") await anc.files.sharing.delete(new_share) assert new_share.password assert new_share.send_password_by_talk is False new_share = await anc.files.sharing.create( "test_generated_image.png", ShareType.TYPE_LINK, password="s2dDS_z44ad1", send_password_by_talk=True ) await anc.files.sharing.delete(new_share) assert new_share.password assert new_share.send_password_by_talk is True def test_create_note_label(nc_any): new_share = nc_any.files.sharing.create( "test_empty_text.txt", ShareType.TYPE_LINK, note="This is note", label="label" ) nc_any.files.sharing.delete(new_share) assert new_share.note == "This is note" assert new_share.label == "label" @pytest.mark.asyncio(scope="session") async def test_create_note_label_async(anc_any): new_share = await anc_any.files.sharing.create( "test_empty_text.txt", ShareType.TYPE_LINK, note="This is note", label="label" ) await anc_any.files.sharing.delete(new_share) assert new_share.note == "This is note" assert new_share.label == "label" def test_create_expire_time(nc): expire_time = datetime.datetime.now() + datetime.timedelta(days=1) expire_time = expire_time.replace(hour=0, minute=0, second=0, microsecond=0) new_share = nc.files.sharing.create("test_12345_text.txt", ShareType.TYPE_LINK, expire_date=expire_time) nc.files.sharing.delete(new_share) assert new_share.expire_date == expire_time with pytest.raises(NextcloudException): nc.files.sharing.create( "test_12345_text.txt", ShareType.TYPE_LINK, expire_date=datetime.datetime.now() - datetime.timedelta(days=1) ) new_share.raw_data["expiration"] = "invalid time" new_share2 = Share(new_share.raw_data) assert new_share2.expire_date == datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) @pytest.mark.asyncio(scope="session") async def test_create_expire_time_async(anc): expire_time = datetime.datetime.now() + datetime.timedelta(days=1) expire_time = expire_time.replace(hour=0, minute=0, second=0, microsecond=0) new_share = await anc.files.sharing.create("test_12345_text.txt", ShareType.TYPE_LINK, expire_date=expire_time) await anc.files.sharing.delete(new_share) assert new_share.expire_date == expire_time with pytest.raises(NextcloudException): await anc.files.sharing.create( "test_12345_text.txt", ShareType.TYPE_LINK, expire_date=datetime.datetime.now() - datetime.timedelta(days=1) ) new_share.raw_data["expiration"] = "invalid time" new_share2 = Share(new_share.raw_data) assert new_share2.expire_date == datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc) def _test_get_list(share_by_id: Share, shares_list: list[Share]): assert share_by_id.share_owner == shares_list[-1].share_owner assert share_by_id.mimetype == shares_list[-1].mimetype assert share_by_id.password == shares_list[-1].password assert share_by_id.permissions == shares_list[-1].permissions assert share_by_id.url == shares_list[-1].url def test_get_list(nc): shared_file = nc.files.by_path("test_12345_text.txt") result = nc.files.sharing.get_list() assert isinstance(result, list) n_shares = len(result) new_share = nc.files.sharing.create(shared_file, ShareType.TYPE_LINK) assert isinstance(new_share, Share) shares_list = nc.files.sharing.get_list() assert n_shares + 1 == len(shares_list) share_by_id = nc.files.sharing.get_by_id(shares_list[-1].share_id) nc.files.sharing.delete(new_share) assert n_shares == len(nc.files.sharing.get_list()) _test_get_list(share_by_id, shares_list) @pytest.mark.asyncio(scope="session") async def test_get_list_async(anc): shared_file = await anc.files.by_path("test_12345_text.txt") result = await anc.files.sharing.get_list() assert isinstance(result, list) n_shares = len(result) new_share = await anc.files.sharing.create(shared_file, ShareType.TYPE_LINK) assert isinstance(new_share, Share) shares_list = await anc.files.sharing.get_list() assert n_shares + 1 == len(shares_list) share_by_id = await anc.files.sharing.get_by_id(shares_list[-1].share_id) await anc.files.sharing.delete(new_share) assert n_shares == len(await anc.files.sharing.get_list()) _test_get_list(share_by_id, shares_list) def test_create_update(nc): if nc.check_capabilities("spreed"): pytest.skip(reason="Talk is not installed.") new_share = nc.files.sharing.create( "test_empty_dir", ShareType.TYPE_LINK, permissions=FilePermissions.PERMISSION_READ + FilePermissions.PERMISSION_SHARE + FilePermissions.PERMISSION_UPDATE, ) update_share = nc.files.sharing.update(new_share, password="s2dDS_z44ad1") assert update_share.password assert update_share.permissions != FilePermissions.PERMISSION_READ + FilePermissions.PERMISSION_SHARE update_share = nc.files.sharing.update( new_share, permissions=FilePermissions.PERMISSION_READ + FilePermissions.PERMISSION_SHARE ) assert update_share.password assert update_share.permissions == FilePermissions.PERMISSION_READ + FilePermissions.PERMISSION_SHARE assert update_share.send_password_by_talk is False update_share = nc.files.sharing.update(new_share, send_password_by_talk=True, public_upload=True) assert update_share.password assert update_share.send_password_by_talk is True expire_time = datetime.datetime.now() + datetime.timedelta(days=1) expire_time = expire_time.replace(hour=0, minute=0, second=0, microsecond=0) update_share = nc.files.sharing.update(new_share, expire_date=expire_time) assert update_share.expire_date == expire_time update_share = nc.files.sharing.update(new_share, note="note", label="label") assert update_share.note == "note" assert update_share.label == "label" nc.files.sharing.delete(new_share) @pytest.mark.asyncio(scope="session") async def test_create_update_async(anc): if await anc.check_capabilities("spreed"): pytest.skip(reason="Talk is not installed.") new_share = await anc.files.sharing.create( "test_empty_dir", ShareType.TYPE_LINK, permissions=FilePermissions.PERMISSION_READ + FilePermissions.PERMISSION_SHARE + FilePermissions.PERMISSION_UPDATE, ) update_share = await anc.files.sharing.update(new_share, password="s2dDS_z44ad1") assert update_share.password assert update_share.permissions != FilePermissions.PERMISSION_READ + FilePermissions.PERMISSION_SHARE update_share = await anc.files.sharing.update( new_share, permissions=FilePermissions.PERMISSION_READ + FilePermissions.PERMISSION_SHARE ) assert update_share.password assert update_share.permissions == FilePermissions.PERMISSION_READ + FilePermissions.PERMISSION_SHARE assert update_share.send_password_by_talk is False update_share = await anc.files.sharing.update(new_share, send_password_by_talk=True, public_upload=True) assert update_share.password assert update_share.send_password_by_talk is True expire_time = datetime.datetime.now() + datetime.timedelta(days=1) expire_time = expire_time.replace(hour=0, minute=0, second=0, microsecond=0) update_share = await anc.files.sharing.update(new_share, expire_date=expire_time) assert update_share.expire_date == expire_time update_share = await anc.files.sharing.update(new_share, note="note", label="label") assert update_share.note == "note" assert update_share.label == "label" await anc.files.sharing.delete(new_share) def test_get_inherited(nc_any): new_share = nc_any.files.sharing.create("test_dir/subdir", ShareType.TYPE_LINK) assert not nc_any.files.sharing.get_inherited("test_dir") assert not nc_any.files.sharing.get_inherited("test_dir/subdir") new_share2 = nc_any.files.sharing.get_inherited("test_dir/subdir/test_12345_text.txt")[0] assert new_share.share_id == new_share2.share_id assert new_share.share_owner == new_share2.share_owner assert new_share.file_owner == new_share2.file_owner assert new_share.url == new_share2.url nc_any.files.sharing.delete(new_share) @pytest.mark.asyncio(scope="session") async def test_get_inherited_async(anc_any): new_share = await anc_any.files.sharing.create("test_dir/subdir", ShareType.TYPE_LINK) assert not await anc_any.files.sharing.get_inherited("test_dir") assert not await anc_any.files.sharing.get_inherited("test_dir/subdir") new_share2 = (await anc_any.files.sharing.get_inherited("test_dir/subdir/test_12345_text.txt"))[0] assert new_share.share_id == new_share2.share_id assert new_share.share_owner == new_share2.share_owner assert new_share.file_owner == new_share2.file_owner assert new_share.url == new_share2.url await anc_any.files.sharing.delete(new_share) def test_share_with(nc, nc_client): nc_second_user = Nextcloud(nc_auth_user=environ["TEST_USER_ID"], nc_auth_pass=environ["TEST_USER_PASS"]) assert not nc_second_user.files.sharing.get_list() shared_file = nc.files.by_path("test_empty_text.txt") folder_share = nc.files.sharing.create( "test_empty_dir_in_dir", ShareType.TYPE_USER, share_with=environ["TEST_USER_ID"] ) file_share = nc.files.sharing.create(shared_file, ShareType.TYPE_USER, share_with=environ["TEST_USER_ID"]) shares_list1 = nc.files.sharing.get_list(path="test_empty_dir_in_dir/") shares_list2 = nc.files.sharing.get_list(path="test_empty_text.txt") second_user_shares_list = nc_second_user.files.sharing.get_list() second_user_shares_list_with_me = nc_second_user.files.sharing.get_list(shared_with_me=True) nc.files.sharing.delete(folder_share) nc.files.sharing.delete(file_share) assert not second_user_shares_list assert len(second_user_shares_list_with_me) == 2 assert len(shares_list1) == 1 assert len(shares_list2) == 1 assert not nc_second_user.files.sharing.get_list() @pytest.mark.asyncio(scope="session") async def test_share_with_async(anc, anc_client): nc_second_user = Nextcloud(nc_auth_user=environ["TEST_USER_ID"], nc_auth_pass=environ["TEST_USER_PASS"]) assert not nc_second_user.files.sharing.get_list() shared_file = await anc.files.by_path("test_empty_text.txt") folder_share = await anc.files.sharing.create( "test_empty_dir_in_dir", ShareType.TYPE_USER, share_with=environ["TEST_USER_ID"] ) file_share = await anc.files.sharing.create(shared_file, ShareType.TYPE_USER, share_with=environ["TEST_USER_ID"]) shares_list1 = await anc.files.sharing.get_list(path="test_empty_dir_in_dir/") shares_list2 = await anc.files.sharing.get_list(path="test_empty_text.txt") second_user_shares_list = nc_second_user.files.sharing.get_list() second_user_shares_list_with_me = nc_second_user.files.sharing.get_list(shared_with_me=True) await anc.files.sharing.delete(folder_share) await anc.files.sharing.delete(file_share) assert not second_user_shares_list assert len(second_user_shares_list_with_me) == 2 assert len(shares_list1) == 1 assert len(shares_list2) == 1 assert not nc_second_user.files.sharing.get_list() def test_pending(nc_any): assert isinstance(nc_any.files.sharing.get_pending(), list) with pytest.raises(NextcloudExceptionNotFound): nc_any.files.sharing.accept_share(99999999) with pytest.raises(NextcloudExceptionNotFound): nc_any.files.sharing.decline_share(99999999) @pytest.mark.asyncio(scope="session") async def test_pending_async(anc_any): assert isinstance(await anc_any.files.sharing.get_pending(), list) with pytest.raises(NextcloudExceptionNotFound): await anc_any.files.sharing.accept_share(99999999) with pytest.raises(NextcloudExceptionNotFound): await anc_any.files.sharing.decline_share(99999999) def test_deleted(nc_any): assert isinstance(nc_any.files.sharing.get_deleted(), list) with pytest.raises(NextcloudExceptionNotFound): nc_any.files.sharing.undelete(99999999) @pytest.mark.asyncio(scope="session") async def test_deleted_async(anc_any): assert isinstance(await anc_any.files.sharing.get_deleted(), list) with pytest.raises(NextcloudExceptionNotFound): await anc_any.files.sharing.undelete(99999999) cloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/options_test.py0000664000232200023220000000645314766056032025716 0ustar debalancedebalanceimport os import sys from subprocess import PIPE, run from unittest import mock import nc_py_api def test_timeouts(): project_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) env_file = os.path.join(project_dir, ".env") env_backup_file = os.path.join(project_dir, ".env.backup") if os.path.exists(env_file): os.rename(env_file, env_backup_file) try: check_command = [sys.executable, "-c", "import nc_py_api\nassert nc_py_api.options.NPA_TIMEOUT is None"] with open(env_file, "w") as env_f: env_f.write("NPA_TIMEOUT=None") r = run(check_command, stderr=PIPE, env={}, cwd=project_dir, check=False) assert not r.stderr check_command = [sys.executable, "-c", "import nc_py_api\nassert nc_py_api.options.NPA_TIMEOUT == 11"] with open(env_file, "w") as env_f: env_f.write("NPA_TIMEOUT=11") r = run(check_command, stderr=PIPE, env={}, cwd=project_dir, check=False) assert not r.stderr check_command = [sys.executable, "-c", "import nc_py_api\nassert nc_py_api.options.NPA_TIMEOUT_DAV is None"] with open(env_file, "w") as env_f: env_f.write("NPA_TIMEOUT_DAV=None") r = run(check_command, stderr=PIPE, env={}, cwd=project_dir, check=False) assert not r.stderr check_command = [sys.executable, "-c", "import nc_py_api\nassert nc_py_api.options.NPA_TIMEOUT_DAV == 11"] with open(env_file, "w") as env_f: env_f.write("NPA_TIMEOUT_DAV=11") r = run(check_command, stderr=PIPE, env={}, cwd=project_dir, check=False) assert not r.stderr check_command = [sys.executable, "-c", "import nc_py_api\nassert nc_py_api.options.NPA_NC_CERT is False"] with open(env_file, "w") as env_f: env_f.write("NPA_NC_CERT=False") r = run(check_command, stderr=PIPE, env={}, cwd=project_dir, check=False) assert not r.stderr check_command = [sys.executable, "-c", "import nc_py_api\nassert nc_py_api.options.NPA_NC_CERT == ''"] with open(env_file, "w") as env_f: env_f.write('NPA_NC_CERT=""') r = run(check_command, stderr=PIPE, env={}, cwd=project_dir, check=False) assert not r.stderr finally: if os.path.exists(env_backup_file): os.rename(env_backup_file, env_file) def test_xdebug_session(nc_any): nc_py_api.options.XDEBUG_SESSION = "12345" new_nc = nc_py_api.Nextcloud() if isinstance(nc_any, nc_py_api.Nextcloud) else nc_py_api.NextcloudApp() assert new_nc._session.adapter.cookies["XDEBUG_SESSION"] == "12345" @mock.patch("nc_py_api.options.CHUNKED_UPLOAD_V2", False) def test_chunked_upload(nc_any): new_nc = nc_py_api.Nextcloud() if isinstance(nc_any, nc_py_api.Nextcloud) else nc_py_api.NextcloudApp() assert new_nc._session.cfg.options.upload_chunk_v2 is False def test_chunked_upload2(nc_any): new_nc = ( nc_py_api.Nextcloud(chunked_upload_v2=False) if isinstance(nc_any, nc_py_api.Nextcloud) else nc_py_api.NextcloudApp(chunked_upload_v2=False) ) assert new_nc._session.cfg.options.upload_chunk_v2 is False new_nc = nc_py_api.Nextcloud() if isinstance(nc_any, nc_py_api.Nextcloud) else nc_py_api.NextcloudApp() assert new_nc._session.cfg.options.upload_chunk_v2 is True cloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/preferences_test.py0000664000232200023220000000321014766056032026510 0ustar debalancedebalanceimport pytest from nc_py_api import NextcloudException def test_available(nc): assert isinstance(nc.preferences.available, bool) @pytest.mark.asyncio(scope="session") async def test_available_async(anc): assert isinstance(await anc.preferences.available, bool) def test_preferences_set(nc): if not nc.preferences.available: pytest.skip("provisioning_api is not available") nc.preferences.set_value("dav", key="user_status_automation", value="yes") with pytest.raises(NextcloudException): nc.preferences.set_value("non_existing_app", "some_cfg_name", "2") @pytest.mark.asyncio(scope="session") async def test_preferences_set_async(anc): if not await anc.preferences.available: pytest.skip("provisioning_api is not available") await anc.preferences.set_value("dav", key="user_status_automation", value="yes") with pytest.raises(NextcloudException): await anc.preferences.set_value("non_existing_app", "some_cfg_name", "2") def test_preferences_delete(nc): if not nc.preferences.available: pytest.skip("provisioning_api is not available") nc.preferences.delete("dav", key="user_status_automation") with pytest.raises(NextcloudException): nc.preferences.delete("non_existing_app", "some_cfg_name") @pytest.mark.asyncio(scope="session") async def test_preferences_delete_async(anc): if not await anc.preferences.available: pytest.skip("provisioning_api is not available") await anc.preferences.delete("dav", key="user_status_automation") with pytest.raises(NextcloudException): await anc.preferences.delete("non_existing_app", "some_cfg_name") cloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/apps_test.py0000664000232200023220000001174214766056032025163 0ustar debalancedebalanceimport pytest from nc_py_api.apps import ExAppInfo APP_NAME = "files_trashbin" def test_list_apps_types(nc): assert isinstance(nc.apps.get_list(), list) assert isinstance(nc.apps.get_list(enabled=True), list) assert isinstance(nc.apps.get_list(enabled=False), list) @pytest.mark.asyncio(scope="session") async def test_list_apps_types_async(anc): assert isinstance(await anc.apps.get_list(), list) assert isinstance(await anc.apps.get_list(enabled=True), list) assert isinstance(await anc.apps.get_list(enabled=False), list) def test_list_apps(nc): apps = nc.apps.get_list() assert apps assert APP_NAME in apps @pytest.mark.asyncio(scope="session") async def test_list_apps_async(anc): apps = await anc.apps.get_list() assert apps assert APP_NAME in apps def test_app_enable_disable(nc_client): assert nc_client.apps.is_installed(APP_NAME) is True if nc_client.apps.is_enabled(APP_NAME): nc_client.apps.disable(APP_NAME) assert nc_client.apps.is_disabled(APP_NAME) is True assert nc_client.apps.is_enabled(APP_NAME) is False assert nc_client.apps.is_installed(APP_NAME) is True nc_client.apps.enable(APP_NAME) assert nc_client.apps.is_enabled(APP_NAME) is True assert nc_client.apps.is_installed(APP_NAME) is True @pytest.mark.asyncio(scope="session") async def test_app_enable_disable_async(anc_client): assert await anc_client.apps.is_installed(APP_NAME) is True if await anc_client.apps.is_enabled(APP_NAME): await anc_client.apps.disable(APP_NAME) assert await anc_client.apps.is_disabled(APP_NAME) is True assert await anc_client.apps.is_enabled(APP_NAME) is False assert await anc_client.apps.is_installed(APP_NAME) is True await anc_client.apps.enable(APP_NAME) assert await anc_client.apps.is_enabled(APP_NAME) is True assert await anc_client.apps.is_installed(APP_NAME) is True def test_is_installed_enabled(nc): assert nc.apps.is_enabled(APP_NAME) != nc.apps.is_disabled(APP_NAME) assert nc.apps.is_installed(APP_NAME) @pytest.mark.asyncio(scope="session") async def test_is_installed_enabled_async(anc): assert await anc.apps.is_enabled(APP_NAME) != await anc.apps.is_disabled(APP_NAME) assert await anc.apps.is_installed(APP_NAME) def test_invalid_param(nc_any): with pytest.raises(ValueError): nc_any.apps.is_enabled("") with pytest.raises(ValueError): nc_any.apps.is_installed("") with pytest.raises(ValueError): nc_any.apps.is_disabled("") with pytest.raises(ValueError): nc_any.apps.enable("") with pytest.raises(ValueError): nc_any.apps.disable("") with pytest.raises(ValueError): nc_any.apps.ex_app_is_enabled("") with pytest.raises(ValueError): nc_any.apps.ex_app_is_disabled("") with pytest.raises(ValueError): nc_any.apps.ex_app_disable("") with pytest.raises(ValueError): nc_any.apps.ex_app_enable("") @pytest.mark.asyncio(scope="session") async def test_invalid_param_async(anc_any): with pytest.raises(ValueError): await anc_any.apps.is_enabled("") with pytest.raises(ValueError): await anc_any.apps.is_installed("") with pytest.raises(ValueError): await anc_any.apps.is_disabled("") with pytest.raises(ValueError): await anc_any.apps.enable("") with pytest.raises(ValueError): await anc_any.apps.disable("") with pytest.raises(ValueError): await anc_any.apps.ex_app_is_enabled("") with pytest.raises(ValueError): await anc_any.apps.ex_app_is_disabled("") with pytest.raises(ValueError): await anc_any.apps.ex_app_disable("") with pytest.raises(ValueError): await anc_any.apps.ex_app_enable("") def _test_ex_app_get_list(ex_apps: list[ExAppInfo], enabled_ex_apps: list[ExAppInfo]): assert isinstance(ex_apps, list) assert "nc_py_api" in [i.app_id for i in ex_apps] assert len(ex_apps) >= len(enabled_ex_apps) for app in ex_apps: assert isinstance(app.app_id, str) assert isinstance(app.name, str) assert isinstance(app.version, str) assert isinstance(app.enabled, bool) assert str(app).find("id=") != -1 and str(app).find("ver=") != -1 def test_ex_app_get_list(nc, nc_app): enabled_ex_apps = nc.apps.ex_app_get_list(enabled=True) assert isinstance(enabled_ex_apps, list) for i in enabled_ex_apps: assert i.enabled is True assert "nc_py_api" in [i.app_id for i in enabled_ex_apps] ex_apps = nc.apps.ex_app_get_list() _test_ex_app_get_list(ex_apps, enabled_ex_apps) @pytest.mark.asyncio(scope="session") async def test_ex_app_get_list_async(anc, anc_app): enabled_ex_apps = await anc.apps.ex_app_get_list(enabled=True) assert isinstance(enabled_ex_apps, list) for i in enabled_ex_apps: assert i.enabled is True assert "nc_py_api" in [i.app_id for i in enabled_ex_apps] ex_apps = await anc.apps.ex_app_get_list() _test_ex_app_get_list(ex_apps, enabled_ex_apps) cloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/notes_test.py0000664000232200023220000001511614766056032025347 0ustar debalancedebalancefrom datetime import datetime import pytest from nc_py_api import NextcloudException, notes def test_settings(nc_any): if nc_any.notes.available is False: pytest.skip("Notes is not installed") original_settings = nc_any.notes.get_settings() assert isinstance(original_settings["file_suffix"], str) assert isinstance(original_settings["notes_path"], str) nc_any.notes.set_settings(file_suffix=".ncpa") modified_settings = nc_any.notes.get_settings() assert modified_settings["file_suffix"] == ".ncpa" assert modified_settings["notes_path"] == original_settings["notes_path"] nc_any.notes.set_settings(file_suffix=original_settings["file_suffix"]) modified_settings = nc_any.notes.get_settings() assert modified_settings["file_suffix"] == original_settings["file_suffix"] with pytest.raises(ValueError): nc_any.notes.set_settings() @pytest.mark.asyncio(scope="session") async def test_settings_async(anc_any): if await anc_any.notes.available is False: pytest.skip("Notes is not installed") original_settings = await anc_any.notes.get_settings() assert isinstance(original_settings["file_suffix"], str) assert isinstance(original_settings["notes_path"], str) await anc_any.notes.set_settings(file_suffix=".ncpa") modified_settings = await anc_any.notes.get_settings() assert modified_settings["file_suffix"] == ".ncpa" assert modified_settings["notes_path"] == original_settings["notes_path"] await anc_any.notes.set_settings(file_suffix=original_settings["file_suffix"]) modified_settings = await anc_any.notes.get_settings() assert modified_settings["file_suffix"] == original_settings["file_suffix"] with pytest.raises(ValueError): await anc_any.notes.set_settings() def test_create_delete(nc_any): if nc_any.notes.available is False: pytest.skip("Notes is not installed") unix_timestamp = (datetime.utcnow() - datetime(1970, 1, 1)).total_seconds() new_note = nc_any.notes.create(str(unix_timestamp)) nc_any.notes.delete(new_note) _test_create_delete(new_note) @pytest.mark.asyncio(scope="session") async def test_create_delete_async(anc_any): if await anc_any.notes.available is False: pytest.skip("Notes is not installed") unix_timestamp = (datetime.utcnow() - datetime(1970, 1, 1)).total_seconds() new_note = await anc_any.notes.create(str(unix_timestamp)) await anc_any.notes.delete(new_note) _test_create_delete(new_note) def _test_create_delete(new_note: notes.Note): assert isinstance(new_note.note_id, int) assert isinstance(new_note.etag, str) assert isinstance(new_note.title, str) assert isinstance(new_note.content, str) assert isinstance(new_note.category, str) assert new_note.readonly is False assert new_note.favorite is False assert isinstance(new_note.last_modified, datetime) assert str(new_note).find("title=") != -1 def test_get_update_note(nc_any): if nc_any.notes.available is False: pytest.skip("Notes is not installed") for i in nc_any.notes.get_list(): nc_any.notes.delete(i) assert not nc_any.notes.get_list() unix_timestamp = (datetime.utcnow() - datetime(1970, 1, 1)).total_seconds() new_note = nc_any.notes.create(str(unix_timestamp)) try: all_notes = nc_any.notes.get_list() assert all_notes[0] == new_note assert not nc_any.notes.get_list(etag=True) assert nc_any.notes.get_list()[0] == new_note assert nc_any.notes.by_id(new_note) == new_note updated_note = nc_any.notes.update(new_note, content="content") assert updated_note.content == "content" all_notes = nc_any.notes.get_list() assert all_notes[0].content == "content" all_notes_no_content = nc_any.notes.get_list(no_content=True) assert all_notes_no_content[0].content == "" assert nc_any.notes.by_id(new_note).content == "content" with pytest.raises(NextcloudException): assert nc_any.notes.update(new_note, content="should be rejected") new_note = nc_any.notes.update(new_note, content="should not be rejected", overwrite=True) nc_any.notes.update(new_note, category="test_category", favorite=True) new_note = nc_any.notes.by_id(new_note) assert new_note.favorite is True assert new_note.category == "test_category" finally: nc_any.notes.delete(new_note) @pytest.mark.asyncio(scope="session") async def test_get_update_note_async(anc_any): if await anc_any.notes.available is False: pytest.skip("Notes is not installed") for i in await anc_any.notes.get_list(): await anc_any.notes.delete(i) assert not await anc_any.notes.get_list() unix_timestamp = (datetime.utcnow() - datetime(1970, 1, 1)).total_seconds() new_note = await anc_any.notes.create(str(unix_timestamp)) try: all_notes = await anc_any.notes.get_list() assert all_notes[0] == new_note assert not await anc_any.notes.get_list(etag=True) assert (await anc_any.notes.get_list())[0] == new_note assert await anc_any.notes.by_id(new_note) == new_note updated_note = await anc_any.notes.update(new_note, content="content") assert updated_note.content == "content" all_notes = await anc_any.notes.get_list() assert all_notes[0].content == "content" all_notes_no_content = await anc_any.notes.get_list(no_content=True) assert all_notes_no_content[0].content == "" assert (await anc_any.notes.by_id(new_note)).content == "content" with pytest.raises(NextcloudException): assert await anc_any.notes.update(new_note, content="should be rejected") new_note = await anc_any.notes.update(new_note, content="should not be rejected", overwrite=True) await anc_any.notes.update(new_note, category="test_category", favorite=True) new_note = await anc_any.notes.by_id(new_note) assert new_note.favorite is True assert new_note.category == "test_category" finally: await anc_any.notes.delete(new_note) def test_update_note_invalid_param(nc_any): if nc_any.notes.available is False: pytest.skip("Notes is not installed") with pytest.raises(ValueError): nc_any.notes.update(notes.Note({"id": 0, "etag": "42242"})) @pytest.mark.asyncio(scope="session") async def test_update_note_invalid_param_async(anc_any): if await anc_any.notes.available is False: pytest.skip("Notes is not installed") with pytest.raises(ValueError): await anc_any.notes.update(notes.Note({"id": 0, "etag": "42242"})) cloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/logs_test.py0000664000232200023220000001070514766056032025162 0ustar debalancedebalanceimport logging from copy import deepcopy from unittest import mock import pytest from nc_py_api.ex_app import LogLvl, setup_nextcloud_logging def test_loglvl_values(): assert LogLvl.FATAL == 4 assert LogLvl.ERROR == 3 assert LogLvl.WARNING == 2 assert LogLvl.INFO == 1 assert LogLvl.DEBUG == 0 def test_log_success(nc_app): nc_app.log(LogLvl.FATAL, "log success") @pytest.mark.asyncio(scope="session") async def test_log_success_async(anc_app): await anc_app.log(LogLvl.FATAL, "log success") def test_loglvl_str(nc_app): nc_app.log("1", "lolglvl in str: should be written") # noqa @pytest.mark.asyncio(scope="session") async def test_loglvl_str_async(anc_app): await anc_app.log("1", "lolglvl in str: should be written") # noqa def test_invalid_log_level(nc_app): with pytest.raises(ValueError): nc_app.log(5, "wrong log level") # noqa @pytest.mark.asyncio(scope="session") async def test_invalid_log_level_async(anc_app): with pytest.raises(ValueError): await anc_app.log(5, "wrong log level") # noqa def test_empty_log(nc_app): nc_app.log(LogLvl.FATAL, "") @pytest.mark.asyncio(scope="session") async def test_empty_log_async(anc_app): await anc_app.log(LogLvl.FATAL, "") def test_loglvl_equal(nc_app): current_log_lvl = nc_app.capabilities["app_api"].get("loglevel", LogLvl.FATAL) nc_app.log(current_log_lvl, "log should be written") @pytest.mark.asyncio(scope="session") async def test_loglvl_equal_async(anc_app): current_log_lvl = (await anc_app.capabilities)["app_api"].get("loglevel", LogLvl.FATAL) await anc_app.log(current_log_lvl, "log should be written") def test_loglvl_less(nc_app): current_log_lvl = nc_app.capabilities["app_api"].get("loglevel", LogLvl.FATAL) if current_log_lvl == LogLvl.DEBUG: pytest.skip("Log lvl to low") with mock.patch("tests.conftest.NC_APP._session.ocs") as ocs: nc_app.log(int(current_log_lvl) - 1, "will not be sent") # noqa ocs.assert_not_called() nc_app.log(current_log_lvl, "will be sent") assert ocs.call_count > 0 @pytest.mark.asyncio(scope="session") async def test_loglvl_less_async(anc_app): current_log_lvl = (await anc_app.capabilities)["app_api"].get("loglevel", LogLvl.FATAL) if current_log_lvl == LogLvl.DEBUG: pytest.skip("Log lvl to low") with mock.patch("tests.conftest.NC_APP_ASYNC._session.ocs") as ocs: await anc_app.log(int(current_log_lvl) - 1, "will not be sent") # noqa ocs.assert_not_called() await anc_app.log(current_log_lvl, "will be sent") assert ocs.call_count > 0 def test_log_without_app_api(nc_app): srv_capabilities = deepcopy(nc_app.capabilities) srv_version = deepcopy(nc_app.srv_version) log_lvl = srv_capabilities["app_api"].pop("loglevel") srv_capabilities.pop("app_api") patched_capabilities = {"capabilities": srv_capabilities, "version": srv_version} with ( mock.patch.dict("tests.conftest.NC_APP._session._capabilities", patched_capabilities, clear=True), mock.patch("tests.conftest.NC_APP._session.ocs") as ocs, ): nc_app.log(log_lvl, "will not be sent") ocs.assert_not_called() @pytest.mark.asyncio(scope="session") async def test_log_without_app_api_async(anc_app): srv_capabilities = deepcopy(await anc_app.capabilities) srv_version = deepcopy(await anc_app.srv_version) log_lvl = srv_capabilities["app_api"].pop("loglevel") srv_capabilities.pop("app_api") patched_capabilities = {"capabilities": srv_capabilities, "version": srv_version} with ( mock.patch.dict("tests.conftest.NC_APP_ASYNC._session._capabilities", patched_capabilities, clear=True), mock.patch("tests.conftest.NC_APP_ASYNC._session.ocs") as ocs, ): await anc_app.log(log_lvl, "will not be sent") ocs.assert_not_called() def test_logging(nc_app): log_handler = setup_nextcloud_logging("my_logger") logger = logging.getLogger("my_logger") logger.fatal("testing logging.fatal") try: a = b # noqa except Exception: # noqa logger.exception("testing logger.exception") logger.removeHandler(log_handler) def test_recursive_logging(nc_app): logging.getLogger("httpx").setLevel(logging.DEBUG) log_handler = setup_nextcloud_logging() logger = logging.getLogger() logger.fatal("testing logging.fatal") logger.removeHandler(log_handler) logging.getLogger("httpx").setLevel(logging.ERROR) cloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/users_test.py0000664000232200023220000002346014766056032025361 0ustar debalancedebalanceimport contextlib import datetime from io import BytesIO from os import environ import pytest from PIL import Image from nc_py_api import ( AsyncNextcloudApp, NextcloudApp, NextcloudException, NextcloudExceptionNotFound, users, ) def _test_get_user_info(admin: users.UserInfo, current_user: users.UserInfo): for i in ( "user_id", "email", "display_name", "storage_location", "backend", "manager", "phone", "address", "website", "twitter", "fediverse", "organisation", "role", "headline", "biography", "language", "locale", "notify_email", ): assert getattr(current_user, i) == getattr(admin, i) assert isinstance(getattr(current_user, i), str) assert isinstance(getattr(admin, i), str) assert admin.enabled is True assert admin.enabled == current_user.enabled assert admin.profile_enabled is True assert admin.profile_enabled == current_user.profile_enabled assert isinstance(admin.last_login, datetime.datetime) assert isinstance(current_user.last_login, datetime.datetime) assert isinstance(admin.subadmin, list) assert isinstance(admin.quota, dict) assert isinstance(admin.additional_mail, list) assert isinstance(admin.groups, list) assert isinstance(admin.backend_capabilities, dict) assert admin.display_name == "admin" assert str(admin).find("last_login=") != -1 def test_get_user_info(nc): admin = nc.users.get_user("admin") current_user = nc.users.get_user() _test_get_user_info(admin, current_user) @pytest.mark.asyncio(scope="session") async def test_get_user_info_async(anc): admin = await anc.users.get_user("admin") current_user = await anc.users.get_user() _test_get_user_info(admin, current_user) def test_get_current_user_wo_user(nc): orig_user = nc._session.user try: nc._session.set_user("") if isinstance(nc, NextcloudApp): with pytest.raises(NextcloudException): nc.users.get_user() else: assert isinstance(nc.users.get_user(), users.UserInfo) finally: nc._session.set_user(orig_user) @pytest.mark.asyncio(scope="session") async def test_get_current_user_wo_user_async(anc): orig_user = await anc._session.user try: anc._session.set_user("") if isinstance(anc, AsyncNextcloudApp): with pytest.raises(NextcloudException): await anc.users.get_user() else: assert isinstance(await anc.users.get_user(), users.UserInfo) finally: anc._session.set_user(orig_user) def test_get_user_404(nc): with pytest.raises(NextcloudException): nc.users.get_user("non existing user") @pytest.mark.asyncio(scope="session") async def test_get_user_404_async(anc): with pytest.raises(NextcloudException): await anc.users.get_user("non existing user") def test_create_user_with_groups(nc_client): admin_group = nc_client.users_groups.get_members("admin") assert environ["TEST_ADMIN_ID"] in admin_group assert environ["TEST_USER_ID"] not in admin_group @pytest.mark.asyncio(scope="session") async def test_create_user_with_groups_async(anc_client): admin_group = await anc_client.users_groups.get_members("admin") assert environ["TEST_ADMIN_ID"] in admin_group assert environ["TEST_USER_ID"] not in admin_group def test_create_user_no_name_mail(nc_client): test_user_name = "test_create_user_no_name_mail" with contextlib.suppress(NextcloudException): nc_client.users.delete(test_user_name) with pytest.raises(ValueError): nc_client.users.create(test_user_name) with pytest.raises(ValueError): nc_client.users.create(test_user_name, password="") with pytest.raises(ValueError): nc_client.users.create(test_user_name, email="") @pytest.mark.asyncio(scope="session") async def test_create_user_no_name_mail_async(anc_client): test_user_name = "test_create_user_no_name_mail" with contextlib.suppress(NextcloudException): await anc_client.users.delete(test_user_name) with pytest.raises(ValueError): await anc_client.users.create(test_user_name) with pytest.raises(ValueError): await anc_client.users.create(test_user_name, password="") with pytest.raises(ValueError): await anc_client.users.create(test_user_name, email="") def test_delete_user(nc_client): test_user_name = "test_delete_user" with contextlib.suppress(NextcloudException): nc_client.users.create(test_user_name, password="az1dcaNG4c42") nc_client.users.delete(test_user_name) with pytest.raises(NextcloudExceptionNotFound): nc_client.users.delete(test_user_name) @pytest.mark.asyncio(scope="session") async def test_delete_user_async(anc_client): test_user_name = "test_delete_user" with contextlib.suppress(NextcloudException): await anc_client.users.create(test_user_name, password="az1dcaNG4c42") await anc_client.users.delete(test_user_name) with pytest.raises(NextcloudExceptionNotFound): await anc_client.users.delete(test_user_name) def test_users_get_list(nc, nc_client): _users = nc.users.get_list() assert isinstance(_users, list) assert nc.user in _users assert environ["TEST_ADMIN_ID"] in _users assert environ["TEST_USER_ID"] in _users _users = nc.users.get_list(limit=1) assert len(_users) == 1 assert _users[0] != nc.users.get_list(limit=1, offset=1)[0] _users = nc.users.get_list(mask=environ["TEST_ADMIN_ID"]) assert len(_users) == 1 @pytest.mark.asyncio(scope="session") async def test_users_get_list_async(anc, anc_client): _users = await anc.users.get_list() assert isinstance(_users, list) assert await anc.user in _users assert environ["TEST_ADMIN_ID"] in _users assert environ["TEST_USER_ID"] in _users _users = await anc.users.get_list(limit=1) assert len(_users) == 1 assert _users[0] != (await anc.users.get_list(limit=1, offset=1))[0] _users = await anc.users.get_list(mask=environ["TEST_ADMIN_ID"]) assert len(_users) == 1 def test_enable_disable_user(nc_client): test_user_name = "test_enable_disable_user" with contextlib.suppress(NextcloudException): nc_client.users.create(test_user_name, password="az1dcaNG4c42") nc_client.users.disable(test_user_name) assert nc_client.users.get_user(test_user_name).enabled is False nc_client.users.enable(test_user_name) assert nc_client.users.get_user(test_user_name).enabled is True nc_client.users.delete(test_user_name) @pytest.mark.asyncio(scope="session") async def test_enable_disable_user_async(anc_client): test_user_name = "test_enable_disable_user" with contextlib.suppress(NextcloudException): await anc_client.users.create(test_user_name, password="az1dcaNG4c42") await anc_client.users.disable(test_user_name) assert (await anc_client.users.get_user(test_user_name)).enabled is False await anc_client.users.enable(test_user_name) assert (await anc_client.users.get_user(test_user_name)).enabled is True await anc_client.users.delete(test_user_name) def test_user_editable_fields(nc): editable_fields = nc.users.editable_fields() assert isinstance(editable_fields, list) assert editable_fields @pytest.mark.asyncio(scope="session") async def test_user_editable_fields_async(anc): editable_fields = await anc.users.editable_fields() assert isinstance(editable_fields, list) assert editable_fields def test_edit_user(nc_client): nc_client.users.edit(nc_client.user, address="Le Pame", email="admino@gmx.net") current_user = nc_client.users.get_user() assert current_user.address == "Le Pame" assert current_user.email == "admino@gmx.net" nc_client.users.edit(nc_client.user, address="", email="admin@gmx.net") current_user = nc_client.users.get_user() assert current_user.address == "" assert current_user.email == "admin@gmx.net" @pytest.mark.asyncio(scope="session") async def test_edit_user_async(anc_client): await anc_client.users.edit(await anc_client.user, address="Le Pame", email="admino@gmx.net") current_user = await anc_client.users.get_user() assert current_user.address == "Le Pame" assert current_user.email == "admino@gmx.net" await anc_client.users.edit(await anc_client.user, address="", email="admin@gmx.net") current_user = await anc_client.users.get_user() assert current_user.address == "" assert current_user.email == "admin@gmx.net" def test_resend_user_email(nc_client): nc_client.users.resend_welcome_email(nc_client.user) @pytest.mark.asyncio(scope="session") async def test_resend_user_email_async(anc_client): await anc_client.users.resend_welcome_email(await anc_client.user) def test_avatars(nc): im = nc.users.get_avatar() im_64 = nc.users.get_avatar(size=64) im_black = nc.users.get_avatar(dark=True) im_64_black = nc.users.get_avatar(size=64, dark=True) assert len(im_64) < len(im) assert len(im_64_black) < len(im_black) for i in (im, im_64, im_black, im_64_black): img = Image.open(BytesIO(i)) img.load() with pytest.raises(NextcloudException): nc.users.get_avatar("not_existing_user") @pytest.mark.asyncio(scope="session") async def test_avatars_async(anc): im = await anc.users.get_avatar() im_64 = await anc.users.get_avatar(size=64) im_black = await anc.users.get_avatar(dark=True) im_64_black = await anc.users.get_avatar(size=64, dark=True) assert len(im_64) < len(im) assert len(im_64_black) < len(im_black) for i in (im, im_64, im_black, im_64_black): img = Image.open(BytesIO(i)) img.load() with pytest.raises(NextcloudException): await anc.users.get_avatar("not_existing_user") cloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/theming_test.py0000664000232200023220000000320014766056032025641 0ustar debalancedebalancefrom copy import deepcopy import pytest from nc_py_api._theming import convert_str_color # noqa def test_get_theme(nc): theme = nc.theme assert isinstance(theme["name"], str) assert isinstance(theme["url"], str) assert isinstance(theme["slogan"], str) assert isinstance(theme["color"], tuple) for i in range(3): assert isinstance(theme["color"][i], int) assert isinstance(theme["color_text"], tuple) for i in range(3): assert isinstance(theme["color_text"][i], int) assert isinstance(theme["color_element"], tuple) for i in range(3): assert isinstance(theme["color_element"][i], int) assert isinstance(theme["color_element_bright"], tuple) for i in range(3): assert isinstance(theme["color_element_bright"][i], int) assert isinstance(theme["color_element_dark"], tuple) for i in range(3): assert isinstance(theme["color_element_dark"][i], int) assert isinstance(theme["logo"], str) assert isinstance(theme["background"], str) assert isinstance(theme["background_plain"], bool) assert isinstance(theme["background_default"], bool) @pytest.mark.asyncio(scope="session") async def test_get_theme_async(anc_any): theme = await anc_any.theme assert isinstance(theme["name"], str) assert isinstance(theme["url"], str) assert isinstance(theme["slogan"], str) def test_convert_str_color_values_in(nc_any): theme = deepcopy(nc_any.theme) for i in ("#", ""): theme["color"] = i assert convert_str_color(theme, "color") == (0, 0, 0) theme.pop("color") assert convert_str_color(theme, "color") == (0, 0, 0) cloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/taskprocessing_provider_test.py0000664000232200023220000000532014766056032031164 0ustar debalancedebalanceimport pytest from nc_py_api import NextcloudException, NextcloudExceptionNotFound from nc_py_api.ex_app.providers.task_processing import TaskProcessingProvider @pytest.mark.require_nc(major=30) def test_task_processing_provider(nc_app): provider_info = TaskProcessingProvider( id=f"test_id", name=f"Test Display Name", task_type="core:text2image" # noqa ) nc_app.providers.task_processing.register(provider_info) nc_app.providers.task_processing.unregister(provider_info.id) with pytest.raises(NextcloudExceptionNotFound): nc_app.providers.task_processing.unregister(provider_info.id, not_fail=False) nc_app.providers.task_processing.unregister(provider_info.id) nc_app.providers.task_processing.register(provider_info) assert not nc_app.providers.task_processing.next_task(["test_id"], ["core:text2image"]) assert not nc_app.providers.task_processing.set_progress(9999, 0.5) assert not nc_app.providers.task_processing.report_result(9999, error_message="no such task") with pytest.raises(NextcloudException): nc_app.providers.task_processing.upload_result_file(9999, b"00") nc_app.providers.task_processing.unregister(provider_info.id, not_fail=False) @pytest.mark.asyncio(scope="session") @pytest.mark.require_nc(major=30) async def test_task_processing_async(anc_app): provider_info = TaskProcessingProvider( id=f"test_id", name=f"Test Display Name", task_type="core:text2image" # noqa ) await anc_app.providers.task_processing.register(provider_info) await anc_app.providers.task_processing.unregister(provider_info.id) with pytest.raises(NextcloudExceptionNotFound): await anc_app.providers.task_processing.unregister(provider_info.id, not_fail=False) await anc_app.providers.task_processing.unregister(provider_info.id) await anc_app.providers.task_processing.register(provider_info) assert not await anc_app.providers.task_processing.next_task(["test_id"], ["core:text2image"]) assert not await anc_app.providers.task_processing.set_progress(9999, 0.5) assert not await anc_app.providers.task_processing.report_result(9999, error_message="no such task") with pytest.raises(NextcloudException): await anc_app.providers.task_processing.upload_result_file(9999, b"00") await anc_app.providers.task_processing.unregister(provider_info.id, not_fail=False) @pytest.mark.require_nc(major=30) def test_task_processing_provider_fail_report(nc_app): nc_app.providers.task_processing.report_result(999999) @pytest.mark.asyncio(scope="session") @pytest.mark.require_nc(major=30) async def test_task_processing_provider_fail_report_async(anc_app): await anc_app.providers.task_processing.report_result(999999) cloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/occ_commands_test.py0000664000232200023220000001032414766056032026640 0ustar debalancedebalanceimport pytest from nc_py_api import NextcloudExceptionNotFound def test_occ_commands_registration(nc_app): nc_app.occ_commands.register( "test_occ_name", "/some_url", ) result = nc_app.occ_commands.get_entry("test_occ_name") assert result.name == "test_occ_name" assert result.description == "" assert result.action_handler == "some_url" assert result.hidden is False assert result.usages == [] assert result.arguments == [] assert result.options == [] nc_app.occ_commands.register( "test_occ_name2", "some_url2", description="desc", arguments=[ { "name": "argument_name", "mode": "required", "description": "Description of the argument", "default": "default_value", }, ], options=[], ) result2 = nc_app.occ_commands.get_entry("test_occ_name2") assert result2.name == "test_occ_name2" assert result2.description == "desc" assert result2.action_handler == "some_url2" assert result2.hidden is False assert result2.usages == [] assert result2.arguments == [ { "name": "argument_name", "mode": "required", "description": "Description of the argument", "default": "default_value", } ] assert result2.options == [] nc_app.occ_commands.register( "test_occ_name", description="new desc", callback_url="/new_url", ) result = nc_app.occ_commands.get_entry("test_occ_name") assert result.name == "test_occ_name" assert result.description == "new desc" assert result.action_handler == "new_url" nc_app.occ_commands.unregister(result.name) with pytest.raises(NextcloudExceptionNotFound): nc_app.occ_commands.unregister(result.name, not_fail=False) nc_app.occ_commands.unregister(result.name) nc_app.occ_commands.unregister(result2.name, not_fail=False) assert nc_app.occ_commands.get_entry(result2.name) is None assert str(result).find("name=") != -1 @pytest.mark.asyncio(scope="session") async def test_occ_commands_registration_async(anc_app): await anc_app.occ_commands.register( "test_occ_name", "/some_url", ) result = await anc_app.occ_commands.get_entry("test_occ_name") assert result.name == "test_occ_name" assert result.description == "" assert result.action_handler == "some_url" assert result.hidden is False assert result.usages == [] assert result.arguments == [] assert result.options == [] await anc_app.occ_commands.register( "test_occ_name2", "some_url2", description="desc", arguments=[ { "name": "argument_name", "mode": "required", "description": "Description of the argument", "default": "default_value", }, ], options=[], ) result2 = await anc_app.occ_commands.get_entry("test_occ_name2") assert result2.name == "test_occ_name2" assert result2.description == "desc" assert result2.action_handler == "some_url2" assert result2.hidden is False assert result2.usages == [] assert result2.arguments == [ { "name": "argument_name", "mode": "required", "description": "Description of the argument", "default": "default_value", } ] assert result2.options == [] await anc_app.occ_commands.register( "test_occ_name", description="new desc", callback_url="/new_url", ) result = await anc_app.occ_commands.get_entry("test_occ_name") assert result.name == "test_occ_name" assert result.description == "new desc" assert result.action_handler == "new_url" await anc_app.occ_commands.unregister(result.name) with pytest.raises(NextcloudExceptionNotFound): await anc_app.occ_commands.unregister(result.name, not_fail=False) await anc_app.occ_commands.unregister(result.name) await anc_app.occ_commands.unregister(result2.name, not_fail=False) assert await anc_app.occ_commands.get_entry(result2.name) is None assert str(result).find("name=") != -1 cloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/appcfg_prefs_ex_test.py0000664000232200023220000003233314766056032027352 0ustar debalancedebalanceimport pytest from nc_py_api import NextcloudExceptionNotFound from ..conftest import NC_APP, NC_APP_ASYNC if NC_APP is None: pytest.skip("Need App mode", allow_module_level=True) @pytest.mark.parametrize("class_to_test", (NC_APP.appconfig_ex, NC_APP.preferences_ex)) def test_cfg_ex_get_value_invalid(class_to_test): with pytest.raises(ValueError): class_to_test.get_value("") @pytest.mark.asyncio(scope="session") @pytest.mark.parametrize("class_to_test", (NC_APP_ASYNC.appconfig_ex, NC_APP_ASYNC.preferences_ex)) async def test_cfg_ex_get_value_invalid_async(class_to_test): with pytest.raises(ValueError): await class_to_test.get_value("") @pytest.mark.parametrize("class_to_test", (NC_APP.appconfig_ex, NC_APP.preferences_ex)) def test_cfg_ex_get_values_invalid(class_to_test): assert class_to_test.get_values([]) == [] with pytest.raises(ValueError): class_to_test.get_values([""]) with pytest.raises(ValueError): class_to_test.get_values(["", "k"]) @pytest.mark.asyncio(scope="session") @pytest.mark.parametrize("class_to_test", (NC_APP_ASYNC.appconfig_ex, NC_APP_ASYNC.preferences_ex)) async def test_cfg_ex_get_values_invalid_async(class_to_test): assert await class_to_test.get_values([]) == [] with pytest.raises(ValueError): await class_to_test.get_values([""]) with pytest.raises(ValueError): await class_to_test.get_values(["", "k"]) @pytest.mark.parametrize("class_to_test", (NC_APP.appconfig_ex, NC_APP.preferences_ex)) def test_cfg_ex_set_empty_key(class_to_test): with pytest.raises(ValueError): class_to_test.set_value("", "some value") @pytest.mark.asyncio(scope="session") @pytest.mark.parametrize("class_to_test", (NC_APP_ASYNC.appconfig_ex, NC_APP_ASYNC.preferences_ex)) async def test_cfg_ex_set_empty_key_async(class_to_test): with pytest.raises(ValueError): await class_to_test.set_value("", "some value") @pytest.mark.parametrize("class_to_test", (NC_APP.appconfig_ex, NC_APP.preferences_ex)) def test_cfg_ex_delete_invalid(class_to_test): class_to_test.delete([]) with pytest.raises(ValueError): class_to_test.delete([""]) with pytest.raises(ValueError): class_to_test.delete(["", "k"]) @pytest.mark.asyncio(scope="session") @pytest.mark.parametrize("class_to_test", (NC_APP_ASYNC.appconfig_ex, NC_APP_ASYNC.preferences_ex)) async def test_cfg_ex_delete_invalid_async(class_to_test): await class_to_test.delete([]) with pytest.raises(ValueError): await class_to_test.delete([""]) with pytest.raises(ValueError): await class_to_test.delete(["", "k"]) @pytest.mark.parametrize("class_to_test", (NC_APP.appconfig_ex, NC_APP.preferences_ex)) def test_cfg_ex_get_default(class_to_test): assert class_to_test.get_value("non_existing_key", default="alice") == "alice" @pytest.mark.asyncio(scope="session") @pytest.mark.parametrize("class_to_test", (NC_APP_ASYNC.appconfig_ex, NC_APP_ASYNC.preferences_ex)) async def test_cfg_ex_get_default_async(class_to_test): assert await class_to_test.get_value("non_existing_key", default="alice") == "alice" @pytest.mark.parametrize("value", ("0", "1", "12 3", "")) @pytest.mark.parametrize("class_to_test", (NC_APP.appconfig_ex, NC_APP.preferences_ex)) def test_cfg_ex_set_delete(value, class_to_test): class_to_test.delete("test_key") assert class_to_test.get_value("test_key") is None class_to_test.set_value("test_key", value) assert class_to_test.get_value("test_key") == value class_to_test.set_value("test_key", "zzz") assert class_to_test.get_value("test_key") == "zzz" class_to_test.delete("test_key") assert class_to_test.get_value("test_key") is None @pytest.mark.asyncio(scope="session") @pytest.mark.parametrize("value", ("0", "1", "12 3", "")) @pytest.mark.parametrize("class_to_test", (NC_APP_ASYNC.appconfig_ex, NC_APP_ASYNC.preferences_ex)) async def test_cfg_ex_set_delete_async(value, class_to_test): await class_to_test.delete("test_key") assert await class_to_test.get_value("test_key") is None await class_to_test.set_value("test_key", value) assert await class_to_test.get_value("test_key") == value await class_to_test.set_value("test_key", "zzz") assert await class_to_test.get_value("test_key") == "zzz" await class_to_test.delete("test_key") assert await class_to_test.get_value("test_key") is None @pytest.mark.parametrize("class_to_test", (NC_APP.appconfig_ex, NC_APP.preferences_ex)) def test_cfg_ex_delete(class_to_test): class_to_test.set_value("test_key", "123") assert class_to_test.get_value("test_key") class_to_test.delete("test_key") assert class_to_test.get_value("test_key") is None class_to_test.delete("test_key") class_to_test.delete(["test_key"]) with pytest.raises(NextcloudExceptionNotFound): class_to_test.delete("test_key", not_fail=False) with pytest.raises(NextcloudExceptionNotFound): class_to_test.delete(["test_key"], not_fail=False) @pytest.mark.asyncio(scope="session") @pytest.mark.parametrize("class_to_test", (NC_APP_ASYNC.appconfig_ex, NC_APP_ASYNC.preferences_ex)) async def test_cfg_ex_delete_async(class_to_test): await class_to_test.set_value("test_key", "123") assert await class_to_test.get_value("test_key") await class_to_test.delete("test_key") assert await class_to_test.get_value("test_key") is None await class_to_test.delete("test_key") await class_to_test.delete(["test_key"]) with pytest.raises(NextcloudExceptionNotFound): await class_to_test.delete("test_key", not_fail=False) with pytest.raises(NextcloudExceptionNotFound): await class_to_test.delete(["test_key"], not_fail=False) @pytest.mark.parametrize("class_to_test", (NC_APP.appconfig_ex, NC_APP.preferences_ex)) def test_cfg_ex_get(class_to_test): class_to_test.delete(["test key", "test key2"]) assert len(class_to_test.get_values(["test key", "test key2"])) == 0 class_to_test.set_value("test key", "123") assert len(class_to_test.get_values(["test key", "test key2"])) == 1 class_to_test.set_value("test key2", "123") assert len(class_to_test.get_values(["test key", "test key2"])) == 2 @pytest.mark.asyncio(scope="session") @pytest.mark.parametrize("class_to_test", (NC_APP_ASYNC.appconfig_ex, NC_APP_ASYNC.preferences_ex)) async def test_cfg_ex_get_async(class_to_test): await class_to_test.delete(["test key", "test key2"]) assert len(await class_to_test.get_values(["test key", "test key2"])) == 0 await class_to_test.set_value("test key", "123") assert len(await class_to_test.get_values(["test key", "test key2"])) == 1 await class_to_test.set_value("test key2", "123") assert len(await class_to_test.get_values(["test key", "test key2"])) == 2 @pytest.mark.parametrize("class_to_test", (NC_APP.appconfig_ex, NC_APP.preferences_ex)) def test_cfg_ex_multiply_delete(class_to_test): class_to_test.set_value("test_key", "123") class_to_test.set_value("test_key2", "123") assert len(class_to_test.get_values(["test_key", "test_key2"])) == 2 class_to_test.delete(["test_key", "test_key2"]) assert len(class_to_test.get_values(["test_key", "test_key2"])) == 0 class_to_test.delete(["test_key", "test_key2"]) class_to_test.set_value("test_key", "123") assert len(class_to_test.get_values(["test_key", "test_key2"])) == 1 class_to_test.delete(["test_key", "test_key2"]) assert len(class_to_test.get_values(["test_key", "test_key2"])) == 0 @pytest.mark.asyncio(scope="session") @pytest.mark.parametrize("class_to_test", (NC_APP_ASYNC.appconfig_ex, NC_APP_ASYNC.preferences_ex)) async def test_cfg_ex_multiply_delete_async(class_to_test): await class_to_test.set_value("test_key", "123") await class_to_test.set_value("test_key2", "123") assert len(await class_to_test.get_values(["test_key", "test_key2"])) == 2 await class_to_test.delete(["test_key", "test_key2"]) assert len(await class_to_test.get_values(["test_key", "test_key2"])) == 0 await class_to_test.delete(["test_key", "test_key2"]) await class_to_test.set_value("test_key", "123") assert len(await class_to_test.get_values(["test_key", "test_key2"])) == 1 await class_to_test.delete(["test_key", "test_key2"]) assert len(await class_to_test.get_values(["test_key", "test_key2"])) == 0 @pytest.mark.parametrize("key", ("k", "k y", " ")) @pytest.mark.parametrize("class_to_test", (NC_APP.appconfig_ex, NC_APP.preferences_ex)) def test_cfg_ex_get_non_existing(key, class_to_test): class_to_test.delete(key) assert class_to_test.get_value(key) is None assert class_to_test.get_values([key]) == [] assert len(class_to_test.get_values([key, "non_existing_key"])) == 0 @pytest.mark.asyncio(scope="session") @pytest.mark.parametrize("key", ("k", "k y", " ")) @pytest.mark.parametrize("class_to_test", (NC_APP_ASYNC.appconfig_ex, NC_APP_ASYNC.preferences_ex)) async def test_cfg_ex_get_non_existing_async(key, class_to_test): await class_to_test.delete(key) assert await class_to_test.get_value(key) is None assert await class_to_test.get_values([key]) == [] assert len(await class_to_test.get_values([key, "non_existing_key"])) == 0 @pytest.mark.parametrize("class_to_test", (NC_APP.appconfig_ex, NC_APP.preferences_ex)) def test_cfg_ex_get_typing(class_to_test): class_to_test.set_value("test key", "123") class_to_test.set_value("test key2", "321") r = class_to_test.get_values(["test key", "test key2"]) assert isinstance(r, list) assert r[0].key == "test key" assert r[1].key == "test key2" assert r[0].value == "123" assert r[1].value == "321" @pytest.mark.asyncio(scope="session") @pytest.mark.parametrize("class_to_test", (NC_APP_ASYNC.appconfig_ex, NC_APP_ASYNC.preferences_ex)) async def test_cfg_ex_get_typing_async(class_to_test): await class_to_test.set_value("test key", "123") await class_to_test.set_value("test key2", "321") r = await class_to_test.get_values(["test key", "test key2"]) assert isinstance(r, list) assert r[0].key == "test key" assert r[1].key == "test key2" assert r[0].value == "123" assert r[1].value == "321" def test_appcfg_sensitive(nc_app): appcfg = nc_app.appconfig_ex appcfg.delete("test_key") appcfg.set_value("test_key", "123", sensitive=True) assert appcfg.get_value("test_key") == "123" assert appcfg.get_values(["test_key"])[0].value == "123" appcfg.delete("test_key") # next code tests `sensitive` value from the `AppAPI` params = {"configKey": "test_key", "configValue": "123"} result = nc_app._session.ocs("POST", f"{nc_app._session.ae_url}/{appcfg._url_suffix}", json=params) assert not result["sensitive"] # by default if sensitive value is unspecified it is False appcfg.delete("test_key") params = {"configKey": "test_key", "configValue": "123", "sensitive": True} result = nc_app._session.ocs("POST", f"{nc_app._session.ae_url}/{appcfg._url_suffix}", json=params) assert result["configkey"] == "test_key" assert result["configvalue"] == "123" assert bool(result["sensitive"]) is True params.pop("sensitive") # if we not specify value, AppEcosystem should not change it. result = nc_app._session.ocs("POST", f"{nc_app._session.ae_url}/{appcfg._url_suffix}", json=params) assert result["configkey"] == "test_key" assert result["configvalue"] == "123" assert bool(result["sensitive"]) is True params["sensitive"] = False result = nc_app._session.ocs("POST", f"{nc_app._session.ae_url}/{appcfg._url_suffix}", json=params) assert result["configkey"] == "test_key" assert result["configvalue"] == "123" assert bool(result["sensitive"]) is False @pytest.mark.asyncio(scope="session") async def test_appcfg_sensitive_async(anc_app): appcfg = anc_app.appconfig_ex await appcfg.delete("test_key") await appcfg.set_value("test_key", "123", sensitive=True) assert await appcfg.get_value("test_key") == "123" assert (await appcfg.get_values(["test_key"]))[0].value == "123" await appcfg.delete("test_key") # next code tests `sensitive` value from the `AppAPI` params = {"configKey": "test_key", "configValue": "123"} result = await anc_app._session.ocs("POST", f"{anc_app._session.ae_url}/{appcfg._url_suffix}", json=params) assert not result["sensitive"] # by default if sensitive value is unspecified it is False await appcfg.delete("test_key") params = {"configKey": "test_key", "configValue": "123", "sensitive": True} result = await anc_app._session.ocs("POST", f"{anc_app._session.ae_url}/{appcfg._url_suffix}", json=params) assert result["configkey"] == "test_key" assert result["configvalue"] == "123" assert bool(result["sensitive"]) is True params.pop("sensitive") # if we not specify value, AppEcosystem should not change it. result = await anc_app._session.ocs("POST", f"{anc_app._session.ae_url}/{appcfg._url_suffix}", json=params) assert result["configkey"] == "test_key" assert result["configvalue"] == "123" assert bool(result["sensitive"]) is True params["sensitive"] = False result = await anc_app._session.ocs("POST", f"{anc_app._session.ae_url}/{appcfg._url_suffix}", json=params) assert result["configkey"] == "test_key" assert result["configvalue"] == "123" assert bool(result["sensitive"]) is False cloud-py-api-nc_py_api-d4a32c6/tests/actual_tests/conftest.py0000664000232200023220000001075614766056032025012 0ustar debalancedebalanceimport contextlib from io import BytesIO from os import environ, path from random import randbytes import pytest from PIL import Image from nc_py_api import Nextcloud, NextcloudApp, NextcloudException, _session # noqa from ..conftest import NC_CLIENT _TEST_FAILED_INCREMENTAL: dict[str, dict[tuple[int, ...], str]] = {} @pytest.fixture(scope="session") def rand_bytes() -> bytes: """Returns 64 bytes from `test_64_bytes.bin` file.""" return randbytes(64) def init_filesystem_for_user(nc_any, rand_bytes): """ /test_empty_dir /test_empty_dir_in_dir/test_empty_child_dir /test_dir /test_dir/subdir/ /test_dir/subdir/test_empty_text.txt /test_dir/subdir/test_64_bytes.bin /test_dir/subdir/test_12345_text.txt /test_dir/subdir/test_generated_image.png **Favorite** /test_dir/test_empty_child_dir/ /test_dir/test_empty_text.txt /test_dir/test_64_bytes.bin /test_dir/test_12345_text.txt /test_dir/test_generated_image.png **Favorite** /test_empty_text.txt /test_64_bytes.bin /test_12345_text.txt /test_generated_image.png **Favorite** /test_dir_tmp /test_###_dir """ clean_filesystem_for_user(nc_any) im = BytesIO() Image.linear_gradient("L").resize((768, 768)).save(im, format="PNG") nc_any.files.mkdir("/test_empty_dir") nc_any.files.makedirs("/test_empty_dir_in_dir/test_empty_child_dir") nc_any.files.makedirs("/test_dir/subdir") nc_any.files.mkdir("/test_dir/test_empty_child_dir/") nc_any.files.mkdir("/test_dir_tmp") nc_any.files.mkdir("/test_###_dir") def init_folder(folder: str = ""): nc_any.files.upload(path.join(folder, "test_empty_text.txt"), content=b"") nc_any.files.upload(path.join(folder, "test_64_bytes.bin"), content=rand_bytes) nc_any.files.upload(path.join(folder, "test_12345_text.txt"), content="12345") im.seek(0) nc_any.files.upload(path.join(folder, "test_generated_image.png"), content=im.read()) nc_any.files.setfav(path.join(folder, "test_generated_image.png"), True) init_folder() init_folder("test_dir") init_folder("test_dir/subdir") def clean_filesystem_for_user(nc_any): clean_up_list = [ "test_empty_dir", "test_empty_dir_in_dir", "test_dir", "test_dir_tmp", "test_empty_text.txt", "test_64_bytes.bin", "test_12345_text.txt", "test_generated_image.png", "test_###_dir", ] for i in clean_up_list: nc_any.files.delete(i, not_fail=True) @pytest.fixture(autouse=True, scope="session") def tear_up_down(nc_any, rand_bytes): if NC_CLIENT: # create two additional groups environ["TEST_GROUP_BOTH"] = "test_nc_py_api_group_both" environ["TEST_GROUP_USER"] = "test_nc_py_api_group_user" with contextlib.suppress(NextcloudException): NC_CLIENT.users_groups.delete(environ["TEST_GROUP_BOTH"]) with contextlib.suppress(NextcloudException): NC_CLIENT.users_groups.delete(environ["TEST_GROUP_USER"]) NC_CLIENT.users_groups.create(group_id=environ["TEST_GROUP_BOTH"]) NC_CLIENT.users_groups.create(group_id=environ["TEST_GROUP_USER"]) # create two additional users environ["TEST_ADMIN_ID"] = "test_nc_py_api_admin" environ["TEST_ADMIN_PASS"] = "az1dcaNG4c42" environ["TEST_USER_ID"] = "test_nc_py_api_user" environ["TEST_USER_PASS"] = "DC89GvaR42lk" with contextlib.suppress(NextcloudException): NC_CLIENT.users.delete(environ["TEST_ADMIN_ID"]) with contextlib.suppress(NextcloudException): NC_CLIENT.users.delete(environ["TEST_USER_ID"]) NC_CLIENT.users.create( environ["TEST_ADMIN_ID"], password=environ["TEST_ADMIN_PASS"], groups=["admin", environ["TEST_GROUP_BOTH"]] ) NC_CLIENT.users.create( environ["TEST_USER_ID"], password=environ["TEST_USER_PASS"], groups=[environ["TEST_GROUP_BOTH"], environ["TEST_GROUP_USER"]], display_name=environ["TEST_USER_ID"], ) init_filesystem_for_user(nc_any, rand_bytes) # currently we initialize filesystem only for admin yield clean_filesystem_for_user(nc_any) if NC_CLIENT: NC_CLIENT.users.delete(environ["TEST_ADMIN_ID"]) NC_CLIENT.users.delete(environ["TEST_USER_ID"]) NC_CLIENT.users_groups.delete(environ["TEST_GROUP_BOTH"]) NC_CLIENT.users_groups.delete(environ["TEST_GROUP_USER"]) cloud-py-api-nc_py_api-d4a32c6/tests/_app_security_checks.py0000664000232200023220000000302214766056032024644 0ustar debalancedebalancefrom base64 import b64encode from os import environ from sys import argv import httpx def sign_request(req_headers: dict, secret=None, user: str = ""): app_secret = secret if secret is not None else environ["APP_SECRET"] req_headers["AUTHORIZATION-APP-API"] = b64encode(f"{user}:{app_secret}".encode("UTF=8")) # params: app base url if __name__ == "__main__": request_url = argv[1] + "/sec_check?value=1" headers = {} result = httpx.put(request_url, headers=headers) assert result.status_code == 401 # Missing headers headers.update( { "AA-VERSION": environ.get("AA_VERSION", "1.0.0"), "EX-APP-ID": environ.get("APP_ID", "nc_py_api"), "EX-APP-VERSION": environ.get("APP_VERSION", "1.0.0"), } ) sign_request(headers) result = httpx.put(request_url, headers=headers) assert result.status_code == 200 # Invalid AA-SIGNATURE sign_request(headers, secret="xxx") result = httpx.put(request_url, headers=headers) assert result.status_code == 401 sign_request(headers) result = httpx.put(request_url, headers=headers) assert result.status_code == 200 # Invalid EX-APP-ID old_app_name = headers.get("EX-APP-ID") headers["EX-APP-ID"] = "unknown_app" sign_request(headers) result = httpx.put(request_url, headers=headers) assert result.status_code == 401 headers["EX-APP-ID"] = old_app_name sign_request(headers) result = httpx.put(request_url, headers=headers) assert result.status_code == 200 cloud-py-api-nc_py_api-d4a32c6/tests/_install_init_handler_models.py0000664000232200023220000000244414766056032026355 0ustar debalancedebalancefrom contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI from nc_py_api import NextcloudApp, ex_app INVALID_URL = "https://invalid_url" MODEL_NAME1 = "MBZUAI/LaMini-T5-61M" MODEL_NAME2 = "https://huggingface.co/MBZUAI/LaMini-T5-61M/resolve/main/pytorch_model.bin" MODEL_NAME2_http = "http://huggingface.co/MBZUAI/LaMini-T5-61M/resolve/main/pytorch_model.bin" INVALID_PATH = "https://huggingface.co/invalid_path" SOME_FILE = "https://raw.githubusercontent.com/cloud-py-api/nc_py_api/main/README.md" @asynccontextmanager async def lifespan(_app: FastAPI): ex_app.set_handlers( APP, enabled_handler, models_to_fetch={ INVALID_URL: {}, MODEL_NAME1: {}, MODEL_NAME2: {}, MODEL_NAME2_http: {}, INVALID_PATH: {}, SOME_FILE: {}, }, ) yield APP = FastAPI(lifespan=lifespan) def enabled_handler(enabled: bool, _nc: NextcloudApp) -> str: if enabled: try: assert ex_app.get_model_path(MODEL_NAME1) except Exception: # noqa return "model1 not found" assert Path("pytorch_model.bin").is_file() return "" if __name__ == "__main__": ex_app.run_app("_install_init_handler_models:APP", log_level="warning") cloud-py-api-nc_py_api-d4a32c6/tests/conftest.py0000664000232200023220000001241314766056032022307 0ustar debalancedebalancefrom os import environ from typing import Optional, Union import pytest from nc_py_api import ( # noqa AsyncNextcloud, AsyncNextcloudApp, Nextcloud, NextcloudApp, _session, ) from . import gfixture_set_env # noqa _TEST_FAILED_INCREMENTAL: dict[str, dict[tuple[int, ...], str]] = {} NC_CLIENT = None if environ.get("SKIP_NC_CLIENT_TESTS", False) else Nextcloud() NC_CLIENT_ASYNC = None if environ.get("SKIP_NC_CLIENT_TESTS", False) else AsyncNextcloud() if environ.get("SKIP_AA_TESTS", False): NC_APP = None NC_APP_ASYNC = None else: NC_APP = NextcloudApp(user="admin") NC_APP_ASYNC = AsyncNextcloudApp(user="admin") if "app_api" not in NC_APP.capabilities: NC_APP = None NC_APP_ASYNC = None if NC_CLIENT is None and NC_APP is None: raise EnvironmentError("Tests require at least Nextcloud or NextcloudApp.") @pytest.fixture(scope="session") def nc_version() -> _session.ServerVersion: return NC_APP.srv_version if NC_APP else NC_CLIENT.srv_version @pytest.fixture(scope="session") def nc_client() -> Optional[Nextcloud]: if NC_CLIENT is None: pytest.skip("Need Client mode") return NC_CLIENT @pytest.fixture(scope="session") def anc_client() -> Optional[AsyncNextcloud]: if NC_CLIENT_ASYNC is None: pytest.skip("Need Async Client mode") return NC_CLIENT_ASYNC @pytest.fixture(scope="session") def nc_app() -> Optional[NextcloudApp]: if NC_APP is None: pytest.skip("Need App mode") return NC_APP @pytest.fixture(scope="session") def anc_app() -> Optional[AsyncNextcloudApp]: if NC_APP_ASYNC is None: pytest.skip("Need Async App mode") return NC_APP_ASYNC @pytest.fixture(scope="session") def nc_any() -> Union[Nextcloud, NextcloudApp]: """Marks a test to run once for any of the modes.""" return NC_APP if NC_APP else NC_CLIENT @pytest.fixture(scope="session") def anc_any() -> Union[AsyncNextcloud, AsyncNextcloudApp]: """Marks a test to run once for any of the modes.""" return NC_APP_ASYNC if NC_APP_ASYNC else NC_CLIENT_ASYNC @pytest.fixture(scope="session") def nc(request) -> Union[Nextcloud, NextcloudApp]: """Marks a test to run for both modes if possible.""" return request.param @pytest.fixture(scope="session") def anc(request) -> Union[AsyncNextcloud, AsyncNextcloudApp]: """Marks a test to run for both modes if possible.""" return request.param def pytest_generate_tests(metafunc): if "nc" in metafunc.fixturenames: values_ids = [] values = [] if NC_CLIENT is not None: values.append(NC_CLIENT) values_ids.append("client") if NC_APP is not None: values.append(NC_APP) values_ids.append("app") metafunc.parametrize("nc", values, ids=values_ids) if "anc" in metafunc.fixturenames: values_ids = [] values = [] if NC_CLIENT_ASYNC is not None: values.append(NC_CLIENT_ASYNC) values_ids.append("client_async") if NC_APP_ASYNC is not None: values.append(NC_APP_ASYNC) values_ids.append("app_async") metafunc.parametrize("anc", values, ids=values_ids) def pytest_collection_modifyitems(items): for item in items: require_nc = [i for i in item.own_markers if i.name == "require_nc"] if require_nc: min_major = require_nc[0].kwargs["major"] min_minor = require_nc[0].kwargs.get("minor", 0) srv_ver = NC_APP.srv_version if NC_APP else NC_CLIENT.srv_version if srv_ver["major"] < min_major: item.add_marker(pytest.mark.skip(reason=f"Need NC>={min_major}")) elif srv_ver["major"] == min_major and srv_ver["minor"] < min_minor: item.add_marker(pytest.mark.skip(reason=f"Need NC>={min_major}.{min_minor}")) def pytest_runtest_makereport(item, call): if "incremental" in item.keywords and call.excinfo is not None: # the test has failed cls_name = str(item.cls) # retrieve the class name of the test # Retrieve the index of the test (if parametrize is used in combination with incremental) parametrize_index = tuple(item.callspec.indices.values()) if hasattr(item, "callspec") else () test_name = item.originalname or item.name # retrieve the name of the test function # store in _test_failed_incremental the original name of the failed test _TEST_FAILED_INCREMENTAL.setdefault(cls_name, {}).setdefault(parametrize_index, test_name) def pytest_runtest_setup(item): if "incremental" in item.keywords: cls_name = str(item.cls) if cls_name in _TEST_FAILED_INCREMENTAL: # check if a previous test has failed for this class # retrieve the index of the test (if parametrize is used in combination with incremental) parametrize_index = tuple(item.callspec.indices.values()) if hasattr(item, "callspec") else () # retrieve the name of the first test function to fail for this class name and index test_name = _TEST_FAILED_INCREMENTAL[cls_name].get(parametrize_index, None) # if name found, test has failed for the combination of class name & test name if test_name is not None: pytest.xfail("previous test failed ({})".format(test_name)) cloud-py-api-nc_py_api-d4a32c6/img/0000775000232200023220000000000014766056032017521 5ustar debalancedebalancecloud-py-api-nc_py_api-d4a32c6/img/icon.svg0000664000232200023220000024677514766056032021217 0ustar debalancedebalance cloud-py-api-nc_py_api-d4a32c6/.pre-commit-config.yaml0000664000232200023220000000236314766056032023232 0ustar debalancedebalanceci: skip: [pylint] repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - id: check-yaml - id: check-toml - id: end-of-file-fixer - id: trailing-whitespace - id: mixed-line-ending - repo: https://github.com/PyCQA/isort rev: 5.13.2 hooks: - id: isort files: >- (?x)^( nc_py_api/| benchmarks/| examples/| tests/ ) - repo: https://github.com/psf/black rev: 24.10.0 hooks: - id: black files: >- (?x)^( nc_py_api/| benchmarks/| examples/| tests/ ) - repo: https://github.com/tox-dev/pyproject-fmt rev: v2.5.0 hooks: - id: pyproject-fmt - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.9.3 hooks: - id: ruff - repo: local hooks: - id: pylint name: pylint entry: pylint "nc_py_api/" language: system types: [ python ] pass_filenames: false args: [ "-rn", # Only display messages "-sn", # Don't display the score ] cloud-py-api-nc_py_api-d4a32c6/benchmarks/0000775000232200023220000000000014766056032021062 5ustar debalancedebalancecloud-py-api-nc_py_api-d4a32c6/benchmarks/aa_overhead_dav_download_stream.py0000664000232200023220000000235414766056032027772 0ustar debalancedebalancefrom getpass import getuser from io import BytesIO from random import randbytes from time import perf_counter from typing import Any import matplotlib.pyplot as plt from aa_overhead_common import measure_overhead, os_id from nc_py_api import Nextcloud, NextcloudApp ITERS = 10 CACHE_SESS = False def measure_download_100mb(nc_obj: Nextcloud | NextcloudApp) -> [Any, float]: __result = None medium_file_name = "100Mb.bin" medium_file = BytesIO() medium_file.write(randbytes(100 * 1024 * 1024)) medium_file.seek(0) nc_obj.files.upload_stream(medium_file_name, medium_file) start_time = perf_counter() for _ in range(ITERS): medium_file.seek(0) nc_obj.files.download2stream(medium_file_name, medium_file) nc_obj._session.init_adapter_dav(restart=not CACHE_SESS) # noqa end_time = perf_counter() nc_obj.files.delete(medium_file_name, not_fail=True) return __result, round((end_time - start_time) / ITERS, 3) if __name__ == "__main__": title = f"download stream 100mb, {ITERS} iters, CACHE={CACHE_SESS} - {os_id()}" measure_overhead(measure_download_100mb, title) plt.savefig(f"results/dav_download_stream_100mb__cache{int(CACHE_SESS)}_iters{ITERS}__{getuser()}.png", dpi=200) cloud-py-api-nc_py_api-d4a32c6/benchmarks/conf.py0000664000232200023220000000345214766056032022365 0ustar debalancedebalancefrom nc_py_api import Nextcloud, NextcloudApp NC_CFGS = { "http://stable26.local": { # NC_APP "secret": "12345", "app_id": "nc_py_api", "app_version": "1.0.0", "user": "admin", # NC "nc_auth_user": "admin", "nc_auth_pass": "admin", "nc_auth_app_pass": "kFEfH-cqR8T-563tB-8CAjd-96LNj", }, "http://stable27.local": { # NC_APP "secret": "12345", "app_id": "nc_py_api", "app_version": "1.0.0", "user": "admin", # NC "nc_auth_user": "admin", "nc_auth_pass": "admin", "nc_auth_app_pass": "Npi8A-LAtWM-WaPm8-CPpEA-jq9od", }, "http://nextcloud.local": { # NC_APP "secret": "12345", "app_id": "nc_py_api", "app_version": "1.0.0", "user": "admin", # NC "nc_auth_user": "admin", "nc_auth_pass": "admin", "nc_auth_app_pass": "yEaoa-5Z96a-Z7SHs-44spP-EkC4o", }, } def init_nc(url, cfg) -> Nextcloud | None: if cfg.get("nc_auth_user", "") and cfg.get("nc_auth_pass", ""): return Nextcloud(nc_auth_user=cfg["nc_auth_user"], nc_auth_pass=cfg["nc_auth_pass"], nextcloud_url=url) return None def init_nc_by_app_pass(url, cfg) -> Nextcloud | None: if cfg.get("nc_auth_user", "") and cfg.get("nc_auth_app_pass", ""): return Nextcloud(nc_auth_user=cfg["nc_auth_user"], nc_auth_pass=cfg["nc_auth_app_pass"], nextcloud_url=url) return None def init_nc_app(url, cfg) -> NextcloudApp | None: if cfg.get("secret", "") and cfg.get("app_id", ""): return NextcloudApp( app_id=cfg["app_id"], app_version=cfg["app_version"], app_secret=cfg["secret"], nextcloud_url=url, user=cfg["user"], ) return None cloud-py-api-nc_py_api-d4a32c6/benchmarks/aa_overhead_dav_upload_stream.py0000664000232200023220000000221414766056032027442 0ustar debalancedebalancefrom getpass import getuser from io import BytesIO from random import randbytes from time import perf_counter from typing import Any import matplotlib.pyplot as plt from aa_overhead_common import measure_overhead, os_id from nc_py_api import Nextcloud, NextcloudApp ITERS = 10 CACHE_SESS = False def measure_upload_100mb(nc_obj: Nextcloud | NextcloudApp) -> [Any, float]: __result = None medium_file_name = "100Mb.bin" medium_file = BytesIO() medium_file.write(randbytes(100 * 1024 * 1024)) start_time = perf_counter() for _ in range(ITERS): medium_file.seek(0) nc_obj.files.upload_stream(medium_file_name, medium_file) nc_obj._session.init_adapter_dav(restart=not CACHE_SESS) # noqa end_time = perf_counter() nc_obj.files.delete(medium_file_name, not_fail=True) return __result, round((end_time - start_time) / ITERS, 3) if __name__ == "__main__": title = f"upload stream 100mb, {ITERS} iters, CACHE={CACHE_SESS} - {os_id()}" measure_overhead(measure_upload_100mb, title) plt.savefig(f"results/dav_upload_stream_100mb__cache{int(CACHE_SESS)}_iters{ITERS}__{getuser()}.png", dpi=200) cloud-py-api-nc_py_api-d4a32c6/benchmarks/aa_overhead_dav_download.py0000664000232200023220000000210114766056032026405 0ustar debalancedebalancefrom getpass import getuser from random import randbytes from time import perf_counter from typing import Any import matplotlib.pyplot as plt from aa_overhead_common import measure_overhead, os_id from nc_py_api import Nextcloud, NextcloudApp ITERS = 30 CACHE_SESS = False def measure_download_1mb(nc_obj: Nextcloud | NextcloudApp) -> [Any, float]: __result = None small_file_name = "1Mb.bin" small_file = randbytes(1024 * 1024) nc_obj.files.upload(small_file_name, small_file) start_time = perf_counter() for _ in range(ITERS): nc_obj.files.download(small_file_name) nc_obj._session.init_adapter_dav(restart=not CACHE_SESS) # noqa end_time = perf_counter() nc_obj.files.delete(small_file_name, not_fail=True) return __result, round((end_time - start_time) / ITERS, 3) if __name__ == "__main__": title = f"download 1mb, {ITERS} iters, CACHE={CACHE_SESS} - {os_id()}" measure_overhead(measure_download_1mb, title) plt.savefig(f"results/dav_download_1mb__cache{int(CACHE_SESS)}_iters{ITERS}__{getuser()}.png", dpi=200) cloud-py-api-nc_py_api-d4a32c6/benchmarks/aa_overhead_ocs.py0000664000232200023220000000155614766056032024545 0ustar debalancedebalancefrom getpass import getuser from time import perf_counter from typing import Any import matplotlib.pyplot as plt from aa_overhead_common import measure_overhead, os_id from nc_py_api import Nextcloud, NextcloudApp ITERS = 100 CACHE_SESS = False def measure_get_details(nc_obj: Nextcloud | NextcloudApp) -> [Any, float]: __result = None start_time = perf_counter() for _ in range(ITERS): __result = nc_obj.users.get_details() nc_obj._session.init_adapter(restart=not CACHE_SESS) # noqa end_time = perf_counter() return __result, round((end_time - start_time) / ITERS, 3) if __name__ == "__main__": title = f"OCS: get_user, {ITERS} iters, CACHE={CACHE_SESS} - {os_id()}" measure_overhead(measure_get_details, title) plt.savefig(f"results/ocs_user_get_details__cache{int(CACHE_SESS)}_iters{ITERS}__{getuser()}.png", dpi=200) cloud-py-api-nc_py_api-d4a32c6/benchmarks/results/0000775000232200023220000000000014766056032022563 5ustar debalancedebalancecloud-py-api-nc_py_api-d4a32c6/benchmarks/results/dav_download_1mb__cache0_iters30__shurik.png0000664000232200023220000022222414766056032033134 0ustar debalancedebalancePNG  IHDRjy9tEXtSoftwareMatplotlib version3.7.2, https://matplotlib.org/)] pHYsnu>IDATxwX6{Rc+b("`A^bר1FMPch%[b4bXQ *("6;]4\g<3|XG)t_Ksnn.N83gk׆ *Ujժ}:u*߯=,. uUl*ɭMBCC1o6o'HмysԫWx-?PΜ9\x۷/t;/䞋ĕ+W\3220n8=oaavFFFý{p]@BB~ѣEfY2eJW\")>M~e Qڽ{7FL9333tիWGVVWJmUVD"ABBn޼-di+&ʁ%ڶm ggg8;;ݻ={vyハ>...011Q(#<#!!III3f UviLHHСC_׮]Lxx8'O 66FӧKOT:]cMLL7`ʔ)RBL:u /ƹsmk˖-rJlR`VV,>₟UXÇزe VXo*$"ի;ӆ} *̎;0zhqx ,YÇ\ׯ_cŊ_t ΝSd^O>Q,>>mۊ=$QE1ޡ-Z޽{x5N8 _~JTׯ~ ]vU[m{q.\_UW<(:}}}-N8Q=C=ZLժU W^O?smhh޽{#88{Fۊv?,1c\o Anݔ&nݺC 4Hmݽ{&MGxx8ƍ+++8uOtǝ;w @~&&L@PP5}$""zsΨW8,,Lil_^|O?+5ocƌ知->LL̙3hРppp(֭[֞`kk Ç5s'OEi _чmĉfff8yF`t۶m߸qCJZ߾}q|Dڊ @"yyyزe wUkF߾}bcpqqA*U```*UCLL{),7|]/b^ue30޽{ӧOGF`jjʕ+E+ DѼz /;UCCCؠUV9s&n߾q]Xb   E۶m_> >jՂU777YڦcNJߛ7o?Ͳe"@Zn]|J߳ Xx1ڵk[[[QF={ܘfEqL0NNN011ڵkEUqx2b T[رc'ND%;hBe_UQcbb? իW!ЪU+̘1QQQƪ꽺j*t5jԀ$xLٕuի@&M0h ,_њR*׍{…[n[n `֭QFAOO|l`u/^,.;88`ҥE8_݋<~j֬ cccZjҥ ,XP)))믿лwoq077GÆ 퍅 ͛w|hٲ8SkժU_A]c]Æ CӦMann}}}X[[Yf?S {~Fy@BDTZ?.o^^4/"HXx:-Z$oUܹ\>>>*vE,w)eAXv`hhvF+W=E9n7n7]]]aBNNںmDAO?Tm}7ܹS'n3F\OzPvmfii)\vM\ȑ#5jP=7o/|Rm=?$/Rq,QI!++KQF a۶m֩,.\xl{0pB배"w}.1c['|9͛B۶m5wС ;V|LHII)!00Pn;7oV(ӻwo!CyDڎQ.] 22R|N:С q-\|S92SNgjj OOOTZqqq@JJ 2220{laŊ ȶ P \tI9Ue000@ǎ͛1yd@ жm[TT w!ЧODFF\m}0sL񱡡!QfM$&&" _Fnn.V\'O`*'Wx @OO7Faaa0c Xr%233f1=Ɠ'O۷oW^rw+X̚5 񰱱;c ;;ѣݻׯ_~Ά=\]]QreDEE!88yyyx) 0Ǐ?pttDa``7n 44@]={"((-[,CRl OгgOd=[.^}}}4kLGFF[n5jʞT8Zpv\ :{쑫S:%%%.]ƒ ">>v}L>o޼:wի#11gϞ˅bРAbJ*k׆!ܸq㝷=s挸ܷo_]Kl뾾}Whذ!ܹlߘ6mz&>|xLe'77Wldjj&Mnݺ\2/"995j1tbo3&&=z[C066Fjj*=zB[y)vkҤ ZhSSS|DLC m0bĈb.wqLL AD"A РA^BB?qa}k_fMkVVVHMMݻwl۷1xqww=bccŸd:to.t`JJNqF2.]lں`Νrwǎ+z0rHr{Q+;;[055Vo eΜ9#akk+tttZHɶԩec244lmmcǎ) *W,'<+裏82̙3b\lΞ=[صk<(7Bppn*S iiire={&}TҖ?)WƍBժUŲ3g}}}aڵBnn\ D,e1SHؾ}Bsɵi֬U-+999‹/G cH$ºuTרQ#ڵk5ږl/Dqk(KHSNժU={(mk.V;wTZ{UOOO L2Ex\,(1p@JNOO9"L4I+cٶՇ{}kZR[ŲvvvkEn?SL)?UJ¸q㄀Ќ aɒ%gB&ӧO_wss>}l!00P1bVXWvZI [>Ty{,Y" *˜={VpttS:ɓ'XN:JO B~οK1cϕWV 0@Qu@~%K&6a*wuuP@l;UB:u2V٥-//O۷X^z ?Az)9r?ҥK(駟׿{qL<.W6lPe9Mݘ;vWW_}%\hQ\xQOU\cǎUY_ZZмyscYs,}v@~W%U~G}\:Uy\teIwk׮ YXXLfIىݫvKqO>DPV h1oee%ܿ_my F)=o|N08ݰ &/޵`} ,mرC܎BG}FDDK=QLl5aV`QWM_i'__b,^XΚ5k4e 6mڈ߻wX8p{e}.Ttt8CvT1bo7u5QJJx{{+[D"4h@5jj*ҥKji+&B5kԜ9s 8vsu :B ? ӧ+k~i1_~6duD"QҮ۷嶫n򚐶IJVѣGUTIxN8!O!hgg'ddd,&תe˖j~XVq[17rE]o߾&܍U-# =w}%ڇJʶYrFC\ի ˾WT%=ZZZjûrcz2ۖJis1d[j%ׯ_Zq'i,ߘ1cT^ @M[/^u 0@W_zRRRbAhٲe>7xJoއ裏@oXaQ *xTwS0+VKo /dbb" :T8sL!h8 Uhc5cƌ… ՖөW^Z5j@Ϟ=q!1=zȕQ7`zz:._,AӦMq mС`j_733Czp]ǏYf[l-[DVԖ711aO4I:QQQ ŃdffʍeAezu 5jԐU} CCCWT Ƀ R[_ݺuall4$$$۷033Sѣ s̘1X|9 SSS5&PI 6?/p<@PPΝoFIh]#e3=*.k:N\.]pqsԎKKBtppÇ;wh TpVhSS2ӧOq)ѣG#((cU('wY\Q}W눍Err2~z{/|Eb 7F- ]:tcܼy͛7/ߥɓ'|2tLA@xx8?u8jU[ʒ|>p^0z<777"c*7o]l׬YSj֬6/.תUK:k׮-.+WWWٳg 3),AAA߿ʲdV_}}}qYU”qk|U3 cQ/&߲&MD"=ܹ@`hٲ%ڴi$lܸ]vŰaʛe->z%7վnkkQ=sE`` .^Ao>۷@~777tMzcF*))L#;+WF߾}sNq=e @ٸ*f*{ӧN8Q}7jj„ ~ӧqi}777xzzo߾QZdzg29}v2?g8xӦM0aB/^֭[d<~F^___s-,ŋ燭[oݺ^w:Qq{gllzu=Wn T仢Jzugb+ee ֧NqZSGY'O%:t___իWȀ?-AXVYwK)wY?slKrQZV֭ e˔mA+{N\\\Mڝ$UhAAAXtMw6mڄ#Fjժ5k;R]0֏ʕ+Y1b ƒ*M]1ӻ??DCb׮]o~?J.݋ 6qr=y;v PzuL0Ai][T9uOQPy|߾}&M4iUVxX7fq]eJkʛ7o.u5\:uꄍ7"((Hn֯_AT*?F4J@^M)ȖSu7R8t7oplffuFޥ8nK.?SlذA’H1J[l%FLZZFw5ܔݻ߅<==]!qՠA1Ic}䉸,}"{.\r\f̘DDDٳp1:ҰtR={e޵j/]ɓ'6d[ p!NFFvܩ0^|G^m6o<$$$jW.3`j^-,Ǐ ?x!i7nD`` BBBZʞ[Zl+W=zjǥ-}QZۣf͚ŋ5]tl \VR;vĜ9s0g+Ws%} UX000?yD.eK%[E:";`xx8^~01-<<<[n!>>o'h߾=4])㖛+hEz'/ɓ'g$*>7Mv<$&&*$5j$vdʜܸqCnM*Udiܲ,I$h-Z_~ ?1cƌ2K.v8P)++ OزeBDl%-[hTY$/_,&d4iRKORNNNprrĉOԵn:Z x~Gc {nm .l޼7o~|ݱ_~)tRW CSqbM$7֥=a ӳgOsDچ]’^J6 ]^pAXd˩RWWW[)Ξ=vL?8]*+19jggW`o.tps5y $irlo޼)>h&PB@Yw]ًB'rU:Zjrh.Zǽ:x;ٶl$lڴ>|؅ROO۷Y#$$DEVZ裏֭ӸE4ddgϖe8"''',[Ln莂ۋׯ_g+RTś7o,;Mqވx-֯__:/_..[[[:s PW7{3QE Uh۷o/֭[ -ӥKqѣxϞ=ñcǔ_PnҤ z;wBق/d;,,iiiW>q&VXvmed/G-t 3gh<"OQ?Xk׮孇8{rrrF˶@޽;w5?׬YS&1>}˚XRr9sȵlÇf9~GxF/_Tٳ'Oo-r@8ѣGrc_Y~,,y__2Cuźu늭TrEy_lذݻܻwǏ/vl:t~(RԽ{%LRjW߈ @"Ə/._xQmXbEuzyyN:ٙ>}ʲ /DիWݺuSY^6u1q,e,--VQǏ[UɎ2uT?.Ν+&W+WÇ˽nmm-&U޼y#vV%VMiii5kʲr@{nGFFu mU֊2׵k8prb8 FU͛r )ShOINV"ZWsյ/33S㱧db4r[txm۶rX9rdb-m6D իű.5cEJ쌬AAAj\tB ke/UgQ6K,[P֭ {]U>q?n?E ` 8PeVV;dy&uA^cǎYj׮]իrss mIY|qFL>]ļyPNI~*[ZB5 |8͛C__7oĕ+Wrزe;MbʕXr%lllТE TV HKKãGpUqLMMq Y[[1rrrpIԬYݺu-}ɂ\@@zgPmFFF… 5uɒ%r7h@e٤$AGGG|a1 W^ 6+WV{tuuiӦ 999jڿ`ll>L8Ƭ,a̘1jlذ)̛7O|n޼yd,ʟ}??"m_SZJ|}D"Q/իWΝ;Wk֬)駟Z0vvvE:Bdd}Sjlll_ϟ?WYѣŲu-tEcaܸqF{_.!WɓŊWDxryyy¶mGGGsjՄK  ɾ烢WpE[bE*m%=N sQFFF_%f ;כj|[hQ3--M===TR~okw  ׯTx_.hB#F/B5OOOOυ/_ZPN"*U$OBvvvUtlHDZzx"n݊;v ""o޼A*UТE 3hjܹ5j6l؀Ǐ#::III@ݺuѣGL0Hl9ZjWo!;+j۷/֯_cǎ!** _Э[7|۷/n޼˗ĉx PzubرrcjB__7oѣ˗# O?T[2i~B^`<{ pttĀ0eqGU&a)8h#^ΝC||yٳ'ƏpYʕ+w^x mڴѧOBRVT k׮ŷ~۷̙3BBB`nnuEڵ+z)Bunʕ+q!utjЦM9C @-c /ʕ+nݺh׮5ڢy8q\ӈE||<`ggGGGxȆ'bĉy&pEܹs?ƛ7o PjU4oNIIAhh(-[Gʕ+۷/.^DNc#*Aٚn.==w"%%vªU`hhNc) m۶8++ QQQXf oooܼy5j(H4lKLsnOyR$tܹ˗/]v'D6m*`ԩv/_Ç9R"zۇo~w@bb":TaܹuO|rʃ 66@[6IDD1HE,--w߉r5b1n84h@ymŋy#0i9o˃F-&]y$"pieAÆ abb+++8;;cҥHKK+mnݺ⠹kx%KVVV011AÆ ǏIdHCعs'&L-[;~7fTٷo{{{ uօ{\|YzϞ=ٳѺukqUTAf0l0l޼bPꇩXfʕJ|H$V}:t $5:tŋ=&7o#dffbʕpqqӱpBh&&&+֯_<"q)ȑ#rg UȼyФI PvmH$;p 6 022ƍ;wtwQn]X[[e^^Μ93f666ׇZl3fɓ'U?NNN011oӦ LB222ׇ4h>˗/ǣG򹹹077D"ٳ"ӯ_?e27g~0664iZ.=z$ֽyf޽{ѫW/T^zzzPX,--ajjMb޼yHJJR-"Mi9O:ޟlmm1j(@BB)!uT?Hiȑ# iii Ehh(6l؀#G$K?"s} ݓ{ݻ{.6l؀;v?.PI˹޽{#((HWpY={k֬ѣGѰaCr6l_糲h;wǎChh\`|rIx%^|7o666{UV\2={ʭK.1}tZFF}=k\x/^#Ge˖ zׯ,.] 22R|.-- .\ g|7jCΎ;  >0b?zѽ{w66v֬YoT|֭n߾-^bb"Ϟ=oENpQ*G}YAGGGi*U(=/ZsUا۷oXv-|}}1zh1H ѣG ''ÇWθunݺ۷s^hhx&3]]]QNDGGc֭*o'i7JaСHOO);xzz"==X~=лwo̬ԶrJA___DoߢwboĉOPR%`ѢEHNNСCqBDR7noիȿk֬郶mۢz?ƾ}k.DGG_~~:\vx!שS'L0Ճ oOxO>033ɓ ;;;dee!::.\PHvA|Eddd]xB,8fq-Z5jׯ_7oƳgеkWDDD {qFCjժx8yNN>c1ɓ'O<5kpq~Z6 #Mxxx:ucǎ8|8,X#Gװ8)IDDMӦMCzz:p tA|K._>f͚(,[L{^qbĉżyqFK.ETT`ɒ%9sZwwwa*[e.Y~~~_BcȐ!?~uG:t> _7|;w*?lذAnZ\^n^ ϰn:6mڠ?~<6mڤrD\~jDjȑ8<^۷oqjr o 6L|m۶>>b ?YΕҤ]NNΝ;'ҥKȀ9Zhg"00P|'{ã.>>^Qzu\x뮮ӧܐ> O*""GA(ƍ? w׮]ѱcG3FiDs^vv6;9 ,@VV_ݧ¨:vӚ1/_`qdR>jժRHwժUz*4hoVuř5j2;v AAArJc%핚 t]l)RV- 2&du }߿_8Ku]` ޑVEOO+W{NTzA,Kѣ\\\+XNϟ \Ojĉ֭?2.]($MeY@~~?ʒ]%nj0t@a ]$?WK$C.~ jAwn޼)IO 奓h:t^8N74^o98++KLrzxxBU/srr׼^ KIm;b=\8d;LMMń-QQ¬`2JҹcǎB'Q O2ԤIP;}MO?T\VWOPPܹ͚5[cÆ *[@rr2q-|'J_%=oݾ}H۶m0puu\&{{PBk'񵰰T0auV;tf͚M6*ɾB[y^jd,iRҥK +SO"".Z211QS6pm/@jj*FU󲭙#նm["҇N:9s&nܸ0>1tPX[[NNNh֬~zPvպ>駟Allx:uꄺuOvaѢE8<ԮҥKHOO9Zj8>>^L=|Pl Ӿ}{1Ⱦ~M7o51nOvg"M?~\˩`)󱱱}sܪU+[l)&d?#ȑ#+Ǐ_v077GݺuѴiS|ge }}}dff믿pMJ޽5k=Ѭst GHHzQR%q)9RYC鹫uj[FVRkז[GuL11m<%&&СCA 6 (IDDKk~Ό';gQѣsM*OJOOO$C,YvvvrϟN:a׮]N4ܧ~9s@OOo޼8::>TXO__^rsAN`aa={V94y'{A,mҩS'}022 V7|bR;==ǏLj#о}{|Ra]evXZVߪư**”޼y#N<._?###޽{x{D<5m 4P{+ >iii055/_"33S<ߝ>}Z\GEuaW}vtcǎUZXre Үxؒ\la-E̙ӧO#)) ϟǴi`dd$n.뇍7޽{x6m$v׿z*&M^۶m@`` q-] (r]TĤm-S {]v!##H뤤`޽*_}*HT+Sϖ2w/ۇyrjr377g}={˗}6-Zիl"v[Pvo!448x8˗/1p@zzzر#stiq2YEz+MM,h9޽{EQnDDaRW!D҉uu,ٳgi&᯿*?iE(^Dn? ֪E۱cGt0`AݻѪU+VV ƍȑ#k׮HOOGJrqO8@\riiibw8) xq[.K.SvݬY3qʕ+5jʲ훊Jv˗Z~̙֭[U\vr~*Ǯ_CrJszgJ[QFhԨFF!55v´iԮgffooox{{cڴis;wݻw+'NիHLLTzEni+Ae7U( jԨ~u&M$Dϔ|ڱc=ZmЇIygϞţGb-LϞ=kv&"КHHH'P%11Q12+;wRZFZwjj*OХK=.]T$%%m/mgkk+HSGơC www̞=[{… X|9СCQ0c 0;vDz`dd/^ɓXv-$ވ#N>nnnݻ77o[[[#** ]?~Ү3 ⴴ4qLe-]<<<$Qzʼn'o 6Dbb"ŻVVVu3RgCXX֮]h|ppp@LL ֬Y'Nm۶H&i+6;;;iNǎQZ5<۶m÷~Pm۶>|80h T\_q]Itu[h۷oM6Ю];dffѣXbU?,ƞ6d̙37n_ݻnWUlITʕ+1b]tAF`nnD?mxzzq߿?ڶm+6Ν;$C˖-śC+:uݏ?(n@-.])S 66mڴٳѱcGԩSXt)RRR H }}} -Z`ʔ)Xz5BCCѶm[|h֬޼yR6; b322޽{9WXZZ}Xh޽-[GSիWGXX~4m*Uߚ5kp1&"jDYyeԩWbΜ9hԨannΝ;cؽ{ʛl&L` 2D|ըQGFXXFwǸq~Vz쉝;w_9D/-[jժӃ)4iɓ'#,, sΕ[ GGGA__UT/\~]i_)FFFr75tΜ9 ĉQ^=TT &&&hԨM;w`х֣ }}}ٳG17715j9sի[nl> xۿ?Oz70iJ"HӨ._,U4i/2yyyhڴ)"##aaa/_2kǏQV-qL`gg7oޠQFu;9֭[ Js$[kĨA{--c.Q4|mI~Hnj͛7RtDD缲k"wG܎ִl׮8ƍve\l"##ӦMSHB"@"`رe 322Blܸ@~kNчAkUVœ9st3p999X3gΝ;YfOPR%`…AJrr*.Jj ;wȑ#9s(qrr‘#G`ffV9^z޽{R+WƎ;Jm 2""""""""hM`)oooDDD믿aaam_EXX;L#믿m۶14h;L""""""""TZ|r,_Hyxxh `*'Pf¬YJ]"""""""""ef`*:LDDDDD DD7 0)bH1HDDDDDDDDŘ$""""""""bLi1&P!QLRrssW 77w@DD @*###wRRR9"""""z$%%arr\\$222K|R|Ҳ"""YzUL&&&TӑOB"?&77Wa?sssSDDDTT,5kē'O _NNN9GFDDDDD`MGGjBjj*޾}+$"""" `bbSSSwHDDTT"055-PH NBDDDDDDDDŘ$""""""""bLi1&DDDDDDDDDZ @"""""""""- cH1HDDDDDDDDŘ$""""""""bLi1&DDDDDDDDDZ @"""""""""- cH1HDDDDDDDDŘ$""""""""bLi1&DDDDDDDDDZ @"""""""""- cH1HDDDDDDDDŘ$""""""""bLi1&D?~ 4l&&&3.]իWc̘1hݺ5addԭ[CŁ zƎ DߣG4ɓ'7oڶm [[[nnnp;?:t#GDrr\ZZBCC 6ȑ#ptt,V ,;hڵ سg?w!55UXܹsHNNʕ+I}(;v,lقZjiWӧO[nѣGcÆ WZ6++ Ms;lC˗/#880~xQFUV!;;Q]]]̜9S|,,a-Z`ƍ*#""""""Lkq㔖ѣIII(Xdgdd6ĉwo-49IDDDDDDD:wmڴQY]\>|/.7lذL/@"?5ݻׯ_ٶ|0HHږp 9:իW ` FvVzիW#--M/^Ԯ]fffѬY3X[[ hР~7dffΎQb>Hx:%LLLYH$H$Eǎi&۷jHIIٳgkdee!..'N_~ '''\pAzyyysDi0bܼyS\TTfΜ.] ))DKDDDDDDD @ }V\655-4R&|WDNTH$pqq p1\v .\uЮ];ӧO兰0߼y<7 ̄Zjؾ};^~4p|eDDDDDDD.IA;*NbbbPfMQuVk֬ԫW/v AP]<@^aTREIII*[ sb…֭[#44D,# Ƹv4h WWzz::tp݆۷o_}&""""""in-ddd$.geeZ^:^JJ:uiӦh֬_#""zÇX,H`tցd&LQ:&!ܹS#""""""A2335֛ @Eedd???#&&f*v]&M^gRYO׮]ʼnQ\Rx1H$###X[[wRbP mi+.V=7>}*!lmm666bBDDDDDDD&%Mݿ999*IgFY<\ZZ8CqQɎL&M\eK[Q }3ի*v+ -r^ܹC$''I5j+"""""""z?0H~~~~J3[XXӳLbEHHVZ ijݺuⲻۧ}A:A[b!""""""j׮ڸql2DFFM}}}!H H0vXpqyÇg#=zB/*sũS-ZPZy裏N>P&..s`ܸqj'""""""ڪUtxyyaΜ9Dzz: prrOv-Z_~hӦ V ===ظq#M6ٳxbݻwGƍaaaLDDD`ӦMtׯW9ʕ+$|ǘ>}:zJ*Xh81?.DDDDDDDAkժv܉#G"99sQ(#G[.#<<\m޽{J_āpuԬY7Uqrr¡C0h x/ŋH$f͚6f"""""""z1H|8 SNu߿Bj/Z*>E"HGNll,111/爈HSv8 cH1HDDDDDDDDŘ$""""""""bLQcaÆ011tRH^cƌA֭aoo###nݺ:t(8ί}}}H$H$;Vedptt9 `gg,Y ["VY;tFd;99ȑ#ptt,V#GĎ; -={ZСC\|Y|n̘1ؼyNB ۷oG=4*&Ms;z2(""""""C"==tcB޽ 33"oCOO۷+5kUs֭[7o"((8wtt4lzj\|vvvxF888mڴU}hѢE[~*Ν;#88zzz8{,:t ҥK1k,y0"o#''zzbȐ!ػw/ӧOƢqHII͛1f[BWWWmG""incQseƏ4jj*dggy;3g1fʔ)x-ƎΝ;kNa?ׯ4hPXH1HDDDDDDq)-ѣGP&v-(ݻqAX[[cҥe&ч @""""""pΝ;011A6mTswwϟ?_& 6T[͛7ꫯK,)Ҥ!{._Q,D$ DDDDDDTDFFvӕMI) ^½{aȟ}wĈjoslXTiiix):%K ''0}R*>&BW ---abbTĔh R  ?ڵk!H͛&gϞÇ~".L֩=HyAzwy@DDDDoߊ˦&SRR$ =lllTg}A7ߠI&eK˖- gg2*&&B򆆆mIII ڵkzj<|6l@*Uxbܾ}k?P8~۶m >>hذ!LLL`eeggg,]iii%;22WƘ1cкuk&&&[.@;DG(f""""""mbdd$.geeZ>33PRmN:hڴ)5k777|׈@^pa8;;#66Vawb…?%,,,дiS4mOw^lݺ>D߾}yo%:͛c{.ҐP̚5 Z] _b֭ ӧO4DGGc׮]ׯ<==P{FDDDDDD`ff&.kҭ755f݅~~~066FLL f͚% 4i233ѿ2o7j( <yyy:u*^~]#AaСHOO);xzz"==X~=лwo}ihJOO۷+5kUs֭[7o"((8wttTYWǏf5'2225(&$ɓ8px8qHǎﯰ~||-+}b׮]HMM@HӦMCzz:p tA|K._>f͚(,[ /66lؠrnݺa2d݋>|}QY~NDDDDDDƍ#88GNN;wˍ5*xlmmiiixUݏ`̙sY={0o޼b]/Jc""Қ./_Fpp0`r?)j*dggy;TtuuNҘt @~ޫW,'m}eӧOj\S,D~Кq)-ѣGP&v-JG~e???euVfxzzI, ԪUK ˏ3F|85kV""5 sLLLЦMϗI,c:4lذLADDDDD!k׮7np-[HCFI D"D"رc֏™3g >\X,l޼F&+VѣGZ,=FDaӚ1'uGGGteruJëWp=lذAdcc#F]/!!y&RRR`ee͛~iLODDDDDVZWWW s̑"3t-Z@~ЦMTZzzzqFȟc٥ϟ 8:uBz`jjoƍرc-xЊ`FF^zW[&&&HMMELLL!7,۷jHIIx8ĉXx1vڅ;+fzy%"""""zj ;wȑ#9s(qrr‘#GUxx8Ֆݻ7ʼׯ~z_^e{{{lڴ ݺu+XЊ۷oeM8&SRR$ =lllTH$pqq7Zn*U ##7nƍqe<}^^^FVGYMqODDDDDFDDVZ#G 66ptt1ub'\]]qq:u ŋ/ʕ+N:pqqatǏȑ#8<߿/^ !!*UZl?C ao2"#A( J*&&5k5JU5k"&&hB$%%!44k׮ŃЫW/lذUTQnRRց `ܹXp!u D")R|E)ShɊ#%66Vl.-edff*UT֩SG&Opvvƅ |u]% ,XK.Ӹv.\P;Juq~9ڵkW:bъYerФ[ojj*ͺ 8CLL f͚U&M$.kP{{{ժU+vlDDDǏテ VVVpvvҥKV###zj3[=`bbubС8p tEhҤ TYfD>Zȟt#!!-ZUKLL`صkW兓'OIII k֭[hڴ)/Y1jLa.D:t882Uȑ#cǎB˹cϞ=V=>}DSbʕQ;f̘BB=z߿ʈAuƍ#88GNNڝ;wFY<4zX: СCSSS|wDzz:~zDEEw - zzzh߾=\]]ѬY3TZHLLĝ;wn:ܼyAAAƹs&LLLУGtGjPreXn^x?XxB Ԯ]zzz q]?~ R iIv HMMիWѾ}{dҖ,MwrKчfڴiHOON8:uǬYe˖aEƆ Tx֭&O!C`޽ ÇѧOnRYO޽W_]vx!-[3g*&tppu0fʽ쌑#GG8wΝ;۷cEg""""Xo~e???en1,XbccUVZuewwRCqeƏ/{Z Eގ.fΜ)>Tz1qD@NN.^P}BOk׮޽[6H;hM]vpsslܸQLZl"##(8._`` $ $ Ǝ~TTΜ967o`l_xϟ?WY ;w.N:hѢEV$""FǍ]2Ef`FFFӴiS>>Eٳgڵ+Zh~M6Z*cƍ{ Xx1zݻqư@ff&"""i&\t @s<@""":wڴil+˫c6lX:&/+n=Ů*Jj ;wg3gBL W[w󃱱333q8p@e5k ggbJDDwttT۽V6&]4z Æ Illl0bE\\oٳlҤI gD. шU @FDDVZ#G 66ptt1uTI¸8uBCC/^ -- +WF:uaÆ;n8TR!!!˗/===ؠuaddTCADDWjZZZ)v=<<&ecc}¢zԵoݺ5lRpBqyȐ!Ů*Ko,_˗//zA*qנZjaɘL֑BC::c%QNnnnb@DDDD[1225lbbtpp(xlllꊓ'O.r^ %`Jݻw>S[۷ٳ'nݺ1eʔbѻP{hq[Vjܸ1QYΝ;rYv3.,--?V[6==޸r `̙;wnKDDDDDDD:u {իWU ]]],Oj%&Ցcɒ%&U|LVׯ秴L^^n g@Z(,4kLi\ >ǎ5 k֬)H;0HDDDZ]vpsslܸQLZl"##ӦMS/00cǎUX?** gΜQǛ7o0|p1q7zh2Nsm|Wce'bDmDDDD8 iUVœ9st"3t-Z@~ЦMTZzzzqF6mٳg+s9]vE=мysX[[#''?Ɖ'm6ddd>StE3f6m9s NU6mZ&""" @"""ZZΝ;1rH$''cΜ9 epȑbwp-ӻwoX빹8qN8]]]|7Xh#.߼ymڴ)4vA -CDDDDDDDռUVȑ#1x`L:UeR08~8N:PŋHKKCʕQN`ذaj'ѰaC"<<ϟ?˗/KKK4l;wѣQ^ """"@I`{{rtԞ}C =ZܻC """ ULv8 cH1HDDDDDDDDŘ$""""""""bLi1&DDDDDDDDDZ @"""""""""- cH1HDDDDDDDDŘ$""""""""bLi1(j>R!|-]!Q1 cH1HDDDDDDDDŘ$""""""""bLi1&DDDDDDDDDZ @"""""""""- cH1HDDDDDDDDŘ$""""""""bLi1&DDDDDDDDDZ @"""""""""- cH1HDDDDDDDDŘ$""""""""bLi1&DDDDDDDDDZ @"""""""""- cH1HDDDDDDDDŘ$""""""""bLi1&DDDDDDDDDZ @"""""""""- cH1HDDDDDDDDŘ$""""""""bLi1&DDDDDDDDDZ @"""""""""- cH1HDDDDDDDDŘ$""""""""bLi1&DDDDDDDDDZ @"""""""""- cH1HDDDDDDDDŘ$""""""""bLi1&V&?~ 4l&&&3.]իWc̘1hݺ5addԭ[CŁ F௿lmmQR%ԫW&M­[J+^yP:#G"99Y|.-- ;?:Ȧe.h҈Mc8.9H4Qې+ rmՌq3R05E=dU|As>\>>u}89]ՋJNNV\\ghh}~߿_BBBg׷ҹN>>={vsI222_8+`pŊcV8EF$={VKmOOOWZZ$Tz*g̘1k@ad;tR鸐-[je˖ٮʽ_\ofdE_tKr%8ɓ'q)::ZԤI=ƦVOEJ?trrr/8U"??_'O$jժʱ7rrrtСӧ6nX{M4_|F{ömݺ$jút=zpm2Ex9۵wKZ穧 /&MT#zyyٮ$<`6ݫ!I˫QE)''GVUgϞUrr~m_f͚ըߒ^/?===mϟv|AA$nݺ5۶m2?[&MW_)((H[n-~K\YFnѣGխ[7`ml-9LގԢEԦM:tHˌߪ8ou`OOOJ3gBzF^&M,IZr.\P\uX,zp)@IС$)##C۳g} 7 I͵P\KH[.s `ztq;*qFu*ڐitnIscǎ)==]R 2M8tPE*S\\KJ5jZJJJ$iӦ3$߶?Vnnn,^v=lذZf[nݻ$)>>6w\IN*772oذAEEcƌ)wzz֯__eyiģFp_WIӧY}W_$S\"&&FSXX<-[L.px#G_~;5tPuE7x\]]u1mٲE:v$SNV\G{ァ-[護ұc4a5nX۷o?OeeeEo\]M R@-_\#GTVVʍWBBBm駟O?U9fZhիWuъ+^}>2c<<~MjРڶmݻЎ&Mh֭zw*--M999jѢ맩Scǎz$Ń7͛y9t_>}dZ+}Maaa i64i&M99@y&F `b&F `b&F `b&F `b&F `b&F `b&F `b&F `b&F `b&F `b&F `b&F `b&F `b&F `b&F `b5XJJJұcǔh5o6*,,T:uQӒT𫯾SO=yk0..NSL9"//`'[}] 2DjVkcǏ *;;[_|f8P?'$W:~xjjڵ:0CUXX;?wޒ#%`(\~,M&www$:tHI >,I;\#%`(X, N:%Ijذ 0lR/}͛%Ir 0GVUK,kwyGE}5R'bhƍZxqcO:Cرcruu_#%`( ԩSeZ5n81B[?ԓO>)???}X,zԦM5jFo;w oO?է~j;dĉqVU4m4͘1pœz-YF}bj-Gz衄͛7iM%ܹsJIIUTT$___uYM4qF qX~{5'0K'x~gΜт $Iߍ C̙3eX?Ӷ! W*""B򒏏4{lh\}4iԸqcW=z̙3uرj)9<Ş?N{`u.\ IrssZVȑ#e{-77WJNNV\\;wTpp˽wim۶M۶m#F5qQt 7j1Byyys=Piٲezw*99Ywh,[_]vN8?\裏A0`@svU-2.]W\*-((о}{b(((.0uTUk׮U=lWڵSdd5w\͜9ӡ]\\_Tʽhذa***Ҕ)S?WK:urvcƌ)`YV͘1BVU...:uc:`ڴi$iܸqe¿Zhޡm={TϞ=3dg}})%%EwucpZ?V777/THHH|IZbzرqqqѨQ$IgϞUbbbj޷o_c ۮVnY,YFڵ>"OOON:57otqKm.]*W:ܲeœKAAr|v"vmڴ-ZTޕ&Ik- =ζqFug}ڻwդIuE<~rr2)uIIRV۸qcyyy)''Grz/?$I~{o~sff233_j֬YO"G:<'-ѹslՎ/ GAAƏ"IRtttc]\\ԯ_?;Ν;~A .TZZRSS۷릛nr֭[g0??v^xIR^^S|K[zxW'I [oU¿nnnӶm۴w^}T˖-GUnC dXdZ+cX\2ם~k{Ho=.\(I#//q)22RŃEyY0?C=Sm ={VEj޼&)___:u/Μ9c 񜼏>HO<񄤋'&jҤI畤:خ3332'W?z)>}Z 6R.:tЦMBVc6rni_~Fb5o\֭sʻZ1 Km͛aÆ*^zIpǎ۸q&uPu뭷"-Z8un_t7駟ɓ'ZCڮ-ZTb-]TC8BCC ںu 5lPk֬Qǎ UB{,jFnԻwoIR||ʍ;w$ISN[K8X,3fLu~G 8P999RBBtP:{l_pAǏ:h <C4]tȑZ`),,LQQQ U^^-[XI"""߾}l/ jݕӴiS5mڴkK,5x`Gv4hlرCM6ULLý-ܼy$^zZ'00P˗/ȑ#rc;Ν;+))I:qN>-5kL]v՟' 6LuԹe(|饗S\\3g(99Y}eXZ1 mڴѼy4o<ӧVk3g:}j߾MV gΜ)bx*WWW߿ 0UrdXT~}mV!!!zաC`(,..vvj˕n@!L01C]t-]v̬v|fft뭷*==HI }8p@~~~jٲe[l)8p@ 0nܸQE!CjjݺuFJ0PX;N:Ik$ ْ$ooo)e$ 7$;v{J֯_HI ۵k'Ik=W$zFJ0Px}j*66ViiiՎw}WE`pҤIR~~ұ_~^nݺz' 7 1FnjҤy5dr-ի7o.I:z6mڤjbVf͜TP(I>5i$j߾}_ʌZ$///9rdͺC[KVFFot1+YwwWFFp^Xo+W^yE:}$G5@ 85suuUӦM9%`W矵tR%%%رcӚ5kg{nRHHHMKXQqq_Ţϗ믿߯-[֬kv1xĉ?ԢE Otlxxڶm"}FKppݺu$EEE㏫gZZ~ 0866Vŕ}/]tMHIZ$Ţq}OV$Iǎ3RǏKnfqss$) C$ĉvsaI 0r-TYz$cǎFJ0P&ժzKՎOMMŋeXn$ O=o>/~_”/M0pjf͚wѨQ5khcbbdZeٳGVU...Zx< %G&NCi…X,8Ij$y{{kɒ%eBB>222C]tQ:udZm:v{N6lz`'+K^ /b>}ZEEE񑛛3z`P\\\ԤI*=zT͛7wfY0_~:r}駺ۍ`011Qwq>gggk̘11bΜ9c$ :}z!=ZΝt-[twVڵkgY1&%%_VU:w[STT(Gj?8q3vE)))zeZ~^PQQݫ[fRQQ4i+V(66VsgP C$խ[WV\nAEEEzWԹsguE?V ]vi % ]vo߾ZJMMUnnի7|S j֬3zX,ZX,*,,TVVV3J0F`AAz)X,=z6lWhh:~8psNuEoVZnkѢE駟"ժM6;|̾P8g}JKKj?~Is$^{Mnnn5j(=ߝTP5h@>5lذŢHm۶M۷j;)-s~'=#Uܹ~M4qD:tH .b$IV$[K,)}xҥԩ#jӱcG=sаaÜ3;^XW/^x***ܜ#j&M8sJ5P-n&F `b&F `btӧOkѢEo{n>}ZN:{رcSf8F+.\6m(22Rk׮Uff򔗗L]VjӦbccs<K>>> ٳ[ssskҤI Rƍ&___C3gԱc_WPP|||奀EDD50^?/*Ijذu7J;ѤItYEFF:JZJ#GTVV\%''+99YqqqJHHsܹS.ӧm6m۶MWllFQ| ?\{j޽|^ ݻw^jUZ~>C}Z~N8>@-ZjՌ3ٟ&%%E#FPVV[jݺu0a$)==]Թs?++W__~Ak֬ĉ⢬,=Zzus;wN&LкuuVEGG[YYY1b~Gǿ @W*** 7ܠ$tMOꪇ~XzRPPN87|SovԩS'WWW]V=z׷o_kNJOOܹs5sLwqqу>_|Q:t(~XX aÆHSL?,Rnٳ.Iz5}t{=zP>}\M6M6lpW@2pX,z* Jkݺ}YYV[Hjm߾]6m$7LW"""B۷$… ٳ/_^aWbȐ!(IڷoRRRʍpx IRQaqI6nܨޡ^`)bHe`Iґ#G֊+lcǎpF%I:{kP}ʽ]4zhTa̘1/¹M`(S${.$쪩͛7KԥKJDžخlR+خKJzKuUՓT{ q%~[2֞-FIZ l7n]l9.-55~.j;z:JMM՜9s4tP~U߽{fϞ-Ţ0CV%??_'O$jժʱ7rrrt!O?)!!AtW>|XՊ5r֭[kΝ:q aw/%u*sQ iӦCի̙SNw)͙3G{ֹsiӦմrΝ;gv$);;۩}h+ב^%mݺuu|ZئM-\PcǎUvv}YoS۶mմiSY,oڿVV,.\X+[mՎ/YE>&OdI4hPJuW 4j(jĉ:r䈬Vۧ_~EdZmc[hX׼ xzzڮϟ?_C:֭^}UI[oU:_Gzﷺ-Ge %i:p }7ڽ{N>-IQNtjСrsssJ_ڞm999ۂk ***JC=ٺ{~UrꞇQ(]|31S:uT_9snݺƵ?#=.nդI*iժ;ٳURnp@2xըC V:nϞ=Nuė_~QFX͛7׺uZuW\PsJ> ]\\TٷoЫW/Iرq7n]n:=*,,kzzi?JNNVI~^Xq_uj^hQctRIRFj֭[5daÆZf:vh}QÆ %IK,;YxzذazoX,2onԻwoIR||ʍ;w$ISN-w0Ɇ dXdX4f̘ 8prrr奄uš^SOI4gΜrc/I QPPC5 ɓT5`),,LQQQ U^^-[XI"""߾}tYI/ jݕӴiS5mڴӧO˕Hedd衇RݺuW^yE[9+ 0w5_NN,X Iv?'ψ@-_\#GTVVʍWBBׯ6mm??/j̙^_YD s HvrKF{?~\ŲX,4h]:`РAڹsbbbÇ]~~~>|&OzjSJJz-}'պukkԩjӦ͕n0ՎS9\\ݻ믿mÇպukIҡCԪU+ܑs+ukt =e<ˁMf;v=zt,Y"ŢQFgX͛gϞ۷o<Eyɒ%hu]p C⋒T5 \ݜs `b&F `b&F3&ӎ;t1jСjР3P5 :(}'p]CpB5lPk׮bIYv2S``>C?^VUV± Ν;~z]pc(<{ ӧOoOvt|ӦM5`IRBBN87xCǏW&Mn{^\R۷o7RVZJE<]$uQo>#%`(Ȑ$s=vӸqcIRVV 0K'''GTn]#%`(lڴ$iv?JZha$ w}$ivZzweXԻwo#%`(|GeZVU%""B?$iFJ0P8dPoǏ/,,ԑ#G'wފbGi?3O)))uꔒ/ܹs***5- cǎgg}B%.C'ԳgO\ׯשSTTTj< ꫯdX?Y/vrK 'NH{16 -Z$yyy9e({$Ivrj3P׿Unnn;w'1vQ-޽{tg ,I?ڵkC;zUybQ||Ѳ`8LOO3<'OJ~'OUcZ P믿{щ'dZ%IWFbhW1Z`(|饗tq(""BO{nOQQQm~p`b&F `bV7`-[z3mڴQ6mZmVxU9x""" ///(((HgVnnn...Vjj/^'xBAAAbbц 욧O>{qmzvZjF,kJNNVrr┐ ???C֘1c-P{LhĈ˓{9*//O˖-ӻᆱt 8Pɪ_5VM~.\]vk׮Zh{.:uꪵkתGv)22R;wfΜp:7PPP:w,OOO͜9pN:N޾}6m$I7n\DDDڷo/Iх ӭ[7M2Eݻwg͚j+Vخǎ[5JtY%&&^+T͛%]RۥKJDžخlR}W4I\]+a@@@{={VFVZiȐ!Zt-@ 'OJZjUƍK999:thJ~7ϙԗ_~YfO?=G:<'- ϝ;gv|I]mUESxxNܹspB)55Uھ}n&oݺu-uki|۵{=<<$IyyySu>s5jԨ{O< &hɒ%4m4}痿I\Lzzzڮϟ?_IRݺukTpssS\\mۦ{/PffZlimo>zuf|&_ڞm999.|jܸq$mܸQ\'OVzh>:w$ĉ:}<<<ԬY3uUӟ4l0թS k)@IjӦ͛y9t_>}dZgϘo^۷״ij4PS,@#L01@#L01@#L01@#L01@#L01@#L01@#L01@#L01@#L01@#L01@#L01@#L01@#L01@#L01@#L01@#L01@#L01@#L01@#L01@3exAEDD( @^^^QPPfϞ]\\T-^XO<񄂂!"Ţ 684_nn^uG^^^ PDDupf pAiΝzzQFڵf͚]6%]\Yfk׮jԨիnMO?vܩJ kVhӦ͛y9t_>}*=4{Ktڜ@ ӭ#L01@#L01@#L01@#L01@#L01@#L01@#L01@#L01@#pU;x""" ///(((HgVnn^ZÆ SVVZiذaZzC\pA/uM7CM4߮O>qZJ7YjF,kJNNVrr┐ ???5+>>뙙Ԋ+4~x-\P..UdΝzG{2:uJNݻ駟jQpUJIIш#%oooEGGk֭Zn&L IJOOu9uy[>H۷oG}@IR\\f̘Q<;wThhv-OOOMr_i @MXv=v ǸhԨQg*11ѡVU+W${޽n6Iʕ+eZ˼_TTwyGtvj ͛%I^^^ҥKBBBl[lquȑrTU'33S(֭[)IlN֭mח)$ٳG<׿ooqOjUܹskoojǗٵVFEuN>m:urrrOVAAۧӧbܹs>|~Wz&\Um!I˫:%5*SfΗ^zI͓u-_WttZ<K>>> ٳ:WְaԪU+yxxUV6lV^]cƌbϥf,9 W@WOOO_PP I[n)QQ4iD>lsL>]7x$?.wj*q7oݫ\9sFɊT``222jTXǏWxxVXL?^Zb5a;S*CR~}۵=zKVٳ]hҫ.Sz>}TUtq/PΒ#F(++Kފ֭[n:M0A&^@}Gھ}>#J4cƌjjѢvU埖-[V;ϤIcѢE?/zURg}6lpwkUk)___:uA9s-+}P=JQ]\ZP'N[oWg:uꪵkתGv)22R;wfΜpt͙3GԵkW}AAAD:tPzT~}kNGVbbs\U"ɶ*+Wj:TǨ'|R=ztv&+ݛ RLL֭BW_նm۔'*22R﯈C55}tIRrr|r%''k Vrr$ij׮]< $-^XrJ/'3&Olߚɓ'%="7أ*)}(JUu:;kjʔ)֭[-55UiiiSvv222tRWÆ t&?W_}%6˅}f3\|rVQQQ+!!A7\'::ZǏ{ァ=Cƌ7N/r,_\<֮]5kh͚5/~*`ooj{yy)''GٵV$dTaŢݻkРA뮻ԬY3k׮]ە0mڴɶRկ_?['Nƍ;ԩSZb jR}WI۶m+WdΖ 4H=իWm_gϞԩS%IfR&M8Nw{3 p4hvܩ%$$rwwɓ'/(>>^֯_'O… :zV^~Xw+1jb]%$ۮK+ڳgn&յ_+wop( 222={خ۷ooƥ8N g-g\)))1bhmݺU֭ӄ $I8p`Fz/Q}o߮>ȶ(..N3fph7|S۷oWӦMq_{$ >\_}~}W>|$iڵ1+..Nw7o.y{{nرc>1@ծ]%{ѫ*IZ`Am͂guW^ڴirrrc}+/ފWm۪E:rH[rKܲeK|)Q-ZÙhԩ˓֮]=z۷ڵkHkܹ9s55gIR׮]Vi QrrfϞ{L~~~{a͘1CEgѣ祗^j~|@ 8P/^z%%$$O?՟'?VZ#G*++Znn8%$$}TX?--i[9~x-\P..908pyJOOWzz/^)S(&&_5PԩSͮ[[#vm^~;&⡆ wop:t7h-0,..ҥK%]\P Ţ!C֞={m6u޽ܸm۶V2-\vbh믿$zjٲy\}vmڴI4nܸ2_-ZHiii?/777_D ,(^zZ`zB͟?_oV>:wƎ{EEEz%]<^pw-^X^{Z KV_[=BCCe˖wߵLNN.ԭު}_WJJt 7W^{ҫ/?^ . =3t9m޼YsUNN,X-Zo :hӦM]% fUR_~?\\?mwjw_̀-p֭z-IWRRR1sUZZ+x. 6T%kڴiS$iʔ))SH\]]5mڴrsl۶MGXV͘1C|$;pU\Eo='t,Xv=v ǸhԨQg*11ѡVU+W$TI޽n6Iʕ+=O?՗_~)___͞=ۮ^~gT:ulرCk~G]2**J=zP߾}_]l/t-[C)((H=6oެ]JfϞm/]}i+W¿=z~ѣս{w_?qFۿf͚BKlJ*]%SiWImGMu$&&FuUaaꫯj۶mJLLĉ)IWDD>}$)99YZ||r+99Y4}tk׮նm[ :Tom۶)66V=zPtt|} {8eiFO=>#%%%7|3fSNJIIt?|ICټy$K]tt\U[lqm'vWL8pqz)I믿n;8:N]7k֬ʱ/Y%L,Y .8\՗l/Qr̘1vT~=\l.]tK8 1C]/Z1U"ɶ"cWŋeZSpDFC_ :˗A.2D%$$&Iz$]܎VzeHI6n8˕QPP+Wj۷.C'Nw}'I馛vZU:ϑ#G`=#ٳտEGGɓx@V ࢒pϯҭIŕ{cҫJS:>=޽{Wr"z^2/z[})=[ntܭZ=w=*Zݣ&x \g ;w*&&F :|ÇkɪW^j(>>^}z5[)/b` WW C;vخuVmMG\/gΜM6IR߿BCCDM>]ӝwi&"@8M^i&hǎ+WNѶm[hBG)3OEJB-[ov3?tq%bϞ=:jޓtKWU?իW/kŚ?~`6pCW_$-ZXK.$5jHհX,2d~mٳG۶mSˍ۶mm!Cʜ0kC8mJFŋ;gi_%I'NT:u UmoӧOKK.v q[nݻWRRR1sUZZ$iԩrss+ dXdX4f̘ L6M2!yyy2e+MVUJ[~"""j^zImeey*R۫/KUpBp SĨnݺ*,,TXX^}Um۶M8q"##%, KNNVpp/_d-_\JNNtqYv*ѩS' :Tnݪ;v/ШQԿGq4iСEU8Y/%V_VĞ՗UٿmѣmϜ94h IJJJ2,VzB ՠAegg+**J=zP߾}+bPP GEGG$衇RPPz!Hƍ_~.\ʕ++88X]vGVqq:vK 8PtEGGW8̙3zgm?UpAiΝzzQFڵf͚ը㕐!CErwwW-4d?Q\\\\j?y4vXuQ>>>rwwW˖-5`{JII,'^o/^z.Z+V_{ڳgf͚>}n֭mԊ+7ߨqƵW#Ց_T>֭[K:VZ]᎜%\K<-\f~;.+26J:W&{Sn>xx %$$СCЭު|PO>9!5zj׉'t 7(((H? Pcƌђ%K쪳~|Nݾ+ui]WUVٞyR"77WJNNV\\jX?˼LXBǏ… /˶2 SRR4b[=BCCe˖wUzzd>~m_``"##u뭷j߾}zו8p zWEZfMcZliO2t{ۛt ׭mGS*//OZvza{o߾j׮"##sj̙HOOל9s$I]vշ~uJ4x`(99Ygc=VjC777u>{f۵i&IҸqʄ%"""Ծ}{IRLL.\p_*,,$-XW,X I*,,8i+Vخǎ[5JtY%&&:Tjjʕu޽qݻwm&IZr8hWi͛7KԥKJDžخlPȑ#橪Nff8PYLIZ c ISN)$$DPu}7TnnC2! :y$UVUmܸC9Tönv]Ul};cǎiڵzgϞYQ9zyp0Ex9۵wKZebQ5h u]j֬k.kTXX6mڤ@zʆ>"Ϸ]W;CWkuJjTVgjԨQ{ &hƌzW+99Yš~SW;@TnZSR:%,wiݺuuV;ou[=nݺ94'-ׯog[oNN$ SRH'NԺu$I7nt89vJwp\Lq |}}%Uř3glᜣ+UW;С:33bt1,۴i222TXX(W׊?ڞ={l۷wFE8N ۗ~[.JӘb$Kŭ;vtƍm׎nm۶ZhQn|-[ovNTuI] j^hQctRI ubѐ!C$]\m۶ m۶ͶpȐ!W-\vbh\LvM{$+))ܘs*--M4uTyÆ X,X,3fLuM:uHL2iʔ)$WWWM6۶mѣG+,VU3f7|#I;^H&z$(88Xyyy STTBCCe˖)66V﯈C55}tkJNNVpp}Yzڷof͚Iծ]rsk?߿:tF@;w{ァNT^=<* 5rHeee)**%$$~DGGzzܘq_t\R+WtM7ݤ?PAAA{T$ 4H;wTLLtaOÇɓU^pqqQ||xɓ'դIiĉ0`@;V͚5SRRvܩǏԩSruuU&Mt]wiРAzGY^p}3](ImڴѼy4o<ӧVh{jӦ&MI&9|/<@#L01@#L01@#L01@#L01@#L01@#L01@#L01@#L01@#L01@#L01@#L01@#L01@#L01@ޝGUU^GTEM0@^RCKS05!1-453g3rB T@qDu"wfu:r{=0&tDDDDDDDDD: @""""""""" cH1HDDDDDDDDØ$""""""""aL0&tDDDDDDDDD: @""""""""" cH1HDDDDDDDDØ$""""""""aL0&tDDDDDDDDD: @""""""""" cH1HDDDDDDDDØ$""""""""aL0L>x_~%ڶm sssԩS...Xb+#G vvv066p(,,ĦMЫW/ԯ__^a}%"""""""7Auw"33Svϟ/&:u]v!** vBN[l QTT///17tP9rϟǚ5k`cc}:rrr```cǎ[n>}u֘={6rJ|SNXb|}}6ܶmΜ92e ֯_/suuᅬ.] ##}n޼ sQ+$q#IDATЙQQQ8}4`„ R_~%ڵkXz5 4nFaa!`ڵbO k׮PߪU#M"֩S+V(sUV7oݻ믿4+$-c^xPmMn9777i AJ͛7}3n8 @""""""""҆$iѥKgjFBBԣ$ܿ_n_UӰaC888hW"""""""""@uZRV^۶m˼G]7nܐ[hSObb"+#"-- `gglڵann,$&&jΣGcU4iD<~m=k_ydv5]aFZuw(*xzT~b,U8_}|`>Ճ1JHG'ⱅ˗/+sssv*UdjT7ѫ*,/:1Wyz_]t1Χyr\Heyccc@NNN#mC^;U*:1D//`jjZiHېΫ[zTQ5977nB P~}k'骔qlTT5jT=""ODJӉx4YfLֶ ;^mz%գMSDF^?DDqd҉)&&&[.Ջ8>\LiFP:M=4 @x뭷wUɭ[viƫhڎ64iҤԆ DDDDDDDDDЙ`Ϟ=Lxrq=4jE-Sy>͛7… HKKCz???v_sNܼyYYYE߾}1}to^k'"""""""" @"""""""""*SkQiL0&tDDDDDDDDD: @""""""""" cH1HDDDDDDDDØ$""""""" ~muwH  ::/F~`ggcccXXXǏǙ3g4ĉ7nZjsssX[[ظq#^|Y~oA`ee+++t| >}Z_)S]v1p@OHMMպnC/###4hXt)o߾]055U;V~W3… ===m 8❴ͅ VI{D0d- ^ziӦ(**BDDV\$l߾عsº။/ {{{#11ؿA!55/:~ )))4h.^;;;/5 O>5jڶm $''ܹsطo_I333q]lذxXhBnG}aСر#֭t Arr2f̘qƕG;vDjj*+ZUDoܸ1bccUl2>>>ZY64h...8t)3|:uٳg˗/c˖-_>rWb1ѽ{wk5B:ulڴ  v]'SQgϞŸqP\\ ===>|m۶x<==q5lR>W3/EW_ Xz5BCC___wtZug r 8PسgPXX(|jj ~K1cccc!88Xab@>K ݻ={}||nG믿DظqҲZ#-łXnԩr˝8qBf\ TnBB0|pں̹G 6m.]$|||85R1^BV XZZ ZyŊ¾}?S#qm@ 8;;sVV, ;whVMقا剨zUT8pXfr|*:ӧ@hڴZ|aa0h PR- @"<(ޤM&ӧ2+V~ﯰ\U[񂡡anԡ·ۻwڴiS|VV8BOOO Sm۶U @"ݣNWѣbǏVNpbrDDDeLu[Kծ]>[N:P'׮][ ԭ[Wa=/^ܹV}g˗ݵk2@0`Vh @ wⱢ֭X[[O?8p+,'$]$]7oFAA0o??QQQnݺHe[yyyָ-(,,G}QF!<</^@VV_ŋK.JR9r$۷#!!999ĵk?,>333 2@W>|GZ]3&qvʜ|NNN3P^=4m-Z5<<<Vܸq@7 6TXQFܼySvRSSq=%הEw?S+\JJ |Ȗ+W(y>OUWe߾}3VY@ubK6uՄ_\\(߿R$N4iӧشi2}]򚪪g8x?sLqA6m .ĉ1|puoooKAaǎ8{,"""zj4mYYY.iSXX(Nzsq`7\qq1~ U\\ gggܹsT|8q'OIJe0g#Pk&MrR"{MfffpwwGddd2ň~ɓ'We˖ޥƊ(tR "ݡNWEv벜^4iD<~͛7ǃkٲ%Oqt"ԍ8s oߎSŋ2?ڵkcŊJF000G;v=Dt<==U]$zcܹJ>plܸQn9ٵ%rssCƨ_>&MCAO$̛7OԥS!ell ѸWݻ7LMMQV-9aaa¢Eip H$B.]o<==ڵkzÍ "xe~w1.ZH%vY55;v ?666Xx1NZO"Z7ob툍{>""HJJҺOUL_ĉJRs7n7hyu4d܍(uN:{-ޜq$!C`ff-z30H~:PXX*u Cjrٳ͛7>T(#mKǤ jݎŋ[nɓ'(5(W[[[̘1Cu(Kvƪޜ^?xevC5j~"h6uUgwpp#:t޽{cv~mر=z`5i?}4u놃qرc?~|$&&b033ݻׯkկxx'sqqQZU9uVKW_# KuU\/V:Oցĸ$ (!!ϡݻw+QIѩS'eep}$u(j(qQ&&YΈEll,^cǎ믿5{eWn]-WHi­[FZ*G6ƫ&ܴzb|ڵm6իWWIDTq4yyy9r$ѰaCDFFbhР agg)SԩS011Arrgʌϟ?GīBA IӔYH޹sb/M h#&0xw D[\Bv\MMMMոeUDxl}OZJ=<(+5277#,^gΜ%0j(dddz8ҥK.mb"R*Uم൹zb|vкuk%=CxQqZi۾}{=pEhܿzx/H~tb8xה)S%M ]v?où$z]Vu۷eoZȞf[o u"~f֭[@_:⢿RVVVر#۸{n^_xy {n%-R^SI%QȞ0jr_>(%jm͛7Ν;+-+Ɲ8*OU=;88KUfj#kժ%~i%oԡ;wo>q:4,&߿?nܸBHfдiSn!]7nq?{)KwG\=4nݺuddd --Ma^ТE %;ڧ(2v$&Wyb#M]v W^ѵkrA @7@vv6(N+?>̙Q~!dɓ'?c,N PXNq;_q,ydW^ZCCCq,Zy___qU~%]ȟtOEWNv$HĩpnBddrOOrMR& .#NyKr#%dߧ|2d)=z$n׷o_("]K/66/_VXn֭ uYyRkcĈ?=GeD~ ӵ@prr˔ٱc.IHH[f̘1b2+[GBBX]nLF IIIeʄ Q(..[*"'4nX X[[ /^(uĉ@077ۧ#Fꧏʿe=}T022&ԉ#BCCUn߾-oggg!;;lY qqqjWWU?pE}Ю];JQxsL XZZ W^[b,jܸPTTTLMz`mm-5k&:_XX( 4H'44Tn=\rEH$}˗eb; )aaÄ\m k׮U/ФIabݷoI^9Hc#Gıc} p5卌PM6ŋ1{lsAo6nd-WG6hbҥ8z(RSS1rHDGG>:t+WPnђ%Knk֬ȑ# ̛7]vE^^=~ EEE000M*utP9s&>sc͚5}Ŗ-[燬,x{{ ر#ԩtܻwGŁkk2m|̢N۷ݱcGq*"**ڽ{82F]rW\{h)ooo:88`֬Y=z`Μ9G||}*O>AQ^=;"889rd^_wt+===#$$lmmÇc˖-ZnFUÇ#((}z쉖-[FFF_>u놙3g"&&033k''Hj*\p&M#,--kkkt_|]3g|_hΜ9?M4lllၝ;w"$$&&&jǎ;ЫW/X[[ ڵW_}/e˖jճa`ڴiprrwر#&L}ڽYKKK 4H>FQ$$%yk#tDDDDDDDDD: @""""""""" cH1HDDDDDDDDØ$""""""""aL0&tDDDDDDDDD: @""""""""" cH IENDB`././@LongLink0000644000000000000000000000015000000000000011577 Lustar rootrootcloud-py-api-nc_py_api-d4a32c6/benchmarks/results/dav_download_stream_100mb__cache0_iters10__shurik.pngcloud-py-api-nc_py_api-d4a32c6/benchmarks/results/dav_download_stream_100mb__cache0_iters10__shurik.0000664000232200023220000017661714766056032034156 0ustar debalancedebalancePNG  IHDRjy9tEXtSoftwareMatplotlib version3.7.2, https://matplotlib.org/)] pHYsnu>IDATxwtUOBBIhAz!tQ:)`Q4A&(E*Б{ %T&$$,9&;wٝ;[< 0-yu#1#1#1@jذ<<<ᡵkצuqO6|p,e.gڵ{߰aô.T…ͺi]H@Poի5rH 6736mZפI G PF?(<<}Zm۶޽{:KW_} ȑC5jd{YfI:ux:$ۜ+*UJv~ܹkܸfϞ9siݫ֭[ٳ:^z%ZaE 6R[h:t`v[o+O< GGGkʕꫯqFIRddK>})S&sӧ')f͚iÆ kjg}`eȐ!6'O5vXݹs'A 2|i]$f֬Yѣ$I9s_]&74vX9R:wի7: ƪk׮f0/((H&LP֭pk׮鯿̙3=$X6@lOjWևotFJ;w$xݺKSRN06{ƥK즻x={v3?y߿4.[nu1u%IedՍ&ۻwucHj];+VnH˾ʹ˗/O!$=NN8ad˖|o *d>|͛gdϞݥsɓP6uT9hkٜ^vرiq.HM6sDƅ nݺu6TPoaƜ9st2eJpǏw9-& TlYh&L0~m͛n|iȐ!v{ФI'M6U_m=g]~=I}O?|}}S,Xߟ~2gp={4vv+Mzj*U۵k,X0Ѵ3f0[{6h@r%)>7nܨ{1beΙ3uyO~CqigyF3g4޿jŊr6mTdIWX1" 8M>]M6U޼y… M6Z`A=s>cժUKyQƌ'OժUKÆ K^-Yі-[0mfPsv矛>lw*S%KUXQ|Aܰ_~]_}4h|G9sTʕ;.u!;V۷WR ooos&7|3IYYF]vUB|~8qbv̾٤IWʖ-ʕ+;j̘1:uvzzm6}tf>.)+O?f͚驧Rƌ={-U4`+WN9r䐏Լys}.w ;s&M.]|ʚ5 *hںuKyYsoݺѣGVZ欖EՠA~_CCCW_Fʕ+2gά2e߷'+=:~͹߫W/۷nW0pBo͞u~߿oNՎ;Էo_,YR~~~ʑ#jԨ#F(,,,YyGk׮5@Сʊ+ٿz̎z9}g_9rrzuD\?~yϟ_^^^H0SrbӍ/TѢEOѢE4 C3f0~饗_||ͺ{3_}\`A5*%״idm 6?672g,˗O5_|עsϙV֬YUtijJ_~8رCo*UdԚ7o^5h@#GL 8%tR_˗W``YWRE… ͡0m۶f>L ry۷W֭Ϳ'L`… rRc,[ nҥKF͚5ΚԮ];#,,,I]Y?siW_}0#Fi{=y| 0+WL:0I&>>>!00رc")O?dd͚!C70ݻ4^xY<<<\0 #66x嗝WlY)x60˟?Ͷ={ti;) |rv6۞={hذa 2֯_~Dg{vQL:عsQpayfϞصkŋm =]ɒ%]ڦD6?cGy.^h3wnY=X76̦<7oޜ?JKJ>ܮYf?DTxqլYS3f"))}-nݺ|#=H *MR&MRl7gu4胳޿_;wܹsmN ׶mt ݻwO_~]ɓ''ZǏ7۷gyFAAAy֯_o{=5^\nӦî)u_6mϪz*]>XzfIcvjeF_ʕSѢE%Kںut]KVΝsΩys玤z*^2gάwX\|9=FrTbEիڰaBCCu-uI3gTnݒ]G)~SZpڵjݺKSO=5j(G{9{*66VQQQ jӦ6h@ rY!v]hŶ}ѽ{wtsMWlloHYzG\ IM ճ[~2r2.] ݺu,Yi?k-7mddȐL/_Ie3$csI[ .47IƆ 7c }FDDM/,ϗmضm[3:7nܰ.22Xxѿ3 u @///Cѱc0߿oaF2el>O{߾}8p._|ƭ[믿6Nj\vaYׯ_o/^Y'ٖVqAo͛L; 4I&߷In:AO )!-}Ys;g-YZl`ȑ#5kt)ŋdΜnYY3fhKt7ni=RB{֬!)-˓&Mri؃ Jf9kh\G͛ט;w~s̱i\ԗN0OLLy>TΝ;gN0!asM۬/u+WvW>}̴sN>-?cc)ϑ=hwƚ5k֡QQQ_m~ײefw,Z;!66Xvѭ[7G߿oyըQæ%Edd1|pEqIOk8Ϟ=k̙L[H=ߢ~0~m+{KL|/`}믿6Ο?}"}##G4!:uw&Gm7)byv3ڴic-VXD0-Zi/^`l5j_%H駟?#>Fc޽ߗL[ti\ ZwcSәidb70[n5sԜ6ݸz0駟y/6h~&.n@) s՚֮]D[_T:SN]jԨ>%~ôMZy?7=s8JnFvcƌqiѣGԪU+w}\ߺukܹs Mzs8pf{҂+{rmn7o<_LfVԩS惧9r$:CW2e?x7rT6l`s,k׮M}͚5O\t<}5۾}ոqc3]Æ S0s{0pH)>M<9YW_}egĉ.`ժUǎKVY qZ<,~uրԖ~]`n*T(AWd]i}_m7MVFR^z?~m6 Ug? CMmҥ3.]r ooose˖%Hc}Q'0?Z׮]3/xiFZn^sZ0󩫇Öv>>ƕ+WDž sB .K6glܹ,"""lZtVTO8autN˖-knj'۔/_>A퍑cσ{߭YY~[oes$0G-#d'R+hgܸq.mӼyss;w&Xo}:lg-_ټ{I}5ki%u\re3]۶mSIw?WI=-kӦM>+WyoޥQoWTT)IߛHslY&4HZ}LR~`]DbƎkFw1ڵkܹzdi/ "k޼yZdƍUV)...A^w][ִi\MBBH Wa%KT9t ivm.'6Eݺu]Zt޽&0eEGGK9qBpp߯}ƍʑ#-7a5jPL-+<@Xb";Y,^X}>s@]9_<<ɜ9K%֤:_W[stbi'vE)b4h`>]ISH ծ]['OݻuuEEEɈWUaAfZ{˘RKRiݺu5jMS;YݺuS޼yZ;WZXS? Ŋտ\r?~o߮+W>,zink|PRGHM֭w/|la̜9.ERSAźcJٱc:$)ڭ[7 ..wj'|b<<<Թsg͙3G2[[}:IaÆ)444ES4?)2̋$M6ث)>}O>:z֭[M6iÆ :yi?֮]-[ش[Zn+Kk.@zꩧ߽[AݥaթSGCСC%כ;v3D'"p5H$12۴7k/_˗ )<ӣg&:a69eʔ1ĹrܻwO>rԔF믿R}oPyL>=A088Xf+_ռ}M>=S#}v3 S\D'^J%Kdɒׯ iN8O>=Hk WM6MӦM{|RZL\ &kuٌujĴh¦]`Hl )NbOMgݼyKeNh6 2O 띎g~߮_nGsΝ Lth2ra|*WlbpE՚5k˛7oN˭Ilr۷o'k'#G x9;xP%lJ{R8pl}!CfLϕ-[$:Ȏ;̖Җ-ӛ|LoOZp~O -nݺ9.^^^YKWnes2B g5r}I͗קfqL%Kѣmyh} gϞ $KkZj&%;wLrcƌ19U6Hl/b}C/$~ƌy]d^4ŋ.xn[n/`fgyF >Oza}ܻwN;""Bg϶$%4iRiϗ%K$:ի].%n\7VE@jMLQn]e˖MR,Yg؍ܔ)SM%l8g *0L%{  һfT^=E}6lh֟{ӦMٱ'~{'JRRb{j^oСCmZɓ6Juʳ>[o63ڻ>{gKrӧmHK!)qqq3gԲeKkɒ%mc)SOZn땐s|Nw{f;w^zp0͚5K 6RZpĈLgΜQڵu&O@2e4i$n:z*ViӦ):::A7n?V&M̮ٳgٳޯm߾]M6U5qDm۶A6{&&RИ1ce9rDի>Sծ][>>>}v+44ik2Iԩ֯_ &Ho+88XyիWj*o;2%Kl^w4@lpppVE:uW_wޑ$-ZHO=U`AݼySk֬l׮|M}Z`"##uiժUKkVɒ%-[Ϝ͙={jɒ%3g$i1csi͚5V"EԦM7.ޝѣGkʑ#yq뫫Wj߾}tR|_OO޼yUNm޼YQQQXZh|Ŋ{pB5iDN˗թS'̙SjR޼yenܸرcŶoLk׮ƍjѢTeCvҿ+)~fܹsk̙u MHHݖd7EqsZj2e<5w\իWOڽ{J(ƍ+:V^mިgɒEs5|g`լYSe˖UXXV^m}z= p… ;MӷoOZnO?n@͞=[?ݻGO=4i\rĉZn+.uNI:t0o{=-]Tʕgn=l0>}ZӧOa=z;UVMŊS̙ӧOk߾}MukÇR{jj۶.]^xAnRxx>}駪X+@ݹsG.]Ҏ;l>~ھ}$hSO~oӧOO㣕+WgϞf@xӦMjܸgϮ5j(wѕ+WtQ9r&2{wRgرcK>3˒%KT:uTP!jڵɓm<%Wdd&Oɓ'+ @*URBׯ浆?A!C͙3GM6ݻwUڵ?~eȐA7oԑ#Gt!sv:rz^7nLtO=/hzWL:5YJϞ={…~+Wl>|8E0l}gsLL38-o5gann>I=WS~ѣw69sݻ!Ci}M6tR=zT7nP@@ ,&M_vX6mt3F+Vٳg奠 խ[Wz{ޚ6mzɓ'kӦMzgϮŋSNzyWhhV^ 6hΝ:~]PB^:wt,I)Eؾ}wi͚5:yvz9rМ9stoZvN:Pyzz*[l*^T&MiӦ=Ԯ][ƍE-b˧U{$Mڵo>͘1CsѣG@,YR:uR=t^iʕ?f͚]vҥKSO=VZO>`ԟ~Zlɓ'kÆ x񢼽Uxqo^6"u͛6%uЪU+*QD/44Tׯ׺uc;vLW\QTT2gά jժСZnhWL2iҤIz/hk\\fͪEbŊjܸZhaRZbŊڽ{,X h˖-r޽,YhѢQZjO?VX;vh…ZjΟ?k׮KsVբE k!~ZnnݪÇ̙3} P@@ͫ~ZzlZa4?4n8I{qr)0#G:u`1FlpW6gc͚5?s܀%ئMըQ#Kǝ|ʗ/<\SyZn<.Hpc@@@@@yutQQQڿ$)W\cxܻwO׮]$UPAv9z ߿_5jHb ؾ}Wnw]7F P\۷+_|iXK.DE"/_|*P@it@@@@@yuvbbbw*&&Fqqqi]$2f(???+cƌi]$@*!2 Cׯ_Ӻ(ҐQ+W+W.#Ha@.]۷m^P ҨD0 k׮)&&FAAAiX*@j *K,I1 C Shh$ O s-s9wʝ;|}} O7oLRR'LDD-[+t>a߿/Ib? 2{ `X pc7Fpc7Fpc!]VڵkӺ8,Ӻ(p@ Y9sf*THm۶կ{uqu7oN"%.cƌʓ'7noF7oLHܡ{ݻw`ӗ_~[Z_s< "##uY_֭ԩ˗/uӧ=cƌ4*IʋիWzj;*[6nܘܱޛ;wͿgΜyRRWZ[uR鯞{8p d=ZO֎;ԦMmݺUl@ Ϛ%H]o?EFF?$+<<\sH˒USѣ8q6lؠ˗/UV:pϟ%\Waz.Bs#ߧ{ >۷F.A H ܹs|ZjW_ծ]TxqIqI$$|EiÆ 4ƍfP|ӢEԱcGUvm}WNߓiӦ?}5n8ժUK9s;pvddKUXQ~~~ Tݺu?*..W\tI+W$ue˖ڵk>|y>K-B r_9rPpp~7e(\<<<ԫW/IҎ;ԥK,XP*XzÇ?hѢ u$izUn]̙Sʖ-*U~[gϞM4;wO>*YWZU… eFߪaÆʕ+#G*UJ>ƌӧO߿YCݲX>m۶ffG+Dʜ9T\96ezӧͼM&I7oZl yyyaÆ _հaCeϞ]*_ 9ZYk֬rʥ^zIŋSfXGu(a @ 6߿/Iznݺi_k8q,YҥK'Hw}uE111 שSqF-]T!!!6i6lؠ&'IW^իWu͞=[9s?/I\dɢ0]V-Z6::Z۶m3^vxs R׮]5|oܸ[j֭xbUT)֮_viϞ=\|Y5ҡC"""yfm޼Ys[ot?3f驮]Ju릏?X'|N:MĉkwڵkvZ-X@f͒Y6m~7͜9lՒ\RIҧ~O>$A۷ok޽ڻw&M_~Eڵرco'̟?^ϟ׮]4qDݹsGK.I&:xv7o͛7uQ-[L/^7|#Iʐ!ի%Khڵvc]_^qqq&O}Լys9rDfR>}l͙3Lf͚O4iz+AkӦMx`~jR.]4vXEDDجkРbKK&MhӦMvo[<5m15nXK,Qƌ͚5Sڵ+ƍz뭷'x,ۧ)SؼoUT1iΝW^yEuUVUvԧO8g۷O޽6mڤ;w*[Ӽvء_U]t1_V^xկ__{շ~>}|vػw *[*o޼<7of͚)66V r8t@bk wo߾6lMkh);٦MkU.\/2AK?SqqqӖ-['OW߾}umeΜ|=**J .$ 2lgUVOԕݽ{qFV۶mSTTfͪ+jZvM}gڵkzw$oݺU 4׭[W[Vu]+:uTb߾}ѣ9 ƒ߯NR{n:qƪSzi7UXjْv1ݻw_|͞=[ rzLqTݻwn:5ml)RP!uI5iD[$-X ˗/Kԩ˃O-Ixyy)K,6Y.F-7,-]7oZjټ`ܹs'h3aI]_Njׯ4i")~K.9,FMM8QR|p+ܱ[t9nAҥK-;+u(!>>Pي[SLq"Gt)f}gZY[LR@30sL1!Cխ[W̺ {`ܹs'hdye˦;,C߾}lcOV̺˶*TPժU>rz˗/f n۶Mǎs/Cx@ (Ry߿?P6mRΝܹsdɒP$>KWǏxzoNS^=-ZToF1b6mڤzyyٽ!޶m"##5kVU\ k̠ۃ'Oaj֬ 80O?pիWw?W-_#ϟ?5k8ѣǕ+WvZRJf;⪜9s{ ە̙3zTpae͚UEUW^1>XuEފVݺuժU+:pY-|||qO/^\,YҬ2c˖-5k*SL:^CKUJ-#ɣ… lc.:: JP!5cwM-ZHR|=D?3d )P!|l٢W*::ڬVZencC:u} ׯ_/gyFzJЭ0K,Zpmۦ!Cjժʐ!w}W%K4[YV9^ރu假BKכy؛؂]= e*U.]*I:}ߞ֡ NYfϞ][n5[d͚զJb-EUV֭[ڴi^u3޽;v2dP۶mO?رcx~gs|;w V$j͒l[<8uw/ȭ߷trzwukzAsQTTT ׼yw{ayyyAZt?^ [)SAAA*S:?C#G{n &:u6m$)/6[nzj]pAOfgݱc9Aۍ7h"k\vORY껨(D?""B3gL> ,%KjIodpرvxX=XvΝk~&Mѭ[4o}ڦiѢ9f(݀@ ѣf 4w}͛7koTbEtȊ+… Z&L;vhƍ:tZhav0aBR9Q:u￯UV)$$DӧOW͚5onݺ7nϯN:~кugYFFRݺu[={V 6TrjڱcvءysVT|ha2.00fkrҨQ$IϟWժU5n8m߾]7o֧~z)<<\R:xüK.y9|@ѰaCZjGђ;tРAu>#]rEof4>>>|X{Y_H*VC Ow"##JJ*7;v9s5k4Z?3g~  *^zI7oNthȑjժʖ-@eȐAٲeSZ4l09rD-Zܹs5p@ժUKO=|}}… SN￵`X[u"1|}}5o@*^2f̨9r(Gi]f@@@@@@@@@@@@@@[8s挆 ҥKO9rP5j(EDD>N>{OUVUl9rN:OuUۇkoԩS')RDP…]*C\\֯_CaÆʛ72f̨,Y|4hۗG ܅aFZIsy,XPt9(P K-Zݻ+,,%Kj*^x1sL_ȑCgVӦMڵk+TN>h9z);wiOOOꫯh8;s挾[-^XΝ+N:iʜ9CӚ4iV\'Nݻ PҥբE 0@sv}xxvڥ۷kڱcYZKo߮;w}Æ Hq5F( ݻwsΊ>+22Rg֏?GSHHM6W^z6m(((HgϞӵh"ݸqCmڴсThXsȡjժi w,/^$/^\:tPݺuHYFcǎ͛7_+C /|(""B!!! є)SR͛7emٲEǏwUV̙3.}v 2Deʔ$?^I͛%I4h4ei^yu塺"6mٳ';pw"##+VhС]5jɓ'믿$=zTGN>F8Iw}YmڴQաC-\Po$)22RcƌO׮]믿رc 2@bⱵqFIV0] M6%y?J$:ua0]~&}Z63dȐ% =O"p@<:$)~B //ٔ.]:6I1`IRhh~i>ʺue 8F(ٳg)_6/~iѢE ѼyԮ];}7jҤIR"""4n8Iڴife =< ts?`xxx!CM>]*V)Su ,Ppp}I?%{:{`ePPPRs\+XTT1cDH79:3fhvoٲE?ʔ)'kk֬Y%wM`$<{C=jժKM2ESLIC4DwM7jƌ>>*V:u+sӧOkҤIZrN8w* @KV-4`Ν; vE{ 3_PHHBBB4e-^XŋO>fΜ+227oj˖-ڲeƏٳgiӦ+-Opܹ/B7o֪Uԯ_?IѣGsΝ;ǦMԫW/EFFS{ւ }vjժ$ƍjӦN8zdcĈ$}wզMU^]:t… [oI"##5f̘98 &}vmذAԧOծ];A!CL2+666ټy$)00P ?6lْ})m ,0{m7z!Iu֬YH)0M֬Y3gN@Zpƍ%I~~~Zt 407mڔ*UJt)itu@ZpC$IŋɍK.`0`$)44T?4}Y@Zp){DEE- (4m移wܹsI/7jƌ͛7`R[l٭^ Y+Ww.Iڹsݛ)-TlYIu=>l.)S&I8t萹\JiVjw6zI޻sN֭[g.׭[7I΂kw;Qr`۶mSM3fHe˦$H" 4Zo2Zl۶ۯwv[ fɒEo-[J*I2 \`swvxzzG[ni͚5h ĘEuXbvI ///J@j!mܸQt ˛6mJrST)sɓӝ8q6Ij*]~]TtdZe$/^iX eݻ1cF̙Sj҇~ .8ݮB S$iڴixb4wѸq$ŷl֬s<Ç]v믿y< LDEEܜͼ+O移wܹsߵkךˡ նm4zh7NwԩSբE :uJUT1gҁ_ԩSʙ3f͚3:-']!C?^Kqpɝ;we˄X_ѢEվ}{ծ][ ߕwܹ?CC5?sӦMIٳg}d&}vmذAԧOծ];A!CL2+666ٸqfΜ)Iպuki=<<<+Rϣ YFʕӫիWŋիW{n}zwn{n+WNo֮]ׯ+66Vwс4qDUTI#G|GЭ[f5k,I%ŏKꫯ&,DFF_|ٳg4= 麗˗/WPP {rJjJQQQʖ-  *w܊СCʕ+vu떞}Ys3<hѢ~M0AzUP!?`vիWO6lݻwsNլYn:.uM\vMׯ_$A+q-u+ nܸq$OUVuAM1kڴ9uEݿ_y R˃X|nԨJ(w}`%KpڵkzJ*iٲeʓ'Muo߾'2e{4g-[Tpp?'mjĈS_{]E}9!!BK*HoR\A:HGE@$RTA!H b)Ҥ I#_b$V:{eN9kѢEB M6Yҗs;&ϩRJ?uE߿?Ց.]2T믿6]zm;xTZ5Ondj׮]K]DDFbŊ|Tվ}{mذ!.ʮeW^ŋo6YX޼ySjv.o]vUz$Ig6|Fe˖ &+3sLQF+ 6<8ڹsgXz&O,)aft|=z?`s=g{衇O8>>^cƌ1CĖ-[Z +dz`||֯_P={VQQQ:uJ*eybcc'Oy{{gdbbbMA+\|}}'Nd}ՠA_IܼyS֭SHHMqe )EϜ9:Kv-{/_ŋ%IW ,ՓrʩR6@y抎V4~$K̝;WR1cdpiFM6U.]tx⒤GjZ|9NqwvZ5h@>^ɓGٳDzO7P޽դIUPA>>>|vܩ hݒ ?5dLWs=l_|1I8o<=ӧ뛙fI<ÕV3ΥKӧOWLLhɪS"""__իW/{1*v{)88X'NTGyD#G,XjoOq$ɿEFFjZb8 .PB*S7o.]8qB۶mm۶i튈$M4)l]ի-II6Nӧ%J$} 6ȑ#Epziҥ߿"""4~deGCCCSaYf'Lx޼yfuMvҞ={R-[R%XBŊKSTSzu}L'|zXbpBpÆ ӫWoQ8iRSsBt>*222I۷ի'OI ;=SUZjx+ڵkjժUSTTy)88XUV^kȐ!>8wMR ʎ?~LʎemȐ!:x.\ jժj׮F[n5_׮][&LKJ>#8qbӃ @iԩZdI2迧z*3 ]tݻ`>>*]>CݺuK6v`)믿4rHI {yU^k׺=L+ӽnܸ!)aH _b-ZPϞ=|r߿_{Q:u\n'gΜQF\*mZp8TJ.]Z (=k^xQ/^o3gwS 'X g͚XժUKKZltt"qʴ^H>W ]we.4;j咤=C`z"B[@M2E5R Tpamذ!vLMv-{TreSM65(8zk-_\111zꩧp8RZxg)Sh*Q>3fh:{w]v`>|>[Zb֯_x*U4~x 80͑Xѣ] $2;%%3yJo˗/`FK\>.qgNw-Z&L۫pYteײԣG3ٳ6l O>ꫯwߙݞy={6Y^>!((H&LPr7o^ըQC7p͞=;DDDO?պuR<~Q}g2|`W@gȖ8TdjԨ!I:|pe8p| &\UfMu\\\eg (#J2w څv]Hk>Cz$%߃I:u:74ʥK&;~Y5kLsQllM#G͛xլYS6lP۶mSsq3Fs|}}UH5lP3f p8\Z`Anz\~KS .bŊj׮|Mgځf)t'/JJx -Z0d۸qyjB *_*i]܅RRw*愴v=|Ξ=Mv-{'||_H|uJߴv!.Z4h IڵkWpg՟)á`˪\THu]^n޼C#2nժUSyƍ`4[^xQ{ٳg奧zj_͛+::Z:t;wɘ1c,Q|yM| 9sի2 CFҫ궎K.ڽ{yG R 4}tܹSUVt;ӦMӖ-[4`UXQWÆ WJ?%`uۉsI Rhhh2,{¹ڹsgXz&O,)7_c~zzqMV&66VO?9ߣŝk>J -\0?~tpsiTw"YfM:\ӧy*RU\9[k֬EȐ;i@wq.aSX$ΝÇm}ɍ,(á?Pݻw[o7n4mT|LwU*T;Cwy'Cn:C;6mڔ k&0qdet-[fPeզM5mT]tѽޫŋKJʻ|r-_ܼo۩zsiҥڱcx >^M |Yfرcf{W> dY `zAőڷoڵkڹsΝ;8-ZTuUbO./^vpB:w.%KtĹ!pʸcكG:h֬Y)/_>^Z]t۵dɒwԩ,Y֭[kѢEz'>L}Y=裏\:܉'6qD?~\ŋ׹s-A=z<<<4l0[Eձc4{lhѢEӇ~Hߝ`bSNԩS4}t-_<ōN8a{^HK|MrvҰaôj*}WY6x kʔ):y򤼽UlYlRO>dmu $Y]hoWe'O*o޼Z~a=3Tw+44Taaa:s.\X.\X5k]rEEUF4x`#:{1;w֭[*""EI&4hPw9N\izS'H1cիHd}ݧ={jСZ`fϞaÆn/wRtx@:uҽޫEڵkڱc>c߿_S6mm6/_>7 VLLour8ڸq^|E8p@+W/Yȍر|} ۷OÇ+00\ t\駟vy˗K^;WnۅvU(P@S~2E/ )[&Ol@nq''xBqqqz\|y}~HiZáYfiٲe4(Ovo8hРd;7\zz[nW׮]Sb *T([l~ZO<.\GG֊+&%K͍͛7n{WO|^x!#ӓXB GjݺU|̙3Zvtu}Ǻv횾 KכX _u9ҥKy{6m]hS o]hϟ?UVwMqv[{?{{~YW^1ր5kvܩ7.ͥ%onxBԤIh͚5<@rٽ`ZN80zS 4oHرڹq;ewJVĕֹY3yaNj矛;g"uٶխ[$鶘=r.?ٳ8?`{???; @JEG(Om۶us$>o>&NU5j0_epi]Kcϟܟ܎k&0qG`;VRBce-[Fڷo6oެxfq!@紺3]h%魷Jƕ+Wjʕ*T ŝŋ?PB^67nLě7o~33I___UPAH07̍lFٷobbbtE24ӹ׿\2!7nБ#G駟p+`3mZrKKM{ubuAkjѢEr3g4Q)6tվ}שm$ = M%<2:r[.Nj^}U2 C5jTz඗vM<r'./ܹsjܸq2e=ÊWΝoW^*ZG)00Py5Ʋrv'mX})-ЫW/zTX8Hwfooo-Zr=˓ 0RYZ_^^^j޼;AN[~Æ ?ɣ9sX^wz7$IzgTT)͛WzwUpaM2<'3ˎHٝuʕTu떆 fK.)N}衇TV-Iһᆱ?3YߖYϞ=Uݻw7_ϟ??2v `]eEt;f~=zTR0˵k&9p={Vڴi:uꔥ|jp޸qisϩnݺj^SHHڷodڽ{ʗ/op™j@?~ƪC6mnݪkSf4m4} SXX+=#ڵ!Hj.\PʕS~'_~͛7+00Pu… %%gb<<<4{l͛WjѢy[n?5oܼ'>JOn `4g>|8!O?t}S+TK.1q'-b :tH^^^QFn%w^LƍURd۶mmƍ:s P2eYf͚nFII^~]/ŋS-Svm-Y${S-xb 2DW\I񚽼{O>j޼աC?^mڴQtt,YsJ\ۦM5mT]tѽkzQ-_\˗/7Cخ]k׮jݺn,XPׯ_5w\= GZzFlٲ֙3g?*(( _y,}i'ww?NY9֭[z'ҭ믿_-)aj[ZުXbo߾|ݨQt`͝qTn]j߾}:.]$ooo(QB 4P޽գGɓ'~K 6￯`8qB*WڵkѣGjժ9#rc{aK#FЬY,ߣqxwv~GIJk1qoXzuU^]GT=/_^3fЌ3V)~MǏׅ  rjٲjӈw;eTb6l06i$2m4sߑ#GZ%ҥt钩:m۶t8 |>9ߴi4h 5m4IWwo ҂ ˗U^T"r-]zUŊK_|ӧKJ"/й{LZqMmj/] ړ]t)իWU|yC;vw-9rD_~;IRŊg@*,o:|\"á*UR'YKttt7o\_|k#]ӥKe *THSHHvءg*&&F%JPÆ 裏W^ #ynrYw}Ν;t@npI'Nlٲ9#rpNw!W sNw!Wc pb pnZyjN1sjF)Yy]pAIʦPZh!I S d[7o^IӧI ׳ 7o$ trJsʖ>p͛Iz衇cǎL25X<|p͚5Kqqq*]zjN:RJ˭6 ,!!! $?^ꫯ<a短4 KSΝ+)adߔ)S\:QF?J,4044TC?-[VtY+MRx9IRŊ]>KkIX }}}%Iϟw'OJ)bIX +W,IڷoYFTfM+MRءC?P߷o,X áN:Yi{N:r䈞z4駟ԡCĨH"z',w@xZ9D3g ܹy<00Pah˖-:p Ð,X ???u@,ԯ_?yyyi:q>c9IҼy$IaHp$!!gi #<Ç7P'Oa~լYS>=z\dySѢE5qDM8QtTHyyy,t+f3gΨTRl@*,M~t |rծ]J,_^uѲe\*u  бcԪU+M8Qqqq:x7nӧ+..NŊӷ~s @Iʟ?̙+WꮻR\\|Mխ[W׎;d|AٳG]vugrԥKٳGm۶aڷo>@*Q; 2JΝ;p0 9*""Ba d*q㆞{9uIΝРAﯛ7oj„ jӦN8ݻU~}}2 Cʕ?k׮]jժ ЦMTN}7X ~m5nXa۷vڥ_0ޒ^o߾z[/@,cǎՍ7T`A}/رcuVU^]a諯R:uq<׮]cYnݺڱcyIɓ'6 ,SNU|y{{{Ӛ5kTdI+MI:t={X:@YlU"EI ײ40K-RhhΞ=h㏪ZYf޽VZeI.kر T|| Ð͛7CSǎS2e2k.<x5kTti;ղ:uRJ˗[m@Y CBB$I?~W_y?,0?[iϝ;WRȾ)StNF$I&X`i`hhq)[$ٳV`ܹs+|$)66J,JΟ?9'O$)RJ,V\Yo>Yf$f͚V`C2 C~-o>-X@C:u$ ,=|}}u=SiO?CQ"EOX,rR%4g 8PAAAչsgx`` Ж-[t!-X@~~~n<Y %_~u }r8yI Ð$i…IBBY`GyDo+O<2 Y^y>|X=zpW@Ejĉ8qu%ũH"rGX01+V̝UȄLMp{#l1@#l1@#l3\tI׺uw^]tITHժUKڵӐ!CTHLw@d*/***JdyԩS:}֮]_]3gԓO>[o &ի%KJΞ=;wիԈ#t;==.K޽{5qDRJiƌz啤\ll-[^zIO֫Ν;f͚n<Y>P\\.KIP/^\qqq2ir8zWT|t˗+WNƍa $ ,N$5ks7o.I:}&X`)̓'5\Р&X`)sNt^gYW pK`e~mٳ'{Ռ3p8ԡC+MR8zhy{{jѢ~m]x1Y/V˖-u5y{{kљ3yZ9B 5d]~]ƍ/J*xr8t1!0p83F@I8p-Ç2 CGѣG%IaeK.sSN1Y%s 7|ui޽t$H"Uڵkݻ-L驇~X?;,m`)𐧧9G1=,LGv } &\+ .H|}}I ThH*UdIҎ+WN:tW޸qCΝS||t^ĥ0<<< ЩS2X&M4v :A%~…r8ڵ *yCSRԬY3mۖM@lR8$/\P4uTըQ.6i$IR#; 6F `c6F `cyhm߾]gϞUTTwﮂ jĉ?~-[[n?oРjԨa~?XZvGf"S7իWO/͛7e Hl.]{nZvK+Wԭ[7]tI%KG}={Zxz%Iz ,M~t9+VL*_|kN+WԶm۬4 K#WZ%á^xOj֬)I:r䈕&X`)<|$w… K"""4 K`LL$s"##%I$ ,ŋ$;vsIRҥ4 K`ƍ%Ik֬qaOp8ԲeK+MRد_?/ٗ1ch׮]AYinݺM6<ٳgܹsX>}Z˖-S˖-(á={Yfn<yZ=믿<;wg3<#!IW^aI&Z`A: c,B )44T ,(0Rʟ?Ǝ 6ם}#%)o޼:uƏ7*,,LΝS\\-z]vwWd@@'___uI:urGuvq{ ։'*UGȑ#6TV-;vLTB #NVZ+""YTTy)88XUVuk kގvܩ>}(""B~~~:u~W'$:tH;wֵkᆱ|@nȬLxBCCuQ]vMqqqke5JڵkմiSX۶mUZ5;V̙3g͸8=ӤIp ٳg /믿Vlll͊p۶mڴi$O93F &+Sj5n8e>,M>5kK֭[2 #C_Yo5_2$28p$ʕ+Z~}<~fΙ3GyT}Y 'Mp~X?.^8ǧ6o,)aGZUV-[dͧ~Z0`Zn`i իp84`-X]fU3˺{cŒ%K߫pš9szd)<$iСnU111p$lٲi-\|}}'NXj=z$魷]weԜ ШQTNuEMM>s5j%m^,5k5h uAS@@ ])ɵ‰M:UTrod:,Էo_UVM;wV5TN'6|hѢxbh\| 3%IڵӪUR,;22RK,$/^\m۶P[;X:^x}w׮]ڵkW%$ըQC6mÇ+Oϔ/իg kip+Ijժ r߯0 I p *$KdZ-i&EFFjjܸq6nhn޼yvuɓ'ܹsИ1cObŊnZt]ӦM0B/0>>^-$*THmڴPΰ3-+VUBg~, ШQ/?IjԨZl)I Rhhh23g%IFW6l@V4$˭ɬ@5o\СƏ6m(::ZK,ܹs%I3fLzRJ)<<\yuw2^zZt﯈?>Y@9C {Yܾ}{IθC.]{n=  * hڹsV//^ӧO>*R)*T;Cwy'Cnڥ>XXjU}7v횚7o~FmVTX1>>ip8bYd)ܰaa|mۖ9Ca$9@ֲy@?K36FXk˧s+ VTIp8V.Y'0 %pFs4ACjРjԨaϥ] ܙ\ ܙ#l1@<]-8dfAáL }.aaan0 9L5.ade?dܻw3 6l1@#l1 ;vLTL, J7PBv@` 0`c6F `c6F `c6F `c6F `c6F `c6F `c6F `c6F `c6F `c6F `c6F `c6F `c6F `c6F `c6F `c6F `c6F `c6F `c6F `c6F `c6F `c6F `c6F `c6F `c6F `c6F `c6F `c6F `c6F `c6F `c6F `c ?1c{) jƌTQQQZbF pRѢEմiS:{ϜZJWDD(),,LSppVwޭ͛Ɏ]tI[n֭[5k,͝;W}Եe;wT>}!???M:UBBBOH:Ν;ڵk?"" 7oiӦ駟~Ҏ;?jPDD5kָQF)::ZZv6mjk۶UcСC9s^ GyD&MR5С|ACqqqzg_pdKl3p۶mڴi$O93FիW$֭[jYfZtiSnԳgOIґ#Gs mo|=dȐxxxh+WhYҗ6mژ9%mMyfIׯjVZlْ%}q:O]fK3~[q 6LqqqSZ\rr1[L1_͛7ޒhgQXX$iРAҥ[2#gyf坛tϟm}6m͛'Ijذ>Cu75̙3jԨ{",Pڕi\.슏?XǏ/___!*[LΗ/-*) 4._lXk/O?-IP~'+V,`PjԨ!I:|bccS-wz4p@ǫTR an+ [h!)azS-qFu-GyD*Z~'URr}@VMؽ{wS,EI *6mXj_Unt T͚5-d%5R˖-%IAAA MVf̙ڿ$iԨQJr|Æ r8r8s&M_=m`)N]tݻ+ @>>>*T4hӧkΝZjNwTB;zw2t^֭eFڀv#@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l1@#l̖5fs=U"E԰aC͘1CQQQnkg͚5ѣʖ-+ooo-[V=zК5k9w[j﯈gQQQ SXX͛`UZrz'NҩSjذaaˌw[S;wT>}!???M:UBBBOH:Ν;ڵkۙ0aիWO_~mۦ/RՓ$͛7Oj/ [5j驵kתiӦ汶m۪Zj;v:3g_po-IjР~ϟ_԰aCuUZRXXf̘Cfj!m6mڴI' ƌիKu֭ *66V៓}IRllf͚6wMߚ b 8Pt_>Cm+WJ5i$rM4w-IZr P;&ܼy$WO\V[lPǎӧՓV;NRxxx6%IUVgKs=qվ}RbM@bbbtIRٲe,[pa*22R'NP;'O4_Nrיi'%;sLF\.j'otr[2.)'{/?pYsŽ?gqYv'΅{Vk~~~wׯ_ϲv|}}m'qxFen -C8ʽ^Is~fis]ϟWŊS͛7- IRgVIopLL8%J讻JsC 78s9v۶m*UTո@H*66Vϟ$ծ];rH (`vemdd$צ [mنv[_PJ@rJr>@Hڴl18_|*Zr|ed=)i0:2.%FÇɁիWƿqw;&lѢ۷oOƍ͛7P*URҥՓ_~ETLbY6`OL||-Z$I*Tڴi6u&)a֭[S,uVs`np82. 5j-[J̙3~IҨQ p8p84x=z#Izgxtt}YIF26$*U4m4mݺUׯ5vXIR@@ƌcK05o\K.UXX.]͛+,,LK/Zj8Ϝ;իWOK.U'+`(Pr;SNչs駟jΝzGy5em`Ptݻw+00P:yͫU3<#Lᡠ Ks .Xbjذ|MW =e˖a9 @6l #l1@#l1@Lp8r8zs+@;DXX&O:lٲ4dm޼9u[NVժU+wޚ={_~_pAԩ `S^{5]x1۶mOz*XTJuYΟ?onͫ%JUV:uΝ;r4|pծ][ŋ)ڵkkСou֭ϏΝ;kذaS<==;[^9/T]}UXr\~ISNUnTti֭[[7-k֬Q=?e˪GZf[aaa9s}QթSGJ ([ rr;5n8կ__ 2;͚5ɓ3tKOv>߿*T|dɒرK -[8pqƍttѭ[t۹sguVdɒ_T)~TN111ưa Ñ5}7p@QHL 6kvҥKOV믿yǎ|r;ǎsС>WX1z[jeޔ?x2l0#...S܎͛t?lDGGgw>,Zȟ?kf 0i$#ն:wmAg&Mʖӧ%IK?-[|򊋋ShhfΜSNiѢEu/^j]W^U}vIR=ԻwoUREyщ'qF}י'ԥK?^zCIV^wyGgΜQ.]}v-[r[7oL2ڣm۶ׯ˗OO֯˗gꚜ4h_vMG}[ҥKٳ٣J*X5|pstG&MԳgOխ[WEիWu} ӧ5zh <8I=a˧u:r[@qǽL2ڳgOmM6<РATD 5lPW\_Z&L IRz4vXUREG?ܹS]wݥ7|3N~ooojJ͚5SUT))RDϟ׮]4g;vL˖-,Y e |z7$IUTUvm>}ZZ~5t4\#HܹtR#666ϟ7O6nܘj] 0$ʕ+S-oܺurH2dǗ.]j4hv 0&NhH21{4޼yr;6%>>ݻYnȑ)[nIlٲ4=vѧO?ٱ~3gckРAlK6$ 0,yƌˍYzN+S={V4rHjN WYh:3aZr${Q&MR,פI}ݒ+W&n㽿@똘Ku>pMIJuy)XbIgTv>| gϞ))[ڵk'I ѵk,xbnZ jժI&ʕ+g4h*W,,XPkK/dNN,**J P~ҭ?44ܰ꣏>P`oMܸq|'Odo޼i>0o^$ĉ wBzI|l˖-Zl$S2e$%<9sFGUddzr8LX^+qQCF־vUbEZ9v^<~ԩ,v'^{ɖ6d?W;v,z"""t…$3*n޼m۶I6my֍7$)66V<맍7ʕ+ԟɓ'~i:Ĩo߾j۶-Zcǎ)::Z׮]޽{o+ @VJrw.)o IyK {"lbƍի';k.3]"""4zh+VL˗WJkÆ ˾}$%|XdT˕*UJ $߿??^GpM7ooҥKtҪR ,f͚iŊ$Μ9WJJxN,""B񇤄_T-}`_ӳ|rEEEI  ᶾe{~Wx?գG} :v%myL[lQhhU|yEFFwJȿHsPGJbcc;v H6z>Oz?0ǫA믿y֭[M6Mƍԟ'OJKz+WNgSғ|||ԪU+mݺ5IxW^1bD6mwIٳǜQ~,sޟe1R3ʕ3_[yqUN+VǏxrZb9:>0tPm޼Y-ȑ#}vuUJ> >aslFe3@vٳGG7nyPf]Bc޼y ;?IrI&0`Zl?SGN2]v*^Ν;ŋKuܹsta.l`֬Y0={q%_?mۦ;wNgϖ /Kiq155_v_SPPnݪFVTT._ŋtҒٳgp;vvء!C,VVM#GLRŋŋrWi͑#͚5SժUǬx g1q===5eF3>'O-\P˖-ӽޫyk׮jذzoVmڴO?)SXOv=d9sqΝb{ԃ>jah钤{.YTpa͘1CRă5<==Qk׮Mޒsu!!p7n/,)b)KVDLLڷoիWaÆ]wݥzJWG­W^hsqZq8y{{K3οVZZ~ڴiPB۷6l`t76nh.p8T`Aկ__ ,$uM6lP…Ŀ@FzOnR -{qHg]k׮՞={k.ŋk9rۃO߿_-Ҟ={R< :ur ;֭[')a>h:thgnQx-$ǜ#nݺ*ٹHݻ'Ͷw?S=zPll˧e˖:S\(ENZO8re1܎ɓS|VF!)aԠ<+.]ZG6G&xGޜ}dޟ>LRQzkN{I8W? @jR:uԦMڻw^}gj޼9! `3}شi6mUVL2tYݼyS'NЇ~(-YD5ҟi_9 7n0G5l0Ͳ5JX6mä%a=ީq:Α~}wH  p:v:t˗/+Oh+QDǜkeTJޯlw{g(Xxq8;ӧծ];>}ZC~ik<$^7#?>s֟x8MܦSP$ U5%ZjVZ]ڷoɓ'k*Pnܸ~)"""yk6Wر#S}XN){Xz3|>؝v^U&)agw>+?9gMuwޚ5k۷k׮]_v=|sbIZjaRz_O?ta/͟_tI?$O>lw .}:z$wf͚oB)I|ʛG5$IW^M6t=3gΘJV)V$e5VZ('N:,XPu֕$UaKR%w_eq5% `.eGǧ7;G*T0)FAŋ?_|9H  pz:v}Izd B *_$)<<<=JR2e2-Z;L$͛g///5mT .Z6ÇRJvfw ")Sؑdޟ`s{%PR%sc$_~p߯Xbv;Gdv34޺u+\]y5 Ms@g[fp|#[B(㉗^ڲeKgw^޽[MR7n6`O"**J;w6M0AƍPzZnŊoڵ9isᡮ]fIUJ_S˖-- ///sGH͚5+CS/>t^s. pǽOSNr8攷h֭)ۺu9[nᅴ#j׮m9/'$NibMsUv>t]RsM;ysܹsblţ>j/tIHw C$C1j(K?~ȗ/!ɨ]qde>3Ν;XOV2ǎK̀2˖-Kv24(:;fiժUe]f/^ܐd.]8uT26l0cH2jժeǧXWzӍ72e ʕ+I[0$Ӭƣ>jA{b/^4kd+ztZ~}4 4024h`H2<==C_Ϯ{ofl߾=sOpeǐd(Pؽ{w{UL#...Yŋ!ɨPq…$ccc.]_>z|~z2َ$cҤI|G~؈IͫW~j߾!(W1}tf{Ys>\o߾Zv$m۶zǵwT͛W~^|yMPP!zt55kL>ڷo… ʕ+'(>>^RcHʮg"Ehzꩧtq5nX&LPڵuiZ~?֭[[j{ȑ#(,,L 4иqTvm]zU˖-ܹsՠAZSO=~I|-[;vhjԨhÆ /_>=3ׯ_?O:qΩM&)a~@. $)'J|UP!^~e󓬔/n믩([%KL%K[nM|W>sQ-)}~muվaDFFŊ3$E5]̆ ZjwZ|ycHgWJH=oѸqcC'O̙3+I&e)@08cСi㏧8%#n{]o=:%^^^ƌ3R ky]:u2Ӭ#=7o4zj*U297o4F߃δDDDOrάY2u?riӦi˖-0`*V(oooaÆCkdFƍgꫪUڵkW_޽{ݶHȑ#c1BUVU:uhر:t萹fTv?/Ix92VZi׮]Zrx լYSŊCСCrJ9rD}ͶK$}tw*)88XݺuSҥ7o^.]ZݺuyYbEvoV=Zhʕ+Wy]wݥM_Ԯ]4|d˵}9͚5Kz)ժUK PIDATxwX6{U@bÂ( Ql+51KbL4O5ĮEcW (*bAw h9suy H+vDDDDDDDDDTr$""""""""bLi1&DDDDDDDDDZ @"""""""""- cHK̚5 f*p>@K;>v8{/HJ;"D1B>^e=U7dQǒ*lDXlu'''XXXUV+~7\r߹s ڷo *([,ի޽{?ɓ'VGFH+̜9S fΜY|0qKbŲM= QT/}+WNؽ{uGFF 5RY_$[2ѧ*99Y={`eegIؾ}~ڴi#WٳB"tQ #G 111 -[ϚTw*z(RKGvz0n8AOOO|.]7nh\||0|] O<)&~z """9ϟ?/spp@ڵQ|y#!!7n[/_Ān:|W*늍Eǎ3{ڵk5j8uRSS+xzzرcСC#}O<"""^R 4h[[[dggŋ~:^| SσpyײuVL2q/^SLANNZh+qqqիWt)))gqDŊ5.\NASumt111kzzzhժ`hhgϞ… HLL?~ضm맲xt׮]_355EfPZ5"!!͛7HMMEzzz0g @"""ШQ#4o͛7GrХKۣ]vSXꫯpU~:uB*U;x`1aÆ8|8}42331`&A\ޣG/^M4ӧOGݺuX|9mۆi7o شiS?͛'>\2~wxyy4_7o`߾}3g}cj1smiFF@__'OʦcΝBCC6m׫W!!!/Ԗ={8A)Fݻ[߿ѣ5?4^__3fѣG5*OD4gܼyS|e'O9 99cƌQXԩSp*[ U|y7?H3L#qVUjMժU̝;w0aԩSeʔA2eРA̘1ClR233 OOO888eʔAZ0j(lmmѬY3L8o.} cUʔ)]]]jժСM%mۢB 044DٲeѸqcLrcmvؼe֬Y#*J8pdFjg\/ׯ_kҥF3uASYԿ+)R8x`gA"vׯ:(=6D"lذA޽{¾}TƐw*g[zu!߭W^b_2Ǐ˘ j/m~")ުZl]v ˗W}Ϟ=q>}4ߌ҇gݻ*?y+[͛7%d\׉'AtY T^],ߩSbU=Z ߃D"&L deeOY_x!hxI~W===a)4| {Q{OѰaCÇ[||0p@A"\r߸q\gAx`ff&1bĈ|e,X +>hؿ8p&LTXmڴǣ8tP'8{,u&h/HТE ԩS 'O+Ο?/Jֳgϐ ^:akk ##|39s:uBHH Ƹzj7N|www888۷ ۷oѿ̙3HLJ}ܹs022ToHHH =z4j֬ '''t111(S ڵk{{{|NBJJ 2220x`ܺu ժUSChh(~ddd£GLo.Fޙ;s >,>߿r2ׯQ&M` RS}IJ8hvԿ>>>D#eʔ *Ulܺu ;$^z!,, zzzhݺ5jԨt\zU,{tI1RGG73̐O눋+ܛTHTQ+ڻw B ر#1'UVPx|%3'OȝAN:Y&,--W^ʕ+x)A%K+VzѣXCz`ii4qkugggcr/TX-Z-p%u-/BBBuMaڴi¥K_~{ٳGa ]vɵ&߹s}ܹsU~ХKQF)-)eGW6==]8xo칲Jβen6|\Ki===~_^}$"RC$?.؈eW4Iob+++{1((HiYM$$$]vu>>k*A~-[VM3gΈuvvh Ś4h ]v_}}Bu;W~.,ZHߢ)p cHVwT+Tǵ`NNa/V{,ݺuj˫#+"""xqU[T,$Umy`hhuB^+W߸qCa䒹q!%EUN:իWѽ{wqiӦi#Gۘ0J$(GQEΝ+nhѢ|~V9N]Q»weɒ%%9siР2M*kĉb9KKg& P][ @M? %nj#߿c/ߍlB$} 1o<*,#{N﷼+.]ZR!wDCWWWpww6mڤv|L" $"*eV;&СC1qDdggٳg{.j׮]DFF bj=z4= @X.yz !!!D||sh޼x!^* .](ի7nBBBvZ"00PaH\hD4"wEqyϞ= R4@PUQF%a 0x{{K.pttT}I177{T"t8҄I5кuk\p@np]R1 5e?~W7u ߿?f͚^zꫯЭ[7ԭ[Dmo޼ATT&Ҥ:'sǠ4cccO?ar5(".׬Y*UҨ+%4n޼VBpp0Ν;P\z/_+֭[#88 4(t|D;&JY*U4.'Gv)WTqqqǏG޽5+s |JԵ "333Ֆ/{[\IjҤ Μ9ҥKǔ)Sz|ee[$hښO K:c0ܹǫ\lZt~˗/\rW\{iӦ Y (S  MImױcG7I &&n݊sKM"ht3>>f8&K&1|L2 Ν;:u*N +++nnnnݻ7!;;pFFǡܢ̥Kee -[~Y];=RMU^@{|m{\3eLLL_/_/_.֙aÆڵk%(? "*e&&&Qٻޡj_䟃ΝٳgHIIANN1g1sLq[n.b,)(֒>s% .*m횷U2f0A5_aH/)He˖a޽hѢ/_Ğ={?J*߿?֭[)))˗uUcMRbjj*]z7.x>}ɓ' ą p91&#S8i<ۺ:tH. oooߴiSmڴ{~%8;;-P-uVnaYf ,+[Ne8o /_DPPqy\vMril'OCO2e%!Hكh@Ŋq+-3Q˹Rv۪UbŊEk׮E֭Ν;^DD*˚.'Oh4:666 ߝO8^@!~O>-.Ϙ1CeP|)LXC¼}:::o޼WF6yr deexի +WN<ȶX,mUTʼnbbby!%%o޼IpCbǏ… rE%0ߏwu/Z*U&& oߎ#Fi?ԩSE~߿'qutt0w\-Jelr///oߎ?o޼Ajj*V[ʕq1bVe3M0AePIJz(cr(<{,ڵkW:p: ,,,;nDT0LDlB+>>^n>E99SN_.ʼ}'O7mT ײ67nĭ[KIIv..۔ׯvԶRJJJ*35m$ۥ\GGGxKԩSZ6 x7oUVi;///!#ԑ,$88ߗ[_LW^g={6uqLT|sw[Pӿńμ-}||٩ۧi8RRR~2i$qyѢE8w\>}:ԩ#>:tV@yΝϔ)֮]Cnݺ_JDcHˋ/Vz$$$nnngxԔMK/BlU\9iL0A͛7ر#] $>P C\;wVW^bbb4~[޸q=zȗ IOOɓzߔO?ɵrO=z'n0669#F@"@"z?YcƌAq1nȑ#pqq?o<m۶ٳ'"""ʼyb2lٲ6mZQIcwޭ6+W꾾ѣY2]iӦrE3JѣG}&ر¿ݍ7'DG9>CCCb#55>|J\rhذa-_ׯχ RZl5jϥco0`|||ǏGݺui&޾}u֡VZ1cFfA#F˗۵kwb ={ݻ~F>}NNNrEQf̘!>ꫯ0yd Ƭ,8qÆ +Ɉ>f6mڄEr}tW^31bq\Lt+VP`CЧOu`Νb:%%:u/\FF6oތ =dtttzj<=}cƌ#~7  4@֭fz/ѬY3yNrJ;8z(;:uA(S RRRs\~]UqĎ;cA^:ʔ)x<|PnEccc,\Pzt<<\c%[*?ؔ9sFY[ݺuO櫧UAzijժ Jڰa\'Oh޿/17lPطoXGff`gg'_ KTT~pо}{c611;ŋ tȞKT s>z]JJQ'A &&&*믿Qwٳﱝ~ǰl2J:%ЫWO] I&*:un*Po޼SŊ˗/oVӨΝ; j 5kVs!C%"HժUCDD/_{-*TmbرrcԷ~mbժU8u8[ժU~u8hժ"##uV߿׮]ëWˣM64h:w.'''޽{q]ddd|_> ///~t;Xrٳ'[TR|>o{FDDVZ,,,PjU4m/ ٪]6.]???رn˗/aiiիo߾9ṟҜ9sЦM",, /_T;X<;IOdd$ŋHLL ʕ+ڵku֭5jTl1_v !!!˗q]<{ )))011AѨQ# ^^^*gp>ƨT5j]j볳ʕ+1m4߿ǎ+dff*UBлwo4o\8[n+Wӧ@pp8u||c3v hѢ """"""""|27opi1ڊ˗/_})FCDDDDDDDDswl'/&?ccۣRJ H1HDDDDDDDDŘ$""""""""bLi1&DDDDDDDDDZ @"""""""""- cH1HDDDDDDDDJ; d$&&"-- ٥}@:::000)```P!QLRɓ'HMM-Pedd )) /_-!HJ;,""B!_O"@WW"""""-;; _~ TP"""YLR$''?]]]/_fffᰒDDDDDADy;X[[а#""P!%&&˗G2e#"""" I$vvvbƆ %-- @Y)GCDDDDDKKKq9%%!""9LRHge?"""""{} \z@DD""""""*6 cH1HDDDDDDDDŘ$""""""""bL}$!H HX(n֬Y c!011<==}vdeevDTJ.\( LٹʕCǎpBǗvDTʴ᜗Wrr2c3g){y"" HMMœ'Op 2[Ƌ/J;,"*6m{yReffիW8s L:u"m<ٳIII-[N?HC՟K;tcǎw}'>OJJBhh(-ZGʕ+ݻ7BBB H>hlD2ˢ#(Y}ݥb333$%%a׮]Xt) ?h,šYfgdd ** +Vs xxx͛Xb)FJv%?m;IIcs._-Zh\ϟDD[}P^=ѪU+|z*/_ÇK9R"ۇ-[ǡCJ3B355;5i_~%1`@BB/^\ʑQiжs" 0k,XYY(xF?(/&~gcJ1"Ф 4ȑ#QV-׵/9uD'm 7"11Q,*@쇩Xfɒ% |H$WzLBc /ƍ?zXd ZjgbΜ9hذ!LMMamm WWW]999JEϟԩSC{1~Z鶳f?@n3gnݺ033CٲeѾ}{رCe UVD"#W\APrer9r$ܹSEamm :3g0ydDF0ydx^z7o666ٳ'q(S ]mK.1a„|qK-ߺ4 <{۷ AHH/_4j(ЧO\vMi/^C_KII pٳ&MR"Umۆl`!C~Cff&v؁Qm=/d"00Ƕm۠'Ć 7J7bǎزeز;ʾ{ׯ_ױrJlݺ}QXɓ'K"66W^Ŋ+{?N:r#>>QQQ8v={ tuuѦM9r =={999QX\r sŌ3۷qm\k֬ %rR, <8wƭ[p-lݺUL6BCC4jժ!::7oVz?H1Hen*T{1V~} ͚5C  ?~}a׮]']###:W\)^ȵiGF5`jj7o ""ǎûw/Dbb"1vXovvv@tt4.\/ 8$$iiisE/_+Jz{{mذ!|||쌷o7nijgбcGDDD {ԨQq|x8yVVz)&:wcǢrx VXǏ۷JA4!.~^U֭[#887obxƷ~7ok.TP:]۷?3Zh49rK,Azz: jժYf:ׯ_˗wrѧOz022BLL .\+V )) իW,WoDDV{4je˖q]b. *@WWϟ?Ghhq!))I.y||<"""ݘ+VXӧlmm1m4";;N‚ #FݻwW/Yh۶-Ǝ '''$$$ȵ }`ԨQذa}r5DDD_C"88aaa}6ԩ+W`4hZf0`mׯ_Dze0j(ԫWOaׯ_BBBP|yvڡK.ܹ3233w):@]peG3gʵr&{~ZӧO1gΜ|-vލŋ(WmbxLLLpAO~|Ji.++ ϟk|% 6ٳg(w|kL2@~HH*W,wuuE^жm[$''믿FtttO*""ÇAƍX|9<((HݱcGn '҄6233ӧeС={6222N1IDDڍcid/[888 &du z߿?/^Zn L;L2rIJ/eI[tZ{-o;;|-|__|}}RcƌANusw!_T֊+vSւ`ҥUZ*V#ׯ:///3={277ǚ5k䎳j*,ZHBX}3f 7oolzzz8qXjժJWPR%1!h?y)_O\oߊ:ϕM696mڠcǎ WxbTƍ >}{@ZJffѭ[7 RGyG-̛Ԭ]rhr$"" 'H$033\kbԼ^~{͛C|~zI4:8㔝|Wܖ#.322$(TvAb8++K\׹sgRYYYJrRb@///;Ą-QAdeear[M-[Vҹm6Ό9R-Znݺr 7+++"_} s!ׯ/Ǻu딶DDGG֭[Nw(]'Kz޺}vZ'Z[[I-[ȍ.\]](?ɞ-cggD߾}0z|(!n[~}4mTi9QAh9OسIK.޽{J%UO"".Lijժaʔ)qF񡂃1p@X[[NNN_Xv-(LIjݿꫯcƪM6^:`„ hѢΝ`dddVOOOK 4nXׯŤ[Ç0-[T7o*-נAddl޼LǏc9m5"%}=66*sq*[ 7jHLhCEhh®??UիWGz_-{4hWWWxxx`ժUyY qv GGGL:Gh[L:{ŋ-[X@JzTPzjҤʖʕCժUQD.==]LL|G%Ey8tsPI~(|+@@Dcƍqn޼#!!>agg'W~֬YhӦ vڥv|}W>}:;b\2デN__^rӧOG6m`ii]bJg&d/-]ڴi]]]lFFFrĪ=S^]yToVVVJd)#7cTϞ=aii)W^M? >>Pu`Um5kL<ݸqw˗/klٲEa2ѣS<~Xe@s^ڵcXYY!++ رcQ~}aذa8wºxxxMB.X=z􀵵57o ,I*8pe=h,/9x1Im}ZFEt~իWo߾ qqqغu+ڵk#FZXLOFBB1~xۅNWWX~=ݻgϞaÆ Paaaom׬Y3Ƚą ȷt;lw?eߗv!RFvkJ[O׮]Vmw^5J$Ց%s ݻŋ}a̙hժlmm&|gaa{Wpm̝;*TlڴI6-Z`… E||<<(+/_C===n@stiq2YWWi+M6ݻ'm\ELDD'UVuYT2[8[F֭憾}B޽7V=FCUVz*>T8qr RRRpRį_YQkի)))t8e'( \aÆ)-{ʕB>_/^Xm)S 667oVYrqӚ5k*A],cb]~]VK˖-+HAw ggg 2HNNƮ]0~xۙ?~<-[ϟ/ʺĉ C||Z@JP  }9^իWbf͚wwTxΓM\R./N8ݻwDDD DZN:/jaVWY&^&$$!&^>||w8@xb>Ea Gɓ'J*)oݺu6D H7od _~v,]gΜӧOQb|e6mڤt+WDJ}B>}߰aҐp lٲ\2^{\lJqo7<bϞ=sTNpI$$$`޽Jg_~x,INp=ܸqJo~4|(7"ΕR͛7W(l#Guӧ\~sTX*CoFl_+m69rϓ6Ξ=GŪN׮]B @"-7|p-ܰ|r\p.\… ѰaCܾ}...J6l*U[nŋcǎG܇ "nwiԪU X`?W"88h۶-^ 5j®33t&-; {AުU+l۶ aaa8uFѣGJI7#UƎ+Yr%uի8pt邵kעYfE}^жm[iݺV Yf|8wT`xΓMOc222B'OHv&rȑ#gϞ[ gz%V\+W*\oaa??| ((He˘޽{cܹ I/O:%GQ2wwwqc@l@,۷W^СCPv*===>|:tݻwq1;vLLΝ.]i_yƃ}QU+V[&N#k.t+V[ׯڤxFcر700rũRJXr%F4̛7͓+3p@3Few۷qmu׮]{Uz]###ݻWB 000ZlsݻhԨz4UBP^=ߊ+pQUV#+>VZ5apvv ,,,Ю];lݺwV:нѣGܹs*VÇ#<<\ cȑ8w<==akk }}}ۣk׮عs'Cv؁5k`hԨʗ/===nݺ;v,1c ڶm@h߾=ann}}}+W;wƪUp5dwnxUVsM(>}:1fԨQ0553Ə;wJ__{w,,,`bbgggL>aaa^z>xۿ? uLݻw70iJ"HӠNllZ+&&FDܻwO s>Mf[gAժUcx{{cƍQ9Z4DDDDDDDDDZ @"""""""""- Ǐڵke˖Eͱ`>SRRPzuH$H$TZUϟ͛lٲ055EڵǏHDDDDDDDDP?'СC:t8d Ehh(֭[8::~7DGGh{wwݻwn:l۶ ={,P3U033?#55~~~Xv- 077/6oG)h֬cƌ_~ ccc`ܹHJJ ѨQbUg&""""k"G܎V?~?Z3˗q9QR>>>pvv,]EҥKZjaڴime˖㓯L֭1j(@PP\RX5 #GTXFGGÇ$$$ H|1~7U```vx::F!.۷HIkϟiӦJ˹Ew}d 6 o'5oh(MF@FFcc'==_5AѠAI-H@UhѢE*S8/hdK@""""ϋ HOOGbb"޼y#neeUQ,H˚FKNNY\YgݻwQre R4ނ <^u!)l<}>3(EDDT"hddkkkyFbRc͛7ЩS':tHai١CbJ*ҥKHNNFBBʉ@lmm?/PD*Uɓ'bdAUʑQiuiADD2"usp}deeAOOݹsG\vvv.>]v}}}뫲l\\ pssK֩S{iժ:B!x>:::000)4뜈>,IiΝCrr2вeK傂eWW6mڈAAAJbҊUD33wO&"""""""Ckfl͛h߾}!ڇA|-00PwwwXXX6mڔo 7})PDDDDDDDDD%[hm֯_/+h"DFFƏ}}}H$H$1bDj`` +sE_@n͛X}z2NNNy)D?Gq=YFLPJ)S۶mCFJ'H""""""""iM`)DDD`ĉprr ,--ѬY3̛7ptt,0ǼyЬY3XZZjĉ={vDDDDDDDD f ʕ+bbbPRR4inGZ0HDDDDDDDDŘ$""""""""bLi1&DDDDDDDDDZ @"""""""""- cH1HDDDDDDDDŘ$""""""""bLi1&DDDDDDDDDZ @"""""""""- cH1HDDDDDDDDŘ$""""""""bLi1&DDDDDDDDDZ @"?j׮ SSS-[͛7ǂ R###?M4AJ`ddSSST^ā ufffbƍѣTCCCؠ~=z4?J$E:n"""""""8HdHƢrTT#*СCpXm۶-={Ze 27oTZ I$H Ԩ,}xv>dPDp 8033?#55~~~Xv-УGܼC˖-|򰵵E||<ܹիW͛ Ο?ōs#""о}{}FFF=z4:w+"##ñcp9q;}>V"""""""g-vܹsٳg"~:u*`̙5kV==lxyya޽W^ʥaÆB*Up)ԬYSa000PNCDDDDDDDMs;>[/_[ʍ5*_||| Xt)233 U?Ŕ)SZ-\QQQ]&(M @l߿_\9r2:::>|8 !!%lⴴ|볳j*@Nвe:<ܱ6m\"˵kη… x)uNKKÃSdggHlDDDDDDDic>[GGGtermC\\.^QFa 2$_ِq~w2e*U5huԁ QfMx{{X+G"""""""*>KiiiXYYɈ)~p KK|n߾-.߹sݻwGrr\wa˖-ؿ?݋N:EN>߿͛77nGFDDDDDDD+e333MMMIII%Ϗ?HiFoߊǏGrr2&N{!==<)S H{ 0OĺuP\|ɶKKK_U|z?>0}t$$$`ܹXre>}_|~ݺuCxx8rJE:f"""""""*]lH%###q9##Cmtq[Z5ԫWG۶m1qDDDD{8|07oX`ڴi 2e ʗ/صkAWFQO\rؽ{7˗//G @,˚t땶ӤpA&&&ԩS󕑍]iE===t@n8ի/;.g \}<ϒ8ޝw`ʕK$8o=Avׯ O:uO"""""""80H-iRZΝ;ⲳsckk HIIg([zzS"j;"""""""0H-錻 SZ.((H\+ -v5n׮[+*۷o*T(TDDDDDDDq`>[ⲯ2999ؼy33ڷo_"ŋ1C7n w{:u PF 8h|r?&LPXfҤIHLL|:tHew/__~b,}؉WˆҥKTtӧOG푚 ???Y3t 6'6mCOO/^@pp0֯_/^ի'&¦Mplܸϟ?رcQJ`8rq? 33 V ccc!00WlӦ ƍWc&"""""" Ykܸ1v܉C"11ӧOW quez___(-sN'Nq|e7oHaϞ=˱|rׯ֭[CCC1Ǐ @yxx ""K.?bcca``GGG 0ʤ*8~8N:P˗HIIA2ePZ5j  h333;v ;wĦMp5yhԨ ÇCWWW6mBPP.^"..033CʕѺukx{{ťPKDDDDDDD BiA#66+WĠRJiJ'!""""""""bLi1&DDDDDDDDDZ @"""""""""- cH1HDDDDDDDDŘ$""""""""bLi1&DDDDDDDDDZ @"""""""""-W?vG(3c,[ !jԨ///7&&&;22Oƕ+Wp z qqqErмys <zD")phڴ)ظq² *Ppww/p\D]$"""""O֡C0tP$&& 44XnXgϞm۶)\hڵ nnnسg5;''cƌMGG5k, }1p@ ?3ڷoTaڵB= ssCOO-[+ׯsV^7o"((8֭[==ŗΝ:uرc兽{ŋ8|0zꥶX̘1 ,mU̖-[Ç-ODNBDDDDDD˗/ܹsQF%||| Xt)233 e?)]]]L2E|.Iq1bڵkW+o߾R/}$"""""O#G*,#KHH@@@@"۵8--MmݻwƂ -ӧOӧi".L'SSS4mTi9777q988Dbk׮w?ϟ_ICټyD$c''22訲lBNMqý{n:lll0dM6 ϟ?G۶m\,$۷wwb>}L'%-- qqqJ*,keeSSS$''#&&HuwwGPPu666طo,--n5k@__+WD")R<#}LDDDDDDߋfffj˛m%W~GDFFM6Jddd믿 4i֭[1/DDDDDDIh@myCCC@jjjdbʕC[ʕS_۷ojժߊG^ j NNNZ?}$"""""O|zz:ظHVmbر0`>͛… %߽{s,_gݺu+rrrZ7iv&"""""OI^xt.(###111:uzA7 ==}AϞ==-[m8pb>}lHDDDDDD###X[[͛7UY6>>^LV\Dⱱ+N< 33q֭[/_2CzT;44oVVVrLD]$"""""ON:up9ܿYYYS|y{qٹⱵ ..Lgٳ3gMND 'G:nrr2”WWӧrIt5V$33Sl-hkknݺ}ѧ @""""""xzz˾ -,--Ѿ}%66/^888ȍQAT>fRߣG݇$DDDDDDiѢڶm X~h"DFFƏ/'DD#F>** gΜQǻw0x`q6Çp E/}zx{Ǐl2#&&Q0n8H>}W\7+AWWʕC1x` Di=AAAp._(!>>ƨR ڴiѣGiӦjc;v ,, _ʕ+-Z`Ĉ&FDZaҥpuuEjj*:wӧ}HMM֬YprrO:v숆 M6E塧/^ 88ׯNj/NO?1*ÇmҤ/}$"""v! :k))) Ehh(֭[8::ٳgc۶m EGG#::v킛kkke "7~Tff&n޼7obd(޽{ܹs=z=®]Я_?l۶ 8Z"KƍsN}cƌ. ]I&GEhh(N<˗^z QY_ p +*V""˗ŖpFʗ8;;G*33Q7㢮.L">W5Wz3YYY W… hR F wK./} 㑚 ===8qB~PfML:QQQXhQ[NN:aر]/ KIC"""*> ttt0|pHHH@@@:w\&J @n @ejԨp""""NZSODDD%rS5s\"˵k.T999صkzjժ%.?|Pi]

|u֡\rjq./_D@@VZɓ'ݻ?>n+k.'%%a۶mP"/}")H$hժ<<<ФI+Wiiiq֯_˗/ӧܹ3Ν;ƍ8ʕ+0>YFFF&ܦ_{Rm۶رc1`>|͛7Dž ް;._Νw K,[pQʕ_~@hƍ3q5̟?ĨQ%K阉]7n ""gϞŋQfM?9r$^|tۿ/^ѵkW4n...ӧ1zhPX>'rirO:Y&7 011ALL NZz*WpI_>_+WɿYfaݺuhܸ1`jj WWW8pÆ ;QȡC ydDDDDЊ`iWׯmbĉ@Żʺ,H0{ltpU\pĨ|\|u} `mm @bZp]s5ѹsgݻ_nO?9s˜HiEP7|#.+kPJ*|:6""]:uGVVrwK,[[[@JJ8vqA |թSGD!*U"=~""""NZֻ xiFDDhӦ aaaJd~ބYUSJD i/Hy_"gHDDDOOOqWal޼@۷/XbccqE\8p8INBrM$$$(͛x6DDDD&wo߾-.WP1}NZhm֯_/&d-ZH6;~x˭ D"D"#m3gΨݻw|x2NUsmsExxxxҤI 'KKKgϞ*KDDDD>鉹sȽ߲e|e>W܊%6""ҥKTtӧOG푚 ???Y3t 6'6mCOO/^@pp0֯_/^իprϣk׮ر#t YYYx1N8-[ -- W_C4i֯_W^÷~ڵk#;;XlxYabTɿCl=Gi@D%HkһΝ 2&qFQQQU[J!!!pppP: _q)@Æ K"jܸ1v܉C"11ӧOW aׯ_Uѣ8Q"8qN8]]]L4I陗 ?}"::ϟmԨqѧOksرc믿еkW|S,--lذ.]`ڵ<<<K000# ^iRNWWW?~NBhh(bccKL2VZjA7qDԮ]~:?W^!''VVV]6ڵkÇF*cjԨnܸM6۷o!H`ggƍc8p`DDDD$a>a+"oՑN"k֬YQJl߾ZƊ3!ĠRJ%J QQ|yOj|wGrŋWM4 ##¾DDDDDDDDD4TxǐK}4|ȠbH1HDDDDDDDDŘ$""""""""bLi1&DDDDDDDDDZ @"""""""""- cH1HDDDDDDDDŘ$""""""""bzQATɿC,=Gi@DDDDDDDDDDDDDZ @"""""""""- cH1HDDDDDDDDŘ$""""""""bLi1&DDDDDDDDDZ @"""""""""- cH1HDDDDDDDDŘ$""""""""bLi1&DDDDDDDDDZ @"""""""""- cH1HDDDDDDDDŘ$""""""""bLi1&DDDDDDDDDZ @"""""""""- cH1HDDDDDDDDŘ$""""""""bLi1&DDDDDDDDDZ @"""""""""- cH1HDDDDDDDDŘ$""""""""bLi1&DDDDDDDDDZ @"""""""""- cH1HDDDDDDDDŘ$""""""""bLi1&DDDDDDDDDZ @"""""""""- cH1HDDDDDDDDŘ$""""""""bLi1&DDDDDDDDDZ @"""""""""- cH1HDDDDDDDDŘ$""""""""bLi1&V&?~ Ԯ]([,7o  %%HuGFF74iJ*^:@ªUжm[5j7|[n)V""""""""":tCEbbZJJ BCCuضmuшƮ]={Zi]qqq޽;\"Çflڴ F]X`xx8DaٸpN>1cУGPC˖-1i$ѣ ɓ'|rԫW(';;}}ѣGq%,[ vvvHOO7|G*V"""""""""j8~xBOO'NCY&N(,Zf*>֭[==o[N0vXxyya޽x">^z+i&?w׵hݺuCӦMJKDDDDDDDDִ|2Ν;5j\OK"33QŔ)SҘZp!lٲX`AǾ} +$/.9Ra >\\NKK˷>** ///(gĈ2DDDDDDDDTZv555EӦMsssK$???qvKcO^˗$-UvӕMI)qqqx"Fٳglll0dȐ|eo߾0Ecbb\lA+fHKKC\\RJ*ZYYɈ)~p KK|bcceuV\ bcc{sblLf ͯuD%?q*ٱ>ʏN[~ꈴD I Ǐ" mla^~߯mv\\.rtȑ#N%s׾ ϟ?_!<0aj֬Yc\o^x%mxzz:KdTwȐЙ3gw}Wo~7ƪve77KŋKIRʕTQFn߾yG_~BCCiӦB|7jҶ89rD[viN\_,VZոvflalvϟ G}T`̕?~K{ {{{+ @R_>}*y5kTXX$itRsfe%Yfegg;nuӦM+ZjI233ze?E{~a]v.oݺuk֯_o\ҫiii[wz+=zTɒ*WXe^z/rLnn-Z$ISxxxr!%$$H6lXd>OY< ,0{]!,nZ۷$\~3gԮ]$IFG׭['&ͦA?99Y}]}={VO|X]t]wݥ^ze˖GjƍѣG%Iwq^|"8p{=mܸQ=aÆFڲe)==]nnnz뭷nTJ,*hɒ%߿5~Bc_h[+~W%֭ϟ*U~Jl2EFF꧟~_-0Ko|AӽfPwm۶)&&F:t<==>}hĈņr ӪU*11QLUVM5R6m;uhG͚5i&͛7O}vڥ թSG]tѨQԼysS@fҬY\SNr8žᡈEDDEy=36'2(0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0Nk*!!AGUffL[nsEeggRJ*kIN*S_^X9R:||||RLo7ozp( @ȱCUuy}禛S޽{sI:w;wرcŎ#<"áիWLo񆲳ռys}W .K̔`モfѣ=) SC$Iwui$L6Mkaɓ'%IիW7S uJ~7$IvL0vIC .tjٳgG6M;w6S ÇfiZ`AcO<^zѣrww73%`* ѨQp84dW|MGS``{l6M0A 6,3gԅ >g}f2|pc$=Z/r S+';Zj:u$&Q$mV5kV5 9Wڵvs)))IǎSNNtwf͚'Z:tP^(W^yE:Ӛ={$\d*4il6}QSNWC@]ҥK$U]_~$ժUjnxN=pѢEE|r%&&x {O6Mw Afxp_vᐛFZLsz 0ZI<<</B;vo@aNܷoqp8tmfiժUjҤIl6y{{+ @*U*{\Tذa"_SN [}N?0S[;wlfӚ5kLyufp;f:o앯8:edd(%%EgΜfSPPnSM뭷R||<(///5nX={9URܙ7(11Q))):U =oy:u;U(ꫯԩSSXX.YbtL%&&*11QW``so۶Maaa:|N:͛7kz74w\۷L P@WDFF{=ܣ޽{+))Iu֭zIII۷v|}}5n8nkŚ7oխ[7%&&jժ.͟naaaz衇ԪU+Zt͛t=䓪V|lժϟo{S$|3f^xM>]111VkԨQrwwինm[Ν;I&VrrfΜI&4{1M8Q͚5+~DD|A[9999r[iq.8jj׮$)>>jlٲE6l$ 2@'**JM6$ҥK.ոdɒ"ÿ<={?,IJMMURRK5r@OOOIÇ+Ʋeˌ9M $9sFk׮^Í ?$]gke˖Ŏرqq … uJ*P&$$W^fS֭+ή]$Irw/)oׯ7gݺ{'oooիWO={ԢE\ޢ gW^y1:}?*77W6Mcƌ1STYYY:q$^z%Q|||{/-Z?Ҕ/BӦMg}VE9tP9r9p}1N4Smp8ӧk׮fJܹsƵoϗk.\СC#I2eJcԥKEFFꮻR@@Ν;YsѮ]sNk˖-jРKԯ_L @rWͦUQFر׿xrnYeeeyKd˵#F(11Q4p@u޽رK._۷og}VÆ … hZti 3wem\_xytT\zx+I ;S¿<͛g}JKKSݺuGg23ϦjժƵ3z322$9]s%]>d䫯Otwwא!C-"O<,DXU9jV@@8}s>c=쳒 oQ͚5<ۦeN8,J?,KIIQvvvvm\9]7/B PnnnY\W޹r p%SumI&NJKKKS``7nd3%Ү];Inݺqׯ7L[f{1egg+ @|7nlzܹӸSN 3~ڿ:nݺ )^z/rLnn-Z$!jmڴI={ԅ TzuZJ͛775Wq{_wС\ ׯ_/ͦ=z8}OϞ=p8f3%Һuko^BcfΜ]vIF%[N6M6M */nݺ)##C>>>W˖-]uڵ:sL_tIC5z޽{<7Sm;;$ٳLI(,,Lv]?~e۵xb͝;W(OMM<`w'OVյcǎb馛tM7xm…ѣzN:oWjtymݺUs5tMqWTxyIMOO7Si!!!Zdt?И ǫjժ.Ͽa;vz̘13qDM4ϟG}>{[hŋQF. kԨ'Nѣ뮻ѣd*tsUݵm6(>>^էO1BUT>J /VBBvܩǏԩSRڵժU+=ݻ*UtM{TؤI8qB_xYr$)iذf͚Yft_Np8}РA>M6UӦM5z2! <ΝkRQ7ol6򗿘) S3<#eees/_nWʕsϙnkLmY詧ұcԳgOvmj׮nIґ#Ga۷OC6Mj׮] %'Tnnyeff*55U[1yѻᆱ[.18SO=jѢˡ_ފ;S/RRRk <7|^}U֩S$Irw/ʠ\:wwwtM9%2(`ne^w^-ZH :zvVZ@c̎;tcǎe- I\EGG+&&FƉ6M/^,0z衇}nݺeSLo>|x N:zGF)''G}ْ\d*\f$IǏ'xO>}p8wߙ) S[Ν+ʾɓ';uO֭%I̔` l2dԫWOtQ3%`*Qjj\oFʒ fYq7sSڵG P\\VZnݺphƍڽ{ܴ`[Jf*'|R>|<9sfIbcc%IC뫅  TuQ&LPNN٣{WӦMSNNj֬e˖iܹRJya*ʕ+?/_Zj)''GnlR?|Am߾]=z(Ͼ8t{ھ}:w,á;w*33SUToxծ]L^ڵKC?~WuA0pԩٳg5`=:{l~g*օ TZ5}Uzcl6yf5mTC|ri@LoС~W=%?kĈC- E)ShڵjРS㽼[oiʕ͔`ƍgXDDon^3,]aZ޽{h"%$$ѣZj1;vЁ㣎;$'ssspH|ŋ =pz!k߾}[nٺ[7xC999S}bFFFQFg}f$ ׬Y8I~}'%ӧO9}wfJ0sJoNӺukI?3%`j`BBl6 =Փ$=zLI& ;&I[Cm$L>>>Ǐ;}ϡC$IfJ0TxmIv=+W$5oLI& #""p8;(77;wԂ di$L?|||o%>oQDDaÆnkTvm?рUV[n111r8ڸqv-!777-X@< %'jΜ9lXI$j…BB<=RRRjٲ*U$ai޼ƍݻz$+h„ 0arssu)_#榚5kʠL[FX `aFX `a^ N:oՎ;t)I(pǎ0an͘1C}GqO5vX>|X/u͛KJf~[999UO $]?tM7)''GovSw}'ͦqƩA_^x9YLI& $IwI>l$L*Utɹ\TI&J7o3[S`׮]p8k߱cf̘!ͦ3%`*=ztykNN}ʣ25jvݵzjmxsjҤ3gjҤI.{L'NTf |P{VNNF{f;c %''KOcmV:uRǎѣGkݺu. H&gemذA4dȐ_(5mTK.TӒ%K S?$)55UIII\tIo$iӦ*֐!C$IׯO?Rd2tssv=}aٲeHΜ9kVH/ujjj׮]gJ(73 4ȸ˷ILո4?$G-[,v\ǎ7VH/.\0+UT^JZR*U$U\ᕇ]vIK\e\~z:oq~WNJi+bĉ.+oYYY+ql5㣌 )tv5_FFfϞ-IjܸqYJܹsƵoϗk.\СC#I2eJu<ϟw)_c`MNv[GDDã{/\cǎ)77W6MݻwwRdeeמ v{1b%&&J|Gqk^*>zp(--ͥbmڴQttK8۸xb\rk)66Vwyرyҫzmq>rZnҜ88 .fS=J|fnw}ܹsRjUڙmۂ9sh.W_ü~]UrҞs*?~.\(35kV][:yd_>}y}YIRÆ 7ߨf͚%S^=Й3gJ QVժU˥f榉'nǴ 2%%EŎ۽{q] /4`[nњ5kZu?4ϕZ.d:8qb+ݮvIev֭Ŏ[~qfޚ5kc);;[o>8+RbbZ,e*3իq}>^UVuy 6رccƌ)'jҤI^Z{jܹF@Zjuw+ Yh pݻk۶m3fTJUV6mx۔$*))IӦMSV*Uoט1cm6=C׺M\lN:tHח$#]xQC(ܽ{wm۶M}V^mY19sF={ԩSt7o^nI>$)>czKǎS͚5 z߯˗k˖-fJ0 +VfS$5o\j$L))):8}O5$IfJ0T%Ip IRʕ͔`n$۷{~IR:u̔`{$\ҩC͓fS͔`'Ї~h+ITT~WI͔`gϞ Wvvtw}Wǎ3Ç駟}f?ܚP2w7U.]#FhĈlcڴi Y1PqƩZjr8E\n:gJaz$yzzjʔ)?~֯_D;vL999 PHH~U^2y|||@91 `ae|I%$$~ӹs甓S=?ZLGwUvvKWpwOʉgN8QP>}wɓQnnn\V~嗲lzꩧ`rn @y1㒤~\PLuԑ$k3ʗC۷k3ʗ<<<4sLeeewOʉy?٣%''w_ʁS%W&Mԭ[75kLwyTJl3[ L'NH~W%p8TxuAǏ$UZU~~~rs3@0+:vg}Vzk9Lk֬fӨQ4} @91_?$=# e*[$I e*ڵ$駟~*f/S?hڴi:uTy ܹs 7|S}(Nܹ$f͚ڳgOM4Q*UJfi͚5fppݺulCO֖-[fp@2vЁ ^! V38`\7hР?SbبQ#IfSvvv͸r.p:?Kj8PnnnrssSVԬY3u~N̖_TDX `aǧm6֬YSy011l6[@Q}N?pǎ-󟜜~! FX `a^ڀ}I֭[(_ 6}l,0@ #,0,+44T3fPfffΝ;`= l6l6֭[<:u2)`n"XBWzzZffX+00 TNr`RR+.___7NZx͛duMZ5q-ZҥKھ}[j盺(QFn]WV۶m:w&M(::Zɚ9s&Mrf͚魷Rhhny{{kҤI@qJbgnٲE6l$ 2@'**JM6$ҥK.iݺF6mۻlMReˌ9M $9sFk׮ׄ~A--[,v\ǎ7Vx_bp׮]@xB\Kwֽ+???y{{^zٳ-Zdj22deeĉz8FQFF>^UV5]gž7mڴ_8P(IϟG}>عZhŋQF{rtymx:tH T>}4bUR /VBBvܩǏԩSRڵժU+=ݻ*UtM{˒$5lPfҬY\SNr8sfLI6mMje(P `aFX `aFX `aFX `aFX `aFX `aFX `aFX `aFX `aFX `aFX `aFX `aFX `aFX `aFX `aFX `aFX `aFX `aFX `aFX%]QQQ 3f(33LsjΝZ`}YK6M6M֭siLM>]L׺b _kJLLTbbbcc@S4hPHݻ{ў={?P=PR+Էo_WSLѦMf 6Lnݺܹsj8CsZh<ΝSn݌oذaZf6mڤ)SW۷~SZ8j(vkj۶^ΝդIEGG+99Y3gԤI\ѬY3[ w-oooM4I۷owi3f(99Y4}t;xm۶ԩ:vL=d[lц $IC )削RӦM%I111tuZn#GM66K[oI6mBc> 2D~zOjfpٲeHΜ9k^ YvΞ=+I8p܊ϐYh c~$e˖Ŏرqq (yJRVTJI׮W\,ڵK(wm\螫mΝu~nV|zdeeĉz8FQFFrZnҜX"Zq̶ތ Imyҫz=g- TO6Bk`.##CgΜ)ql*Zj?@H(I͚5$(;;qw66mZ}%W`?WVjjk+o ۵k'򪺭[;nuXXXU^\)11Xxz2`^9&77W-$)<}UN>|X .ԧ~իW_~/M}\(nujŊ߿Ӌ|?((H ,޺um,lNS<)))޽{|ZjC9]2NB>xq:u 7S4Cڰa߫m۶ޟ1c%I'NԤI\)99Y͛7WvvZj^+W6Tǎ(wwwڵȟ ٵkqO.]3b;%~?'N+"IO裏 p#y0?듳َe޽mۦ1c(((HUTZjiӦ)))?+B``4m4jJ~~~Rnv3F۶ml٢ 6H R((똘]t:oq*ٳ TJ͞=[Sx kH2-*I (~NN>IRÆ 5a„"? HNjd-y6lYfi֬Y.שSbȯ<M(::X-[f\<1nnn0`ƍ3ghڵp%IjӦMڴio]{o777W~$W>|<%IKK+ Og]x?,BcŃĉժU+ժUKު_ڷoڱcG9}@ @nyqݽCqΝ;Ǚ:˿wE5V'Ϥgff*11Q-_WP>>ܹVZm۶??^h۷=FnV^]PΝ;I&VrrfΜI&\#99Y$UVCBCCգGuQ1c~B Wb]vi޽bY XyxxӥKŋ]~F, 2===K%IV'u}㺨4iy>77W<$鮻R\\\/?g\m*oooڙߌ]pA }g9|֬Y#I{K}VH6m4gҥK0a6l(OOO5lP&L{n*ʲeˌ9Μ9k׺Tph.?M6EkӦ˗pT'77W~˻y"ǭ^X% /sϊU?rfmu.lN:[>PnnWy?wG }(N?Ss{GK.ՙ3gtܹsZ|,JR͝v%Aw% .tvY<]'߁;n͒[oUUVG}-Z( @AAA ߮_"τU׬Y3IRJJ\޽۸{5<ڱc$顇KIWկ__7.]y֭u֭](YYY:q${XF5j+_ZwNFF.]j^\ 5kԨQOȓcǪs:s}W  ]v.~z:,,̥5R:u S^Tn]z뭥̃)ܹsƵ3 y:RgҥzJ6qgϞ5}v[[ԩSnڴIO?}W  W^km~~~F86M={ty_޲+m޼^Ϟ=ϓ?XFFFWItIURr(,ڙn$IvpN#}yYYYR֮]'|R5jPʕաC}w뮻$I~G{@W]֭վ}{ICBcfΜi((wqzyiӦMںu>s 0@]vn>BѸ~wEEE)88X>>>Whhf̘rrJ[Փի޽{kʕ%޷`#vOqa1JWjUڙy.lNzm='/Q.]ON\-($$DK,QB2e;{OIIIE;dM<ԹΜ9+VH|Bo˖-]ҥKZ|/_^͛7ׇ~hlVXaɓD%&&*66V 4]#77W_W4iٲe:t̙#7}UI@ɼ'O8(O6¹u8#uzzzTV-?~ުY=j,X޽mۦ1c(((HUTZjiӦ)))L$)..Nٳԩ#OOOթSG={W_}XO?xSO=r/cmٲEBBB$]_.^zi[f+t%-X@ݺuS 奚5kE:t>ҿouEj2>o"##x|ѬY3IRJJmIjڴWSu~g߇zHӼys:''ıy(|*p\&pm:tK=^zq׺ݮu (':tІ ^m۶-3-'NԤI\͛+;;[Z_-ر]v weK.o]>6\9_JVyWJ]Im6=? Rzu9s٣={jϞ=.on$v9?~xk.w9nԩ7n$iժU%nPzta/JӦM{nխ[W,PѣG+&&Fl2L8Q$>#cIR͚5T??qXzX3,VX @kF788X{q7[o뫣GkÕJ⬬,u]JNNV {(ŋY#Fw)ɿzO?գ> \֭͟3Bjj$''Yf)nW;w,P/Rݻw$9RosddV\)777^Z]t)ѣGCS֭3sƲ~AS!';v47nR}Å)NZZRk~ z[I+99YnnnOJcQ0| Ijذ&LP?ՠAI׳U\Yيk͛7kڵ>|@)**T ;V0-YDZd”(I;vlTpw]7ߔrssCiܸqڰa$_W|[Cj\\gܹyʻNYe˖׃.r-̙3ZvK5qzppڴiS6m/_\Wn|I_ݻWgϞ$uU*U*r\JԵkWI֭[o>kEHH,Yjժ?~ڶmΝ;kܹsVjΔ)SOK_~ U~$r6yR:sVX!%(AAAZbj׮,M:U:tPhh{9:tH6M/7>c- @KO8!I>FƊUvͨ3u򖲻Z'##CK.5p3̻VoڴIiii=zgee)55UiiixIv%~ ^u]۶mӘ1c*UOZҴiӔTC(aV\\ճgOթS8gϞꫯ[!孡YYYzT?ڵ?M8QwuU&ooo5jH֭[3x|Ɩbϥ\7?$ؙӳ|||WXv]tRcSO=ҳ͟}%qF?TEڻw^|EX2UzuC'NTƍ ͟X*?׫ j֬Y5kKuԩЊDFF:}`Gq fQ4ig3tHQgp\K0WTykNm%q ޽[!!!Zti%={VBBB?00P^|Ɩ @m\_x.\B.:y5\saYFt&P̬$\[jV:uʸ5j2224fݻW.\PjjƎ+ͦsΩO>QΝ;Kmۦ?>>cm߾glY$BFF$bN^ W|͕?jVW^yEfR``<==umi2eS ՘4ikcˊ ʼIƇzȿ:8['oRoz[I5k^(rco$}'[զM͙3Gt&L S 6Ԅ ^Ye9p3ز"5k&IJIIQvvvvm\7mT+):?v!Iz衇Roz[IN:]]ttyKoVhO?Q{.%]=z?VZרQľȏX>cˊ`h׮[n-v배0j4jHu)4OQ[n]z뭥νh"w$ui=E{tR9sFPJJΝ;˗+88X{56o޼zg,eE0˸?~crssO.հlٳ+6o\͛7+{)Vƃk֬HP>S<{QqU~}5nܸXiݺus(G Ռ3YnuV\޽{^zRzԻwo\ҥy.] [njРTfMhBCէ~Z콉zWa뫠 2EZ;V  ђ%KTZ5?^ǏW۶mչs_|||Nؚ2e~iIگ_?_~F7dM<ԹΜ9+VHݠe˖P6J_|QōtI"Ǥ$}we3F%.www^ӧK5sLS5KZj7?c1cF ԧO:uJ 4жm4{lu]sڴizJ~askɒ%:p|M=# U6m4f/ $}j[ ޽mۦ1c(((HUTZje ,S 777)>>^={T:u:ugϞꫯ+7?>SeeeIz2bbbTreegg+""B6oެkj኎T>+%+,YD-YDaaaJLLTJ{xf E˗/WRR uM.dĈEswW^7o6mڤ[?׀ԵkWvO>)̢ecܐ!CԶmBc4ҥK.y7gO͞=[+W.~*U4{lIo9믿dO>)qwqG~zNJ]QfAg}V7>cgZ7kСCi;,pu 7O\n\j7ƯXB7~E2aݺuƖjEΑaÆ+!Chܹ%2zGzbDŽ/7\ƉEi޼>Cu]Ŏ)\0I:uƍ'IZj"""p8T^=>|X*kϞ=[>>%> cǎƍ]o>{)NZZ_M6[zaTzj.\`\\(Vg@hذf͚Yft_N&HcY6MS~L_{Kj@B8kΝELF_o޼ٸnѢݫ_|Q+V0%W^]=zĉոqc/)y۟zg,\E\粲t I*.5jԐ222tA:tȸ.Nv$IwޭBۻΞ=_˖-ҥKuԫty ԩS{1n Њ*a 0p;wqHlW:N2G 3F{Յ cfܹsӧ8Roh˖-~S7,@:wTyyyIv{ɫQT+h֬Y n6M>]SL$9s8Yׯ׋/(I馛t?VB\缽/:>`ʕ+WXo\Y'<5k /Pcǎ5 '8ܪݻO?T7tS`EujժƵ3zV9]lN:]]ttyoVjSDDN>J*iСC`eu[ QӧO\:?:.?~ı߯ÇfSϞ=K `͚5$(;;qw66mjƕZyuNNNݽq'NP׮]UgրJ `ڵty֭[~z:,,̥5R:u S^Tn]z˿%muݺusY=ڹs$iԩzJ  `z2ϟ_\-ZHpjl6cKݻy"m޼XسgOl7jH!!!kٳEs9}ƍ)4&33Sݺu?,Iz饗=T `[V%IqqqJHH(4f̙ڵk$iԨQ(udd4hР"=Z*U$9Rvv]#Gtyѣ_tybqc+==]/^T޽qF{>^էO1BUT)S 777GܹsO?ĉYBCC5|p=뫯ZK,… /ɓw߭\ 0siذf͚Yft_Np8HW+f_~E",+p##@&Uܸ&}B.nkF)FX `aFX `aFX `a׺kŵnᆴ}kFX `aFX `aFX `aY2`_1c233˭ʕ+ջwoիWO^^^Wz+WzAdٜr7k@y[btL%&&*11QW``_)--M˖-СC5gY2cuR`RR+.___7NZx͛duMZ:/(::Z7VjjO$ƪVZzWKN:ZjUc֭kORQdիWm۶{;wV&Md͜9S&MrFrr^uIRV߫rʒPC;vTbbf̘~Ն;\penٲE6l$ 2@'**JM6$ҥK.y7-I={RfϞ-Ior X&\lq=x"Ǹi3ghڵ.p8Z|$)88Xmڴ)r\6mtK/_.RX&$I>>>jٲe:vh\oܸѥÇ SR4N5cp׮]@hB8kΝES:'OTǎ ///r-zo+33ӥ+Y,8qBT^֨QC>>>]s!㺴:7Ksy}GѣGzjM:U|>,ߢ9rԼ~X"|֬Y#IZ~`i)Y`oooH*ӧOᜫV';k֬qfj,J?,KIIQvvvvm\7mT+):yx2`v$]zubǭ_޸vuKmFTNBᅲ$խ[WzKuܹӸΫ 2`^9&77W-tpjl6S~7o.r͛={4oΜ9uǎMe֭[}8%$$3sLڵK4j(yxxxݺull4hPuFJ*IF)^}ݮ#GJ5zBsl޼YG){q8zJ.W+EN0vEDDh nŋ5w\IRPPL رc5uT%&&*,,L/7nTM6MIIIcǪI&5uT/Q׮]լY3… ڶm{=㏒*Uh޼y<X* ђ%KԿk)>>^UV5]gʔ):v{=%%%_~ 2D'O.v .hZ|yc4h>H{R$u]۶mSLLu!yzz*00P}ш#TJ2pssS\\y͝;W?N85k*44TÇ׃>XVڵm۶رc:yUfMs=޽x y{{W,JRÆ 5k,͚5˥:u$HEFFڞ6lgyF<! #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,}t3!D$b}S4FQ#~cTikV)U_UUE hb&1CD%Fߍܝx=x9|NҾϹDDDDDD 1HDDDDDDDD$aLIDDDDDDDDD 1HDDDDDDDD$aLIDDDDDDDDD 1HDDDDDDDD$aLIDDDDDDDDD 1HDDDDDDDD$aLIDDDDDDDDD 1HDDDDDDDD$aLIDDDDDDDDD 1HDDDDDDDD$aLIDDDDDDDDD 1HDDDDDDDD$aLIDDDDDDDDD 1HDDDDDDDD$aL>x_~%Zl KKKԨQnnnXd 233ˬC055|||p!ǺuЭ[7Ԯ]pttD@@_^f}%"""""""7Qew߿ÇGZZLDFF"226l@HH5kwb?OHH@BBكc_~kJJ .{~zlڴ Vرc+$5@LL tۙ={k׮mۆc۶mh׮`Æ 3g: #&CܹsXb<$IDATF)ɓ'#++ FFF8r:u$ѣ7oӧ#&&K.ŷ~s1118y$nnn߿?<==%K_hM6 &`9wwwСg͛02ԟ*dF?N3XO/DV˗/G^^\RLYXX`ʕ[lzI5j`ɒ%%7k fܽ{}%""""""""LpϞ=ѣ100ȑ#/^@hhNm{Zl <<<ТE ޽{!B111y&࣏>zF%3HDDDDDDDDLP>:tPYS<>sNm!11D=IHHUS=uօ^}%""""""""$k֬ڵZlY=ںqztmGz㑑u_lڲիW%222S;=5ӠAvGiiiꫯJqrrBHHng…x 6n܈˗/cȐ!%ʌ3 ,PY!ك>}… ؽ{7v])VZ_ћM&Pٝ(k<G͚5àA駟Bu~z\p)))Uu.??+n݊7o"##ٳ'&O֭[tDDDDDDDDD$$"""""""""ZcH˜$""""""""0&$ @""""""""" cH˜$""""""""0&$ @"""""""2" VvWDL"##1| 0550zh>}Z:;QFYf-답k˗wJJ i666A6m7ӧUϟDŽ ЪU+ ۷/~'$'']!՗ ԩOOO,\O<ѺB۷pqqQF {A^^_0vXiFFFb߿Q)mոqc =z .7zyUСC?Cʤ###tR 2mڴAz`jj kkkh~~~ -'Qnj3СCTVM/;wuWT3ٳg1|p4jfff[.zm۶i;D%$[n#G 999{˗/BݺuU_^=ܹsjC.;;[;v ^_w 5jo ...Zioo/oU8*FY8cիW/ܸqczzz]2˜1c^رcRSc|.]zAR]?|߼y`nn1^9rgs *۷o;ysέa"D= nݺaÆ(((@xx8.]l޼yyyغuʺRSS克/||| GGG">>'NݻKx022_|>pOHJJB~pE888VnnnQ =zaвeK!11gϞŮ]JuMr wb͚5g0p@DGGI&J B@@8D۶mQfM"66DHH1e5X= fffh۶-[&JD,b|EӻϊN:pssÁOٳg#00Ю];L>?\|6l@ڵߗMMMΝ;UVWjԨdDEEaݺuCpp0 }R_?z?s FBOClڴ dzgk׮iӦz"̛7舯 ...HLLC$i$շo_aǎB~~ɂ-Չ'T5b`jj*ݻWeB!//O> ܹ;vnG믿2LXvڲz#ﯪ-XnĉJ;vLfRVn\\0x`ֶĹÇ ֭.]$85R1^|^ X[[ zyɒ%®]?#qmH sFF*;wVUÙا(剨rU۷XfJ|6*ӧ@hذ\|~~Я_?PҖ- @"/ޤ&M̩S2K,)$%%~z\޽W[qn͇ۻwZhQ|FF8@ ӺM6iU @"&kraѣGq'8~xpe2&Lл%ƿj۶mbVZWDTuhW.j֬/^o^T3ŋn۶MixP Gvt UEн{wXUVlmm駟[_ۇBѣUOu/#//5k^uM@єWmܸ?L8Z=rȲ$vl޼Y<.ߊ" hٲ%<<<@-{-6=Uomm-gggUU\P P_VbuU{`J888w?~z%uV;^:쌹sŋ: ???4m M&NV kkkd2 6LcTk֬ѩo$=LrrrcCCsssR^^^033 >>/x̙3z h߾=ׯCcRRݻ ՗L&QҬ%וdHk&`p UV%GEE >aʔ)U6l&M^^^ +U_nܸǺu,W^=n޼s;ɸwkży`oo{{{8::;wƟǕ.)) >d+JKKÕ+W=9::VH)kk.dffFLVf}+/{ 9x^B/,,Ŀ>>>z].mQ&ޏ7S[NiDy]U3}LL TL:u8E ą p1͛7cĉx"zÇزe8{Y]U3}Eh\@ 'NJ<߳gOtYR6l@HHo>{{xx`Ĉ֭_)SQO<֭[+mرcx h5]#p˖-8PgϞNj/Ɲ;w{Ɠ'OvZB̜9SQP"_K˗:xM;dffغu+k׮Ŋ+tnGt\t GYl޼9&NXӧOc;;2I61^#J:wf͚y˃N'{ DS?j722‚ pFQբm744ĦM~6l@?{AqQ,X@T3}Eu։_֬_^i{#G￯Axbg}V"'Wzu,Y@hFFF(#G(Oˣ7Do'N`̙>p]Vi9ŵ% ]6ƍ(̚5KSխ!gjj ҹW޽;QZ5 :aaa¼yip:qℸL& :t~ 퍰0T^n^T6ƫᅨuBD~/(1ȑ#FTT|w1q2O|Q5߼y7oFtt DBB}gǎP|/UqㆸA˫_nxxxs}yyyعsgfee#9  mћ @7|!88Xr/Vp׮]ŝn޼By[8&_\vϟؼys?@ѨA>Wowww\~]~U3}yqڲ*)שSb_Ra(%^cǎz~'uN%9&@qqqի?CCCl߾]Lj׮vک, t-m>?$i3@U;@ю7)5)ruuEtt4qU9r_5lmm{N*5ko@DҦkWuVZ(xMń>ES^:6mz* iʎ>''CEjj*֭ >uԁ10atUy7SWi(.D[j͛(홈^Çz'Mrw֭[c/"**JU3}e{L{;/4&LP.yPl۶MgMH $%%^^^w`ʕZ}kݺxxRF>7z Zb$>7o,A۶moݻw˥D7+۷(=jJ~/ bTE|ymQc|ڵ@.DTo޼)o^mY54Qe*I\<(xM#՝W]Z5K#M/e غu]vӡ91HHMME޽q ?PbBU5j ߿vsP~}ٵkWX2ts;ԩ -- )))*˖&M(>=@ѷ|#1J w_M4Sw/'O(7.>U/ TDT1Jcy~~ڲyyyJߧz711 W-SSS1q-333qĴ%ԝW\J̙3:Ayڵkz*舎; &۷8h٘1cNu|eǏWY?o߿8 *((He9߿M]QnݺՖ6222lٲbũWV|!"ROI&Snݺ"""!ޥ֥IU.\GUU{GH1)O[L?`ES=zH̯gϞjQE^tt4._ƍUk߾8myv1ǐ!C=z$m9JHrrr^z ɓ@pqqRSSKٲeN߾})SZfĈbw)SZG\\XSitN %ʄ Y(,,TZ&"#ԯ__ /^(vرc@vڥC lmm꧟ƿUe=}T011.h#BCC5n߾-oWWW!33LU  111ZWWT?wpE}ЪU+jQ*xsB X[[ W^UZb,_PPPPLUz`kk+5j$;/O'44Ti=\rEd}˗%b;s(fAlm +WT////РAabݷoI^DCȑ#=z`̘1v&&&prr* b>}:3fM6mk׮Pѫ#tpB>|:t("##8K(Zhzcee+V`СHLLf͚;"''O?aݺu::(O:9RSSb |={bÆ @FF|}}___m5j@jj*ݻÇc߾}Ɂm^|YbwuvڅZjn۶FU em߾]BU\+W(=cq4^S0m4D.]0c 8::"66/GpL6MU**߸qGFΝѯ_?mV\/!! Bjj*Q-F*'"EVfΜoܹ3&M///T^/݋_G骢kԨŋcܸqx:vٳg ;o'bժU+f̘"88ׯ+"##U3n8=zq%HKKí[} ~ G">>-P+=*;ID @ijԨfΜ)~egg'={V!ԭ[We;u"""T_o VZ%vQ٣M BFFPV-PfM!==D0YiÆ [CwK7DT: tQ  III:G?w\mF j3f-j1>((HQR]?”)S>%KOU|o^W>},uh+ 8PeM4bcc5>? ǏwשNZZ`nn^=˖-+u4q @"ɢEp17)lmm111ѱcGDGGcΜ9pvv9sڵkeĉq%?͚59ЦML>111ZR9ӧJE޽{'u֨Uw^bС"z}ݹsΝxyykҽ x{{&&&7< 6=E={>C׮]ѴiSXZZkFN0uTDEE!((rDTd2-[ .`ܸqpvv5 akk:/k0uRWӧA011uV̬Tmcزe u[[[XXXUVꫯpE4mTz֬Y(L4 ...m۶Ř1ck׮b7+cmm~644Đ!CJu$M2AP'8H˜$""""""""0&$ @""""""""" cH˜$""""""""0&$ @""""""""" cH˜$""""""""0&$ @""""""""" cH$c5qKIENDB`././@LongLink0000644000000000000000000000014600000000000011604 Lustar rootrootcloud-py-api-nc_py_api-d4a32c6/benchmarks/results/dav_upload_stream_100mb__cache0_iters10__shurik.pngcloud-py-api-nc_py_api-d4a32c6/benchmarks/results/dav_upload_stream_100mb__cache0_iters10__shurik.pn0000664000232200023220000020232414766056032034152 0ustar debalancedebalancePNG  IHDRjy9tEXtSoftwareMatplotlib version3.7.2, https://matplotlib.org/)] pHYsnu>IDATxwTg,Į`bFc4hb5DEcŎF `AQĆ""˼pv]9Ν;wܝ-AN+QaH1HDDDDDDDD$""""""""a 0tDDDDDDDDD:@"Rj޼yH$H$7o^Q ݽCD.~8_k/H(D9rXxyyuq>[ 4c fȑZ-<}J*055-5j#<<\|wߡvڰj׮ZTKJJB`` VZ!CF+1iiiرcv '''-Ze;*w^3...(Waii *}˗/k͛傾]t)r_~-BQZ5(QFFF3FM6!66Ve>e ٛ _q ni[~"] ^]tAamm SSSTX;wn΅]~>}/=;wbڴiy.+0m4deeI&(W{|ׯ_Gff&RSSuVx{{#))),h(WkժUN^su=t/_300@fcccDFFҥK44T[F,bŊO]~8 }BBBFۦ .St\? SrrPn]3fff&.22RVCi߼y#w޶ckǏ'R"''')S@\~-CԩtzzzXtx߾}J'z8|sQs @@~~~ZɓNnP֮]+.1 әcZ.]k׮m[.СCiϟ?0ٟѣGk׮rDBBƌ#>vqq,,,4g5JODh"Tv777%|´OU̩lٲ [>i@O,,,L-bŊmSbEq &i\2,,,```KKKTZ:uܹsqU HPR%/^h4_@@lcǎaРAV,,, Hj*>f͚&ML2⬋M6ܹsn|{~Mt022TAa߾}rc=)#}MdgݻѥK8::eʔ®P۵kGGGB 1b߿1ApaQTwuufffm+W\0`.)Rfff߿ ˗={6ׯ%K5kď?'OhgqG9sF|)Zbbbp|A ϝ;'>צM<ώףGprr,--QZ5|8{r*fffzBʕajj D"] Srr2V^->ӧwVSpuuUN6קOcذas{Azz|o }722s;vm`ݿ+WD>}PF XZZPĽ{ t F5`ee}}}bŊh۶-f̘7^| UVppp1J, SNţG `No̙3ѴiS-[FFF@50`?C풒nyxxO> ?^a]+qB 5NQ7A$hӕIٸq`lltF%JR߼t Tv&:QQQ*?΄)k۶{I]V011QpCVZcqۭ[i0Y9#..NݻV\)mJJߪ}LMM5k֨<,կ__xd_#FQQQ*g•H$–-[?~022S79miÇrdoFL?di4wV]ʕS8W9:tHVm'Oۙ jС?RgSV?\OY} e˖U}݅8Y}Umirxb+11Qu)A :I* }^Vׄ]1OtF i͛7B5.J)̙3G9#@5kVf. ᷸bccDmeʔɵ\AHHH,,, '!AɓʕC˖-aaaG!00YYYE߾}q5uytE(\"I&]6O?ŋU_dd8fʕQV-qqq ݻwgϞEccce1a񱡡!䄘 &&}ŢEzJHH@ŮzzzꫯPV-XXX )) ^­[vͬU&LquKK~nB֭q\.+>}… 011*T9sqqqcƌAjPzum/_Zn {{{}OFRR0x`ܽ{Weq!{lٲWM n/~ZazM|nڵk矑RJ%J@XXΝ;t$''o>F6LvvvӾ  5N>xؚXٌfG]r%L"N4bee͛|ݻwq5G*[!!!000@-PJbIc 4jŧ-$$$Ю];ك06l /3 TdϠZvmTV 666ǻwW^3ʧbݺuZ/33ݺuCHH\ݺuQn] %%EZNY^ x\rhҤ lmmG\rO>EFF-Z(lܸQJE[\炑h۶->|(>gccWWW#== A||,,^Xe˗gϞ :u;zhiedK߫W/۷rbbbVZi۴i#xxxq r_|)w{ԨQJPmdqMjDfff_rExRR<嶹zj4ὒm#nO:U3/S fff“'OԾ-Ǐ/n7`Yv\_eFFF.$&&Jw vbǏWgUZ_)͕~v ~/ -Wj߾Sʭ cݻw N-w!L0@=zTh0sLa߾}‡>,ڊpԵ AAAJs0c ʕ+ ϙ3G [۷O5޽{>LOO\]]Ŵ_%J*>|XȵN۷zjMk>ȵ600&N(ܺuK}i@OS^~-.]ZL;g4 Ν;WLSD 7>>>reJ*j˞TvYQ(--MWdV,0[>,,,W7fU/,--52-mӧOٳFۄȽ9_ǎYYYi\ٓYE3J]zLBBPfM1aTm#;kٲe,}k033SnE_~-)SF B$?jUi0o޼B˗/fTt1+{zjy-\PLfA}=<<0spp{>Ν4OߋS]1L6M#((HO5:~E)S7mڤUY!\K;TdI7Ξ=+VZŶ+pq-֔炛6mkhhU]A(R(va 믿 Nxs"m0H}`JީrppPxҢ.%7Ҋ+K.]T^.Zʍ92\LxZ7o:[N6;___# H$cTFuÇy"{ y666u;'GGG1}hhNNV]ݓ{rO)#G;;;qZ_+q!4]͘1CmdZɾ5(GQE/^,n|\s~V;6ς ڇeժUEW4 6T׏?()*O`^TeP?kkki RJitjz'H^7-BBBwa*yɹ:MrYWZƍry)+ n(݅m۶(8 ##C ݻw(6mٳҴqqq ݻw?q͛ѣ}WR...*8;;… VZNeucz=uUSF1s|T8̬xJSlڴ ݻw~Ѷn9y$E1tRq-/|*Lκ\|uV||tsA>}d 111WL ;ݛH[ ͛MSD ԨQܸq#7n5jhف߼yH888Jw=̘1Ǐ'QG elҤ Ӽy [l}#FSNZjC*UBɒ%U}x"bhhI&iwpp,=)""3gŀ: IpDⲢΜd_'e'pED\NKKhi̙o7n,^޸q =-*/_8sΩFdSR...K?~\"4`RJh.]4\tit.25gő9sȝ{}ży󐕕???ԭ[_5t:uMtLtt4=z 4h<P_E[\PAAArjP|y)g](uIԩ۷cÆ  ą p5\~o߾K{}hW#bHUPAtf1Ϗ?bذayFl"UJ4J'].ٻ]yi6jQQQ0`srrŋqEDFF")) YYY0/"n+}@Qy] ի&M=[8p'ND зo_辥4i#BF[ғ(Txzz'1={6K$&&ʽ[nU~!]8qmiN2Ү@ g`<庎*$Uw[ޫOU7hrS[W嬧rKP+"{SNRAIMMxС*0@kq Y+Vօy͛7ƍq )))o pssj̔)Sv~ӧOG5о}{ھ0΁4u}]V]>h~ \ +ܹu>9VZ?~n߾ID g 'DIIImꞳ&dtl^~7m$կ_ϟWٌ^Źly] J޽ѻwo# .]…  3Apq;ԅI6Q^=ܺu+y8pϟ?+WWs|*5j_x6E]e9Uy*ʣ8TucqQ\~:"-Oq[lOn]TЎ9"eĈ1bo۶-W-[=r jժꈝ;wu{,&MBj 4O?寿7oVD*}777۷8wqEܼyS<>s 6mOnXs ++ R>,\]] ݶbŊ(W\eooM6Es.\@6m/D¼k4XX%K>nRlx\뢜=s挸<{l?@E6e,1\*TÇ|[nŒ%KhO8zhA2eʈ˲C@ ŗL͛7x ʖ-rׯ+^b7n Y\9@:@}CCCv(uL2bAU8m۶x.]/ŇVX*Uwȑ#]q|9۷8zzzXxd2L2߿? wݻpBDGG#99~\-sx$%%ij}ڴi۷hݺu#:$gAh޼9;nDb`OL"966Vn&4E<&wff2VMv,uEI0O.xbŊݻw8q^zhڴFVV)))طo_Ǐ/.>}ZaWM*޿/N8dggv{i4W~bҤIrAwٳgc{{{ HcVR`fG!""BOnmM6ɵD7nIvȖ÷i@ORW)))IqLE'ٳG̹vk}VZbˣLܹS6󏸬m!{[:uJe>*={鹂yr֭wyy?CBB/D"xyyLeG=ݫr҈dIvjo8q޽dڵ]J,Vkֽ{wqy˖-YY?8p@ILLٵk؃TRv45jm~-llldw9rVy~?7^^^r_aN,Cׯ/2*7iαQe떂8l}.$B] [w߉:j/0w\q֭s H&3$R@" zAYDD/^>}+W*][,X@|}\ aÆȑ#o4k,W0M6p׿Mԑ,$000WKs+++ׯ_G=4~oK. NR:Çr-s ,Ÿ8s-L"N}A7Md8駟˗… Zo֬Y]xСHy :v(~ͱi&i9͛7}Μ9rm9M%DE`+ޕ bccř4edd3f?С¥L2ҔQqV#G5MOOGΝn:49r{V+о}{ψVQ~}o?|4R_w"::mڴw8{,7n[M)_# ;v@VV&MիWQ >+~g,YQD xrAUV} ZddųlaÆ\7p1޽M4ׯJ*ӧŋf۷O)f͂ CzТE F۷ocq Ԯ]ՃkܺuKUA)T\W\A=pdeeaǎرc*Vztě7op\"e*Ujb!Cv؁ꅰl2 9~-4i"N<p\~].`aar?8}7b ٳdYfh޼9W4\|Y 7xʖʂ`dd:uz갶FBBqeW__¼~a۶m˗/ǚ5kШQ#TRfffGXXn߾-NT-{?Gq.h``{m۶x10apuu=222 >>^euŋҥ """Eaҥh޼9`ddׯ_ҥKr4cǎׯ"<< .… akk Nu܍իcÆ DH<{L\@D"~g!++KprrrY~`dd4Ge1/2˗U}»wTCy y*իcccyZ[[ TNݺuվ&ҿJ* J󊋋j֬2Y9 aΜ9Fe744&L07o 4P}ڵw [n1b4I#+/A#Fnݪ6&4 z?yжm[yXXX[lѸ/H$? ̫8WnnnbWRzl&&&ºu{6+WV^ϟ?s6 ...*󱵵=Z "ʲ){Ϟ=+TVMתN:«WrϪ leAS ‚ \~}b邝֪,=ۏҴ.]ڴiq̄Nx4ݯ"uIЫW+WN}V7n```Q;vn߾6@QFy (/TT oƚ5k#xspp@V0~xjܸqhժ6l؀ӧO3dUX=zĉaoo_Pf͚عs'|}}qM{([,ZlAcǎj^:nܸk>DZZʖ- ggg <ijĉԩ/8q066#wqơB ̀7o"((z*>|H$%% e˖ zXnp}xX`&N۷l}鰲ѦMtUreʔKyfx{{Ν;HJJjԨ`Ȑ!033SUsppӧqAڵ ׯ_ׯaaa *G=zt622š5k0l0lٲbrGFƍ U^pmlذ~~~GZZѹsg|bw{UdʎkV\լYW\7كw۷AʕѧO5JnOiѢEhٲ%n݊}V`@dR߇/7o >>fff(S j֬-ZK. [j wS֭f#-3g~;v BBB;%KD͚5ѴiSxxx[v Uƍ{yy)y8{,BBBpq={/^ kkk888aÆpwwG>}M/Y^pXNBxx8 WWW9@':|0nܸ3gʕ+>"""cccڢ^zڵ+ +++yN8#GĎ;-SRR`iiˣN:pwwG׮] ;k \dɒApp0vލDDD 66(_<\\\йsgWm~vvvX~=f̘___8qOjѢ+#00P:66@RPvm4o? HAQXXMzL*"YLL J. APdI<\ J"eׯ//\-[qHp"""<1c/߿Ghh(K. Q`(Ξ= ĉ4% ~.(@={6LMM4~틯KCDDDcHDDDDDDD cH 0c @""""""""" cH1HDDDDDDDD$""""""""a 0tAQ@ 44`kk DDDDDDDDT8222pvvtPP4iҤADDDDDDDD_Wq ױ 0c dkk+._zEX""""""""e_{ƥrbɎgooaiKj. v&""""""""a 0tDDDDDDDDD:@""""""""" cH1HDDDDDDDD$""""""""a 0.GJJ 233HDDDDD VVV077D")"gb$++ HNN.Q@jj*>|SSSTPzzEDDc!WO"@__KEDDDDDZff&A$''#<<NNNl HDDZcHLL([,,,,x ?͛7Drr2aaaQE#"KD||\lYXYY1GDDDDӃʖ-+>P%""#LDJJ nGDDDDDb_NDD`1!W__-zzz"""m0DDDDDDDDD$""""""""a 0tDDDDDDDDD:@"IH$H$(:i7o޼. 3 I68 NNNݻQ%"t9:ҥKE]#]˗#,, ի H>iوluQw,,,G۷'?yyQFغu8-- =ºupy=z;wP\",)f9u UO?]AL1=xW^E&M4΃'}xvvv[׬Y3|~:V z*=Z%%OHHH^#Gefnn.W5hD@@Ê+DTt͛%J{F֟DDcH%JĉEX"* zaԨQQBOOїI뼝;w"++ >|FzzzgIDD T Iݻcƌ ahh[[[aٲer(sAxxx|066%*WVZaΜ9z"##1sL4h@w2eA bk׮O+;)uwwӬZJaqA"^19r}TRh޼9~w0bժUh֬J.pd,Zׇ9J*WWWlڴ YYYJEׯ_C?qn;o<3 d _PNXXXdɒhӦ ٣ +VD"ȑ#4habbGGG5 x޽{;wKF_}tYnT\rE|ɓ'*rKIIqAcbb Y~~~pqqɵߣw޸y4o޼A۶mq}񹤤$\t .]O?:vBff&0x`!C0w\cϞ=stO>KLLD@@]v@O-[L///ٳ;v[hKZI;X`ϟ+p-ܺu ׯΝ;ѻwo\SN@DD_u!!!ׯ_}wvţGp DFFbٲe}}}lǎC@@ϟGVV)Sº|Ř={vcwݻcƍ>|2H Ç ##q]ܽ{;w7D:ڵkb! fRJx9o߮&@^)?T d_9;;gϞhԨ ^xb߾}x9<<| 55D||<,--1~xivvvHKKqҥ\A8uAAAHII+~x]QpĈ~ׯ)SVZ7v*=z4BCC1|p 0e˖Exx8xFFw.:vXnN< ҄4 .~f+U-Z 00۷obxx9ƍ}oƒ%K#۷Xr~U\o*U,Y x!qС\e8q{>}}}}~׮]S;;|Q. qll,n߾愴TT[fbƌpuuEff&N>?9r$J.]*}WZ۷oUV?~1;۷O?+H-5kAaʕHJJ[bXʥ} Tx1,Z&g4???ڵkcǎH\߱cG4ocǎELL ~'ݻ7'uml޼YukР#$$0vX⺆ w=z4l٢tDܼyoF@޽{]ʼ{n 4H|QFׯZj[na=z4֭0[n AAA([|֭ѩS'tҥK.1c_~k d/{ꅉ'Yfx-Z%sss\|eʔ[ߪU+3>||JJ > 2eOV=0\4h/ʵ|r RRR`mmdoz󢢢0m4AAAptt׻gϞhժ1vX<<'um >\ !PY@k~9:]vhѢF0"Mbooo@޽ꗡC~CZZwߩ<&u՟DDD $!11ΝCV"NNN߿?( j߾=z ͵͛7-Znt; XXYY='=^ ˒rԩ5k&\4vvvZ]@vח[o@8W_VZm ZnnyZ矰U:V#[׿sݻwƍda,_\BXM6od_x:@4ܹsƍl4+VTˋ1EIի ɲky#Яrח 6%Y˖-Ѯ];idozluV&ˊ+R_}8ԫWH௿R 6݌7n(>|8ttDbwqqœA͚5k-D8axH$\W삚STT?~;wӭ[rNq^rNuV[(NKKbPp ጌ q]ǎ^KIO322d7ׯ/B UFFv Y΀zɒ%.vR;̨QkҤ ԩ*p+Q}MZ\VϹs;ccc8;;-i yfr >>ϟ?ݻw:O]%ݻ։J;vL}}}P^yҡrIؠO>J0f̘\(ңG~VD36l4g(/tΓ`,iPʕ+x}IDIDDT$RRJ6mBCCs TRC,mڴ ݴ|֭[1x`8::jժ2e ={k;CCC9rDYfe˖AΝ{nIwV.-[>6m aU)'n<^%J(]jHBTRf͖޽;lll+gUj VQFb}۷Ž;?ڵk㯿‹/T]լY{A%Gbpvv  .(믿B=d!t JBƍ0I*8pUƹ>h,/=Ӷ΋b-5бcGΝ;Uސ?gggunݺSTkkkiϜ9*WuۈCzz:A 3g}ox ~7mV>SX5kT86Nڵ믿FժUd_pp5L>իW[jԨ8^^Lz@4<,:tyA (<&DǏ$""*n$i%J ((Hlbmm-UE]@ {bYf̙3C`` &Mqf7nN__cDFFb˖-P!!!osmרQ#ȾMOOǥKȷr9lW'K)#;I5%RFԭ'Rd߾}HII6?~~V%`m_߿_?x ~4k rhRY[[cر8p޽{{apppl۶M6&M`ٲevbccqaqw3WCh@LQ'@tXe&u=dHX=~X8/ s|p "ݻgS5l^fh-ZA~|W*ǨQ0tP4k ׯ_ѣG SSS1tSN! HJJI^ GEEǬ%Lʕaff$\rEe9evlgggq988Æ S688X}ЗMv+VM?m4DDD`J?*'ȑ~VUpmy&222u-[A@/,YR[yPV-ԪU C AZ}aҤI*D=УGL4 WׯqEtA.;N:*ѬY3_ׯ_*ߓwމ]}OLLLPZ5<~Xm:ul ortEn݊SNa믿Νt Dy RUח7n ";S]^f 644xQ$M>z(p5k|200?(_m޼YFQ^ppp@Zp}Xdui (/?@]Vv 8{,^zrJm6D ȑ#ݻ[lUW:/%%YYY OJJŽ;އ#W7nիWP\_ʎrJ7=Cj߾=?GX=i߾=?~PܸqCM!&t;w7nc211S___ 4H6DDD#v&ʃjժ.^'OZU=дZSN˕*U/\pRiiib -e\nsR⌽ RJ)m2aqGFzzz4[lO>Wz?@v׺)S(L?*H۷}j4]VVxљÇ?~(vSu_O?[ܹsqF@Æ :/))I1dffb̘1TY{_|={&7 "KƍcJ볜7=P6ߨQˤu&WVu:w,nDD$ʃÇna5kҥKt-[޽{h޼< Ν;qeܸq'N)S}XXX`Ȑ!vgΜA5?'O[UV~:` ^ KgTE4pЭ[7@xfͰk.3f ƌ &]T?~fҥ :ׯСCԩ6mڄFk?呶bCV4ڦEb@[Y+Fa0a[QF8&LP9d+4lk׮Epp0.^YfsbWk߿?dfΜ3gڵkضm6m={UiVBrпlذΝ͛7?}u:u`Epp0"޸%^[WT)!d?DDDaÆXj^K.ahٲ%>~D7PN-׮]CF児={ǏYQb'LLLеkW'7f1.a`<۷/F["22?z}}}\ gz-֯_+\omm oo\cdeeܹs*[ /VNz1|iq?gO322pA\~C͕~~~ 允=mÇ8qN8!cǎ2e :uꔯ}ї#00O>[aWWEлwo[wEHH8!Ծ}Ю];[Nly+Sm`=Ə>z###1 Wʗ/c̘1HII%Kd4 7|;_RR_ a [ݻwSw͚5&XIԬY3#55ЪU+~wÜ9s[㏹cƍb`!?VXH5jJ*a޽RJE_]󒓓~@54f߾}o>dffb׮]J{}(lق;vUV10l0\tIw%KУGԮ]J>lllЬY3/x!:w,ԩSq?͚5C `bbTXѣGrk`p_ K>>>̜022B%дiS,^>|47n_Eݺuajj*~֭Ǖ,N6FW}RJ YPV-[Ν;~3f .\߱raqFwicԨQp<<<`kk CCCۣsػw/#R{ƍ1x`lٲ000ԩƍ={vZB@@M6Z*,--ahh2eʠcǎذan޼l즇 5k&>dYfƍoPJj¤IEw~o5PV-̚5 !!!\r싾 X">>^cڵxnDD$tʷ˗/NǏ 14o<m~~f+V/^`Ĉ*,y DD($""""""""a 0tDDDDDDDDD:@"""""""""Y g&""""""RDDDDDDDDD@""""""""" cH1HDDDDDDDD,&*QQBff&^ ""ń @|KCDDDDDEǏ`jjZĥ!"ńdz% (++ xe>wE]fnnSSS$''#33^D"aS""""/Lffngnn^%""ńD"A d݁322dDDDDDTTLMMQBH$. },FD$$$ˡSSSXZZܜ?""7D XXXuQHp""""""""" cH1HDDDDDDDD$""""""""a 0tDDDDDDDDD:@""""""""" cH1HDDDDDDDD$""""""""a 0tDDDDDDDDD:@""""""""" cH1HDDDDDDDD$""""""""a 0tDDDDDDDDD:@""""""""" "3f̀D"HDDDDDDDDDo޼+Vu1N0vXdddήE7L777TZ022ݱtRDGG0c 4l666044Dɒ%ѢE ,X޽S\+QU>MzD055EŊѾ}{,Zaaa66/_˗:tz?gҥK!ŋxΜ9333L>vvv*)S-/\͛76l(IU5iҤ@cXnnn,Ά;n8@tt46lؠ0… sWhڴ)lll`bbˣW^ؾ};ӕn`3A5kPre)SժU5\\\?6icBȁ Ft̙31x`qEl߾&L@HHz {{{cǎ-}*{-޾}+>~^zÇcɒ%ؿ?jժk/_"!!PdIxzzݺu cƌ#Go>HDDDDDDDNg-ƒPB//_\klܸ7:}}}l۶ =zEyfq^6m`֬Y*zzzh׮vTRHHHwڴiWB rĈ~~~HIIAձl2C"ܹs:u*X%2ؠnݺ[.7n۷odzgЫW/xyyi мys9rʕÎ;˗Xv-&MݻJ˨!6o,N rAzJ.ӧ ̚5 @v`1g D #GXb-nطo%Kjmjj*TW^lٲqʖ-+ݻwѨQ#aÆvVe?0}t]-|LwA:uTV 0tPرCїjH^zqĉ_PH;d_xm߿/.7h@eچ <~d|9I|4:)K%E )CVzz޽{ⲃC[={4O&%!"""""""*( Regg)OOOqJCv`"""""""  ///LrJ;v @vP.g0, #G̵}v`ffX~=BCCb@\rpqq[8LOOǘ1c.=zP8_Qn]U8|},[ @v>}(/QAdT(͛)S-[D*U`aab׮] aƍ>lll0sL̝; hѢ&N:Dx-:M6!++ COO>m6={;jԨ+++|!!!ظqah׮ѲeKL>mڴ?;>~Xf$"""""""*,A.t/AŊ5ԣ|زe :tk]@@<1br?O(bѢE:uju#GĶmԖr+r5 J˲zj7N>4T(N< ???ɓ'x-ajj ;;;{߿ZI$\C͛qExIII@ժUoիWWnj3˗/޽{BLL QL4j}E޽5jƍc͚5˗/GGGo'OFժU>f"""""""` DDDDDDDDh$ DDDDDDDDD:@""""""""" cH1HDDDDDDDD$""""""""a 0SLV kkkXt)GXX$I*V2Ϥ$,]7Fɒ%ann5kbʔ)x2]v ,@ǎQ|yիWǨQpEHJ"Pԅptt|˗/}N>:MWtiܹ:u>PR|86m### ND_McQPDDDDDDD8::M6hذ!aoo,DDD`ѳgO\zSʕChht/ݻ#FP&!!ݺu| SSScňǀ\yDFFЯ_?j *T@ff&._˗իWؾ};2[ $"""""Nff&UE޽{OB %޾} SS\Ν .]iӦɭtܐ777ʣ{>|8<==GΝ;֭[Q41ȩ jԨuӧŖy}UKOOիj”)SriѢF ;p+ѣGѿ^ti,_\|$"""""ψ% %%P߾}?>| S|i=rHqZM6ӧOʃc*+"߻uÇqM@͚5 <*V̼nnnJkԨ̐@ʔ*.kJH$"""""b+)) ?Ɗ+1`#)) 0l0H$ݻ'. DjժkUsΉ˵j*""$"""""b FR~̙-[+˗?иq\iлwo&M_U2DDDDDDDl޼y2e <==ѲeKTRHHH@hh(v%ʌqFkHKKy?iӦa޽xO'O`055?-Z bժU 4hN:h۶-F;w(ݧWr Q1M6aӦMJӔ/_[lA?i_}}} 2$OZZZ]vǏqFlܸQ.vڥe|YԫWO>r Q1pI!00O<۷o SSSݻwGaff=~W\te˖sUVō7vZx 舮]bҤIprrwYK"(,""/_|E\Uq_Qޭ@DDDDDDDŀ(NBDDDDDDDD$""""""""a 0蓉7L777TZ022ݱtRDGGk?^^^H$yyyigbb"֮]vڡ\r066F2eРAL8NR{K,+J, ccc8::o߾8~xHLDDDիW1h 뢢p9;wv܉N:}*QFŋrϿ{޽Í7ptQ/_'^~-|DD"""p >[l~}$""OmڴAÆ {{{dee!""޿={ի_~wI888(]l4ӧOGHII ƍwww!)) ѣG[?|]t1cƠo߾(U?̙3ؾ};,,,v|/QNA.tUř~E]{.233նpE޽{OQF?+9 UbZ 8qeʔQ6-- FFF޽;Cn݊#Gʭ_mʕ+hܸV%"""/(HDDD&[=<)) ׮]ӶDDDDD0HDDDE.)) ?Ɗ+憌 ɓQ###.]͚5ٳ+Fǎaiijժeʔt qÇ*'޽{F$"""!B"իWǔ)S`̙31x|' _Fzz:qoZ*oѢE ڵk[nʣVZs+-- W\k||DDDDD0HDDDŊ ^ŋC"hOʕ1uT8pW^իW~A" %%ƍƍn#.7+Ñwzyˣz&~~~x}-_߿'$$h}DDDDD9Iuא4zsUq_Qޭ@DT HNNӧOo>} ==fffx)ʖ-+jժxx6lX[1cƌrߏ~h"xzzTR úuiiiѣGcZ;QqDKX[QAݺuQn]4noߎgϞW^*okkkwsɕD\W,Z{Z߷o_̟?022Bձj*(Qtd"""" +Æ C~^+nA;v$T4>lcǎJ)U5ju؊OܹsqtA )۷oB %J) ;z$&&ĉ;;;*U ,JsY,˶mԩS?gϞ!..;v@rc1m:u|".7iDeDDDDDy ;-,,, eQQQַ̻nZ\~ʼdɒy.KBBN< h޼DDDDDy ;\(ظq#AZߺuk%#G|9n޼ puu^O/^d =* '兔iV\cǎ*UVZɭD"D"ȑ#sm7nѣG`q5*W}}}L:@v7 JYYYqJ.4Td׮]Xd @U(TRCDDDT͛)S-[D*U`aab׮] aƍ>ЦM4o=z@agg +~߲eP\9yػw/_Ç1bS\/_t*T޽{SNQSٳTX;vP9.!6$""O*&&6m¦M)_k׮aر#ʗ/cccXXXz5j.^X}6Ǝի]6M/^hORR.]ƍdɒ077G͚51eMDDDDDDIA"""/_|E\Uq_Qޭ@yԺuk\pAmÇcӦM022~,\ʪwKKKlݺ*y vǏ+\oee]v{ 㦢߇ⁿDTo4DD,22I&az*._+V\r۷cȑ @cŊ BPPVXe"!!V.!!ݺu| Μ9K.~1`ܼyȏc ){>|8<==kGΝ;֭[y?Z*RSS`888ȥy4iHԫW7n܀^{As… K.Ŵi_t nnnȀ츩x7}(>A8z(0Kj?HMM̟??Wʕ+'رcҤcZjaʔ)ҴhG ΕS7.i߹s=aee###+V@BBl_~N:tS}v~>}zijJC什j*7~zݺulٲkݺu?pBM4)]{֭[%Ik֯%KwI }ݧ+Wh6m|}}SJjѢ7o.ooo:eܳg{O}NG8}VSPi_zU$5k,]oa p~_O81]z9r۟~ e֭I&C&8 ,J?6LsѺuݻwO?ꪘ[($io͛wiӦ)>>>CxM6-k7wG\///s_gon׵kβ=ܓ3ή2K;uSNeyРAZ~7|S֭+@F!!!6|}}5w\ujJ>6nܨC>}z!I{Wtyxxܛ7o'58td#8}w#g]|yҥK߳,6`W^i_K=?kXf@A:t$OOOY&di{Q.]%///uE{QϞ=ӝF.?:/P}wg=n8IҾ}_gZ歷ޒt{1cd߭[7ժUK4gݸq#C3g(88XԶm[5jt{ A7qPP"""˗/UW6ioZ>>>;7j5jЬYa_/[lr'Yқv`wdp~on_yuMS&M͛?O?UǎvZ?@\~r}ݧyiڹsf͚͛˪]-Zd02Kq1믒?STTt風(l6-ZH}qZz /ˊQRTbE{pIRRyZ~'ݸqCW\ ӧOK*Tn9pf}w g奍7jҥ>}f͚6^z%j̴jJל9s4gTz뭷Otag)<<\:t蠤$IO]4lذ|klٲ[nҥK$=l鞹{GͲ$9-9( XU~짽g-_<)666: իg)111ׯkŊZ~}ܳd,&&///ן)Iz饗O3gds9gnݺÒ3fhޏu)%%Ece߶m[u}Ej޽%mڴɲ\Qy.?^v:woV7?]xQ :yO$͛7O=2ԑ{L/N:ѣGk߾}y_]vw:zFͤB`0+M6՞={+ÌGTV-۟-[CXA\\z}ILɓ'x?5}tIgfeHǎUtiI1 #ӺR7L c??S#FЭ[԰aC۷|||ڵk_֗_~)ͦ#GGzΝ5kHN ԦM}:t|y;rǒ`߾}0ٳGVR``~W u5sL=*SJ(z~7=OA]V-mNdTfL9x\eԥK2w*T_w 0r,#Fd`ڵꫯjǎ?tM]rEԬYt^^^z?˺qㆎ={O5j( m j={';:{l̟?_{$jJʕKwW^[n x^㒘LPiǜӮ_FEٳ5qDkN%Kӧzj\RvB=t-^X7nׯd߿_})s AD]@o>ӣpkذaZ`A{0ze{͚5zꩧtt;wNΝӞ={gx6$$$})Y&Wի{QQQ$??? 8PڵSՕP͚5KgϞҥKh?)Zh=3 $ >}ZӧO[zN3}3p+88XÆ ӂ 6?ԫW/kÇO^Zԧ~nݺi*SLe,Xwy'{gΜљ3g_w_|Zj~͞=~2n~Լysk.@,Y2˲.]$jҤI*UOt:xx mڴIǎӀoKxIo +.@DEEI4p@kNիWWrrBCC5k,={VK.UbbV\i-Z(,,翙<}u@fϞ=+IрԱcGլYSnnnڿ{=;vL[lQ^"\\\t}m۶jڴ*W+ڵk:y/^~Aaaaҥ~7yyyzk襗^$jܹβeYƍnzpqqц ԡC͛kÆ zGyfZv{|;~>} VZiСjӦ?UViܸqj߾}RF-svuQ֭3V͛ÇXbhBC Qnݴk.ڵK˗/t5mڴLѩS'3F'NԜ9s+((H>o{wC)00PIIIԚ5kv{K.ٖ_?¿T...zwN-oƍ4hP˗/Yf_vmO?6tL,^X3J(KŬEݺ83DLLvj?>ӷ9pႶl"Ij֬YIgJNNζl}Gp'K=C4h/m.?rczڿ{9=S$50 ƪ[n:|$iƌ?~|rJ%%%IrltKSʬO] hƌ 6 %IŋT׎wĈllQf4{l%&&DZjj׮>p#,...N={Ծ}$ISLɓԙz'zwrww$˲\HH.^(2%jڳg$_~j޼صkt{n?yׯq=z4Ouhz3hԨQ:w\2 K @޽[4a۹'88ԩSs,!߿_Խ{wUPvʕ+1cHvޭ e_'7nCup t]gZl:/ۃ9rD#Gt{&edr|wQXX´{n͝;W͚5իO(<}Z;vTDD\]]5dK5jPJJ"""m۶,zw4iҤl۸|:v쨔롇P&5ې$5h@^^^m;0<<~@Ν~rq|Eﯠ mݺ۷cFA:pΝk*<<\ׯ_ڴicǪSN:.**J]tQTT}&]>}򭽴Y{5u]$[N;hCݻwׂ grPׯӧhժ6mZjƍgK 04dEDDlٲꫯԪUteZl{LsK"ܹs"I:theTw}W-ZP`@XpJJJRFW_9]v  +/F#F8Tk׮NiK&Mۼu&#-&&F]viO\pA[l$5kL57oT^?K^xNc}O:E7f{|5k}ڵkM+Vw}'I?~/-77;~aٲ ֭kp'0uȎ;d4qDgԩSGOlluÇKf̘ᴙtYYr$~_bb﯐I>={~v5ɓ'MJ'߀ܯ̄t{5jK;@Qg*B?s$j#ԃ?4 XR\\z}ILɓ'{3bO8\rrx m޼Ytw {ŋO(Pnݺe6wt{VO?$I OLLٳgJ*=O}VUzGn¼/JJ.mIrݻwK&L~;ffiԩ9?t}kݻ[j0Mk׮$_/ ?϶L||F .H ŋ;:W'~Uj'I75rH)SFjRUL_5ՆaZ|$D6XJWvz&8ڵkik֭n7zh&MŋK5jW^yEG-xu 0@uQղeKUREŊSLL٣ ^UT̙3#3f4hzRH[lQǎedF6m{N.](**J6M-R>}\oj2Ω_ ̟i?`y{M3SuU]vյk״~]pA*W6m;Ĩk׮S?ͦkذ:999۲iﻹcX 9LUd}}ȑ#UXl]d?% ҅HRl?@s=z Wxx5l0-X@ˌ~'WϟOzXX´pB_^-[̲P_ΝK3gt}6l-Z$WW|y @G}Wzgu%M6fDEEI4p@kNիWWrrBCC5k,={VK.UbbV\v²?VZY;}zhӣ>*Iڸq{=;wNz/UfرczG+3F PrsjZtG ѣ5kf͚)00PW*UYd~>}a[V4tPiFǏתU4n8o>6jSLQtt$iʕ8p^vԼys=cp^}UgWll$)((H#Fk֬QF)88XsՈ#ԢE }(* J*bbb @6nܨAeĵ|5kkT2uyXBԭ[t_A[ne˖eX&JԦMt_*ͦٳgKah̙N~'#gJDzӦM3gNA5 :ud>yd!DڰaRRR$I#G̲܈#ehÆ n/2eʨUVھ}6oެ8(QI ҅HR d$ѩ{uصkCYK{o]x~]blK{檯EQpT/$$~ݠA ___yxxW;vԌ3tl;|$tҪTR*WRJI9:u6@PM6M6M-[,&`RJJf̘a}РANoQtt˪],;s$ez_UVMSJb 7*!!A?SN&@QgjiӦX&%%E/_޽{O?)%%E6M?4 2{lٳGԯ_?5o>^)bŊIn޼o*%%E={տ+WN5gyxx(!!!z4$,YRM4ѳ>_U'NtVdR``5k7Ou)S&cǎѣ%>,?P&5L 岓zxI30`|MIիW3Ϩr𐿿lٲzϔ,Y26:S`JJJ?ɺr߯{w8IDDtejj߾}=vXufp]֛7nHz믿۷k׮قpȐ!Tzue˖uMa:ԥKEEEfiѢEӧOgf_jU@zGa ܹ:w[nܹs2 CUT/1mذ*SP4Ĩk׮%I| V}ioԀ066VϟϲܹstUIOJbTfMժU+e˖9Pܥbccխ[7>|X4c ?ھ$e̖gvM6r5mٲECe;Na* SڵUnLiٳgUNs=:~&Dqqqٳ'I2e&O\(}7oC[..ٺx, $wަ~oaT|rEFFN:RJT"EFFjf$$$(00Pw$M0!ɷ ffԩS3 Ӊ'cZp$RJ PRJz'%I[lڵk3Yf}СCUR ee_VX3gJD>Nal\}ڧO}ھ}MfY8uVI=z>>Խ{w1BJʱ|Paaa3g֯_o%kժ>}hĉ*W\ϗ)SF .۵o>?^XZh\gS`ٲesKJ,iI8aNgĈ1bD}}}5j(5)IR[o魷nnn=zF LRn]I_37o$s=f`[n2 Cב#Gr,!-X@6Mݻw7$LO=Ν;kƍYݰat颛7ox?~S{/_^| .O>]ڶmʕ+KΝ;^2 C6MsUŊdT(IO>RRRSO)..N'OL^^^;w SKS :T'NK/ƍKkҤL'N T*U5}t%%%ҥK$zyԄMά@i 0-3õtRyl٢:u,g(0c3M0K=v虖-[J:dI& CCCe4zhZ$f@v.@jU/d`j $I5ktwwwIRRR&`*$EGG;̙3g$I>>>f`vڒÇ;͛%I 64$L2 C}RRRr,afGf`gNpʕ+^{MRRRt%%''G#g[ܹs\3SK~aEEEkתqf`oU&Mf_~]#Fc=˗/i @777]tI?k׮eYvjҤ-[&0Tn]ӝ;P0 -_\M6?Lrr^yuQ2 CcƌѾ}q936o\wuAu1=9sU|y_^W%dT(Iŋ'|/B*TPrrOMyڷo #<0ۙ`^z),,L;wa:|TD }ڴi*V茾ȥ<~:tH6Maf)))IW^ah y oݺg}V=zЅ d4|p.]Z 2e:uӧO;rtoy裏dU;vh:p:t 0߫I&Zb3 ?zuj߾$3f]6lXY3⋺uJ*˗kŊ*]t26M/~G5h@a>S&Mq93}:pxl5mT3<#I:s&䒩wѷ~ի;TXbzyfUTLLp3/lz@^lOA7 ܵLL+<<\K.UhhΟ?7oj˖-Su)yyyCymL)))z5g0 IOMHHHWԩSzG榈UR%oKǎٳg+99Y~~~0`@e{Zj)99Yk׮5$\2n߾]AAAW^yEϲ}f2 C;v0$L-?3~miٲ$СCf`l=zTZUty3M0TxIR͚5~]dI& $I?sI&`*]$?yfIRÆ 4 S`@@ G}>|Xlѣ&`*|g奓'Ojܸq7( @7ӝ;nfX> 6LAAAڲezi?gݻwѣ2 C... : {@Iz'cӚ7ol6$i…$0$IZdI@38ՠAt j޼\]]eaÆzu :dz`r^k]tI񑻻3<i|ά@i 00@ #,0@ #,0Vp%-^X۶mu%I5j.]hȑsgNyiҤI$awYEEEi֭:uf͚y-\1Θ1CSL~K߯J*IΟ?+66V7nSO=+W_tNTxAk2 C+Wֻᆱ=]$YF/ꫯgϞjذS: { *(44TO}/WWtZjCM6:~VZqƩ}[`8%y~?^qqq۷J*匪ql/_^fR^$Ik׮%)<}^yYFx{߃4o<.]Z[nfKu~}BPLO?ʕ+ 0dFe{~M;v֭[Mw6nݺe2aLW\Q>}t%UTI²,GyDi&s=uu @A1ׅ T|yz9>ӥK}ڳg&,%%E3f̰>hР\q̙l;w.uT_fs( J*fϞm͛纎jժ9[@2ĉSt˖-+Iz&$$$D/ˑΝ[} $wwwq$xf4СC TRR<==f?wZlin ? }}}uEDDE=믿J4iJDDtejչWUVubgj >(IڼyC Ђ dԮ];3MZTTt風(l6-ZH})TO0 X>/;?8 I>|&s%&&F]v.I4lذ|o(jL}QN~XsՅ 5k֨]v3gl6֭[;󙉍UntaIҌ34~|m(L(I~a߿_pSuOIRe*\$é\]J~gvs 'M$///͜9S.]rv8N:k׮M6o/N`Ν;Kʗ/cǎ{*S֭%Jdf4 L;wfn._={dfaL۷'g(L@X `ax)u3}݌u?jՒ$l6%%%ex݌ d-/t{^f(ڲ .нk@),LLFX `an9rܠf\9ݻ7ύ!͖z80|TJJJHC@ #,0@ #,-*U{g8W`5 K #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@,^pA7n믿GyD˗ffӈ# {@s+8SŊ @biU^] PYj믿-ZEX"##UVPh,曅H`f=ۙ3gܹ \VZaw \8}tϝ;-[PoBժU @0@ #,0Kk.8q{LLĉ NW~Ĉ3pX*\p,Yݻwk^#ձ0K2 ,H0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,0@ #,̒^ח|||ԢE +ƭ;l_~ W_޽{w^-\P6mR:u @ իWwyG?o߮oǏgϞvZ!8aݼySnnnںuz!Ν;nݺzuq͚5KSN-23٣^4zt_^ 4$͙3GGY&\~zȑqqqѰa$IW\ѷ~[] e]vIԼy,u~{|P,9rDTNea3<X%WLL$jժٖ-[t >}:W9s&i;w\$]).@ҙ$%^bѢ (ECP40> EcDQ4XmH?%%%eYk9O _vUpٖ-[nQXaw=#T`|( DQQXyV͚53g%k+VLt|PXb:!!!nݒ$/^>^GUŊUBl"FΝώݳg*W\=0FKJJRtt$qYDBUdI#zoܸ!ɱi崿t9\C]Ya2ղߴ,Sʕf/_Y"{WtĉlO=9zA/0Y&l۶{,˅د۴i e}گ/^i-]TTLuԩ -[Tv$IAAA Pf֬Y:r$i„ rww/>3gڴi7o* @:uꤛ7oj՚?$_?|! ~}2d^W^y%CmڴI%K,fQ؝p?CsѦMtyxxN:8py(QKn! 2",0@ #,0@dd4u 0@ݫiӦ) @UVUb-9Rvu۶mӈ#TNyyyt׀4w\]~=믿&MTR*U4i_]/^sg=jРJ*%ooos=ٳ{=EGG;u@돇*V:wх 3%%E6lرcոqc]>>>jܸF+111瓒~͛7OcƌQ&Mf[dd ΐ!222˿oYԬYt\oF#???{;v4]ov6oެ@>UVU``6ow^͚5K?4iʕ+Xb*YիÇouP9sDdd&O͛L2K[ִirw.'9~А!CTF yzzRJ֭VZvK28Mv I9 6̸uV]tӧO߿?OJ*eYʕ~)Om7ƌcll?m8߁$زeKܹhܸCu/PԩS}.""P9c|p[O@@>׬Y3z;t`$''G3HNNS;Eq|hӦC8иyf?˙#.]j/^<ǿs[ns soadVϞ= djoF88MTT$OTvTzu%''+44TfٳgtR%&&jʕY]_~$j{ӧO+$$Dy|iKrsss=G}TqF{:wz_~EUV5VBBB;w֓O>SQQQvc㏺t맰0ժU+:/^cgnjJSӦMU\9ɓꫯi&EEEiĉ1bDz ð_{zziӦɓ'^mT׿e~VŊբE mܸt}ٙ2e$I^|Es=:ykZp*Tӧ繽4>+VL:tP֭ՠAU\Y>>>ց'(""Bk֬V^qݻwkĈJIIn})-YD_~.]>}]>y7ߔ$s=zWԸqcEEEiΜ9oi&5*X]HJzi|FRRR 7F!!!Y5tPCQX1/Ȳ\JJhϩH2> ?SÇn0 ^3$6͘;wneL߬f ?~|mfXfMFDD=Qt kO>1gkd%Kqqqk׮5N:e-f;vpss3$<@>߸qx In8>4Zٖpgr8ѳgO{>(2=\QP#.^h.]ڐdT^݈Nw?))իot[Jm/?2-˼֗s?t-rݺu3$...ƹsLuI=p#PO8a/W^ oܸa_bܹ,YP9@i92>믿1rH'0z^ohhheBCCe~im)_Zާ?T|e˖5$ʕ˲+Wi֬爙3gjժL˜>}puu5$=z0NnN! @ԩ:~$tzg/6lPJJ$iȑYK]ѹWbb\\\/Ùj׮-///I/բEtyIաC6ls: Kگ/BT~}j*rZRz$I_|EVLJ%KگM8 IYn_ Q|ts ?G_^TT)/2UVU.]$I۷o׵kLjʕرʖ-+ooo5jHo\z[ >\kV%TT)5nX/}wZqqq*Yl6| ho֭[kWW v*OOOIRrrN>H?Ib}I{oݦZf$YfRΝ;]7n0UY6MnnAuwwp?uo(ͦ &hܝrrr55k}Z)HK|=9(i_~ qdH$"""z^ts>G$$$hϞ=zH9u-ݻ7mIRRR '|R!!!rnܸCiڴij޼y٦չsg-]Ty]?_{D۷_xiŊ$777 4{݃(`!!! d{׸qc]zU'NTUzuժUKKV׮]s<Òn XR,U\YJ$9r$DGGt=%$$7ߔt=TRjݺ֭[g޹s+崮^_Us='wƇ]VqqqCf9o%ur739()))?cjngЭ[|i@81n8Iŋ'dZ歷P> sǕ,`ƉI&'.ԫWOAAA矵m6;VzDz0 0M^l2޽[3gW7nhԙ7nܰO LRRݺu)@JIIь3g-MC)))zLBBmۦ۷_&Ol?gΜ$NV:r}O%JP?+P_O=TOa׿e0`@{aaa% ͛7~R!)籨Zjk3c s|YLծ][֭Npwqt5jvڥKj_ԻwoU\YNҲeŧLb_6[9 lj0}nZ ?֭[… i&kÆ ޽{ZСCծ];:tH'NL7K.Յ rJ=m6]pAZ. 0(@g϶Oaׯ_.]d9sս{wٳGpΝҥK0 K~3Խ1lYIn'{ ҏ?-[jǎ˗rJIΝ?ڵkڷoFignݺ?~|r/^_:W9u}fH֭UN1?ݣ)(u̍E9nnnz믿 '\]]dYFw.\޽{E߿֯_N:oom?9 ljO>%3moذazG0 ͜9SfR-[VKNpss2ܺuk'ӧONo  JHH^z%I?4̝;7riyW׮]qFhBŊS 4n8mܸQ..//=uqv{i*V$͛nQFoթS'/^\eʔsNoڗbfTRj޼%I}Ν;UltϥFH@~pt|2OJENcQ8$*֭[hǎz뭷iӦiN>r;N9rDK.UXXXCCCgϚSA}(qb۶mnoYqFÇu_ݫ7444ݽ}2<{MLξ}DٶH@8t$OOOY&˙~9sfm~ ֑#G೓ږ#n:\xq6mZTݺuSOI=k0u~~~8q}aZiO[,I]r3>dgٲenio$Xv{3c s|WFԤIuIR%zjlR2կĭ[Zhmٖ-[fy/~~=P/va|A>3ڰa}<`/E䳈ruuի=1퇋 *ϲlڍ\--G>L~qdVHOMnx),,Lnݪ^{MKٳgս{w}+W?S +g=zTԻwo)S=?iLJƢ(;i|([,Y"I4}i@8q- >/*V=r+O?tRCA___8C䣨(uEQQQlZhQ3ݼ67FGG\7+mQi)SLG8aL?zb$}/ry3ԕUV_t钶l"Iz8 #ILLv]8aÆHf7{$fvֹsl@^n]KFgP>}ھoRJiӦcǎĉw'CfzjIgdxQ:IbJuFQ*T IY :̎G_7k,۲iof s};'Ξiwv/S˦~2ux@rJk׮/f/r֭>,I1cFRF U^]JR*Ur϶mگSOL{mڴu;z衇$IW^ULLLe5vXժUKSڧ#GJ[~H !36mOqjժek/qH;Idž5k[:'˟my'ҎIIIٖMLL9G{Vb<==3sچ(ioڽ{wԀ${G>`݃p8Ӿ4hʔ)~a/ӡCL\v5$~~~ٳg3ٹsjH25jddZWNrK[nUT1$K6\m Ce]6_{wkʔ E7a)bFo)aeXE!F*)E*iDeEIow!w]ח\Ιs>;g9sPHdS?=:!"0WanP(X1Kl}c4!oVZVNNNδDccm?~(Y-OD\ĉ?~R)777+媫߰իW euDww$I~~~1JsspppQ__߄2< &)((߿_k6fu~z@i#s"D `ӧOvBLL &-P(0{___\xhmmEhh(,}67;a:\| & {|(? 7lqݗ/_䬎13z<5 )))z*L&n݊4[ɑ31RRRG_Ʊcph4yO>h4fhvJTT_"s'/_tdffaaaHLL^NJ+WTTT޽{0X&tu;rrrCܹsP5IDAT͛7a4ǝ;wΨ7"!!0LjHKKZlFii)޽ V 4i=qqqǏQZZ&"44$o޼A]]*++'NLZÇQ[[> ;;@4{Ȟ` [???w,-ŋnokG}}///Q__?ܹ/gXZT*(//ZTlB @\RN(SWW'l:dB㏑-Dxu|BN'GGGiǖ5>[ǖ @!V돉2 ->||D###ԩSV!gggqڵI#233מ={ϟ?1!o߾IXv?А>>P(^GII 26#ܿ۷o$IP* ٳg)((@KK V!I|4 bbbPVVۛ-qssCxx~ҿA+%""""""""EDDDDDDDDDvDDDDDDDDDvDDDDDDDDDvDDDDDDDDDvDDDDDDDDDvDDDDDDDDDvDDDDDDDDDvDDDDDDDDDvDDDDDDDDDvDDDDDDDDDvDDDDDDDDDvDDDDDDDDDvDDDDDDDDDv?17Aw@IENDB`cloud-py-api-nc_py_api-d4a32c6/benchmarks/results/ocs_user_get_details__cache0_iters100__shurik.png0000664000232200023220000022237014766056032034202 0ustar debalancedebalancePNG  IHDRjy9tEXtSoftwareMatplotlib version3.7.2, https://matplotlib.org/)] pHYsnu>IDATxwX6{HQEwQwc%j1$F~m)E {6TP lHoe \;gΜYwZ*0sLy?9998~8&MOOOTZ055EŊѢE |طo43..wU:t~;wě7o| D$** ;vɓ+++&U")L4 4@r`nnwww 6 N*Tqqq?ѪU+TX&&&pqqAΝi& x=}+V@޽QN8::pssC޽1o<ـtaOm5ڇBp ?P̚5 [FժUaffkkkԨQ:uŋt⫯Bf###͚5àA_!44 ߁}"ׯq :v(ܼyPںu~vvv?,(/''Go^Lg͚5Kf͚Ušsήs~WPm4<4i"ܻw̙3b>>>*gF=}T5j+B۶mK.i$L.ڵky>&3gɿm.Wq+1;NjHwݼySԩFy᫯^~qw<==:֭[GL#st {􄋋 s\xIIIcǎ!00[lA>}4Ozz:Fm۶ɽocc͛&&&x%߿{1sLȑ# 9ׯ_I&pww%02QY{̙_+V7LLLpUܾ}">>RǏG^ 033Cvٳ׮]Cvpe899w { 4@aggwŋ EJJ ĥKТEsNʽxxxhU撺oƍ*U 4=5jpIJ֮]0ddddYZZUVprrBff&>}`dgg#;;˗/N:j C۶m(W|y4k *TD"A||c` @*i7on:!==]!m||0}tV}333Zj靜{ 999J Wڴi#8;;q}>+ ҄zp333!00?.ԨQC&vǏ>eko߾Joԩ#9rHLMM={q%55Uu8{xx~*ӭXBLgiiѤIĴիWW9,7nf;u C 6芇]\\wj={[[[=$@H$-\r luJf>c`Jݻwsss;VF ~ {P~}!--Mi;v= fhcI@1w\ܺuK|i&]Ŋq XZZRRR0zhϟ?Kǎ~MrcΝ'gpI4Ԕw.ԭ[zz&$$D,HOOʴcƌ۽{6mR.++ W_033SA6l_],,,piԬYS{PT\7n/͛78tF,k*G>ĉ'4ۦM͛7UWǏ{wwwWzu} $BjjMqϞ=ѻwowqqO?$>{,\4..W\/D:Ƚ~B>1112e ׯ+++XYYnݺp]Ǐ VQ_ '''\rhܸ1~DFFD~R:CËrJUZU#.2,l޼{+,,,```KKKcǎ9so~6m7ohѢfΜϟŋ;w.7o *@__666Co>q}j.D.Xw^ec~G?.lg]'0Rvn$&&bhӦ *UH$񘤳+oNNN011!lllPn] .DTT&[ -zܹpuu:WWo>Aƍן} 駟mذAԵޯ\;w3g΄?T333bŊh۶-̙borr2V\.]4445jժnݺ)ƎK.=1cƠ^z&M`̘18p8˗/ܹs_~Eƽ{F7~{nHDu9{y{N>|B'O]kq_A ;;w[%vRgll,^Z :999Œ3 @6mme V쟋ݢ &P޽{Bڵ5>_zz0fA___mҥKՖ]>s挰o>8~%(]Zl)n7w_pALtSi 333S;Ӊ':LʾqK?7Ο?/T\Yi~ v*UTS9wq8{/۷ •+W XOXSE=LRJ¦M Skŋ5/!++K޼y#ӧ,7-22Rm~[ߧ~qu-Yf;`.pA tE\߿AK8 0}Μ9#.W\Zaaa=z`˖- dʿwAi_X\]]!Hw[fd1b:**JVxݻC ANN@__^^^Qq`r]{nJ*Oqe<|٘;w.^~UVӫW/ԫWW\ozxxy ,G޼{۷qƨ]6,,,gϞƍHIIAǎqիiӦś7op<iii?~<0mڴyE̞=YYYC6m`ooXC(%r&M L߸qcq9''_~444Dqeq]aTTI]TeGώZ\2<~omڴpY1]hh(+*155'V ccc$%%Çy%r=Tv.א=zWU޽,lݺ&LPOI^[;YXXnݺpuuK.!)) )))`hhzر#޽{ .̐ǏƍHJJR˗/Ѷm[nݺhذ!,,,s!>>߿?6mڄSLL &Oׯ_>>>(Wl۶ III8tB+qY޽UTAQ\9޽{qo=z\Έ%J{AܹsYtV4Szu]߾} ϒ%Kȭ9rѱV ;S#K.ɓ'> D@@\1Pwll`gg'ٸqc̹’%K}}}XL3t*TvޭuЎ;kkk1ەWZw Eԩr€\ʕ+رcO*M3tP1/www-f˗oŋ'@H$/"dffʥS})l˙W^mvرCalK+Vhg~m+Au-e3+sX000k߽{'.33SgϞ6}޼y4ﴴ4˜1c4>LWjj`ee%СCr̙#WS׾̙3q*E= aĈ™3ggt?5sMVA׺oV\r,!00P}*ۋiU&=zTi7o+W~ו˖-S[Maӧegg' De[Gɓ'rz Om?~J*ɽ*̓233Çaii͛Yf*TPƂ prr‰'`gg'F"`_`?ܹsʕUqկ_?ۣm۶ٳg_~jHdYx $ <<<2`իWDž `ooN__cǎ)F8zrfgg_U:пm?DT5ڮB x5MR9QFaٲe ebllBi~&&&ܹ3:wQ֭[bڻwآ;v[?x`L> ,, 7oTh*U׾j2du6l R}6l(Nlfddu֩MollI&!77SLAbb"6mڄcjTd]֭S9|||TN޶eE'N> SSSt&&&5kAO?XbE>edd`rcKիW=t6m`ԩ>}:{CU_M&Ɖ'Pvmcҥ*ωS=\\\:gkk1c(,o={sݺu]'OoOOO4iDeKJ"PH"}Vŋ WXX\^[߸qcq]Ϟ=Zt:v<_ݺusʍyJqVZ6}vvܓcU-e[ ,ZHtQի u`ׯ_/td[F۷Omjժ%$^S[¶+ v͛7?ˎ秪eD~'OڵǠLI,sX011QٚO䍗!ٿgZs ~id݉'̫}ڎ;O'V/zQϽ{]jԨ!OLL,TYA5jy&Vh!նOMM5RÇŴvvvRO>D= STVLLzT[~7n\^III i޽{'ի\0`pBCY';B"HhdEIQuǔ)ST>)۷ocڴi^:vء6ÇCEDD 66@S ȑ#ⲦcLI[5c$;ʕ+ Gvv6N8 oܶ]j@q۷N=?.FɶvT6lŕ燠a|IHH5*zURבgϞɓ>Li:A[ls}\>V?^fw}ZŴׯ_/~ uAÆ -[}VمKSnԶ755o߾jsuuWy)vڅsb?~B:c q'O^Q:uT>yiكÇCSIIIѶm[㣙mHS t디B畜,:ʟ8~Ü9spU={W\ko޼?$Kfv 2-ZP>>>"ʺ(sqY:A.߿ukʕz* ;.j%D(3nڴFy~,LLL^gff*LFFt iY91`Q)m`}JRa!;;K,_%NP&R7SL'0x`t,.i}[:qW4h@ts}&))I Ɣ)Sp9Wʾ.]}}}5G^tHׯ_#00/^իWρ`+dž@"YYY@|X?+W׉ޏs{%ߏ 7@ ;vH6S/^˙rjJvǏgS s˗G֭닞={?..Ǘgu-4 ʶSbJ:ssEɳsXK.Aݻ{*ڵCn[kJI]GdgUȻ~Cl%a}ݻZmI1UF:u N7+7УG׮`rA-e~gs0 L#^YYYJӬ[F:{+qUd<뷽Fu~ _t ظqxqmÒ%K ]> GE\.J7*+ZTB37oޔVUV2>v/)HAoLdjɒ%سg7o.W{n?UTA߾}Sg!L+ܨKqYٍlŕgi_ cݺuك5k(L SlٲFF4SN<[ퟲ.mKΝ;3f\n],^W\W&6l. ]Y\ea``֭[cڵ ;իWri-%v|rݶjժ Ǐ<^aa8vYv$Gڊ uߐʪX~LVW^ի>}*vO9wxS.v-sww4ht1(ԪUK6rdYtQ٠nAe( 9ldd~'NDxx8Ξ=+/Ϟ=W͟?gϞř3gJ< ˅]Uڎ۷+!]>4fR-4Xb rX~YaO"`ȑ9r$"## .ܹsxik׮E`` Z-ݻwleݢEhǎq(QJwҥB~quօ.TV0m4L6 @^6m9o }qF@LL Ξ=899YGtH$ҭ[?J9ZnWdh:PAʗ//.'%%!55Uօ"CCCqYKTC7py󐚚x|8t萸g,ۚԫ]8UXX:v6ke}}} l.qaaa!;;7oޔC 6DÆ 1~xyҥK /~(mVsĿ[<6lؠЮ} 6hTI\"d֭6h`B[pwwѣxbÇ駟>Ҽv_ׯ|ݱ_-pRW C O|qh=HPPOe SNruMT> y…Zzj_~B|'Gp%Ev<2uF}LŊxb'8 Ӛ Fʕ1sLnǏ8QFw$66VaBRN!00AAArVy8HHHXn!)s$4nXJJ ɶKLL')[EɎ3,7,a^H;կ_gϞ-ݱ`I򟋲]~HB77WV)]v/_^B?T ]dΚLhhhKu]\ʒOm۶_gz).='ҥK]rEnRizYڵCc1Ҋ$m]A6sUl)vG{f͚:t'>;V貵l^^^3gj2uϞ=rq[kCD%@"hL6Mn!Ch˗[}-l\lf̘q9ѯ_?̙3GǏcZti&ܻwO|ݩS'- ===gϞ˖-ShQĉػwVnTu lA: bA4kL_ѣGC ѪL7mڤ*k3vb6Fla]Ekӵj5_V]^k}}V899a1cիŠݻwQws]ׯS*MpB~ bܹ! DD7o `hh(L6MSH!lذAptt 7o.p?BV^^^©Sl<|P9s`ii)|||GDDKKKa¹s焬,y%%% s WTIxre* K\rb͚5߿/&77W}}}XL2Yf$0qDJfee ǎ "TPAi`1+ >fm<~X?*|7; >܄ G222޽{Bf|:t4ݰa4FFFܹsU~w҄{ ݻw<<<;s%(沈..gΜۗN<)?(dffʥپ}`jj*駟'寫nܸ!TZULuٕ>E9RJrͳgҤ 3fEo SSS/yb>GVH-mVLcee%\R۷͛o߾j>ҥmS˕+' iN<)899)|/TӧrU4_A&M6l W7988BzzBxaƌV}  U .]@XpD P;t"r-|'r вeK/^ŋ垀cӦMׯFIKKðaðsNmmmѼys8::^Bdd\K= wUM4AJ`cc4DPPRJr 橉@qBc^_|4}}}Qn]ɍZż4^+W7FJ`bbXAvҮղwqq)Tw]UN<~L7iIaggwŋ LOO.][6iDPfذaGl9?tPh VvWUuP}hےo߾m%S@@>s񵹹9Zj#00 -[`Kڤ췅%5j#..w۷4~z.>>:t8 -[DJܻwbڧOڵKӏҶ~z[Oll,ի'7s}&MPNH$\vMر#iӦ{)S5 7or!99~:УGqb,eoߎCʍkeeVZ YYYx );;;رcr=O}vWux5_0\K^cǎDҋ=ޫW/Ru?!<<\ 6m4++<>hB~rD @-[*gll,ZJkذa.YD3H$BUuAaÊ󐕖&tYmٻv*$$$ ^z'U&\pAmٲ3ffffihh(7Ni^m#}lܸQmՑ,+Enn/jOoj\ȵDS׸qcݻEd-3336mڨ-am[ȵ-Wx*uuu-pz0bA___ ]tҜ8qPeYjʖyi_u[qb6m411V\)Ѿ jaA  j˟;SSSaܹއn(pA={*ׅ j4xƍB4@/]jPZ5SSSVScP6}b طo?}J}6AK.ÇSH$8;;_~%jժ˗/1|plڴ 'N7kΨ[.|}}ѹsgGڵ+BCCx)Klu):t/6l؀k׮۷pttDÆ 1|pW_K.̙3r ݻϟ#55fffP5jݻHǏƍqIܹsqqqʂ\\\P~}sppp(f@C$H0}tk֬,TX-[İaо}{ܹ֭ݻ=Bbb"ʗ/ub8p KT344ɓ'vZ޽n›7o (sX*UB||sիx^~LXZZEl[Tq4QJ[f¡CpIDDD ..o߾ѰaCj \-*V(7Ɉ6###)))صk>ykΜ9Ol2?_N:aȑQF/>>gϞEPPBBBp}z 033{{rR(cjj*ۼy3N>H#77puuEÆ Ѯ];tIokٲ%n߾Eb+iӦ2duu 6DXXۇ}!88^BJJ ͛[nرc5hǏGHH8SN!&&_N:W^t=z4F[n!((.]ݻw} *T@ ~ɵ|'LDDB+_~+VrM0K,/>}z)Hޛ7o`ooAP\9DEE1@DDD:Q_-.I>}@^ot Cę3gĖ?#DDD$""۳gN: [l^JDTbccŁŧN R."ibŊ$FDDDD@""Rŋ=z4_t}FF-Z}7ĖU+WرcK8DJIӧOi)p @""R)00~~~ʕ+QF(_<Agoߊԩ+WܼLDDDDDDp`""Htt4Uر#nf͚"ѹsgtܹJDDDDDD~0HDD*iOƑ#G/^ ..IIIZnO?>>>]\6l؀'O){{{ ???0}|8 ,DDDDDDDDD:@""""""""" cH1HDDDDDDDD$""""""""a]*=y&:},k@abb4#>e͛7Ѽy.ѕ+Wt0,+WbŊX""""""""Ƌ/ޝq,dX"K4DDDDDDDDTXv``""""""""" cH1HDDDDDDDD$""""""""a 0tDDDDDDDDD:@""""""""" 3(M )) )"{###FFF]$""ʇ@*\<}iii]"""""*EHNNƫW;;;H$. RH$/RAׯ_Fff&JTDD$@*1 *zzV,HJJB||<۷q)NB$.WPVVV A&&&ptt~BBB)d1bC boaaQʥ!""""Zz!""9 RHgg?"""""w \z@DD""""""*6 cH1HDDDDDDDD$""""""""a } !H HX!"*Qn٥]"""" GB68 ...ٳ'n݊.. ŋ]$댌P|yk'JDTt/%%1͝;WmZdpH0HS߿FV. 6Ƚ޸qc)eee!66OƤIPN?EDHݻw#99Y|iӦ" JT6TrPoرꫯ ł cGt${-Vf[v Juwiiiصkرc/^ ccZЬY3333˗ܹsx%u[nRJXR"PPn^ku4)=wʕ+h޼y$"#zq5\CrI}ڻw/޽{Xd !!,b\]פI| D~Xpa)Jy3gfϞ [[[ڷldIDD1HClmm1uTW!MzؠA15k֔{_W_#*tۼy3rssa``Cm۶!++$"*t2L8jՂ9ʕ+̟?%TVZUxxx\r077GZ0qD44Tr011Aʕ1bܽ{ WWW277O?///666hԨ~<}^#Gb6mqAat,Yppp!ʕ+5kO>… c1}NN!H0eeٳ4۶mӨ|o޼/5jԀ,--Qn]|wrebׯٳ;w 뫰֭[ [[[XXX^z5kUHSZI>3@||<.!uT՟DDtn bȐ!rT"44k֬Çne̙jsθý{flٲ]v-΢344srr]tAPPBڸ8={gϞqԪUK!]NN;wʽdDEE8z(BCCҜ;w]v;' 66um{{{޸qcXYY!)) ԩܶ|:00~B㣰.== ޽{ .]K.aҥ8|05j8 ׯ_W˗h۶-"""RSSqE\xwv?Dlٲ999àA̙3|MDEECx^JJ }a˖-00Pbݺu3fܬ111X~=_lڴIlRXNZ?~I!۷oq ܸq+V͛ѫW/_111kװ|r{/^}sv HHH@dd$?<'@__[Ƒ#G<ٳg ===iʗ/0}tcsܹ+V`ժU:t2H C ;; Rfܾ}o͛ Qab*`U(lܸQCm)?HT0,,  @ZZ,,,0uT!-- ۶mի.] 44ŶEX${]tGƧ~ SSS9s0`\p͛7{h֬ Թ~:_!C… z*ܹ:u+$$[n5k~7n%K0rHԫWOi7n܀ .] *i;v?W_: W5jf͚%;'{Ϟ=ܹsZڵ 077Gpp0ʗ//F۷oaff&&N(խ[7O u4h˵z|2amm ٳ dx^~-prr¥KPreqwooo/@TT'C wM,]@g$WkZ°aÔO ]󲲲m6@^!C`Μ9ĶmW_=?HW\sƑ II[ŋe ŋիY&~Gg*]6&NUVb0!((!!!E.+鮔CbKdoݻw۷Oa˗/}/uDZ#XXYYɽ'A*!% wrO% @.'5zho@XW/^PYm*Me-_@^}ȭ߿d`]KYZZbժU[r|,X w#,ѣGȻ?l:uK1f``NLSjU+pvvb[ ?Yr߼y#PWueӦM^ ֭[]vJ>0 @|pBTƍ ={: ecce˖) ʕ+nƫV I :|}D뼣Gj֪UKl9X N4?HLP#ǶILLę3g'O`̙~* "(sa( @$m?8xD"|}}^kܿn7n(NqAi\N74k9833S rBU7ouJo?سնd;8::*v~666ݻ25JaeuvGӦMUiCWL󲭙#լY3&eZj4in޼0>ԅ 0`_zjPvzELLno͛7o . 33SJo/_4X[[qJo_~-c=[ôhBmdߺuKe \';&Lcǎc9o5"%}?&&_~O###UǍ7VZQFbLє= P]IZhbu)?ٮ<>7[[[ٟDHof̖ڵ+lllҫT$$$*sYfb]wMܽ{^ׯi&GN:XllWիuz갶VԩS駟X|9Ñ,A0c 3g..7oF6m0|pVVV8p._'iӦGnn.BCC1ydd5kL//]&}_Pٳbz#]ulI&JI"ѣǏ˝h[Ӊ쬻O(֤I+lR#LjSV@V\Yߔ ҮtؒZlA-EMSN!11.\ `bb"n>z쉵kx9֭['v׿z*ƌ]fs 00YYYx".G.DNb~Mɶ)h'oǎHOOjdٳGzMDekž[ڵK߻w/f͚OOO888ȍӫI}gmm/wFll,ܹ~ NNN 6׼ys EBB8 >}(<400@V(e;4Xe&+CMwTtλ86 &}e?"?4C:19{,֭[\POզ@KtmyЩkL@^VZUVA޽!vڅƍݶbŊ1b OOO\v BZZLMMtq?@ 55U'%{Ck񘕵quuRSSqe唝dׯ__\ g}2-g&mpO4 111ظqbHHq5j]~*qݭ8ʕgVFj׮ڵkc]6RRRcL0Av֭u &`ɒ%xΟ?:ȥqU$$$(})7쁴@ \vMIlle'&&&Q߿_`} C<@ފ+ Ǐc׮]Xl&"""ut"hbb".4+̌ |&L(jSV@ŋlXHgT%,,`:i3K!|||D﹏xC|!K]e``=z'N@LLʡ֬Y#nD?PvmDDD`Ν7osYL TTT8AT>}駟ͥKxb>}Ϟ=CJlذAl!!!lo޼ѫW/׭['.G[.==JOMMŦM ʕ+aaaZw@^]dJq/<b:}8qg]V;Ҿ}{ܿ7oDXXB"Mb'6o /0_xL&&&8~8o> 8mlii).kMVѤ 9s޽{\8ZaH˫MYbŊ>J5j7ֿ~Zm4 oi鍵2ǏU&.;wN>233+J[(ʎ|r{7vvv*[7Nȑ#fݺuqݻHرcu8q4}qTٸq۷FH78p@i0:99Y쪯۾^i Z дigԆKMMUz 9995j?2}7::w _=zHn"eTՕRҺ,|ڲe 9R}P٤uٳgcSN=6t 6TIHHk̼y=;x4ҼSRRm6y6mVL˗/#%%jK[988u&СCqASL޻x".\/_e˖J> ?zVZz011Wp X@^ovN/ooot 4+Wڵk#G*:#!NMMgTAAAbUK.ׯv܉Ǐ=jժl۶M|Z_\93vX ,, +V@TTKT\X|9?f͚k"MVlhVZbŊx6mڄQ!Mf0h o߾Bxx8͛{ k߰aCܹsM6ԩSѼysdddȑ#믿Įrw!\cڴi#putָ}6.]WKlIߢE0x`tm۶Eڵammbҥu>} ???ԩSzBfFؾ}dhԨBth'OXOb;;;d988`7nbbbдiSL2ZBvv6N<#99V bܸqXlBCCѬY3㏨_>޾};wbժUHkX1sرcN8/_3x@ oQik#ucpHyhCe7 jʼnM}||uݻHg0/;;>,TY#F ϟ?7|#^__TWX!򳶶ƶm빹 R2Gߔ!GY2 0ػw/]!C(qrrÇv҆:m޽{ɥĉѱc"ʆ .ׇ^z)ꪌz˗۸z@@jǎh׮/_.էO5_c믿VXodd 6( 3VXQF!==I 0GV۝/55;wΝ;O?ܝ;wpyתU {QW勞􄱱8L1+$&&bƌx;4Xj:w2M-\ϟ?Ǟ={p]1Bn}jհ}vT^A뼴4ڵ PfM۷/v؁lٲEe"""Y:"䵼ztA///2ҲP [VYI7[6m7,--all |gxbߺu CnPNA__666ĬYp=tIn~wرc*U&&&Z*Ca߾}jǸ ) TAc`Ϟ=NNN022-Zh~ CF)'''_Ez`jj*~~˗/ѣG5(\euV^iӦv0335ڴi͛7c׮]*5jΝ;WJ0tPi4~Wa1ΝCϞ=CCCTX:u۱m6uԿUVaРAhԨ*TXXXnݺ;v,0}tSnnn!ʗ/\ׯ_WWJSD&cN6 aaa=z4WSSSvژ0a޽C& {ncmm 333Ԯ]ӦMիWZ,A}!))PԹsgw$t0ܕ+Wħjcƌʕ+^z bccEUɓ'pqq/33x-j׮۷o+}r_w|9NĈUNŖ14{lmQ~HÆ 닩tDDŋu^ilGgZ6o\dڵJ2.X &(!H H0|+ 3""B`]@^kQ٠3cŋ兴4cڴiCZZm&^ceL4 ۷oGdd$&OO?)Μ9s";;XhQ>^:lܸ1oߎ!C )) ӦMSHÇҲJ,--qatǪUlRlcQ٣3]up|wpwwlllЬY3̛7aaapss+byYfj֬ڵki>b:P .… ׷HP9*999 DDTB111t/99KCDDDDDDq̬ BDDrB_|$$""""*A@zz:bcc+okk["""Y]8iiig H̟QƥT"""ʏ@*D*UӧHKK/;;KFDDDDDvvv] "" \\\wމӃaaa##. D",,,J(DDDDDDDD'!""""""""a 0tDDDDDDDDD:@""""""""" cH1HDDDDDDDD$""""""""a 0tDDDDDDDDD:@""""""""" cH1HDDDDDDDD$""""""""a 0tDDDDDDDDD:@""""""""" cH1HDDDDDDDD$""""""""a 0tDDDDDDDDD:@"O<ĉQV-\r󑚚Z#""l2 6 M43LLL`nnWWW 0 j>|8$F?֨lO>ŬYЬY3888+W7fΜ[n؉vJ1d$%%復"44Xf > 77B?glٲE麨(DEEaǎݻaggWhkҥ:u*RRRޏALL Ο?$,Z轔JTaHKKN ???a۶mXz5"##ѥKR}EBQB888 !!w?[n!((ݺu硧q;v*UR_Ō3=z4<<<`mmxa޽jADDDDDDDPPCY111\2 ::Υ\M68w pYlRn1ydY0{l ձ{߿ݻwWH7|plذ...wUԩSh߾=`СXf ̄QEDDDDDDD%GPu;w0rHL8k,^YYYZG]1i$L%!77cǎ4lk׮U`ʬ}#FPFOOC$&&̙3%Rٮ%8~8߿ NǏ@*Ο?077GӦMU/\P"eٶm\Vܹ HеkW7oxM훈JTfEDDԶ I)qqqȑ#1g=vxƨX":ve˖!55U.]TZغu+ׯ;;;5kğ9P"""""""*U R8(p[[[P(|}}!H HVZaݺuػw/lll摜g͛7˗/qq?xrssq]y &`u\HL4 m۶EbbbJT&{N\(04\"o֭[L#H9sѣv.^͛7<{  S۷ܼyK,AŊyfy 'ŋKh} v!th:U.F*U}6nܨ6}*UիoTTRRR +VÇܹ3֬Y+611Qe@A0}t̝;ФIB"id ׮]C͚5JKKC˖-q y݆[hQc&""""""il-L211333 L/ԴHVի}ѹsg:tQsAv@l(K`ԨQ ? c5=<"""""""1HeIޔu֖ `ffhL R% =x*IgڵkXyTqbmɎLݺu圜i- YwSRRpUdJ[{j|qIa}6mG'))I BVTPe!""""""TfS\P&77W!~~~%R\\\?#.(ӧw^ݻ ½ U""""ɓ'8q"jժsss+W?>RSSwDD-[aÆI&pvv `gmÇ/pLY;vDŊall ԬY#FPY])@x?Cf͚̙3aÆ)wp)eHLL@1cB`*y4lPUO>zzzɓ'ֿxBpvvFFFBLLVVVo U-#iwNNмys|WzPn3~xD;4pp/*/^ ///ӦMҰm6Z 'jѮ];4l={DӦMQB˗p֮]/_ի)S(GNСCԩS666@xx8֭[˗/̰zj.ZHLLD׮]ߢs055ŕ+Wo/LDDDD0 0iiiԩS~ϯ^ҥ BCC -Zׯ * {.ܺu AAA֭Ο?==:-[ W\#bcc L.] G͚5;? ,@JJ .] '''DTǠ$}`0OQPv}A]tbccg֬YQJs˫G"ӧOs$""""zߊڣGYYYjggg {~򍎎,--D"lذA;wӵlRVH* OD7Mc;ʼnݺ!<<}aff4k CXX ;I&5jԀ P\94mƍq!q&F˗>CÆ QbE UTAϞ=vZܻwOJZn۷oc֬Yhذ!`bbjժaĈz*~B3p;w0rHlR!ĉQvmyޏs4iZZ7޽&SGvlSB___!MӦMѵkW@bb""""4ʛt!o⍅ b…Zmv_CCC߿;v,Ǝ[|da٘={vIDDDD۷O\1b4zzz:t(ND9sȿ͕Z^`]v;).LWzuQDDDt\̙-[(](ر>>>ؽ{V7z=z4 U6""]&榶l@8Xf III7o:w 8,,  4VVVZ탈tDDD쐲ƎJzsss#"#..6-͑"AAAJc޽Q j*bŊH$Z6mqxxxo;q,XhҤ ,XUD$"""5aǏ m۶Q&OH,XP㢮YFe˓cر߿?ك`:tݻw/0ߘL>ǰaô*#ի6DD 1,,, L/ &''Hy̘1*dff/ QnB{z*,Xk*\ʗ/_~GYADcN*CmV͛ٳgpBԨQ˖-È#+sV3g ظqe_z͛7ɓH'i3;$qvȒP!H,SS"ZjWׯooo|wGΝq!xxx &&Fa{aܹKe^JJ ڷo~ o޼ɓ }ǏG֭={b…E:^" N*CV4֛@211A@@ɓ'˭cƌAFFzꅮ]z_g[]CZ`dd+++tgΜAIpƍ"HDDD:,)kΝرc?~ }}}TPZW|>&&&C||w`ʕK<‰'~deep%qVZ=z%W8 Xn]ذ_кukb믿` 露:;;wȽ~AXXb qA"MϞ=oȫ[h&777nؔ111ȍQt^Y?FjÆ Ҕ+WVVVHJJBppVAOiDT 露:;$]˗/]vR-]!"*m͛77 18Y ,w0aXIB"@"` GFFj[ 4HHg/nzzzҥ 3gt Ett[gtqqgܺu8gԄ+:t#Gx9JtDD%eBZZ1m4!-- ۶mêUM1qD9ڵk gϞhڴ)*T|.\ڵkKyvL2XQ̙3~bٸz* WWWҥKXh>} h׮K. N*kCj( }hup,^FLL ~믿.tP ǎɓ'z Bj鉁#ڷowbڵ8z(n߾DB Aн{wD$'Od>|066Fѿ7HOd"""p)͛E\\Q|y+ÇcÆ 3** UV-tʢ֭[ܹstgvHmܹsG\f?"...Xp!.\vMahhF[j' eggɓ'c%X*"%: ТE |ѣG'N`ҥb7 t jsrr͛79@ރl_z>}eꫯJ,DDDDЩ&L@ZZ pqlR\׶m[ԨQ'OFdd$,Xٳgk5k֨C};v,={ 88BUghhX{`emv#++ }A˖-QjU".. qJ,DDDDЙ+Wp9ȑ#R'ND@@"""xbSx_U?)}}}L4 {;wNmJNY J.]K.UO>Xf K,DDDDaЙ}#F(MCbԩHLLę3gJd0Wbϟ4WfܰaG!..Nrhժ A). ?`nnML#._pD۶mkժUv>>>ruD:n96!..9r$̙n+VDǎl2[l҉j))).~}}}t= y$''ٳ/_˗8~8~wرZ*TbbbԮE%"""""""N߽{'.[XX^LNN.|71cUH$DnФI/_y&֮]+WٳgǹsиqcQrNaddT`zwiiiEo@@RRR +V`ٲex֬Y+R:e˖=z4Os"%%FBhh($IKDDDDDDDDeNMLLgddLMMjժɽرcѯ_?:txnK$̙3/_ƩSp5\xQ 4o\<XZZ˚tMIIYwam fffɓ ט1ceUc bŊ.}t"hbb;;;O| Kj<{{{U|ԩ#.?{XFDDDDDDDDeNteΝà v]qv%V@jj* ڎcQYRu.B.]"*A:Zn {իWUJzڐmWخw\&""""""""*{t&سgOq9 @i\lܸ@$~~~%R\\\(?#.Kوlљ` Xv`DDD&LCCCH$H$>|8}r} g#:tBK.ŋ*ӧOɓ' 6,֊DDDDDDDDtf @Xx1L6 ~~~HKKöm۰j*;&Nuϟ?GvаaCM6E ```/_… Xv-^| WL:uBPN ##Xn._ 033ի9 N7n۷cȐ!HJJ´iҸÅ 7n7Ԧҥ `fft}FFߏ̣J*غu+<<< ]V""""""""*t*ݺuCxx8/^Ç#&&FFFpssC~_  ǎɓ'z Bj鉁;b/_Gll,a``{{{4iݺuàA`bbR؏H'q!>^A.T\ ggR.Qa!/Ky"zXǗD'Mc;:3 )bH1HDDDDDDDD$""""""""a 0tDDDDDDDDD:@""""""""" cH1HDDDDDDDD$""""""""a 0tDDDDDDDDD:@""""""""" cH1HDDDDDDDD$""""""""a 0tDDDDDDDDD:@""""""""" cH1HDDDDDDDD$""""""""a 0tDDDDDDDDD:@""""""""" cH1HDDDDDDDD$""""""""a 0tDDDDDDDDD:@""""""""" cH1HDDDDDDDD$""""""""a 0tDDDDDDDDD:@""""""""" cH1HDDDDDDDD$""""""""a 0tDDDDDDDDD:@""""""""" cH1HDDDDDDDD$""""""""a 0tDDDDDDDDD:@""""""""" cH1HDDDDDDDD$""""""""a 0tDDDDDDDDD:@""""""""" cH1HDDDDDDDD$""""""""a 0tDDDDDDDDD:@"""""""""'O`ĉUQ\9xxx`HMM-RXl &M&&&077+ Cʕ+ z3f n߾]v1d$%%復"44Xf > 77B?glٲE麨(DEEaǎݻagg28t!!!r?zV† l25Pe%""""""""ҩaaa0``aa9sŋ8uF D.]ݻB-Z=z8q.]zЭ[7*'''z{ѣGqe,Y1cpBHZN0iii000ѲeKq]۶mQF L<X`fϞ>֬Y[1vX{App0:ݻ+ݰaΟ?ꫯ7oO>M6ERRDDD/*:ʕ+8w`ȑr?'vŋ#++KǤI2ʕ++wssԩS<޽{.+'.1Bi=== :3gΔHY,--tfffJ>| iѴiS||| .HYm&.תUKa˓_ JDDDDDDDDMgunnnj8cȑ3gVH{QF>::)))V^""""""""*tbVtզ9RRR]"((H:{{{ݻ666 bbb[re A͚55.~yyI'޽-,, L/ &''Hy̘1JkS^sssqYJDDDDDDDDTvDPv ##Ҋ߀@$&&"44+Ve#Y˗/Rye-Q٣@q933SS"Zjr1vX.^7ye_*ka[P/^yZIDDDDDDDDZZZ˚tNIwam ...ɓuV4˫.(;-h|A""""""""}:1 ܹs***u]'ږZs^ӟT.LHHCۻ6e8y)**Jfl6[cGƍ+77Wf8Pw^$[;wѣGGyD6MV2) g͚"vm/T=={$) C7|#Ţ &ݡ{$I򋑒 0c[lч~? І dX+m۶u<qƌ*,,?O}O퇃3>fI&L_~pRIV\^zbfݻ+))I3gά8 R}Q>}tYѣpu]wiӦu'jjذᄎ@0o$=;ufϞ-I_b,' 'M$Ţw'OG\8p@111 iӦ)??Vs>رc&MM޽&M#G8O)F3kryI[Z| kJMMUjjNOOWhhrss+wI(%%Efܹs5xZ}6.Y?HzjG/eZxb͛7O۷RSSհaCɱzեKرc4o<'TFCU;g.]`ßCE*}}ٲeJMMBeeeiX, qK'DGGjUVR[۷Wll2224c M4ɩ]\\裏W_Uǎ+zHԅ 4~xݻm޺۝pC+X6M/Ål6\\\\Nغu6n(I9rdTLL,X]v)>>^/Sے^{Վo[?QVVtw;a:! 6Sk)44T_|HҥK#Ft*I:}֮][/ۯPV۷~mt7bhʕj߾}Y,yzz_]w]M$lܹsʆ7oVDDDRXXhC`۶m+}EUw9ڵK Wת?ZPPP{:tvݻu=hϞ=*((PӦMչsg=#z/0'CuVPPǏKZjU&M[yyy_꼗QIIIN:_Vvv M2E~isTվaPx%:{ǧ`nnnQXXQF… ɓ'W9E"##uw_gϞ9sh׮]ڹsõuViƩ^Zn]i{=<<$IVN7nRSS%IÆ S~g׷={Գ>ѣGׯ &>^`~޽{.hXfW~}ܹǗѠA:7PBB$)$$Dn+ J)!!A)))ڳg>segge˖SÇk׮pݺuX,lUX,\:JÆ ׎l˓vaG̙3GqqqJ/]9]]]5rHJ*9X'p3w}5yyyyӧeX͛j׉'j<ԩS.GgTrb_Mz^Iر:;;N @G}ztI%&&*44HItQ7nTffZ۽{e}:tռysYNW׊I\\@dd6m$WWW 8^WCRm۶U9nk֬ѣ>"믿-bxܹ~ݢE:W$xzuqM: 0~`JkѢEJ7Tk˖-Raa7n+Wn34WU4|:wI@W%%%[]gϞD%''W3c ڵK-77rpbX4|J۷$u٩^׮]ӧOW5j({bFK:Tu*ժ)<<\VU/ܹs%Iqz,7w7n;vTy 7ܠnkW^[ըQ#j۶m;w} 7ܠx{.Yi&IW ֒%K4d(..˜@%%%aÆNϿqF=z{^}UM4빹VyoNxbk^K&''o,vZt+))I4h 7ރȚ 뮻;wرc:y<<<ԬY3uE4p@]wuW\ jS\\SN)55U~eXZ1Wڶm3gj̙N׫W/l*>|xtFԡCM0sU1N4I6M:uc$ on$Y,5lPڵSXXyuh9 r7&Fpվ}{egg8>;;[[a$ ~e˖5oٲ~6Rb%ͦ5k) C`6;{nvIҞ={`077W=csrr`I&#G8|O؆ ) C`%I_}XBt-) Co~l6͝;WvqO?yb4Rc[ݻV9/< ժ f8MM6{ァzJGUTTnfC͛7$>|X7nԾ}ddXS͚5j@Iz'U\\c*??_YYYWnf$y{{ Rn8RO=233?Y:uT;K/)33 ,u7_믿">^6M7oݻe⢅ ǧΚP=C$=rssӘ1c/hΜ9X,If$/zGܹ:l6mݦ_|Q8p`] AW+W^yE:y.\ ???E uX6mZÇyuY@ mu!OթS'#%`(\v}'5x`:uHI ]]]uI=c6lΞ=[͛7;п/l6opc(LNNV``l6뮻Җ-[ʍpԫW/߿_6MF_'sJKK3<#ͦ}),,L.\={{є)St5mTK.ܹsUן@ ԠA{Zlz]pA.uYl6z!m߾]˾8pX_~ھ}z-ͦ;w*??_^^^zwf͚ETPO?bfbH999luQ s)22RGbѰaԸqc;wN//U`8LOOWΝfuo` fƍuw>˾8P8}ts=ڵkl6q㏺$rssә3g4tP=:sL~U3ƪP5ҿo}jܸq1EJIIQd;O?xju]5n8I$CɓvZiơzb xFJ0M/bھ}{8`.uIeh`Y{բE#Gjjʕ ٱc~gy{{+,,%8pX\\XǫX6MRΝ+7?,WWW۷O-[]bx 1c4k,]pA-Z~*FFF]vp>S%8Pf%&&J~}3h l6}7FJ0sJ*Yk9tO׮]%I? 0099YE#GtVZI9b$ G$tM&I***2R@oooIұc$???#%`(%I;wt+VHn6#%`(fӻᆱܹS .bQdd 0>sVVV~W\Z*((FmYq5rSf{iСJLLʕ+շo_lڼyv-&-\P>>>u< %'ƌ_~Es̑b$%$$Hl6$G~@3ԣ>LUΝuufn6g2^y+*..ɓ'uͭ.z`P\\\ԴiӺ@-j 0+ `b&F `b&F `bɓZ`V^;vɓ$???~z4bպYΩ 9sm۶ժU-*ժlZJj۶Ν[W=QPP秐M6M;??_}Ǝ5iDnnnW5i$9rĩN[AAAсj+`xo^zI6MԸqco$9rDiii:s4vX>}Zuy/_!C(''Z~~RSS%%%)  qC!!!:vyUUjVZݻݻڷoXeddhƌ4iS󻸸Gի;Vx?""B= .hڻw,KӦMSFF$iԩ8qݻW^ S~~&Lu9+ \7b_2+uzdٴf#%kuVmܸQ4rr_uA;U{Ւ%K* JEEE鷿$)++KiiiƜ?^o$Cȑ#%Iׯw}Td0ΖTR9*44Tt!#%ktR#*⢡CJN>kK/묬 ]VgΜ$ 6L..c>|?&pM0^wu"pBI*®ڴi$[;wr\XXzKaaJ{uE^^^W4tۯ3yK:e؈]vIZ *S֯_o.r\Ν;+bӊWC@飝;wj0`:uT;vhڴiX,0hu tqIRVۤIy{{+//OK?*))IԩSJJ*Y[|[Vzz;Byxx8Ki>|pu2p„ PnnzӧĉƝ8qBӧOWϞ=uYyxxh„ gگ}||j-Iͭ> 5j(vɓ'W:_gzutթp1m۶3gF\ vnbѯ}ffbhΜ9~^UtVN7nRSS%ѯ_JǕLR 3JСC1cСCl?IfmѢΝw\ OOOsj_zHG ꬇7xC U-י^%iÇY`r@I۷߯?\W֎;tIInv=0`4l~6ټ˜d%&&JT @C@uqItڊWhhV"""pYV-^XsΕ$*&&FO$kjܸvQ=7pn O8QK,QFFbcc{L 4ڵk믫H 4[otT|yyy={$9<#d 2D9990&00PIIIjذoܸQG矯W_}U&MzÆ HݻWsε5j>@wuӽ7\^PGUqq,|Nׯ$}ZQQQ:ynF۫ 7衇$%%% m~uQ5mTjӦM<Zlnj$ \|,?:Im&I2RLI}=M4$) C`AA${$I 40RnAo>$I-Z0R{Gb l6͛7OE={4R'|R6M|}e_ubbb?J f$ QQQ WQQ~ѣGСCOԳgOb跿:k@\G4n87NE\nfSnݴpZ5 9VJ/QFl4h@Znw50P5ydiJMMѣGu+88X<7n\WpBRފTddd]L GX8qBg… 5e8px9u/ pi ;{WfC|W~l6 4H|N8 .eXSOi…ubhc$IO?t6n [h!If-C}'Iھ}{6n ?M3fPAAA] o6-X@{QDD222/u)㏫}۷:v;C>ŢDe8p?:~$ԏ?X=6 ?>;vL6M԰aCЮbPMGbbbꦛnԖp͚5X,ԩS'u~_U#i3ꖡy$ww:m@2Gwi3ꖡO5e|}}վ}{yyyU{bњ5k$CudXl:uꔶnZ=E6}ꗡ#W:Ձ01@Ī}?lnӦMQv.]v$Ţ q\OfsuWj H*YWlÆ \\\ԥKu:+C:9:&F `b::pĈuAŢ5kz5s8LMMu1&Ry8fgܱcks…<#L01@si}$I-[fԭWmVm۶kYQPP秐M6MX;w… *$$DX,X,ZnC~OM?WW˗kȐ!ɱT*!!AIII 04/ >4 >>z.ժŋk޼yP߾} :]fٯԩS'?^۷o7s.]`C1]-*WWWZJݻw׻woo^Ќ34i$ktQoBBBt]wS&M2z{{o7t/PSuVmܸQ4rr_uA;]k׮?~u&OO5 #SK._11...:t$Zvh ,LnڴIRɖΝ;W9.,,~yz \LڵK WתoTiݺ{+OOOjJQQQZh-@)RPPǏKZjU&M[yyy_.E{_믿l}2e>Ss qj?|sbٳkǗV\\\t+22RwyuY}3gvڥ;w*<<\[nU6mu9 5$Yz&}|}}+޳gO==z}믚0a>K$j ===Νq|aa$ASM* J)!!A)))ڳg>segge˖_Çk׮i گ֛'ɱ—FXIO8|MB`OOOKSN+9y;v_ggg_Np52M(X*޽~mtKb\p3UأGI%{mVۯCCC뽯عsE\L0~`JkѢEJRTT|}]np52UصkWS cf̘]vIVubbhڵku*?Fe_~W p1)*ժ)<<\VU/ܹs%I1\g…?د+߿瀀RW^[ըQ#j۶m;w} 7ܠxýe088XK,ѐ!C c 3bĈ*ߛ2eJ?6B(IV9WNxbkpv.J˦+>>^III:xAiܸq= 뮻;wرc:y<<<ԬY3uE4p@]wuW\LJR۶m5sL͜9өz%V8GTCС&LPy&F `b&F `b&F `b&F `b&F `b&F `b&F `b&F `b&F `b&F `b&F `b&F `b&F `b&F `b&F `b&F `b&F `b&F `b&F `b&F)(&&FAAABBB4m4jbܹS .Գ>yxxbbhݺuN͗S*$$D~~~VPPbbbtZ ^5d_WjjRSS$_^'fff*22R{-={g%$$>?\'p1 4 k?dLnڴIΝ;W9.,,~yz2JX.]% i]vIZ *sܹ~]O+\fC@ tqIRVۤIy{{+//O˥hJ*Y[֭[+==]ǎSaa<<>^ZP\\eZxb͝;Wuʮ̓~~W_i?[jĉZd222L=cjР֮]_]EEEjРz-ýf088XK,ѐ!C c 3bĈ*ߛ2eJ?6aÆJJJRddݫsR5|pf p~)==]?%___uESLQZZ.wJViʔ)ҥ|}}[oU?_6p3 Rm۶̙35sLիWrGy{{+66Vu6'Pt+?@#L01@#L01@#L01@#L01@#L01@#Leu(((HSHHM:b 8PZZjjŊNsy-\P}U6mᡦMSN5j>*X,իzpZ| kJMMUjj53(11ҥK5j(͙3G..''|R;v('t رC~ d_ "--Mj^|EjjŚ7o222Էo_aÆK/88X[S*--M W9OzzuIyzzjԨQP˖-u9ݻW_}6nXcOcǎճ>[P@ಈjVZݻݻڷoXeddhƌ4i52224}tIR.]a5h@+,,L6m~JWhРA:yڴiիW}tMO=Ν;Wc_7pnv?F @֭[+FY.+:Huy[***$͞=ٳ%IEEE5kVL>]rqq\!+>O.KگGQ :Tti]֩6M˖-$[n֭nVIҲedʽ{x@sS}p6m$Yw;wr\XXzNطo:Tadggk۲e%I^PP,egg… ND۵k$) @U?4((=ڹsg8['%%~ݩS'ݻW<5jjJ:tO>Qǎ奆 }6lӫp K@Ǐ$jժڱM4/8Un~}qAݻ>3 Ϝ9_ իkmΝڵkVrssEw8pΜ9Sv(((HSHHM:b 8PZZjjŊNsy-\P}U6mᡦMSN5j>*=z/z!5mTEEÇ'Ξ=kq򔛛[ouJCFI#5J=z]wݥ>}^O?`Iө[otิ4 ]ԥKmذzHHﯰ0jڴiz+ݴ@ ɓ'զM^Z۷/7[nzꩧ{_($$D͚5ծ];?p%հaC#zKW9]h.Sv^zUUmWnfGi?Rֿ@նnݪ7JFY.+:H=?Qo$IgϮEfϞ-I***ҬY*gȐ> _Y_~j<==/A9u=+{P#QS\\kǎsϋu~mhںݸ8u]{ܹs5uTIkn޼Y=BBBciӦMҥ$iڴi̬tMOOٳկ_?}os?*.]j1bDc\\\4tPI%YvS5l6-[&I Rn*׭[7z뭒e˖f{… z$I<̄%Wreffڿիݻץ :[ysmٯ/\P|~.gws)!!AoԼysyxxGzF-['6/@\=$lݶm[֯_o uFvԢE TfÆ -[ꦛn*}gi[oVVe˖δksNui:v$TwPPP{Uw8['%%~ݩS'ݻW<5jjJ:tht˗/wܡ3gjϞ=שSXzKqqFH-]T:wYݑ=zSU5zhZJGѹs甗 -\Pz*%7` *S\\EI*9<#<<ܩEQQQJVY)))QQQVk5) wޭ`}gv9sF׿l}Jv=[*Yذ}jշo_O;C .TrrVZ/͞=[SL1͎%׵kWS cf̘a/::Znnn_n}+3a]wuj{jjJN0y?K*YX՘?sWxnw_#O{g[FU)(9rH_i0F֨ɓ'?{PYYY8q,Ξ=AvKjz^w)777~\R˖-ݻwaÆ[nӧ׿jLR߻e.x5h@EEEo]VcƌQll$)00P111jjĉTjɒ%JMMՒ%KTIĉ|~ܣ>HI… jٲeJKK_|}*!!ARɷcƍ0նm[=s裏~AW/o]iiiJH0kotP#W _e)=Ptοo9soYSNɓ%<7pKj{^w](l_|ѾΝ;T @Epp,YF)777WRI 3yd=ӒJ˗4p9r^{jYd"""$I+WԀtw+**J_~$)$$D_~<==+СC={x { V>}4yd{#h~TF){nΕJR.֨Ny6m^x9&NoQ_ϑ]W曫w-Tz ˦_~JOO?@yyyW]tє)SZpqqQbb-Z]-Zw rq_>>>ꫯGԍ7(777]ӧϟd/{׿U>'WWWSN3fl٢O?TF.SyG.}͑/);uӫW*tuu/K\uוҐQPGX,*wv]~\m۶̙35sLիS2EFFڷeXcsްzݷ~[G{@<=='N{KeN:e>eSSN?C;VnEٕJ^wv*w iǎ͕OO?_VNNL ۀҔ$Iz'ԨQ?絈I| 8p@111 iӦ)??XBTVVZiZbE>~TM?FV7 Xn ;v$effV{ݻץϤs8[n__pyʾ_]v\m.+77W6lɓ'u99rDVX9kڴK7oVHH-Z^Z_s6|ɵ5]ui2dtIWjjRSSZ=꡸X<˽l-]TFҜ9sj|C}^W=zhƍӶmt=T:nPjkN-ZСCS 6HZln{w/Ku1ndUnԯ_?}j֬ }v%&&j֭VDD6nܨJ_۶mӌ3aÆ{YfѣGWZ @ iii%"!!A/rshB۷oǙEEE}@ 0~`JkѢE$___;Ub(**JR)))KIIU1/ڵ`ڵ:sL={VWTrD͝Rڞ-IfRrr*88Xݻw3<I*3jԨ*t9-ZF~W3Dv=z$7Uot/27DGGjUVpԩS%Fdddh钤.]hNzߴit"I6m233M~{?nnnoSPPFi3k׮ٳ$)11Q̘1 \nÇ΄ z?~|jjJ0aBTmUc?Wtv=[RZ,M<ҭyyyzoɓծ]TXX3ghժUѣRSS5`VW`7jk֭ڸq$iȑ޽{1111U:uz-JٳgWfϞ-I***ҬYa?W^yE{9R&jР7xC)))ZvƌXIR``bbb  ĉ%I Ւ%K%K(44TJ}G}~… jٲeJKK_|}*!!ARjqU:ϦMpBϧ~j/33{ .4s=Qcƌ_W8`ҤI+5e]5R>}vZfiĉ bv;P[K._11...:t$ZvS5l6-[&nݺU:[n[%mCrss5lذ+x0"88XK,QF[aT%%% 5yd=ӒJ/QvwZZ/^{jYd"""$I+WԀtw+**J_~$)$$D_~epe%$$hĈpR6o\Wj~nG}e:y$Uy@Hs')F҇UW=zTϷ{1G}Gj*\R+W0&$$D_|E;+=t'((=ڹsg8R]v;q´cOwq맧~ڡSO>hIҔ)SԴiSG>\ڶm3g:ܵ^z9*;22QE=Xs::j:Qe^Re.Tq"W9V*]Fo!L&M-"GݒTS[ 6ɓ:w9UVi Ց#GzUq(((HSHHM:b 8PZZjjŊ5;|pc%j+<r9sد/^FI ˆU}w#TZF@8{ڑ{{{+//ϡS2J?b[nׯn5kLھ}uVegg+""B7nr ƍ WWW{"t,_ܾһT~~RSS%%%˞b=3JLL,zvvtR5Js̩?{ƍJLL԰a*KCaÆUq„ ;w.\kÆ ry)))j۶ml6+ ]\\Էo_}G:t&OW_}\N /`?\ik DAAڑo=<<$A@uYFUuf͚%__ w]G/_]yyy5jRSS+{Ν3<#ͦ^~SRKKKeZ_|QZZx͛ WK/ÿ`[nQVVN4%$$믿^\-ZelPp5WhhV"""Wea|M_xKwIU?қo|P}QǎBko%I^^^7o^_/Ѳe˔I&i۶m6lnf(%%Eo~gIo?N}sj_XX(IVuUթ,+eX4yd}Zf{mٲ _]wV6m*]pjVZUnH޽վ}{*##C3fФIa?K.V SjjM~ՆnnnW| B-[L˖-rL6m*$$l2=:~/_˗W:wOjd.N#z$9]hF3f޽[o$i_nݪ7J*[$)&&~c|||;ꭷ޲?iW///͞=[RɃg͚t V] 1b詧ҝwީ͛]^^^jӦ DٳƃJx޽[SLQ^tM 4Pv裏jҥZz4iRmfP7&5\&y:qD:*s){8WG=:epN;گ˽7k,;wN7|x ر~7ȑ#JwåKگGQ :T/N>k:fW{[n֭nVٳG˖-;s?;W۶mcjرN[*66NtQ7nTffZ'J6sFeuRՅR[q}]pӦMJGܹsʞĸyf}СC橪Ξ={sr#-3=zTv۶mU+?k׮ZhQalذAR7tSuJܹ~]ZjUz2d@@@Tr{UԦΉ'&yxxyo~wyGNuf T:X-TrGxxS5,$KIIt\JJ}`TTs̱__m…l=dڵ׍FoL&MnvDm5)-:ڰaN}ԱcGP?[I͛ p ֒%K4d(..˜@%%%aÆL}Zk׮ufӲe$IAAA֭[u[oUl2l6u4M$Iܹsכ7ovƾ}tС TW';;S|p٘&ܵk$) @U?0((=ڹsgԦΉ'&yxxyo~wyGN\㒤VZU;I&V^^~lh^\=L={~S077x{{ۯ+cXԭ[7Ow}5km߾]ںu7*88ة^!$M 5$YzSZ:f͒o׻wѣG_믿<5JX,N _;]Iۇm-PgLzzzگϝ;WBIR NiTX,\glz嗵zjIҝwjE@2)*ժ)<<\VU/ܹs%I1T#00P'NԛoT^-ܢ,M2Eiii'}ꫯoT>}ԱcGP?[I͛``-YDC QNN* TRR6lhɓuQ͟?_iiiz*9r^{*(,,Բe˴lٲ*ǴiF~BBB k@Iׯ$LMMaoo???\~JDDDDDDDDo&@E;x F gوFtt4lقjJ6~$aĉ_8ǚ… ~~=l޼۶múu0qDJDDDDDDDDo6xe >ҥKq93ܼy:""""""""*3#pi J%Kkzjh?Bڵk䟔֮] d}UVɭGDSVXQ|V0o<ݻw_iW"""""""""IǏ[FOOcǎx!m۶rnnnhӦ 88 :7o>#ɭgܸq1DDDDDDDD IJӚK. ˹gϞը$''GY;IIIܾaÆpppЪDDDDDDDDD%#Zjtmۖynܸ!MѦDdeeW"""""""""@G6EZZNiڵkYYYHLLԨGǪiҤxj;#=z$N-ִ+%%EzkŒQ~quwULLTX<Ճq0W_}c|ѕ8/ϑY!N$333c /_VZ;TT=&!UquuըnW_U݁7R/_=Y_|ubՅ1bOMMEӉ)ⱑJkGچv*"""""""""Utbx|^^Դڑ!W&jsnn.nݺ ~JN$U)))ب(4jԨ{DDD1H1LMMM899),KKKXi4ԙ.m;vΫ(K*GU %QF ~㉈t<MSMLLPn]q|Td`ڴi)u>''ӦM`ƌr9s&ٳg={vXl:$""""""""mLV^ SSS_~Xl"## ???1/R60k,@tt4z={ ::{A= 5kZn-qZ퍿QQQXnw a͚500Љ TVSNسgF |We888 $$ZtR|7oƅ z~~~j' 믿bΝy&`kk}bh߾FNDDDDDDDD$K'DDDDDDDDDTB$""""""""Ҙ$""""""""aL0&tDDDDDDDDD: @""""""""" cH1HDDDDDDDTA$ $ @7@tt4/^~ưƏ3gh\'0n8j 氶qF|NKK7|: VVVС<}*** SLAv`ee c駟u҇W_FFFhРݱtR:t?RRR0h \xvvvZ_jH>}0j(m&&&HNNƹso߾r]3gffݻذa"##3 :hѢ:'pssСCѱcGԭ[鈏Çd̘1ƍ+U ⱉ :vTWȵQ嫈߸qcƪlkٲe}||liР\\\p!Sft gφ=/_Ɩ-[P~}|n&xccc{h׮5j:u 55111شi===޽ODϞ=qơzzzGc?|۶m3xzzڵkhٲV}g_~-㫯zj"$$J?@Q8pgPTA*<<\a]cƌBpprBAA}@ػwo{hݎ _-$qFenG_E[oooԩS;q݄ͦaႵusG6m$\tI{p k"c2@+} >*Fjۂ@pvv.第,Y wѺU݇777O111JQx?p@/T+uT3ӧOkkkдiS!55BaРAb[Z.i[H5 D$>>hٲ%`ee'''̚5K-+;;H$5j#""č6lؠQH0HD˜?HyxxPTTDܿ>8^Ï칳gjV`` shܸ1)))wW[%K9/]WJ"`U7"z}dff7o?յJ ~pRHOJJkb]m۶U&UuKddd --TyMU3}~~>ݺuʶq[PXX>FBxx8^x,\~/F.]nrss1rH۷oGBBrrrk׮Ď;Qj2bڵJfB<߷o_t]R[lAHH q{λa̘1ի_3fQO<Ν;mĉx 5]tGVZ%:tϞ=/_;wCTTrsslܸsUm28doHRz^?"##x9v [[[ƍfQ%33.]u[nS*SƦADIÇ%ݻwGV*Avm'U~55`ɒ%rV#CfQ7c۶m o-[`pqq~ Ǐǒ%KOU=Weߴie͛7vX ˗/|geRkƊ+L|a`` 2هه""Eԍb,]F7Pu?K'jj?vbcc}lllxbL:DT47o+|DDu꙾*', 5___nܸ!nhWɮQtD_AA[999H!CLi[f` uuxyy&&& T8@.,ܳgOq筛7o*|PFږ:;I:655պŋ˽1n'OP2jPz 1c8PNU9 ~4ر@ɇ&Uk$1^Dv|m'GGGt{Ƃ p5رcz$ kJxit Dƍc<~HLLaffݻwׯ_ת_L_>//O⢴su֭ԗFdGJwڵ^tqI @7PBBϟC__wVSN ."~&mK"IL-PP㢲M{MX\zǎ_ kkk$%%ӧ˼nݺ[>n4+[nZjUpO+lWu?Mis?:ڵkc۶mW￯vh0rHaÆѣѠASLS`bbd)R?.WZ ɧ)ysΕ^zG=La"99[nU&삹,q.諈t^6%ZjzxPVVkennGGG8:: Xx1Μ9KKKaԨQ(>'''qzK"]xE^?dU' ks?vڡuJv{&ׇ6ѣiӦ)ܝ}=z4ŋѸUL_^"h^pAK#U)SKv3Çs'1HIKKݻXvZڷo/޴=歷.+%%E[!ʿ&u8::/EбcG۷qJ`JF(ZK=_Ya_~}PK0<7oǝ;wVZVv;UqTzwpp&x/; ^HmeeG|תUKHKިC8;;v)|߾}thN%YL!ѿܸq?مPf͚iӦ+C-4nX~S<<)=zh܎!u@ZZ²&uEJvr{OǏP-_eHLDxyBBB]?n@-ĵKԩSJ{+O55KGw3U{X^XXlAA鍌5"""(mXLD1jI eeR:{F}Gv^ ۣk׮ntDol 8PV4|̙3G:>C%ɲ'O*,Dz7~u >^s~~0ydiddd޳jժr]'&HDYlΞ=1cƠy0665\\\w!..N\3-[9P~}t 3gDLL `ffV%NDO"`ժUp&MGGGXZZB__ҥ \v 3g,w{ULh"9s14i###;wDHHLLLՆ!ߏ;vW^ڵk /^D˖-ժgÆ ikkkбcGL0+{<4ho}}}1\II"J$""""""""G0&tDDDDDDDDD: @""""""""" cH1HDDDDDDDDØ$""""""""aL0&tDDDDDDDDD: @""""""""" ?2`̕0IENDB`cloud-py-api-nc_py_api-d4a32c6/benchmarks/aa_overhead_common.py0000664000232200023220000000416514766056032025250 0ustar debalancedebalanceimport sys import matplotlib.pyplot as plt import numpy as np from conf import NC_CFGS, init_nc, init_nc_app, init_nc_by_app_pass NC_VERSIONS = [] def measure_overhead(measure, title: str): penguin_means = { "Password": [], "AppPassword": [], "AppAPI": [], } for k, v in NC_CFGS.items(): NC_VERSIONS.append(init_nc_app(k, v).srv_version["string"]) for k, v in NC_CFGS.items(): nc = init_nc(k, v) if nc: result_nc, time_nc = measure(nc) penguin_means["Password"].append(time_nc) else: penguin_means["Password"].append(0) nc_ap = init_nc_by_app_pass(k, v) if nc_ap: result_nc_ap, time_nc_ap = measure(nc_ap) penguin_means["AppPassword"].append(time_nc_ap) else: penguin_means["AppPassword"].append(0) nc_ae = init_nc_app(k, v) result_nc_ae, time_nc_ae = measure(nc_ae) penguin_means["AppAPI"].append(time_nc_ae) # Uncomment only for functions that return predictable values. # if result_nc is not None: # assert result_nc == result_nc_ae # if result_nc_ap is not None: # assert result_nc_ap == result_nc_ae penguin_means = {k: v for k, v in penguin_means.items() if v} x = np.arange(len(NC_VERSIONS)) # the label locations width = 0.25 # the width of the bars multiplier = 0 fig, ax = plt.subplots(layout="constrained") for attribute, measurement in penguin_means.items(): offset = width * multiplier rects = ax.bar(x + offset, measurement, width, label=attribute) ax.bar_label(rects, padding=3) multiplier += 1 ax.set_ylabel("Time to execute") ax.set_title(title) ax.set_xticks(x + width, NC_VERSIONS) ax.legend(loc="upper left", ncols=3) values = [] for v in penguin_means.values(): values.append(max(v)) ax.set_ylim(0, max(values) + 0.18 * max(values)) def os_id(): if sys.platform.lower() == "darwin": return "macOS" if sys.platform.lower() == "win32": return "Windows" return "Linux" cloud-py-api-nc_py_api-d4a32c6/benchmarks/aa_overhead_dav_upload.py0000664000232200023220000000201614766056032026067 0ustar debalancedebalancefrom getpass import getuser from random import randbytes from time import perf_counter from typing import Any import matplotlib.pyplot as plt from aa_overhead_common import measure_overhead, os_id from nc_py_api import Nextcloud, NextcloudApp ITERS = 30 CACHE_SESS = False def measure_upload_1mb(nc_obj: Nextcloud | NextcloudApp) -> [Any, float]: __result = None small_file_name = "1Mb.bin" small_file = randbytes(1024 * 1024) start_time = perf_counter() for _ in range(ITERS): nc_obj.files.upload(small_file_name, small_file) nc_obj._session.init_adapter_dav(restart=not CACHE_SESS) # noqa end_time = perf_counter() nc_obj.files.delete(small_file_name, not_fail=True) return __result, round((end_time - start_time) / ITERS, 3) if __name__ == "__main__": title = f"upload 1mb, {ITERS} iters, CACHE={CACHE_SESS} - {os_id()}" measure_overhead(measure_upload_1mb, title) plt.savefig(f"results/dav_upload_1mb__cache{int(CACHE_SESS)}_iters{ITERS}__{getuser()}.png", dpi=200) cloud-py-api-nc_py_api-d4a32c6/CHANGELOG.md0000664000232200023220000003335714766056032020571 0ustar debalancedebalance# Changelog All notable changes to this project will be documented in this file. ## [0.19.2 - 2025-03-17] ### Added - Optional `response_type` parameter of `nextcloud.ocs` method for calling OCS endpoints that return raw data. #341 Thanks to @janepie ## [0.19.1 - 2025-03-07] ### Fixed - ExApps(NC32+): When using `HaRP`, use `unix-socket` instead of `host:port`. ## [0.19.0 - 2025-02-15] ### Added - Files: `FSNode` now have `creation_date` property. #335 Thanks to @SunnyFarmDay ### Changed - ExApps: no longer require the `AA-VERSION` header. (Nextcloud 32+) #336 - ExApps: `AppAPIAuthMiddleware` now secures the `websocket` connection. (Nextcloud 32+) #338 ## [0.18.2 - 2025-01-19] ### Changed - Default "User-Agent" for ExApps now set to `ExApp/appid/version (httpx/version)`. #329 - `System Trust Store` from now are used by default. #328 ## [0.18.1 - 2025-01-14] ### Fixed - Chunked Upload V2 not working on Nextcloud 30 and later. #324 Thanks to @DrZoidberg09 ## [0.18.0 - 2024-10-09] ### Added - New `webhooks.unregister_all` method. #309 ### Fixed - Files: `user` and `user_path` properties in `FSNode` when Nextcloud located in the sub-path. #297 Thanks to @vwbusguy - `files.download_directory_as_zip` method now supports upcoming Nextcloud 31. #304 ## [0.17.1 - 2024-09-06] ### Added - NextcloudApp: `setup_nextcloud_logging` function to support transparently sending logs to Nextcloud. #294 ### Fixed - NextcloudApp: `nc.log` now suppresses all exceptions to safe call it anywhere(for example in exception handlers). #293 ## [0.17.0 - 2024-09-05] ### Added - `message_type` property to TalkBotMessage. #292 ### Changed - NextcloudApp: `TextProcessing`, `Speech2Text` and `Translation` AI Providers API was removed. #289 ## [0.16.0 - 2024-08-12] ### Changed - NextcloudApp: rework of TaskProcessing provider API. #284 ### Fixed - `nc.files.makedirs` not working properly on Windows. #280 Thanks to @Wuli6 ## [0.15.1 - 2024-07-30] ### Fixed - Corrected behaviour of `ocs` function for `Group Folders` app routes(they are not fully OCS API). #279 - NextcloudApp: `get_computation_device` function now correctly returns result in upper_case. #278 ## [0.15.0 - 2024-07-19] ### Added - Initial Webhooks API support for the upcoming Nextcloud 30. #272 ### Changed - NextcloudApp: `fetch_models_task` function now saves paths to downloaded models. #274 Thanks to @kyteinsky ## [0.14.0 - 2024-07-09] ### Added - `LoginFlowV2` implementation by @blvdek #255 - `files.get_tags` function to get all tags assigned to the file or directory. #260 - NextcloudApp: `nc.ui.files_dropdown_menu.register_ex` to register new version of FileActions(AppAPI 2.6.0+) #252 - NextcloudApp: `enabled_state` property to check if the current ExApp is disabled or enabled. #268 - NextcloudApp: support for the new AI API for the Nextcloud 30. #254 ## [0.13.0 - 2024-04-28] ### Added - NextcloudApp: `occ` commands registration API(AppAPI 2.5.0+). #247 - NextcloudApp: `Nodes` events listener registration API(AppAPI 2.5.0+). #249 ## [0.12.1 - 2024-04-05] ### Fixed - Incorrect `Display name` when creating user, which led to the parameter being ignored. #239 Thanks to @derekbuckley ## [0.12.0 - 2024-04-02] Update with new features only for `NextcloudApp` class. #233 ### Added - `ex_app.get_computation_device` function for retrieving GPU type(only with AppAPI `2.5.0`+). - `ex_app.integration_fastapi.fetch_models_task` are now public function, added `progress_init_start_value` param. - Global authentication when used now sets `request.scope["username"]` for easy use. ### Changed - `UiActionFileInfo` class marked as deprecated, instead `ActionFileInfo` class should be used. ## [0.11.0 - 2024-02-17] ### Added - Files: `lock` and `unlock` methods, lock file information to `FsNode`. #227 ### Fixed - NextcloudApp: `MachineTranslation` provider registration - added optional `actionDetectLang` param. #229 ## [0.10.0 - 2024-02-14] ### Added - NextcloudApp: `set_handlers`: `models_to_fetch` can now accept direct links to a files to download. #217 - NextcloudApp: DeclarativeSettings UI API for Nextcloud `29`. #222 ### Changed - NextcloudApp: adjusted code related to changes in AppAPI `2.0.3` #216 - NextcloudApp: `set_handlers` **rework of optional parameters** see PR for information. #226 ## [0.9.0 - 2024-01-25] ### Added - class `Share`: added missing `file_source_id`, `can_edit`, `can_delete` properties. #206 - NextcloudApp: `AppAPIAuthMiddleware` for easy cover all endpoints. #205 - NextcloudApp: API for registering `MachineTranslation` providers(*avalaible from Nextcloud 29*). #207 ### Changed - **large amount of incompatible changes** for `AppAPI 2.0`, see PR for description. #212 - class `Share`.raw_data marked as deprecated and changed to `_raw_data`. #206 - `ex_app.talk_bot_app`/`ex_app.atalk_bot_app` renamed to `ex_app.talk_bot_msg`/`ex_app.atalk_bot_msg`. ## [0.8.0 - 2024-01-12] ### Added - `download_log` method to download `nextcloud.log`. #199 - NextcloudApp: API for registering `Speech to Text` providers(*avalaible from Nextcloud 29*). #196 - NextcloudApp: API for registering `Text Processing` providers(*avalaible from Nextcloud 29*). #198 - NextcloudApp: added `get_model_path` wrapper around huggingface_hub:snapshot_download. #202 ### Fixed - OCS: Correctly handling of `HTTP 204 No Content` status. #197 ## [0.7.2 - 2023-12-28] ### Fixed - files: proper url encoding of special chars in `mkdir` and `delete` methods. #191 Thanks to @tobenary - files: proper url encoding of special chars in all other `DAV` methods. #194 ## [0.7.1 - 2023-12-21] ### Added - The `ocs` method is now public, making it easy to use Nextcloud OCS that has not yet been described. #187 ## [0.7.0 - 2023-12-17] ### Added - implemented `AsyncNextcloud` and `AsyncNextcloudApp` classes. #181 ### Changed - set_handlers: `enabled_handler`, `heartbeat_handler`, `init_handler` now can be async(Coroutines). #175 #181 - set_handlers: `models_to_fetch` and `models_download_params` united in one more flexible parameter. #184 - drop Python 3.9 support. #180 - internal code refactoring and clean-up #177 ## [0.6.0 - 2023-12-06] ### Added - Ability to develop applications with `UI`, example of such app, support for all new stuff of `AppAPI 1.4`. #168 ### Fixed - AppAPI: added authentication to the `/init` endpoint. #162 ## [0.5.1 - 2023-11-12] ### Fixed - `move`, `copy`, `trashbin_restore` correctly set `utf-8` headers. #157 Thanks to @tschechniker - `upload_stream` correctly set `utf-8` headers. #159 - `headers` can now be `httpx.Headers` and not only `dict`. #158 ## [0.5.0 - 2023-10-23] ### Added - Support for the new `/init` AppAPI endpoint and the ability to automatically load models from `huggingface`. #151 ### Changed - All examples were adjusted to changes in AppAPI. - The examples now use FastAPIs `lifespan` instead of the deprecated `on_event`. ## [0.4.0 - 2023-10-15] As the project moves closer to `beta`, final unification changes are being made. This release contains some breaking changes in `users`, `notifications` API. ### Added - Support for users avatars(`get_avatar`). #149 - `__repr__` method added for most objects(previously it was only present for `FsNode`). #147 ### Changed - `users.get_details` renamed to `get_user` and returns a class instead of a dictionary. #145 - Optional argument `displayname` in `users.create` renamed to `display_name`. - The `apps.ExAppInfo` class has been rewritten in the same format as all the others. #146 - `notifications.Notification` class has been rewritten in the same format as all the others. ### Fixed - `users.get_details` with empty parameter in some cases was raised exception. - ClientMode: in case when LDAP was used as user backend, user login differs from `user id`, and most API failed with 404. #148 ## [0.3.1 - 2023-10-07] ### Added - CalendarAPI with the help of [caldav](https://pypi.org/project/caldav/) package. #136 - [NotesAPI](https://github.com/nextcloud/notes) #137 - TalkAPI: `list_participants` method to list conversation participants. #142 ### Fixed - TalkAPI: In One-to-One conversations the `status_message` and `status_icon` fields were always empty. - Missing CSS styles in the documentation. #143 ## [0.3.0 - 2023-09-28] ### Added - TalkAPI: * `send_file` to easy send `FsNode` to Talk chat. * `receive_messages` can return the `TalkFileMessage` subclass of usual `TalkMessage` with additional functionality. - NextcloudApp: The `ex_app.verify_version` function to simply check whether the application has been updated. ### Changed - NextcloudApp: Updated `info.xml` in examples to reflect upcoming changes in the [AppStore](https://github.com/nextcloud/appstore/pull/1145) ## [0.2.2 - 2023-09-26] ### Added - FilesAPI: [Chunked v2 upload](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/WebDAV/chunking.html#chunked-upload-v2) support, enabled by default. - New option to disable `chunked v2 upload` if there is a need for that: `CHUNKED_UPLOAD_V2` - TalkAPI: Poll API support(create_poll, get_poll, vote_poll, close_poll). - TalkAPI: Conversation avatar API(get_conversation_avatar, set_conversation_avatar, delete_conversation_avatar) ### Changed - Default `chunk_size` argument is now 5Mb instead of 4Mb. ## [0.2.1 - 2023-09-14] ### Added - NextcloudApp: `ex_app.persistent_storage` function that returns path for the Application persistent storage. - NextcloudApp: `from nc_py_api.ex_app import persist_transformers_cache` - automatic use of persistent app directory for the AI models caching. ## [0.2.0 - 2023-09-13] ### Added - FilesAPI: `FsNode.info` added `mimetype` property. ### Changed - AppEcosystem_V2 Project was renamed to App_API, adjust all routes, examples, and docs for this. - The Application Authentication mechanism was changed to a much simple one. ## [0.1.0 - 2023-09-06] ### Added - ActivityAPI: `get_filters` and `get_activities`. #112 - FilesAPI: added `tags` support. #115 ### Changed - FilesAPI: removed `listfav` method, use new more powerful `list_by_criteria` method. #115 ### Fixed - `NotificationInfo.time` - was always incorrectly parsed and equal to `datetime(1970,1,1)` ## [0.0.43 - 2023-09-02] ### Added - Basic APIs for Nextcloud Talk(Part 2) #111 ### Fixed - `makedirs` correctly work with paths started with `/` - `listdir` correctly handles `exclude_self=True` when input `path` starts with `/` ## [0.0.42 - 2023-08-30] ### Added - TrashBin API: * `trashbin_list` * `trashbin_restore` * `trashbin_delete` * `trashbin_cleanup` - File Versions API: `get_versions` and `restore_version`. ### Fixed - Created `FsNode` from `UiActionFileInfo` now have the `file_id` with the NC instance ID as from the DAV requests. ## [0.0.41 - 2023-08-26] ### Added - Nextcloud Talk API for bots + example ## [0.0.40 - 2023-08-22] ### Added - Basic APIs for Nextcloud Talk(Part 1) ### Changed - `require_capabilities`/`check_capabilities` can accept value with `dot`: like `files_sharing.api_enabled` and check for sub-values. - Refactored all API(except `Files`) again. ### Fixed - `options.NPA_NC_CERT` bug, when setting throw `.env` file. ## [0.0.31 - 2023-08-17] ### Added - `FsNode` can be created from Nextcloud `UiActionFileInfo` reply. ### Fixed - `files.find` error when searching by `"name"`. Thanks to @CooperGerman ## [0.0.30 - 2023-08-15] ### Added - `Nextcloud.response_headers` property, to get headers from last response. ### Changed - Reworked skeleton for the applications, added skeleton to examples. ## [0.0.29 - 2023-08-13] ### Added - Finished `Share` API. ### Fixed - `options` error when setting timeouts with the `.env` file. - ShareAPI.create wrong handling of `share_with` parameter. ## [0.0.28 - 2023-08-11] ### Added - APIs for enabling\disabling External Applications. - FileAPI: `download_directory_as_zip` method. ### Changed - Much more documentation. - Regroup APIs, hopes for the last time. ### Fixed - Assign groups in user creation ## [0.0.27 - 2023-08-05] ### Added - `Notifications API` - `options` now independent in each `Nextcloud` class. They can be specified in kwargs, environment or `.env` files. ### Changed - Switched to `hatching` as a build system, now correct install optional dependencies. - Renamed methods, attributes that was `shadowing a Python builtins`. Enabled additional `Ruff` linters checks. - Regroup APIs, now Users related stuff starts with `user`, file related stuff with `file`, UI stuff with `gui`. ## [0.0.26 - 2023-07-29] ### Added - More documentation. ### Changed - Reworked `User Status API`, `Users Group API` - Reworked return type for `weather_status.get_location` - Reworked `Files API`: `mkdir`, `upload`, `copy`, `move` return new `FsNode` object. - Reworked `listdir`: added `depth` parameter. - Reworked `FsNode`: changed `info` from `TypedDict` to `dataclass`, correct fields names with correct descriptions. - `FsNode` now allows comparison for equality. ## [0.0.25 - 2023-07-25] ### Added - First `Files Sharing` APIs. ### Changed - Updated documentation, description. - Updated `FsNode` class with properties for parsing permissions. ## [0.0.24 - 2023-07-18] ### Added - `VERIFY_NC_CERTIFICATE` option. - `apps.ex_app_get_list` and `apps.ex_app_get_info` methods. - `files.download2stream` and `files.upload_stream` methods. - most of `FileAPI` can accept `FsNode` as a path. ### Changed - License changed to `BSD-3 Clause` ## [0.0.23 - 2023-07-07] ### Fixed - `nextcloud_url` can contain `/` at the end. - work of `logs` during `enable`/`disable` events. ## [0.0.22 - 2023-07-05] ### Added - `heartbeat` endpoint support for AppEcosystemV2. ## [0.0.21 - 2023-07-04] ### Added - `app_cfg` property in the `NextcloudApp` class. ### Fixed - All input environment variables now in Upper Case. ## [0.0.20 - 2023-07-03] - Written from the scratch new version of the Nextcloud Python Client. Deep Alpha. cloud-py-api-nc_py_api-d4a32c6/.gitattributes0000664000232200023220000000063114766056032021640 0ustar debalancedebalance# Declare files that always have LF line endings on checkout * text eol=lf # Denote all files that are truly binary and should not be modified *.bin binary *.heif binary *.heic binary *.hif binary *.avif binary *.png binary *.gif binary *.webp binary *.tiff binary *.jpeg binary *.jpg binary # Files to exclude from GitHub Languages statistics *.h linguist-vendored=true *.Dockerfile linguist-vendored=true cloud-py-api-nc_py_api-d4a32c6/.run/0000775000232200023220000000000014766056032017627 5ustar debalancedebalancecloud-py-api-nc_py_api-d4a32c6/.run/ToGif (28).run.xml0000664000232200023220000000266014766056032022523 0ustar debalancedebalance cloud-py-api-nc_py_api-d4a32c6/.run/TalkBot (27).run.xml0000664000232200023220000000267014766056032023053 0ustar debalancedebalance cloud-py-api-nc_py_api-d4a32c6/.run/TalkBot (last).run.xml0000664000232200023220000000267314766056032023571 0ustar debalancedebalance cloud-py-api-nc_py_api-d4a32c6/.run/TalkBotAI (last).run.xml0000664000232200023220000000267414766056032024004 0ustar debalancedebalance cloud-py-api-nc_py_api-d4a32c6/.run/aregister_nc_py_api (27).run.xml0000664000232200023220000000271214766056032025516 0ustar debalancedebalance cloud-py-api-nc_py_api-d4a32c6/.run/TalkBotAI (27).run.xml0000664000232200023220000000267114766056032023266 0ustar debalancedebalance cloud-py-api-nc_py_api-d4a32c6/.run/register_nc_py_api (last).run.xml0000664000232200023220000000262314766056032026071 0ustar debalancedebalance cloud-py-api-nc_py_api-d4a32c6/.run/ToGif (27).run.xml0000664000232200023220000000266014766056032022522 0ustar debalancedebalance cloud-py-api-nc_py_api-d4a32c6/.run/aregister_nc_py_api (last).run.xml0000664000232200023220000000270314766056032026231 0ustar debalancedebalance cloud-py-api-nc_py_api-d4a32c6/.run/register_nc_py_api (27).run.xml0000664000232200023220000000263214766056032025356 0ustar debalancedebalance cloud-py-api-nc_py_api-d4a32c6/.run/register_nc_py_api (28).run.xml0000664000232200023220000000263214766056032025357 0ustar debalancedebalance cloud-py-api-nc_py_api-d4a32c6/.run/ToGif (last).run.xml0000664000232200023220000000265114766056032023235 0ustar debalancedebalance cloud-py-api-nc_py_api-d4a32c6/.run/aregister_nc_py_api (28).run.xml0000664000232200023220000000271214766056032025517 0ustar debalancedebalance cloud-py-api-nc_py_api-d4a32c6/LICENSE.txt0000664000232200023220000000301714766056032020571 0ustar debalancedebalanceCopyright (c) 2022-2023, NC_Py_API Developers. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the NC_Py_API Developers nor the names of any contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. cloud-py-api-nc_py_api-d4a32c6/AUTHORS0000664000232200023220000000115014766056032020012 0ustar debalancedebalanceHere is an inevitably incomplete list of MUCH-APPRECIATED CONTRIBUTORS -- people who have submitted patches, reported bugs, added translations, helped answer newbie questions, and generally made NC-Py-API that much better: Andrey Borysenko Alexander Piskun CooperGerman Tobias Tschech Scott Williams A big THANK YOU goes to: All Nextcloud community. All Python community. Guido van Rossum for creating Python. cloud-py-api-nc_py_api-d4a32c6/Makefile0000664000232200023220000000347514766056032020416 0ustar debalancedebalance.DEFAULT_GOAL := help .PHONY: docs .PHONY: html docs html: rm -rf docs/_build $(MAKE) -C docs html .PHONY: links links: $(MAKE) -C docs links .PHONY: help help: @echo "Welcome to NC_PY_API development. Please use \`make \` where is one of" @echo " docs make HTML docs" @echo " html make HTML docs" @echo " " @echo " Next commands are only for dev environment with nextcloud-docker-dev!" @echo " They should run from the host you are developing on(with activated venv) and not in the container with Nextcloud!" @echo " " @echo " register28 register nc_py_api for Nextcloud 28" @echo " register29 register nc_py_api for Nextcloud 29" @echo " register30 register nc_py_api for Nextcloud 30" @echo " register register nc_py_api for Nextcloud Last" @echo " " @echo " tests28 run nc_py_api tests for Nextcloud 28" @echo " tests29 run nc_py_api tests for Nextcloud 29" @echo " tests30 run nc_py_api tests for Nextcloud 30" @echo " tests run nc_py_api tests for Nextcloud Last" .PHONY: register28 register28: /bin/sh scripts/dev_register.sh master-stable28-1 stable28.local .PHONY: register29 register29: /bin/sh scripts/dev_register.sh master-stable29-1 stable29.local .PHONY: register30 register30: /bin/sh scripts/dev_register.sh master-stable30-1 stable30.local .PHONY: register register: /bin/sh scripts/dev_register.sh master-nextcloud-1 nextcloud.local .PHONY: tests28 tests28: NEXTCLOUD_URL=http://stable28.local python3 -m pytest .PHONY: tests29 tests29: NEXTCLOUD_URL=http://stable29.local python3 -m pytest .PHONY: tests30 tests30: NEXTCLOUD_URL=http://stable30.local python3 -m pytest .PHONY: tests tests: NEXTCLOUD_URL=http://nextcloud.local python3 -m pytest cloud-py-api-nc_py_api-d4a32c6/examples/0000775000232200023220000000000014766056032020563 5ustar debalancedebalancecloud-py-api-nc_py_api-d4a32c6/examples/as_client/0000775000232200023220000000000014766056032022524 5ustar debalancedebalancecloud-py-api-nc_py_api-d4a32c6/examples/as_client/files/0000775000232200023220000000000014766056032023626 5ustar debalancedebalancecloud-py-api-nc_py_api-d4a32c6/examples/as_client/files/listing.py0000664000232200023220000000104014766056032025644 0ustar debalancedebalanceimport nc_py_api if __name__ == "__main__": # create Nextcloud client instance class nc = nc_py_api.Nextcloud(nextcloud_url="http://nextcloud.local", nc_auth_user="admin", nc_auth_pass="admin") def list_dir(directory): # usual recursive traversing over directories for node in nc.files.listdir(directory): if node.is_dir: list_dir(node) else: print(f"{node.user_path}") print("Files on the instance for the selected user:") list_dir("") exit(0) cloud-py-api-nc_py_api-d4a32c6/examples/as_client/files/find.py0000664000232200023220000000115614766056032025123 0ustar debalancedebalanceimport nc_py_api if __name__ == "__main__": # create Nextcloud client instance class nc = nc_py_api.Nextcloud(nextcloud_url="http://nextcloud.local", nc_auth_user="admin", nc_auth_pass="admin") print("Searching for all files which names ends with `.txt`:") result = nc.files.find(["like", "name", "%.txt"]) for i in result: print(i) print("") print("Searching for all files which name is equal to `Nextcloud_Server_Administration_Manual.pdf`:") result = nc.files.find(["eq", "name", "Nextcloud_Server_Administration_Manual.pdf"]) for i in result: print(i) exit(0) cloud-py-api-nc_py_api-d4a32c6/examples/as_client/files/upload.py0000664000232200023220000000140114766056032025460 0ustar debalancedebalancefrom io import BytesIO from PIL import Image # this example requires `pillow` to be installed import nc_py_api if __name__ == "__main__": nc = nc_py_api.Nextcloud(nextcloud_url="http://nextcloud.local", nc_auth_user="admin", nc_auth_pass="admin") buf = BytesIO() Image.merge( "RGB", [ Image.linear_gradient(mode="L"), Image.linear_gradient(mode="L").transpose(Image.ROTATE_90), Image.linear_gradient(mode="L").transpose(Image.ROTATE_180), ], ).save( buf, format="PNG" ) # saving image to the buffer buf.seek(0) # setting the pointer to the start of buffer nc.files.upload_stream("RGB.png", buf) # uploading file from the memory to the user's root folder exit(0) cloud-py-api-nc_py_api-d4a32c6/examples/as_client/files/download.py0000664000232200023220000000073214766056032026011 0ustar debalancedebalancefrom io import BytesIO from PIL import Image # this example requires `pillow` to be installed import nc_py_api if __name__ == "__main__": # run this example after ``files_upload.py`` or adjust the image file path. nc = nc_py_api.Nextcloud(nextcloud_url="http://nextcloud.local", nc_auth_user="admin", nc_auth_pass="admin") rgb_image = nc.files.download("RGB.png") Image.open(BytesIO(rgb_image)).show() # wrap `bytes` into BytesIO for Pillow exit(0) cloud-py-api-nc_py_api-d4a32c6/examples/as_app/0000775000232200023220000000000014766056032022026 5ustar debalancedebalancecloud-py-api-nc_py_api-d4a32c6/examples/as_app/talk_bot/0000775000232200023220000000000014766056032023625 5ustar debalancedebalancecloud-py-api-nc_py_api-d4a32c6/examples/as_app/talk_bot/appinfo/0000775000232200023220000000000014766056032025261 5ustar debalancedebalancecloud-py-api-nc_py_api-d4a32c6/examples/as_app/talk_bot/appinfo/info.xml0000664000232200023220000000207114766056032026736 0ustar debalancedebalance talk_bot TalkBot

Nextcloud TalkBot Example 1.0.0 MIT Andrey Borysenko Alexander Piskun TalkBotExample tools https://github.com/cloud-py-api/nc_py_api https://github.com/cloud-py-api/nc_py_api/issues https://github.com/cloud-py-api/nc_py_api ghcr.io cloud-py-api/talk_bot latest TALK TALK_BOT cloud-py-api-nc_py_api-d4a32c6/examples/as_app/talk_bot/requirements.txt0000664000232200023220000000002714766056032027110 0ustar debalancedebalancenc_py_api[app]>=0.14.0 cloud-py-api-nc_py_api-d4a32c6/examples/as_app/talk_bot/screenshots/0000775000232200023220000000000014766056032026165 5ustar debalancedebalancecloud-py-api-nc_py_api-d4a32c6/examples/as_app/talk_bot/screenshots/talk_bot.png0000664000232200023220000023527314766056032030506 0ustar debalancedebalancePNG  IHDRGR ?iCCPICC ProfileHWXS[@hK "5ZIPb {YTp6tUD E be],ؕ7)̹3@G$CB)itS@@[@Č /D^qj  qA*H\QʛO)I1@K xgqg^MB 6T8q O/fA ~<5:ľxClmDK?dM3cXȊR@Ǚ<ɐ+XUšq9ü̝.* 3!ք'dKB!st vq!68X32l A A_,6dYL#/Md*_g }L8;!b E(U!v,ȍW،)fE و%q- CXQ88Na__04_ls BڸYp.%8/H $;/LW|Q^7Hy3] cB x0&A'^ ǃ/H`@t5;yO01|`F$zAY/A0+:LYolD.xq>y^"%CFX0P*:p]&=A[OOCaGv"d]?瑪vn*\yf Cy [ڱ9(X քu`Ǥxxu=!oqxr4NNN_}w4`MM LEBH &V@t:seppw.}p0Cqå|K9q AH`p0% ,k@`Qp%p ܁  "4D1A,{a H!H: 2!+ d RC#'sHr y"O(ZjB( Gh:-FKuh5m@Okh7`XX4ebblVcX >DqCDOgK |'ހWx?@% ^6!EB(!NýCxG$uDS9% z bqD"I>hTH*!'&.zHLҔJʕv)WT3YlI"GyiemfEr3EbM$Pr(s)(uӔ7fʞʱ9*U~QESN2NETe [*oT՟F-.POQS?TU٪<٪ U_,՘jԊ]TS'[9+PРiֈXK3Mf&OsVSh͜Ƣqiihi=ZD-k-VVN~mMmW$ڕǴu0+N2:u>2uut/+ի׻IBQn`gk0`iZ#GpG?!jhgg8paထQh)>ccǍ{Mh&&&-&t&=F754 5n14lfmh6Ϭ9Ŝaiڼռ"bEmK%2re{+kdVVϬֵwm6~6mmml/١vnvvvQ{w{##GpPq`:9:90yerwʨ)R RMiicƮ3m\ɸOn &MLKP!x)}nt|BMam񤩓DQdk& MZGCb#EȷÔ)jLNf7mſMǧs01wƃ̙[f!2f6`vϜ9;R}ӼO߼h~ DD\rcMEE]_Wz̩uݯK3v.s_q9qp~+vXYѪU KW]3q͹rMk)k%kEkZo~/** WpyƺMF6},|sKȖjĭE[lK㷚˶!ѽ3ng[GM.]jZImq/ TP^l/+|_`;hyPi01)pfCG8jze)l)n8!:w2։wNy:3gN3[=z/4tuC =.6]5e'^9s}µk]߼1FMgn]t9w wK+ox?ݻ=|0GG/<ҳ IS5Ϝ |J˃џJj7ovu}:3p]K?S򧧟|!}YkowE1G+ffzTh|F+? "?OX~Fw{lmC ..u&;WJ 6|Ϝ?s _f|HNeXIfMM*JR(iZASCIIScreenshot pHYs%%IR$iTXtXML:com.adobe.xmp 1400 Screenshot 964 2 144 144 1 @IDATx/A" `{7k[LKXbM4{{oAQQH߳{^n{_X9|杙[=3gνbA B@! K)-~[B@! BՅ B@!T# »T:'B@! «k@! B`F@w^uN! B@W׀B@! RR=B@! ! B@q4~,B@!@ZKk^-h ! B@!к)`_)/GnB˸hUoJ{ iO4);6暡uC4{'ХKKr/B@!  #Шaq* *F[;٥xȣ eC!B^h6>|x袋¬YԴI:@= B/&l{=;:Q?[~B@! ʊ@ܗ "_9'TApDyqZyrμвuЪum.\BP#˴+S$v^odWgUW  JtN~B@! ʆ@ oU ]3Ӽ5ҠzUF\ZZFrYn9s(T6WZiйsgko+QFsZwy9FmuH9rdرc۷7eʔЫWЭ[7Kk۶mӧOСO81}TY>̌>{5! B@#P/;0?d7tTE=#j\hժMYC'r-aƌM6a^${lj7.|aذa]v_~]~\FNFa;0gΜNmY0aB8o۰'x"|F?##?SB[!! B@EN‹gR|mWBwv-Ks }Gꫯ~[.}zaon.l g},0lqK1ctׅx]Vy ؅5w]w]ìueŒ|*{z}1B@! -"^J )?/.f gn GJvox+ߙs!$;~M\B2 q2{|4|B@! @eİe!-d s ]V^+tv50}WKzItsZ*\B@ȳdڤcDZVTJ! B"05rZR# ٻa6qB.=Bkowj}sĈm4j D;ڱ?%鹗nVJ/nKͶs! B@ 5 V_񶳦N SFz]mo! =!L0g8- חŶ|};$6r[γe<=-ϗpj/B@!P" ibuCB\a|9n1wV}XXE1E6qA5y['mqN! B@"t%wfnqHЭ痼)? c= zj+9d_6X7_@:B@! A t"Ӳ"8bPh۩WXiBv 4Ltv!U'2eږ! B@R!]hٺx-D'oV\s;[lirL̘jۅo̜ZSȡmB@! (y /-s 1e䯇EuGd^A7sl25y NMpaB@! X5Ѫ*Fk>s8Z} b`AU?@Ѧ]G q(l_Yk]⯬4LB@!  oUDEAh'1 O VmYU$+8*ۺ}Gyx t! B@F!Pm9rl#Cb! ŤSի?+׷ub,B@! D 1B Zί6XoȢP8-)/Vn~0ˇM=y-&4Ifqv{TY^>d+٩ِi3+m8W?!u Һ:B@!!P  %fƃ:)kr6m3+rd18hT7JOwZ>mL5q׵+A!"mƾN‹獓B_SRxb( gώai).B:KZU yH<{燙3f_V@Y¼yjA٦MJO]dFWұ#1 /2f :tΩ̏z[6B]\o۶mh׮a!5Oi1K*ԥ`6֧^uci ! ҅@{PCz i9!Bڦ}[qoBv-V_]B|駟)߿XwuvmVXaZj7yENWNP{yZ ki5E7n\[kڰky|^wuG ۷7 Y|?6t\?׿5t=\r%F!g}vrA:wnЭ[\z0Ç;.k#?†nh}|wŸp>cmnV}guӧ] [ouu]J+d!)6̰ҥKVӺ~L7{1.'uQ;(gd76{q@xnFv]PK{! Xr 9sKOeR+l<~>z(:܉(mlh["(BU6ʘfdU{cBp [8(SL W\qEp嗇?ܼhC W^ye~n6#ԉ]B0[c묯cm4{[l>Xb+vaFԇ}_:kE `"<~9a#a@8olx )STsx}r 8˸=v}ϊCYܳ)u 'rGU>Hsq\q뭶ބ7VRh٪ު8֐ٚ]ۋ1VirҾqÇL8#w={4}x)dML?҅^ڪF/U<3u԰k'n/v>ca$ 4q/b۷zelԨQFv4bZ_|E`u{)d_ _}Uk;c=XSO0ߓ9QOe]kqXc]-_?Q+{| n?l^ogĈ_@;ude?{w}pfGbѦ&?ll 믿]7oJB@%MY-, έ^"g!78Xx{ZoG7;u ڀc\bFqu)S=Nn-iC  TzY2珨+۵C7HeO7r7^xg{<l 1n6_=&$xOyxt3G <\IB`G'p<F.7p 1T^xs<#p*9[o5?4'N?\xVܶlzC4s'QtR!g'E~s)T(8^J mbC-G R CzW$]bL!\aDs9v A*OEEoo1tU?~y?!xzOz ! ͆p~r~o` .C:JF.b '|/|;QZmi[ | ՌԧO% }dww_sαǧp@'= pS:'u<Cd+<c!W]uy 2JX `dG_)d/~adx2|g,/v/K__`{xy19GDŽixYUo'7+EOxrmXZt^(_'|G3A1:cl^iW^?p#=kJ5??y>VCe3& V _cvB@!#D{K>o+qñ{ +";wpCoqpm5+zݎoHyˍs~_ )ut8/gS.q%|b&#z:}jBy,@Tb v>r$#.:'BNKus1r9d }yH V'I?誈DT`D>]n3zs[^ԯ&G~YhŲ8 d]ev>:1&4<O݆3tf"I" 6&RB@,YB~7 SeR/8xN+GW;0o|kN&ϔ4[7^LM(]ެ6좧I1(hRMGN=Pd_~O ľ0x[w4&|݅X^7ClL /<˦"qdBgŖ֡|zg0 &)W\Xؖ=6 <3l:.D<;IuN<:BJ `H}8{[cP1q?1˂ctI+0-2k 욐rꩧB&u212B:cyV95gN;4 q/~V΅B`F jq\8)x3e -sPp>~> a%&s0ɸDZ½!\g[*S9=gTJ`oYj)dNo8DHfdc)'J|OXym5!\ xw_hh\pB ac$nyY*N {q@z ^x1xSq1:WLMKY}knu!Llc3A';Cp*9'?1;!`?ACH; . W ׿yxYqxBQJ2%DXkbX—hG(i2}qr 9%xsϵzᏝ⚷<`W&=5icĽOR¤?ˈէ*ݳ1\cr! Ҁ@5͋ѡ1V:0_ .& qbA4qQbK>=9ڃtaxsYzvݖV]'Kʸ_#(|!(B^xLt/'<@ڈ?A!d E_:AOǛ'CֿE䂤 z؈ Bv.2xZOh4n^XKPgz!<&@Ts}h 4yy*$`e&O˾K ^˫u@˥CH&yƀ?\VS RK<-ˆQo/Oxyk!k}A`ٲqk2cn!Mϊ~x}SxuxLdyF'cZ>5C +#nB@!!b|`u湿sdjT܃ q.pV^^/܇m1B i&nSc^E?< p*ҚLqՏs3@TV)P@D !ƅ℁xJϳS(C4I ɐEX}tdAx n]'z~ ^_7˳OPs?Σ6[Cʺ<-oVzpe)v+go'+O^<[^B@!d!o :9zqyYz{IcC;?s_OaM'|z]C}8=n|hRKxh:x  &" a"́,q`s`#KɖzV?9{H}'ѕG}ΩNy #ؼG,ㅄ泅Yɖ˶EBd>ӾQ2LOz*ع|<ޯbvP6|Q&~ ;.~ɧrپFY9mdӫs+K#A>s#]O+urdaMEݥI^O#cC 6Ν\X=3 e2YV]eLWܧiM tϣ>ˍ~|t)[`X!Ϯ:fɩɷ6|B^]J}p:t6T'3#_"K>M?_V=.2Ax`HozQ`6%u\K*[! D 6)ӑq~'oJZTLEjsl 9E6 ! B!6-B@! XX*?<č B@! ("eVB@! "adB@! @-R,B@!m B@! ʆo٠b! B@怀os B@!P6Dx ! B@4Dx(! B@! [6hX! B9 FA6! B@ ޲A+B@! 0 A! Bl Z)B@! h6Q B@! eC@lJB@! @s@@9lB@! ("eVB@! "adB@! @-R,B@!m B@! ʆo٠b! B@怀os B@!P6Dx ! B@4Dx(! B@! [6hX! B9 FA6! B@ ޲A+B@! & BRW~zJ/fkڔ%:RM_#B""| "VSEEJ,՘: kix zlQ 7 o]5Jck k)ϟi lw})B@! ʇ@ oteee` s[li[֭[*tʞ˰gK cbNuǞ]G^:WTwSlW'1c]<-=#e5?^SOV[-;6L:5mJ2+m$x=?Nr;2s2[oqb G! (/]J#\#dDB>\)9sf2eΝ;N:弾7:iҰa}ХKk׮裏믿M#NT4?Ȥt'7i0{pKy~e49J #r9RIai~Lg̘|Ͱ=zTIoOw]IONj{_,O?tXk5- ;snEvڑ|.׿5ʧ)#B@! J@ ԥΉ#7 &ɓ'[nVlᆎe ;[=ztDO {G|÷~暰z뙷!#'iesq駟O>ļx$ȓѓkx==G{~yyOH k~o^il" FIV0ˇ;ڃeщ{0Mc:ˋtREt9p? g϶zgs<⁕|=ClLi‹]ᄏ3lFᥗ^Zk- z#^F1"ᭈ_K)ad%#B@z^DrsnęR]we.q:!LkkBB pW^ye8蠃 =s^{eb!dY>tol?|p#/x_~?HDo5X꫍ :I:DI/h!GIxAzX;!8k<xE(rO/7矷`aR08xh{PmE~$SN GqzWW_}$D<3L?مs7 a! 'A㡇Jӧk6x1NT_ >[~gwM7]< ʃd3:t08K0r$a k'(!L2SQQaA&IRc|>h!^ߟpQ Z@{HTu|S$<~p1DŽ 7B̒Nytyo6c Ͻ= ޽{zۀ-<D]3T@\!/=86w\ F yVBBRn B@! J@x僈A B]ě3v<@.'p~*o6 &Ii9 ! MG]Fy ͚7tBB T MB{=}-'i(] KX!a{=H q. DA/mat= O.bh=XMI$RՇg!dŠ{rwn3uꭷj! #H`moק#7E%zĈhs,(o|s+B@Bk\$%9l8mxnCZh > 4$XWCNr()_(QO.ęP].?G/15㤱! ^,r䳭>i_~ar|]$0l'Vlx'51Mmi)&d6!"0hw2:cV'Nk8a𱸲 ? en%d~\?6 <0x W!Fs&R1e"[k&pppIB@" 1f|tж1AfŇ=D;Vf`Շ #!WD7Hoe|xYC*d(<4A]0x[ƅSIpx!d?0G4͠Iv79:#>Nv! BZ "ÍŖ YAHd4:dvܖ.t`/5YBx5jP.Y?=9#***aEO(9 LbD( rqu%dD'Z`KH,<=E;׈v `C}L LZl#؀Mi_]1"_σ)XB|!IhW>lh{6 肀cC.66BjN<3ʤ?C P6/=}blYp ` [|w_ 1ӄc66{k('R &B@! ʃ@xS7X fʗod@\)nؐ,HJ:1+gT#@pbʹCCPNt+GKrA²,k,ЏnS7fPl^X~"/_>6rx?اp iy?rV7*sכz|C3@='%^`\<}JuQ_)gR$ޑ|W2f|S*m?[RBĄ~}KpDe8qI9$3.I{^um:U^WTT,dͷϧ/-mr6gRA}l|]iXmii:^P4?N:6t.B@4 oVJZ^'x>#WXfxJ C CP=byqjȧǍXwENݖB@<15K LaOok3 <5W匓~p݂colbR+]㵁љB@2!вd2++Q:fB } 6"QE!EB@!y /cU^b eY/ֳ%; vX;€l&WNn}xxiێ ! B@,;">uBY,c=f5DO?Cz,%F>_uI^Fܖt)_! B@CvUY.@XAtF|{B"cDD! B@ &O+QM |%m!w 5|ё )ye>9 ! B@4<J)a xn!QH-]:I>Cb?pWؗҲޜR3ϴ y4b{Y۔B@! B׉fN,/zAl!`}Ώ^h'ܥ-=B@! (ξ9&P=7 BufkTOoVG=M- 7L 2f/_ ! B`F 8!́j,ðy}J1^v$~|Ov!ݔI%aQȞNL4:@1"^]vw Y߲\Y+B#>B@! nMp 6x eIVxICR Ky[rl)yQKiLb?)׍l*)$= .dwViiRsa]Š-#! B`Q"P9Qe$8X9PLϷ(@>R4z0yЯ_?7#G w]B!ǍƏoKۋta2ʹV[m5u^Ӿ HwZ3f᥌oi:0MVr@qiRFvǖR%]B@!  np". U|yb7쫷?s*{9Z\q8{aS=)Îo9# ?<y0|ХKPơx 'TX$2X8XH/{N`i0T2dӓO\/r .B@! @1?Spsyeꫯ>,ufmFmd]ۆ ~_=|a +o =5>aرasadQw=܏4B3(Ҝߜ׋ '6 04!>˹p^=$ͅ&B@! Fs8 ApBvǾΊu\yeNnF9tP .>J@IDATFxn駟;{7ި<\olUx}ɦ7z^7ASY'^>WL)Ӊ5@55ʙ NBB@! @pNGB(.6D:<|qA8w[n ]vYnbvyЫW/{M{;l:G!_}ynj6xEv4VLxi8#MϗͧL933R}@y?:Pr.B@! ҏs ! pB줘Vx$g yfL6=|?|駝fx{E' bo߾xc#J6Q7XNJ:ql$f !VVyܹV]&[~ ĵ~TZd@KB@e Yq=b:FZ𢋌{v2!˭',vmV[mem{**l.7dI繟6|1r<2݉o՞nqê8r^$Fv#դ7^{:kxk!B@,+@K9?FP]g zlI3BX 1!|2=NJc3<3 Zk0b6g&dcoz14tb|dOZ:aњ|Jp;\g/Fl\¬ym~|ѺmhզzTB@!Ed/.ē].;O>^xᅶqaN e HxD`7l\,061l|L^.bwox҇6;5|m#Bƥr"PY\vƋzm[`fXՊ{1zTG! (-Y㏇>:V_}u#„E|OmZBXN? @!χzrGbi{Xm,'yaaނj 1ȣLm&?M?Gv ;U"kӟ{&m/E|ĂIs4f]YnڱMa-ӈ0vnUXXoP 7Ϯf6իEۛSXcC醆?cɓ O}X'B@wß\<.ڻFu]\sMkf '?ܖu{n!+_d;cl"E2I3tYuaTB뤖=i)¨~W5zu͞i XnOjժc^RL0ksaE<':^UW^w喳W^1P_rF W_}u8#-v9FN#B@ûw^C[)c{8jq |x-|mXkb`J ыjl6xQˏ}S&1-ċ*Bg>qEEic 7|c8xiǜSV\Gu]ʻ>o2! Bp/FOico1nVsif܎-Tp/ZYS'bqmCڒe4Ζck< "J]|BxeV[V]'qގ/^5!.$\W텀B@,4^Y.1-X."* i\2L18hPkdLX: uz!t׮]UD ~r-m%.̰ݻGzC~}0mڴ?3G:l&fRLޞB@! @GNtcprW ih*)'L[^j+2TΙCϵmaa~\tWHY$k]|F,{X,{乚 kS׬ǬJ>^ 7tS ~7x#z mH/@ L رcٙ7x-E^^+m! B o)FG W&"Caa7 U'yt]{ 7V]uUkpA:ywuW{m$2L0!\s5o0QGeDo9~}ꩧafaoy3!Gu=^x_! B@,F-Na$χ1I_ -anOF'x<5|QI3LNH/AFN;L{A†nhdze Lg]B!lHl@a &5k!HȖ! B`! oSd1X jJXj$-cSŒ ն8(1w0'¼Y;:䙼',˺@: [x*'=iGe۞ ! B`IF@LmղZMՄ4di2:B@! h2"Mmd⥕+B@!Pj-KB@! X.ՄB@! C@waB@! "u\X] #-B@! 5Vpxj ߝ?~nT=kjUc#Ȱ ~L|ϣ YeY'|/ӹB@ōorW e6gΜW_֭[۷oܹ,ѡ'')iɦy= q/BO,UYۼ<2nPމСCÄ Fm:udm2$|7aܹsÆnVZi%K>3՛n@UVʡW^Kz%ubOdžt#??qD֭[v!L2% <8lX묳NѣGo'|bvaҵkװꪫݻ۹~B@4GDx J",pz(@ e۷7u]nfdÉ{(qz!#98 toӍxy}osםsLV+q.ϱ=< ?=7"ۇz 9LA ɣ=tw#/kD2Gȱ 6FiG!fӽ yN,_{xwy>2zz>lSO͕3>8"O >hzs} >Ǝʞ~k;.wyɇy:yXھM7.{g mIB@,.cR˂f.7b7cdžNWŐ>H///~p@ gpFxe rq|o2v|ᩧ x~B޽xNxI!!~W_oy@^#xNjrLt!}vNvyghzhD,!)i;s􂃓?H]T2E O/vrcl}?=uiux:{sFmobx>/"#$Ryx!`qw7ܰ>uM6$<ӆafDA!nBbk(_{g !xyb[a¾kD9=o^! Fҋے%}' q1ϭn?!5bzGثzk뮻.@.7ly{96u{NI$>o!3mCl>ork+^?FXf:_}F6хn3H,-!?d+zj_Z@۷|sϭ8N:ӌyh:a32L*!xkNj[66?V'&1zh" <bK' `3Os2\s<=$?~5CD}~'*t(BY ۄapr "~FH@R! ~̹0˿_㱄:r<,^7c'g,rBIO~Rixv19N.>^Q.a'#F0e==. &iFd3_]؊X]?R{hl.{/g pJs}積T'~9Gz:B@!8mNP Ul,A!v4<`L2|BGYT!E&-QNB\!76JJtBywBOCwۂ)fm\H^?䣏>PH|4 q=vRC|(mC/ViH 58V'v.bF}&$$L@K{@BY~3 R}/]H0\HHnCY1am\b]OT37MY^2äD<\Uz! Xl1d7 i@BDxս^{{&jr˫~^2 a;cB^;e$D%V>x vb&?{e!]x_!dxzY?ڐ%" :Ƕ!8_ H5=XD<0? wN~Џ'kxii? V'f\õ t;KuQzpBغ8kCD! @sB@^F#ޤ߮kry5E 숣er+0 l&Bt!>,Z&|uYFb^}U# bbAbx} Ab1dO;^ 22rA&t ^:av CDe 3]ㄱJ o!P<<vvΒK+DP,p+Qф?e/{0=N^!7E;ZŒwY}dCVCKڈAlG_~1+M1cc%lHcb , "G'x`^& f7$o}=H?دs9p~ S ]qfk47 ! X\7EgxˆKQs"mlp Ƙ4ZE;fnUةWpD[gB?$/.D\QQaH 9u8@>!xy] E/},2сRkvHA:CB>b!,!Ah  CϨ%^ (}qYFV(&ͣX<_So4$|x3)a;ucƣ_R#XH.x N])ff2c='>$H*#>!<0ތ-ʀsM2z\h )g=裟/GZֆy&y ?k=.V:p-VXbeU׮.uec> ,OWW_z?-hv[fى+DvV 9(l; .+V&Ұ>~wFv1뀓X{$-fxRO.p;/G|dPs}V.sN68gɣLc|Z7_NmNە갼jew}0[xX)ӟSiOKY;ʹNRg:ٶ^*r 8wD! @sC`2~9nJ]kgǨup֫_mXW$ |HZ~Cҝ՟b: Kz5{)16{:A^XS}9tn쏷B`YE-/ӄ7uʌaիճCGa߭1/vED[LҐb4G}B@!P.g }nC !z uK~zҔ.R ! h +ѭ:qmȖ՚P_(ZyMZ J+V2B@ZU"p9jASŸxX-80 =v^҄B@!PU8HJRMxY'%."֗Uz(Q! GN7U,IkQ T^&X4eP! BAM(Y ƶiF_1F:|!]M6%B@! e@@ JB@! @A@,B@! ("eU*B@! "g, ZR|낍.p`XqOlS>ezu.B`iA`X=׿󯙐fǣ#%_}/{'A7^x;4X43)y`u)G(_,*Θ1#̝;7t5DO8ѾTӹs H: e͵e#\KYqOBu{}/epݻHD{駟p@8k i| &Mrԥ=>xGN:切G{챰~޽{[>So <@s=êj6M<9<᭷޲6l3ٳ壏|Fy>}:cS(*q᪫ f ۷T</S<v}s걥ўCYz/~曇c=6geҌU۶m-:T%$?:qx{aئyT{_Z[Uw $}v X~k믿n cuXF6 80t-a뭷6}:u]\C|}s=N <0Zkl>o9&zÆ / tyGtzvG! @|[ZsS6mZK;~ӟĔ{ 7n-|gaaСa 7#G?>|V62eʔpazH~ Eh/c ƍ 'tR?~^·~ky_}3&wq} kf8SA>s+3vHÍxV/7*$?[n%罆8N jvSB_C<=m99O4m vzĈ[>Ao~'c#uzO0`pi@9cj|g})| =^0bCҖ[n:( B}6>|mFJ!{8]gm_wuaO?яktvupZW˞kk駟\SC Hӭ:`xr 'x.F vqǰۛmS! @ ި:$I䂍7xA$" 7 z,/z7RCMI( QE) ?D*R (" + @lsv{ooɲ余sswΜ{Ǽ믿ޮK3f(gdDb$p %#%#%#gp3U2[2Oir|}bO<ǤEyrqbܲzI'T24yU.׋p饗zZѾ3<t;ӈ1n#~l7s~mb#a"x: /@&[`W ?ybMlo=ן"x}'|bҤIDܾefmR_ze SA$Ys<2f Abl}#sf|1Ŋu.ۨo;}tsqǹ} %BtIYYy4 qa}N(gb)G}!$!v2l$zm2OWx.a^d> cȑi72a $ nKW3e q4@ӟt2dHKҡHLq.üNx^݃=!7xBN>d@'9fv0G|8'<7f2ofwD|DD<Sf7O?СCyZh3.UM7ԉ4~s1i5lFɣ!Ґ3:+ dy"l#q~[ĺr*[_?%7*D@h/aD\{7 |2 <7`ў+/0!h+- c?Oo(/YӍHnGu@DB@tWRBXB#o42}i+c}2 ޳#F,t1"ΘPY!QS=c [m yWx /V7O4Dȥim}Bƒ|5C9" I +[ސџ^rMqãGN!TZGG?ra!Il44}ϴ/zbQ'~b1{ErO1H|/vn`$3b!!x>I /2i1K,qⷷj+WB/>(s!Vs0y 6fİ֘1ch;O%Jobʸ$3,n )*iaol裇6|::QH;B@! @&Nr)WGD@C|^lwC}bfC x<]ȋ+7}~!pxd/BXc#a !Qm7.'Q>AXQ5^G<Dǐ $;t}V`n$4rD S -ěMi"x<j"E6iaRXg?Y[ydic%Cf"=>H"bF!,6.^( 2ƜEQ"FOSO= 7'K1[F-6@ Z>=yn;J]? i#r#|vؗSN_P.2^[Fd/ޢ,x{&BWXע>Dϣ烽CvpX!̄ g>i73/󅧗1y>/ O}z@9䁥hzLaY?nf,"??-B@!P/KчEO!o6.qūш|[bD].9 C11 Q4e5uPsЇW/†ЇͤALb;Ao&FNs2ץ `DnX.}.mbqd뮻ud=H`錴@?hCY ץ^W<.2bM_8GLhCQPS}7K&C`CB:"f< ҟMf k)#18b!< @j iG `2 ifB$bz;$?~qc31XB#  F/%B@! ڍ]tUb91H[eeX7,/weKFJ,;L2BWxhN婰I{m9OXNFJ<m{9Jf\[,KfdyMcяg*;X>WV(vaS07lŭzFJmt},E옣4BXct}sF,qn,TGXn~ aؗyFK6|q1B_W^&Ms{!sz- c.v0@xt[~FnKvcP0/}(s%x=]pedޞy`!ch-OːyXjˊ1le >~ǘ:?-J_j%* (J69Kr|zޞ݄S #Br  t>id;636ǰ!gx #L|(qԙgl)*}31:Ae(OlX6j:toc%[sIJY>Sji8Y򉑥#$}l _ҍi\WC.+⃣oE=c ecJ 6 Eadasc#9eF_h>2\!v*jRSagC1*ͦ5l|V4}|xѣG;wn;vl'/^xtꩧ3fx<<5u]7ᇽF;7d҄B@! @BB@! j&xf&,ԡ!mjl%୷JK+blhhHo1bDիWK[&MJ-4 &~{1e~zy9 e]6i̙iWv;IB@! @Mrd7YȂvY3IohSA$_~te9Ń۷otM6Ľ/<=~4gΜH{w'rHp $#'pBwwM7W_}I_eUZ2*,B@!P W)^}Rc pjv̘1I=Ӊ'6h?!M>{|W~:񵯥'iȑ_WRgϞ[xѣG??{^~[nuv~S>}uQiڴiȮ#/! B@t-Vn= E˨FZ/5B Hzmݖ}]}0~tNc=WBv۴6xo0{ZvqGjcmQy;[{$&뮴馛'뮻.-c˃NK'bi/eX}#D5.&KAh[{w[݆ٳ93O L˯UzE6szږ! B@z!P2ɬ j 3L˯6^ o?ޝ'&z%B@! B^T|ΞJ sZ9r:m̪kTIlS'M/,[yz[*D! B@%w6̚"mbw\#-k;^ Պ;@1QB@! "!P%͝]5Lg,(ͷW h\^Xѻo5JB@! @ЄgqYf.{D=l9mǎjz8=jug̘[`:S=D~mosNrH׾j+7壎:*ivKݟ'nܹsL6=: 4~#?~|ZyRK-UL^~M:5MrJcOO_җoկ|%iV+;pr˥78mi6B@,a$*XIpR~_G#ӧOK~Fݎ.oT+^}QDpFY%V+f);@\x#L{l¿K'{d23bĈV/MR#tF;h#mbLA~ӟ/~>ƊAo6'Qa{5פu]7mvi̙N|.^te#8xq0eʔti9كB@xu!fmȾ3L9D'M#ʺj tlNz?Ϧ/{pZf‹B.~=m"|{KlRGqVhb%*u&e MjDg?Kӟr |_u/kz- F>' {﹮ ɓ'玈8M=iС"#ǎYg@+z[ <}4s 9/h!Qc`#޵~L3"Bry7=Vʓˤ;6J=4#"Xӌ8x]KsLs9*}2x˿%qg^Ʋ%#h/<,4\Huw7quyy3=kvO3/^5SwL u/odo~Y=.|O>ۗYMF/#^/9w}C2C?X&F'#%#YC.18&`"+(MG)Υ1R\?([o-kKM}F KFK`h7%tVlBa_xyV94fϋgKVmb-v#g92Lc6ڲ뮻;r{%򖱦݌o'uoOeJ7J6Evyg;#wܱdĵOlZ˵BJFTK%^a!t%!?J裏Qz#ɷ .nVbN<䎠 pRG?h.\L '}D9aBk1$:= {זcD4|povn馨RRi?hu[< Q<招*~`80ΰ];?8H^n(rxcq]wyrlG~8ce,09nj 7ܰd e3dx/e<+mP2V 62ƹseB@@Q@NqCH>sO}Sy*;[,%> /8oOK>\zaЛ]7H^$Bt`i.a0+ H$'S,Ys![5) k%DuYQ?{=<vE'?YH EH R>3~.vhaBTcKi.2H^KD/!olǓzAlVRhoZaԇ<2SiaqX s A/П?hl! 0DGa 9፛_%{\7]7$9B ۄ,߲gvp;i-Q~@eA[=}hkʄ盿n&%nJcKH5:(<7Cc9B"tA ;ȓ! Ir 6$~;!d8Ղr 9!|89*^mӾpnJmAvjb`$ҕ; O(ģ5'ql`g7.xQ x,=hy"E !F H[1P8bJ٧#&ZK+Cv}5 G,gK*2bqȧ9F$M1ۓbI?x\f#LFqaTodD\o?c#~pDE",wgʪ A6BI%^ިk=L`3lly?IlY4x{\}&>ùD1<`bXǂId Me'+C$hdr:8b9E <5A[n/~\~RʑnNdDl.5¼^v_!p'~a^TR~#֘k=%/&\3b|,xN-L$M>N"YډY,DBVqq@@~  Ad?j$aMzͮ}o h+HC$DCyP f&@̓ } v"P?? fǾge}!}s LOs)B`| 7}ittryK<#ڈ:k~(b!,}cYHTiM&L"} 7h7;'C<# [KEz>&խMiOQol]v1 ozL|bW Y 47n. ^lA;3̃¶|"$B@;d.5٣me*VA4r}5$4qה8y~}8I8A~99ʷuD\,&ʍI5vQ.zHu! I!,63cZ=M '^0'rJ @,{^ D~q0eKN{ '.W\czM_ X Z^c$wsW75V]]ˉ  ~텝hI;/Ȱ+(|E;/l뫮׏ 9xp;qfM9 BL~mV&; zD8lprZޞM> l!;ƦÇ{h+tĶ<0Yu?ڍs \f<CF1^ ) Hv1_N6Qo `7Óo\c5ޢ;~f&91f5ҪaԼ6B@.I'Z*8*Q;%r֛Gxve9Yk@#28X6z`^6~aY?|Av1kzxc,a“!f펇cN&'Rq#]IEbg}|y.H \E/'83XXj ")XQ'uW^y/y0-Df'^kH#u![Xb5=[.1r ɂ@:) IunYZ0^x}zbW!)B%Hs^{xl<餓!E7g1^le;r.kּe,n<'qv./{G-??48pCX%ܔ,?:,v!#(n"/E< #Xd^t W\84i!ў˟1b cO#CM7ԏ `;?9<a7Í*Ok~/! VZlvTk8<Ʊ1ڸ9~n*!P=\HwƑFxGxsy ׳[.|]^0jn9U0cdd%ܲ wm2,r[5}UZ M22qHG;O:2^K.1fF2؈0IHFL]0 :3vC,zpLx#> Tj,q.r`d}؎P>*XL> \X%42frLfXFL@dfy΅Nomxyƶ&ıJ1\mP^q1LJ~Er;b[ |YawlZ K+k00Ǫ C&1[at&dV>0cax8ɤXf-?LB㜆} M!/[2] A=t> d" cP1}Q}B`IA ~X]ǜ<ͮc\/ͬ%&W&sc"w\kxe2V:&1 ΢lp-I͑(NׇͯN;SM{Wֽqw'a^xyx;ZTq'E$ BH+n2x9X< >^z,u]b>wvHm~9_ ɣWkܮ]LaǼNE&x"(h&z&$'JKsGZG%]wJ+K0ċa@u(4rI ! $b!>O[{ˠ> ,h2Ϸv~Ӊ f7[=@9CW]L~ 5q-BB#[Vk(&-9FHm BmT$u}G'i1y 1OK_J6Tof=y{-1z֎+'[l(_|Ћf"7yECZf-U*Si76 =@֖CQB@,~?[v5f]8^p:ٯt-i.OxQp$VlYN:2!yZ/!=Hc#Zbmv\q _//R~+/RL'-_ìrHK:K,ȣJ {Îf :B@"-zzZ@Z7V}d%04`Eٮź]B@%"-/ -"E(|Pu(Aۉ^~g<I! VKB@! .ŀB@! v#B@! ꃀo}p! B@No'=12K! B>GiB@! v#B@! ꃀo}p! B@No'=12K! B>GiB@! v#B@! ꃀo}p! B@No'=12K! B>GiB@! v#B@! ꃀo}p! B@No'=12K! B>GiB@! v#B@! ꃀo}p! B@No'=12K! B>GiB@! v#B@! ꃀo}p! B@No'=12K! B>GiB@! v#B@! ꃀo}p! B@No'=12K! B>GiJħj!B@! >,;sӭ[7Wiqp:\C{)1NՎm亵/B@nv1nKk޼yzS=SkX$Z!ZC-eJB@/Lx!fJ3gOCCCGݞ={~o߾bɓӌ3R>}ҠARK-g.O%"XBGƒ~HrP_)-ʆ^\Z(uGZ2VN{S?oǶh[fNkF:thzwt+{"h㥿e$#Mz?/yGn!! BhsH`ڴiiԩNt9"m 3gN>}C|Yf4p@?r "+k&=NgiOkVlJؔ_u2yQJi^+/_nՒOu#=$Zbټ7)O_YrB@! @&ѝ8qCr{ oE!<ʰO'"I/~ o[V:4`tǦg}.fʉP&HY؏JD:t-곊-^M( K?҄_ϓXE"j%ꪫҁV^ye͙;wnZ>91g}RO /x ]mc9&پ曧uY'~QE[! BqI-oxC5ۚy!]a :J 7pCo.DwVHmǧ:Nd ?OzW!̉!cر_~b-!C8<@~VYe';'o}_z3fLZHZHjbnr}?dkcǝvr'MH駟}sI.t} /Ǒd_Jyvs}Y7uYFV[I"dl vBQVp[ QPr.!/'|^~eb¸̲Eh n#P2sp;Q?{o &v36wL_%B@! @!P:iv!]H 1 gӔ.-AVȐ`?m!HGK۰a蠃p#zCH󩧞:sAC=1׾5er|On)jW_^U{e]~F*c0kgӹg!56H:ie&xh)m~CE/瞘]H0SNI7^h~/sx" D} ! uA`&P#C O~POH?꠼I7B Ef{( iG '?Q!wO5>BB$|-t12lAv}w>:)C(nL},7lGbMV 㙄\=#qB &AGz DO5`pqƫOU>'?7PxSL)Vrs&O? w#=Tx)["B@t,@S tAi:!rA[z "Ah/> 1 |#=D a4x_!SQDD҇p 9P6B9? bK<<+;ʐODDBUH5}3MÇA< !;قAxX' ÓWҊpax"h%`Bݸ!,}.p-HB? B&$Oë >%̢6i;|pW;̋]DO*c&a^v'2c=F>d ^<_s>{=ae'ӽ`BY&rC/}(m^"c7c 3w3 X: ؊y} ! uE:A)^!AL˴:^eQa:B8 |)wEqkrv 2B9&bA^ J1$c7mB!#,brD<(!:-j0]:LRci׈0X= Hr@Ni=fb&E&+u !RNl.}c y3 ,뮻z]{ xٹ lœ8o؍8crB@a{fi% ^ aB9{=2U/73 d<0er׵*7ƪYɶ&B@! ډ@7pܲ*<.c3iqF܃Y6jw@EуA UwttRk !R\jMl+%呟KǼ|?~1xZE8I h;:[LQcc0quQ&Ւɋ(.b09~?Ⱦ.*cOk6Dp~a}y8r^t1(xԘ_LvDJ۰0 So] 6(C;1MVԉI!D! x4DsVr· 5[.ĸ(^kO;pmq_l~~t^Ϧ6 )): R􅛃lM\aw)n+mCW(\˅>#W_T]m"܂sDYM癲HWLE ! G`8W*@4wx˜@łL(-$Ç;qhk%|%=/o~PfB@tu iE 8*^qykUN<'xQ2Y\p/=F<ôAB@! " ^:GMP@Fﰗ5\{9^0f_ 7$ Dji%B@! B^4#io.]!,Cߢu7vBe ˪ |Z%*&m#aKk/B@! ZB`U( y}#7\VhZ⵭wKZx|n{wm,B@! @[XgYbj!_%Յ1%O/-a{!C Ԥ] B@! E4{SNuGR˲cTHp%xVpeGZ_ݥMOo} ! B@#хN0A$u VtB ˜E-WB@! 7=s] 苲jHG_J?1NoҴG~i7NF{te9ַz+i=ն ,Vcx~~aiP.P<?p7Y::aFڇlG_hAA&IB@!#\ +Cg3[*I|;.=鮻*4^UqƥO}SiUWu^WmyZ%b7ӟNAnF_'mVi̘1X8~g,F˂4R}lNf[RQrʃC[8%+]! @p*8 O!pj^ kF:}>Z1Ŏp:qLz %$cҤI /L~xZkB(C8[o̙NbyG>R ]kf‹8Abٷڜ<6Ƭ~^gln)lf6-_"B@# BHܥ% _]5 ;Lxd8 e߈#]׿zUtA ̇ܣ|ڽiޖKWD_Dxsu9bd Њ|S{R}̟4huޣbmPB@! @@ gD+Ctǹoy}bog,#Gi_lB[Om=r4@/#mĠG/[Z46̝͙^aW,6< gkuۖ@wmXT-iQ4B@!rs.K" Duqb{>hF0^Zp i-Hl_ٿ+}ߝv9/n馴wj..7[iHϾ29m2i%#Ä iQOo[AE\٭B KFqn ļGou& !9FB@! xDe[yB؏:/! Bk.k XKkoxGv38pIv+<[_,ƶdV*-ͯ" 7ӄ'}{J5F^ Fd`aFŃ@F _~/fqӤ=+ ә>}:՛{ vxA} ! @\51 8B1?4z8z#Ǿ'tүnNjpg4ǪڀfYᅴ}ܻiu6O/J֞dc E///TAat c@8򺔡n#?Շ KgqB a(q ] ! @\|!k~~GOpFZYD/?܆+O!U6?C3 "f6xc3x7|s_!~s"z=#F# ޺={1 A?rH/BakB@!q@z\>֦eXW Oz4ii[7N^RЗ.kRt_o|i˭JşO;4/ K-1{ 9h#vr<@=C#LfeB@!PHnWijNSw<ηܹO+cuζds7﻾n% 1 '%xZŧ;_Ik+UR,S8sQ_cCV@B@! XL[ ]`]DCia H"eIK4ZFY 29|4ztg&Ó хمRexqD^<αc6 I@q㼈R9}ݨ[7䢍f:B@! bDI&mVhb]}3sӀxzŜiƼRҿWNXxBp+@= @9٧--esZzr8iHk&Wξb^_j/R 2fW0 ޱ=b_ e&A# ( Lf#4c:ͳL.{UVGQ<^H_B@! @[ZT̪t3"jc /.[Ιې>ëCf{imBøo4g~|~/?/պ3al[aVdH`.q֣W_ɋw];7/]%=*U׮! BZxKkCeem:>w j^+'Tv3僧ԅ<9.ŨCxe1L Q.?v=~ԯzB@! Fuh>nulZSEk7,n@6@4lFbZLF *e)ג[ oPhG![B@! XlvW1֖! @}_טyy>7PήuxMJVB)́+\[(L..\@ݒ}8zdy󪎇j }AZO\ζyoiT/귶 򿵼)_Io^vQW1_B@!6rb 0?iΛȮ%nhB=iջgԫ V7G~lC믧~ .V]uԯ_?' (uIn\I 54MRLzd^WKr7"4$sѕI@eCG M2U]T) D>:* (SMmtlaݡ:`P22 >b^gMf*קlp }yA7|3?ޏ>1^k饗N>he^T4o Qq0/n4iҤ28 xӜ9sҎ;zf kq Cc3ӂP/tP.'84c)aoNR>e j} {B[NHӣh3WO5\ 6l3כ2^-Dluq{#dܸqiW6mZ:C4R^KW]uUz{PK.nQ|޽k݌Ѕ->;m&3sL'?|!‹\|qy睽ܹs_c"NӽoX 3|<Ҙ1cW_=AhtAѣG{h7|s 7oO?pBH@GOmO"#ЙsҌso - us =9q6}4^_N6Ex+psY-Y,Ik{,]ximIOZiÃ/۾c.qA@@L9:cېdH^c9&HXn |Ϟv7դ|$}$+ڋM5?׍)t ;}!]%n\?7.F$vvZF=ؙg 6tS!_vohzC ?x:묳Zkmz}A.!exiwO- iCy皎x -c!t(isO/}6DOu`zㅆD" [<GЇWloK0{ұ~9`y9yG^`|0x7ꫯ:= K! jB 'fqb Q ֔ *N9楁}{{/\yEx \Kɓ's9'}0uk`,rUxiĈ~\8!li-H}I"g/H:nox !<;~헆nF+ 9Cƣ [y啽={y؃W(SO9a/[n?n o?]J%E2P e2QP T?D! QTƄFBJm<ǹ3ܻsڟ9s9yĐŗI ?DOomW/?93<;ȶ?T $˱0qz ?OyS%om#o6 .YT:_~y9qvm[?/퓟d|Ν;'dlxLyhy"#.՗vک3lA捴?q aq~o{Bn-vZm"$‰^u-V9J\,km(I'?7\n5V|15,5X4R|#)tP#Xs+!z]N8ᄦIç>F>TX9aO432o+h"O;:˵r8Fr7"H?#:~XI֦ get oVWż*<Ծ=iS> ( /C-WUF'H1Q~iڥ>$Nd#IVH,yы^]xp mُ;6@뱏}lR='|v^࣎:#6Ke[‚*bz}ܷe,kWe\!3O͘#''cLHz Ȯ쳞F[N'N.,鿶Szxm߬iaKn'@" FZMᆴ+m[BM [^խnlHکg!]%_Dv_$BO}{X<")rH[# uNjg+!$A,C4JyE•A1KMY^e!ka6;y0]6\vwWyvwܱywyyqe+'}# EmƉV'wY os"~G czAyh eSD HFKv\Sëp%s-mj{9.T OmE$cG,Ȋ5AMQݮ<ŒDDekŜOUb\ӾqBRx }B\yF@K_'7`mD.Qyy_6C{?y-eKNQN XOvt.T]B$߷AvyiQC?[8j'u l7:W uFFvv)X BWlGJ|'4BKӼ7MQK|'@"5p^.3HӅR*)D%b ;kE2UG:q`AȐضXgQfm=eⵣ~~q`"9!H4Ëcܕ᳾J>΃Jxд9g5~O! `} R_<voN'&B?o8>R<ڨ6jMH~7ܹsbG]!#<ݼnɧG_yX)#= .]q,O:iwW_01VAvky}цydyon76"Zm+ aoѾ~qN(cVQ/@"$EZu7Xi-Rߨ-'  )e# yͰ >WVCp"N6" YC2ͮҷ '_юt1AycNʃh{EI[zTz 9$Ts3ƼVluQ^'2xYG2FDȪ ]5XcUm8Vy=#eF&@",7{Ei ֘soߣܾM.{Y-Tz6R$%yy. 剚WoܰF"EB$dӝҵK b૟~z9V} xbN"$on@"(.#:7To<"έ Lxٻ6S5EWveIe3HEmWFyctIYAX#ߞ!샗ɑخu2=S޾3l>qTF0xzj`l|u!a0dDe梾%bysN8!"wa,qnL tHt<]6NQNꁹBߵ)ʯD H\{c]éDxW=]u+țرPsͷG\W֛Y//@cwyʶ)GZydq`YDtlB\enoEb$lEx8lW"0t"Ia CCtf^`l>vo*&$fNÍ汱צFx ]oщLG}[2't )0&-ڐ@"L6[&ǻ{5'[[n o,78ce7][@J+VRS'sV._ǮeV}?sv,.zŻ ׾nX=RO|ۂo1%QFpq C/h[lz]&t S7~_KG7_-knʎ:F+3(oPZ芭2d"+c+m"2ZFJvI|Uˠz$D_,yMj>1+_>Gv[FetDfV"}WVifZiQmdA=Jry#y Yʚ՛P7wXxՏ#-ԑD2>4@ԉmWWQDWϨ1.ksoCҭ/oceb^uwg8mn[ceգ_PVK~\* =ח}ߕVvvwmf7~lzGizS+}kiD0ƥ_[~eb.J׾2!a;biMD oO=f3ꓐnaּ iӵ˭0GZ:;5 sk9`mʆw_}@-ww!]El-uUgG>.MBAe#_xuw?ژq$;mVn_H/N8vRޛDph_k"F/D _6=^,aQD 5ҊţIz`-H5OȄ0Saq_ ow@.K 4@x;߱-?ھ3A2ѳA:SZdsښ$@", f5/y^y^K}Y);PW˫ϲj7g]ntbc]:wZw1^һ^ f$a,xʌg@2q0 <}LD H!YË-%rt-4' &^Tڸ8cʩGZ6~q73se_Zʹ?,r{ܭݰ=^=Y@Gx jS=c&ă.ipwrdwyD HEAW=pEigIqXNA믧mvF%mr9'n[|唟N,h ^Ak" s%i nȴD HD H&}e: !NnVNK9|'c̆4ْg?+eW"$@"$K-D<w\:‰Ey7כYߖGO)W5RNڼ8\_O fħw,1a<^\IJIID HDDQpe:OrJ_Jv}xy.+'~v,]rČ.^z^+y:O4D HD`z Ju]\aV#^:E"՛6ڢ7*|eMPv~YgG>y\ryg{}b7-p8k7o^susD m%@"$@"01;+֥|3Rka`4x*jRf\~ϔligmޫ+?_{{OJrAx u׷e7>IzΏ#[MD H']^x a:˯imbOOfTqemWHɦ(թN~`kO}Ѯ [eOQF}eD He⦲n)u ٵ."_--$Θ1; ,CB_ڎ̠z[9Dd޼ye5TmZyE7,>i$byZ4DAdحNâmzB"=@"$+(n^grh, gqFX~Zk5Bӟ/T!c2wܶ#m>rWtu yO~j6LdKʩ{:Hۖ夓Nj:GgV'?I袋-l\uUFmT}{uIP?(]>a B5F|;(?ݱ/}Ңx;yy{ݫa MD Xe9g5&w,ٝ5cfm/ynp /[neu]˥^ڷ.o}[ַ|+媫je_UzU#Wn$ Ozғ'?r'ooD [?.~My?].V: Q׾6:蠖&K_RRvJ%*oDT kvrL2ǾU]Qq XA.c?z#>#q`aL>СMum3tFo}C쩯Heg^[g=YDw]r-F6#s~3Y> c}^6drg;ev*}{M6c^wl__8^)OyJgw޹ØH'nV> e/>Ėx`OSnnmW쯞Go  l9^+z-oS(#oN!WP%CEVۆJ_|B1/zJ,Jֆ֓_+#WZ|#L%p@K33gbz[~=iu&XT;W9rJ+khZ^Wb/yK3<CjEKcYOuYMmin)C1.]컶~"$ntu7 ys s?t~mu2u{z5/If[ur9Xмu{GXw=vS&O|cYȋKaԧ>ԓ~|htz5)^x&]w]]^㫷?-BRRqD*7OWBY^J !b|qˋ_Iz[_pM.?V׶l2/| 2@͙<[x^Ŝ#Bxb/cey_g56l& >oJ[zB{q4a䪂>-dh:7)@"$.k2.3#ZpxxЈtI6< ,7^^"FHUS->9C,]FHcehO}j+ۗ0dr<9.]ң# ]Q; Uol;/}Keze n}g>R=evhD%yw7L_1D' É%5ƙ]!b ɁU+ܾSbFFVҍh^#R=MCMf'?Ɗ dJ wTFl+ ; TFImDBBs׻޵ }P#l.JϦ`9Oh D o 3lx#ʣ膁q]3HWxeW"$2*3kjf5,чy~[8e>]tҭ6_b.XcGĞW=/Mb E[qԇN;ݯx #>C|fxBO"bLb"᤟NFa?H;wm\1~3QySۋ@W?"{ JV+rǣ{7YT $ʰM o*oQ&fFԜ#N5~s~ڎ4cNd{Akcob[P஄}m@9s &Q[/D Hmflnr ķmYv`@E+Z̬gV}x]/I:F*1Ӄq-f"D Ҷ#YBg"fKBJE1GjuEnZGfdxREs _ubnxbXe؉W=<ׯ{?y m[ׯ}k̈́⑖WG&$-z=q2ڼnW`(#BwO?Ufb.{)&%&*lIDATBc'Me[JD X.@XyiMW]>ګ} M2ʪ3 egN,H)Dv o #k&3x!tFnq؂Oq|J Z.tg8H/뮻n#HsGHCk;*qRn\WcmhR/o' b}O,MQ<7a_b(vKlH.7zyD=D2z_p$J"D|, #v~]A0م!6)3ceqgW*ճFyFaicJ`]Qp,ţV~uFǡz;Փ2:x{ǣ Ovh7ݕw\\E7Փ@"$uWWhܕ3tg.ՕDe# ˫̥ٓ 8kw1E>bFFob1KFl[eB gJ V(b Ȓ' n HcDPC dwIbk?,Wg<]j#be&ULxāH9bSk꘸lO|umr- tO-]$dp>ĆNuc>pC#=ʘie *|1$4eۮF('/qS"{v4s-n#ȼ浲Ѷ8l7nzBj^ ]NbS']€J 5D %WO`+y{ D HD`R 2-4zi8P<UJχǩWЍ WZh+)k7-xm4;TҶ~Ç~%3llVu?5v׸VOT Dcxa{ أ9&ZD0Tsr #Un+_Ў+i7 Uܞ*3t j=я1\z s۞c{?[f+oU׎\HG h!VV߄k\a+_},' C 6ؠm_WgˏS=ie" ;7Щ2tI' Tt+sA2Y[okܶ䡕9qmaC|]\ߤҷ"fQ_B_Q6}u}om?{W{򕨶|sVˋCJU|oC(D HnOVX]Y=^$4Gݬ$jyQ/]VVbK uo^?7|M' t>PJ=Һ.F3Wt+=9>iq)9Ĥp8t-R{tLpG}[[ᆯZ0MO{;wnž@j|p‚%{{NJT6*^Zv=D}}k:`blוZG-SqXŲ#\УOؠ7^!ǮK_Љ~ ݴ~nv)weFJNU6LbBo~*ͧqchꗉh;}$ GJוnnzw툴ve:cŽA}'ׯ7Z(*m3t_>=J:~q"$@"$XWKԝEtt$@"$@"p{#\ޠf{@"$@"$S9SgҒD HD H%@%nND HD X$]C$@"$@",I.ItSw"$@"$RG R4 HD HD`I"wID HD H:Ix@"$@"$K$Kԝ$@"$@"-^:Ż%@"$@"0nJ6HtNr7HD HB`:v~@p [n)z'vn"$@"$ξVZi3cƌ2{ G`p8No!W_}u\y啋 `: tic"$@",}p7\4{;ܡmYOK-X ^vem̙SV]ua;2$@"$@k\wu kYfNwTEa&1@Z.g/)@"$@"$Eu|޼yӻ:W\y{ÁSgo%Av ieb|100d ;W&e#bsϼKmAOD z{`zMFﯥH^yG"7o[#o,j% c/}ۭ޺|nT9apm,ݣqM6+rx$G.y:>q2Ǩoߏ_?<G{%@"Loc^8u`J? 'P[?nw^tEe=lDk_ZySZ7<,+:"< ;](k#/O|}#[]ٱ /.bCأ;nN/g?Y+' 7򗿼|s穾k䰯xY [G-m;7P]:Fk{P~#ڷMϾA\ pRwQGSO=]͈`[7p14<ۢoꌆS|ɤA:O?{ż&N|NB3X(a[7?# ~W#[nPGA~J"$S  &KL#);c١~3AZGI? .<,AxSH%i={vۏ8/81$?A9KgOȔ~IÜ9屏}l"xO~&FLmۗc?쉶(ӭ H:jc|}}1M<]q zn+G7Gydy{B`\s? o(7ݫvuǾ8_B ۤ$vbLcy]StHex&/ߎwD=>amhs'/tx}nkC7?m"$S ėp1ijg83oDO(1ˋu{ݫW*gqFٳz{-7VP;Ff͚5c'[4f]{l&l^Gr9?6lӈ7q5P| 6(wP[;.Ek,\.e-l+}8!q7},}~ esis1&k. Ɛ #ͧG# 6>lG "])Zk-tز`];V\V}ѧnWtsᇷ7\_r7]On}?\OuunvUşG<bS+ڗ >#v 0oǕ}!'ң$I~1:ߕ;~[$fOID`J!࿾ ' 0/ z֯\Sf8,5«>@b0uYSW3GwȜ}&'<]~<9͋ Qgq t3FV~F|8KneΝN&37͎?mͅC9+^n=}{c=lTk:G}y!e6j8Z\=T$oPǿo[(3뢺7<{VO;"T {Ƹi8jN;fb=ُ8[Amvi S?{J*a|#N$>IOzR#,V[mmd_c<`ѥ}@&>M/vB'?GQYg{HGبp1o,?ϴ"Ǐ`]66p:'ވq/4GIW`` i~s?TSTnN.!By"ЫJc=1wC;6猑es䳟lyƕ:x͈wS_I|ǁ阛D Hqe6ֻE50#qI±.?Xj޾!c%Xrs#VtX,@]AD|2F#: jǗ+I@tĆ"Z(Cg<-&{5)A^d4/FԤ!{E IUф.6!6tjځ @V+a.1~[ya8 E;P+R$lcEB'{NtSWk6Oo˜MC] lGh+H)@oa+q!k^e^@ D>]xiTj8FfE1s'/Uċ!WYG'\76CDN4$J'/+[dEl]^H BD <^)[UYݱ`>X".m.KIti7HKvȱ<ۈԶEKl4 GC4Q3B:<_و ځM6&nĖK>6ŕ(+Ɠ'ɀ[ݬj#H߶#N$G3w܅-6V|l+6[_g}vz*K~_9$l0'‹X}LmXc)f66x4NkyNZx#m|c4o 1/`#NL9}υ^nD tJ{BWs^MgTȻHN;_KöX01OXĆ*0ueqD|!~G^$1]yc%2?ei^ba0"qͬI컬lqv5du[`5i 뗉=ט /^WN-Xy]#K;W8J{GXWUP$u!~zkA͕!Pa#2kݪ/#QO;ґ?D#@.s0H.rzRCsm)8 =tb%?IWDem?t[yP_:z9oQ/ű[򖆕vl ]]]G71=f߉-sN匋0 inGv=tǻhn!~]=QO~nz86HF 7Goŕ.!}{!p5D.U돜eJ$"ץs^>Dڶk "Qb0wK.K<( `_0uYbi'?Yfԥ.:ՂIg N͑[ĀuPaS&V1nLG¹p8aCZoxLG? b.͇(3G;"H.!$/R3Td:b hKlFsuOxm-mcGٮA/b`P_dD_n~$xTҟ~ҜR'ƄWظc',]і+*]qlT™o w^$!A}t"a;ꓲYz8Re~_4k.0Lݾ~"$ZUə DQS_ꖧ̠%1 7EMdqlӆ?}DuYK^8!]bLܗec_< Dű;|"m. qQZUgbEF8d~$`t6O<@ 3b߶rp0_;6y#8WҀL OӲ8`'FB\F"/!jآ ? yA(cj,%€bޤ..~l_bN1SH9) HlwB&Sbb}G$;7!cPxԼ*bx"7p@#ʪdbGDb{o큷XVOwpL (b!:;w)]EhBmon}Qaf -eQ0gxQ q v;!qIyE/n0 hesI. Pau<csԑN!.b[$"t w5F1mn\oui~w96\nLb r1ډ6ۮGxEŇ' lyNrroa4siE O~lSmAtW&͐hW^S.blnmW#m6z>=m)s׍UWxă#z 8Y iކ$oP=I6̙У( m6{!\nZ;NTUU?HئOh\G5c1/<[l?8w:vyo5Vw c?ǼR2G'}5A'aƜqqf㷤Ɨ=Dz':էUV?ՃAus$d"?ǚ[s߄nOlk۝2.e;wn9u)}<kR6n7?썴u21wFy)?(=nZ7~_젺8#ꅾnXK5yMٮzܜ˻fknۑnz^*OvwqNE^d7ڊzaGHݼHm2q,eyJs2QΖVc~Q'En^?-rnct#oPZlk z:"-ݲq?zMD`2_߹ӑ'&'$t]*A:ex^j<:h<`E^dcqCb.vj"` u0kYIi/ӍJT^cnVȯD HD`#렫Ʈ.U 1vQHvAw@RO3.CbsB1|o;ѝeD HOhP!]NZ{1 DAXL)vD\ҽe_G$<-smWKD H"? &.-{*.?gCE"}ۛLS#{5<֋-?^Y.HD  Z΅{`4-M#m/ { mp8p$'Q6DˏWoKD H9!}  YRKzzAX4_M@"$@"^űp-ki1t^g[kWq)]@r?HD HD` 8n_ے)^gv-(.ɕZD HD`F˥15<+T6>wMq5^z!׳᜕DL40?MLD HD`#Ƌ]뭷ޔ ]n ׃8HD He ȲMxCnD HD Ly5!:ٳgO^@5xE]۝ʹD HD H ~7Ոr׶w@ 7UM7ݴ\4 ֠^s5m@=^5kVYy[0cg4AɴD HD H#xq [DWc\UpLӥIxAtb6 eR"$@"Lkxjsu^t*kO\$Z&'@"$@"0-r 3-;UN;3+n^'@"$@"0y2uK)kHIENDB`cloud-py-api-nc_py_api-d4a32c6/examples/as_app/talk_bot/HOW_TO_INSTALL.md0000664000232200023220000000072414766056032026377 0ustar debalancedebalanceHow To Install ============== 1. [Install AppAPI](https://apps.nextcloud.com/apps/app_api) 2. Create a deployment daemon according to the [instructions](https://cloud-py-api.github.io/app_api/CreationOfDeployDaemon.html#create-deploy-daemon) of the AppPI 3. php occ app_api:app:register talk_bot --force-scopes \ --info-xml https://raw.githubusercontent.com/cloud-py-api/nc_py_api/main/examples/as_app/talk_bot/appinfo/info.xml to deploy and install this ExApp. cloud-py-api-nc_py_api-d4a32c6/examples/as_app/talk_bot/Makefile0000664000232200023220000000545214766056032025273 0ustar debalancedebalance.DEFAULT_GOAL := help .PHONY: help help: @echo "Welcome to TalkBot example. Please use \`make \` where is one of" @echo " " @echo " Next commands are only for dev environment with nextcloud-docker-dev!" @echo " They should run from the host you are developing on(with activated venv) and not in the container with Nextcloud!" @echo " " @echo " build-push build image and upload to ghcr.io" @echo " " @echo " run install TalkBot for Nextcloud Last" @echo " run28 install TalkBot for Nextcloud 28" @echo " " @echo " For development of this example use PyCharm run configurations. Development is always set for last Nextcloud." @echo " First run 'TalkBot' and then 'make registerXX', after that you can use/debug/develop it and easy test." @echo " " @echo " register perform registration of running 'TalkBot' into the 'manual_install' deploy daemon." @echo " register28 perform registration of running 'TalkBot' into the 'manual_install' deploy daemon." .PHONY: build-push build-push: docker login ghcr.io docker buildx build --push --platform linux/arm64/v8,linux/amd64 --tag ghcr.io/cloud-py-api/talk_bot:latest . .PHONY: run run: docker exec master-nextcloud-1 sudo -u www-data php occ app_api:app:unregister talk_bot --silent --force || true docker exec master-nextcloud-1 sudo -u www-data php occ app_api:app:register talk_bot --force-scopes \ --info-xml https://raw.githubusercontent.com/cloud-py-api/nc_py_api/main/examples/as_app/talk_bot/appinfo/info.xml .PHONY: run28 run28: docker exec master-stable28-1 sudo -u www-data php occ app_api:app:unregister talk_bot --silent --force || true docker exec master-stable28-1 sudo -u www-data php occ app_api:app:register talk_bot --force-scopes \ --info-xml https://raw.githubusercontent.com/cloud-py-api/nc_py_api/main/examples/as_app/talk_bot/appinfo/info.xml .PHONY: register register: docker exec master-nextcloud-1 sudo -u www-data php occ app_api:app:unregister talk_bot --silent --force || true docker exec master-nextcloud-1 sudo -u www-data php occ app_api:app:register talk_bot manual_install --json-info \ "{\"id\":\"talk_bot\",\"name\":\"TalkBot\",\"daemon_config_name\":\"manual_install\",\"version\":\"1.0.0\",\"secret\":\"12345\",\"port\":9032,\"scopes\":[\"TALK\", \"TALK_BOT\"]}" \ --force-scopes --wait-finish .PHONY: register28 register28: docker exec master-stable28-1 sudo -u www-data php occ app_api:app:unregister talk_bot --force || true docker exec master-stable28-1 sudo -u www-data php occ app_api:app:register talk_bot manual_install --json-info \ "{\"id\":\"talk_bot\",\"name\":\"TalkBot\",\"daemon_config_name\":\"manual_install\",\"version\":\"1.0.0\",\"secret\":\"12345\",\"port\":9032,\"scopes\":[\"TALK\", \"TALK_BOT\"]}" \ --force-scopes --wait-finish cloud-py-api-nc_py_api-d4a32c6/examples/as_app/talk_bot/Dockerfile0000664000232200023220000000044314766056032025620 0ustar debalancedebalanceFROM python:3.11-alpine COPY requirements.txt / ADD cs[s] /app/css ADD im[g] /app/img ADD j[s] /app/js ADD l10[n] /app/l10n ADD li[b] /app/lib RUN \ python3 -m pip install -r requirements.txt && rm -rf ~/.cache && rm requirements.txt WORKDIR /app/lib ENTRYPOINT ["python3", "main.py"] cloud-py-api-nc_py_api-d4a32c6/examples/as_app/talk_bot/lib/0000775000232200023220000000000014766056032024373 5ustar debalancedebalancecloud-py-api-nc_py_api-d4a32c6/examples/as_app/talk_bot/lib/main.py0000664000232200023220000000650414766056032025676 0ustar debalancedebalance"""Example of an application(currency convertor) that uses Talk Bot APIs.""" import re from contextlib import asynccontextmanager from typing import Annotated import httpx from fastapi import BackgroundTasks, Depends, FastAPI, Response from nc_py_api import NextcloudApp, talk_bot from nc_py_api.ex_app import AppAPIAuthMiddleware, atalk_bot_msg, run_app, set_handlers # The same stuff as for usual External Applications @asynccontextmanager async def lifespan(app: FastAPI): set_handlers(app, enabled_handler) yield APP = FastAPI(lifespan=lifespan) APP.add_middleware(AppAPIAuthMiddleware) # We define bot globally, so if no `multiprocessing` module is used, it can be reused by calls. # All stuff in it works only with local variables, so in the case of multithreading, there should not be problems. CURRENCY_BOT = talk_bot.TalkBot("/currency_talk_bot", "Currency convertor", "Usage: `@currency convert 100 EUR to USD`") def convert_currency(amount, from_currency, to_currency): """Payload of bot, simplest currency convertor.""" base_url = "https://api.exchangerate-api.com/v4/latest/" # Fetch latest exchange rates response = httpx.get(base_url + from_currency, timeout=60) data = response.json() if "rates" in data: rates = data["rates"] if from_currency == to_currency: return amount if from_currency in rates and to_currency in rates: conversion_rate = rates[to_currency] / rates[from_currency] return amount * conversion_rate raise ValueError("Invalid currency!") raise ValueError("Unable to fetch exchange rates!") def currency_talk_bot_process_request(message: talk_bot.TalkBotMessage): try: # Ignore `system` messages if message.object_name != "message": return # We use a wildcard search to only respond to messages sent to us. r = re.search( r"@currency\s(convert\s)?(\d*)\s(\w*)\sto\s(\w*)\s?", message.object_content["message"], re.IGNORECASE ) if r is None: return converted_amount = convert_currency(int(r.group(2)), r.group(3), r.group(4)) converted_amount = round(converted_amount, 2) # Send reply to chat CURRENCY_BOT.send_message(f"{r.group(2)} {r.group(3)} is equal to {converted_amount} {r.group(4)}", message) except Exception as e: # In production, it is better to write to log, than in the chat ;) CURRENCY_BOT.send_message(f"Exception: {e}", message) @APP.post("/currency_talk_bot") async def currency_talk_bot( message: Annotated[talk_bot.TalkBotMessage, Depends(atalk_bot_msg)], background_tasks: BackgroundTasks, ): # As during converting, we do not process converting locally, we perform this in background, in the background task. background_tasks.add_task(currency_talk_bot_process_request, message) # Return Response immediately for Nextcloud, that we are ok. return Response() def enabled_handler(enabled: bool, nc: NextcloudApp) -> str: print(f"enabled={enabled}") try: # `enabled_handler` will install or uninstall bot on the server, depending on ``enabled`` parameter. CURRENCY_BOT.enabled_handler(enabled, nc) except Exception as e: return str(e) return "" if __name__ == "__main__": run_app("main:APP", log_level="trace") cloud-py-api-nc_py_api-d4a32c6/examples/as_app/talk_bot_ai/0000775000232200023220000000000014766056032024276 5ustar debalancedebalancecloud-py-api-nc_py_api-d4a32c6/examples/as_app/talk_bot_ai/appinfo/0000775000232200023220000000000014766056032025732 5ustar debalancedebalancecloud-py-api-nc_py_api-d4a32c6/examples/as_app/talk_bot_ai/appinfo/info.xml0000664000232200023220000000211314766056032027404 0ustar debalancedebalance talk_bot_ai TalkBotAI Nextcloud TalkBotAI Example 1.0.0 MIT Andrey Borysenko Alexander Piskun TalkBotAIExample tools https://github.com/cloud-py-api/nc_py_api https://github.com/cloud-py-api/nc_py_api/issues https://github.com/cloud-py-api/nc_py_api ghcr.io cloud-py-api/talk_bot_ai latest TALK TALK_BOT cloud-py-api-nc_py_api-d4a32c6/examples/as_app/talk_bot_ai/requirements.txt0000664000232200023220000000010714766056032027560 0ustar debalancedebalancenc_py_api[app]>=0.14.0 transformers>=4.33 torch torchvision torchaudio cloud-py-api-nc_py_api-d4a32c6/examples/as_app/talk_bot_ai/screenshots/0000775000232200023220000000000014766056032026636 5ustar debalancedebalancecloud-py-api-nc_py_api-d4a32c6/examples/as_app/talk_bot_ai/screenshots/talk_bot_ai.png0000664000232200023220000032741514766056032031630 0ustar debalancedebalancePNG  IHDRGR @iCCPICC ProfileHWXS[@hK "5Z]TB A^\X(XiQ,,/Tu`Wޤ93{(U _X( 'IO@4@Č /D^qj   q!JH\QʛO)I1@K xgqg>M|, 6T8q O/fA ~<5:ľxClmDK?dM3cXȊR@Ǚ<ɐ+XUš9ü̝.* 3" ք'dKB!s3@xp !EF(LA0bBЩBvV)Uؗ ے-`G*Py~6.G? v/d& #= :D8Ey!R bׂ8X<.H>)*ljp¢A`@$fI :'pd>pP0C#d=BxO`x\ u_@H6"<8P*:p]&=A[OOCaGv"d]?瑪vn*\yf Cy [ڱ9(X ֈu`Ǥxxu=!oxr4N5NN_}w4`MM LEBH &F@t:seppw.p7}lӡ &D\$pjpc`l|; (R}6\b0sA (l. (8 ΀ ^~|FP =0_$@b$BGʐd+RG9 <@z'CUP-BG x4  Х: ֣݃' 5}`St0Sc`,, K2116 +ʱ*k ֍aq"N\x'%|^Wx?@% ^6!EB(!vNýCxG$uDS9%č: bqD"I>(TH*!'!.zHLRJʕv+WT3YlI"GyiefEr3EbMSr(s)(Ӕ7fʞ19)U~QESN"QYS-7T*ՊOMRRTilUl z˪/jjL jjj.խYYM74h545h8Li\M#F3h\|viZQZUWS_[SU;Q{v1nLJLuOFL]bZ˺FJ}ҧoпgL1dpڠo#mN7fa8`dlb$2ZotʨX8xq^dIs6IϣM MCM%[M;M?Y%33gN1gg6o50k1â%ْamֲ򽕵UBgzlb6T?6U6Wm \ۍP;7l ~}HHϑ‘U#o880j88F8slp|9bTG}srsstgF7~lupBu vޕmBVbZ^ tJ -F4c '3sQϏ^^^vlcp|t}}vqw?e2s{/޳X3Y' ͠ A͂kCB%6bs0am*qFE#ǢcƮ{72RQE[GO>Cy;:vFl{-nbw$$HZ'&LN<3BA 1#u`\и5zJҮ?u &63`:!=)}wN3Ψ粸k/xռ^%iOgY>Yz˳,М9srw%+7 5¶IƓNًJDݓ&/(@ 4jɃ"ߢS1U8cݴӞ6Ν:tf2gnʘ:|=sBK;yNV{;?i~s<%䗚qɍ 7/ u.vY~R^2/KK:u.\ڹ}٦WصRceGƮ_M_]횉kΕo^KY+Y۽.b]zِZE@E]ay/oThsO[[nn Z_eUUhۓcV`Gَ;;wjm{Y Z#ݓ[t}}~ @AC*#SšZq<ъcǖ_p|eDɬZ'9|j[L[g9lo9s9sM._p8;;/z\lykL~O^ r*k׺'\y#FMgn]t9w wK+ox?ݻ=|0GG/<ҳ ISϜ |JCџJj7ovu}:=p]K?Sҧ|!}YkowE1G+ffz'h|F'? "?OX~Fwj{L P:tV+[fgSgRUWs/|s$eXIfMM*JR(iZASCIIScreenshot pHYs%%IR$iTXtXML:com.adobe.xmp 1400 Screenshot 964 2 144 144 1 @IDATxUozBC D! 4i{ACB/$$}=ɼ-ɖlM]w%vaiȐ!e;8@"u@Lkkzyv:ujz7SO=^z%w:\{'W/Q3Ϥ'|9ĤI+j9h_Ҍj>ybyzD .H+Rz$lmMx I?LNNbW`|Ym?V]uU/lY2Dz4p@'v}P:唦}guQ3ΠK9;={㎵B2ߩo߾e;٤El|- ^No}Ѵryҳ:Uh;vܸtwSO=5M2!W_}u(]Ֆ)4%ٴ"U_zUO;wN|RTyWy)\ M奇ݻwO ,cǦvo|}Llns@ n9X\.҄ !믿N?'tE;.^&Mvm+]zsm5$ N].8I+~{wYfm&r!SNIK-{\pA'4swӣGK!s# vZK#ڝ?[?]wԫW/]O?4}i)#w-9F~+袋:QoN/sۯ_?Cvx83kzY3Z }:裝;xmb{oFv.]xY~ -PZdEXqF88"?nLE_9S- |u%};>HH`/tږ'd +GgQ;O1M:5!Jwŗ_~/x']'S@"_!7QpӁ]EGN;%ί@ /kdwM묳,Oo99昴;y睗N:$[nş㏧Ik>t'i}k(\ /tuqm:# |oNtwt_iʄ>SS_| ל2ՙݟ#q?G%sϕ,w} ׳<¥ʫVYer#%#t|^={N\sO6la)v}˺hӼgwAn!qKFcwGu!-@zFKz|i=(u䖶r˒?5Ugk^Ϸ#(ǵ?ٷJ7RQ_FJ_K-,y^HiLy5Ϸꞷz(=]_yL2/ow%*ُ#rQ{*iFn= oVOSXsHy-MAi+I.}/-nr/~QGyKFr=1K{i|#ԞO9Mc7ey{;1 ~Bhѩ{:W @D@kg\ppX8[iw5-kB}ǯg5\N8y^>\u=B-uk~yyx+^O^yɰKS&kkD9D`y"niًKgvuג=[<LD#g&y :-!ܥP~¡|@(x);'򱁏I񪫮R'<uHbfte;oT{QILnNE"!-N!0D3 ug8Gؗ/jK*okVT_d<<-/<?A>B3 c(# OWwcH,36bK#"]v2r3//o4i^o )aG'7_!@ +=á'\m^_g-֝#]w:قѵGЏrgęӟJ:<L-O o[arܰpҚ5b/&{W[k:ÖX4 4+kM~bQ1ǎrLY{ 1v|2,4 &rJ| e-1bRnLjq\3ӟxZDc f=S"KRIE$ [I:]r%]mM #6h=f<>U=/`_6|=.FηIKDx,'`b7g~LTO/6b||z몑c7 ^_ۼYBLuVƿotNȟdyl5kb1ଚt=B#0Fέ0 v[Bg@oKDcBM<پK}SBXcaerf{lrfrݐx.m @D u.6g6vb.]Fdgp ?cVY1Zeˤ^eQ /68>}BkN돆^ -1I0yαqQz}I0d1k Baa}"v$ j, dz,u7;92Y2ϪW:iK=9yR,E=H1KqR̋*lAԆ 7$±Eq/Ѽ> $3!HE?MBy}Gc ?oDjb ܠ>,"HW#l~{_̏u4{?sl(? %JyG4+;jWX yE ly|+4F*ͫqKT-,)p?xXx@ 0o ]OLҕ,3'AM8Ipt,}8]q8~hQ 󍕺,tbVh$s1B6*^N:)zsHZh҂ˮ| &RGі͢6\j+'Y戹݂MqBˌA{~Ŝr/ۚrr]dVTxH$H)pWkB xBzYO0t "b56 A_r!xnFe-u؉e0|lJ"Cx5/=>jx-pp 7͈dׇi^I[,Y!bzث m); Ypf=P˹#7좶?rV@XalXQ=?H?TsJynFAB22*SDp`2 '[5~eb?@`B \t.qy?=s=(5"p)Վ՗!p 8v%-8ָ1g,*i!NgZ4usiR%VL&dQ#/8CC#eNV!R'/# jᒶw1,<}:!_xcMe5X>w+2?!ȌZ/N(K{ K)PG!6yjf@!FlV!QA=,fȾ*a1q6[B+XKm{}m 8uw O!v@&WVUn8[S-!2[[Q ~˲nRi78y7<"EWXa? M| ̍o+KlY2N!njuB[b co'CثX;n78:Dc%D~_@ 0fD(xO1qTBqq}ﮭz<Rr'L2{,|6#mx@7aN]n>9 1zo+C0F=cVVQl>`~%#t~t&ٍEYDŽ-gr60ٞL 3b hFfEmi +BTT=LRQ9 iq)Wf#d)[M@yu]+5wGi,YƄ=t-? mvVj/Jʹ03bKO˰d'1ne GKXwMdb"|Vl@  tmZgo!3U6&HG@ W)ӨtA Sp {>4y]c/NcxB*kE+:oo7n4`u->dg/0Fn(ym6RY>=fYxd Tq'瓍%/^_Y;^^t` Mzi;Ry!ys]!+H9+ ,U</^O9ͧa,Y"qz,GEY=Ug}'5C@ =tm0Oy3r]!te4{u &Б>8OיMO+_HP;DRr*ߔmz5. {g?F<{]Z=:Y<4ӲN1&d.k>KYq4D9Aľ2iL@u B,`:E,Y(A=]!ߩehm7zzWJ`i"_&Uq!L+VeaO<'He3Nּ6,j7F8f@ o Pt畮^o^zv 4aZllÆu ^&z7ulg۱ŪNI}[-ύ 6k5: f,i˟teAnσH~^ҼnyZmmGiGPW0gE:oJzQY֘2^!U_S*/Ey)]vS4$ƱʲT½ޚ:{CLܾZ,Vj_m6@`Bd0d4Ul!CRͲy"-V3δ[65Avki z[nͺq#O|k0{y^3j]}!oRMMΧ|l$+$S{"cvUA+JkJ$cO4o=T^;nrQƤ5LJ6q}eH.ml dJzѕסJmŊrs@ 7:Pđ$ZQU|l^v?_%˄W|˶iAZxCw|Ы߀h[¶Kz}*# 3p'@CQ=@ @`B=nN&^qS9M8>MXS'[8rQo=M_AyZ &@ @ m^p;ަ6}iWkV;^v4y4Ӛ_*  @ @  :&Ň1gk.]S5;U@ @ mʄ:S{ױBf.KȚ7dIt37z;@ @ x+DfI*S&/ff^ Nm[E=mh7@ @2N`sN"D%L]!@ @ @[#P좝]fwq a @ @ h^֒]ۭм@ @ @a,eP.~ c0nG^ @ @ RtަC䷱MYyc jm,nQ.@ @5`+*Tj[aƌi5$"[a˴U:u:ZmSm@ @ 4' FtҌQ EjpG[WwhFzC@ @ wn4l @ @ f[a6*@ @ @K#@ @ m@6?@ @ hi4¡?@ @MۦG@ @ -@ޖF8@ @ )Axh<@ @@ @ 6E o@ @ 4Ax[@ @ ЦmS@ @ F oK#@ @  m 4@ @ miC @ @"M@ @ Z -p@ @ hS5-JOԗWTiyگOԩO_cV2m@ @ FLtC)eu@ @ (B`OL&~'Mf^bvڄ ;vlU[_$/Nv۴+˓3=z>#GSz>ύJmQ?{Wy)_eUY0!9馛/8_iw*J76^sՎ&l:!;3qcP/_@ @ !_zDZ}$@Dȃ۷oZpS׮][d_D7tO3jذa>}Zj%isi5t; +|yx!Jݻ{j?CާOχ( ?K+/`NDe`9",V[m5O\~;6h#;dȐtW5X# 80!M\cW^yCB~sSGq@ @ tJAD poo͑Îz*Z|{,mN0ɃA`{ݖ]vϧ_=i%L~~wE7^lKaXOgy&Nզ) q$<ر[^b~~ۉ7vliJVdiK,QO{Џ_߉9gwGC։KFOX#r*YS$=UUU9Ic28e27u\*kGM{"N8=g$oerJ^}tg8d %< / cn2K,nV -y/+d}r<Ѕ .HsOZ}z뭗 a&~N1O580zV W @ -Ω~WH/K{">s>WXa'q<> /,f HtG]v%=N֬ k >wxTHwuW}Vت=HE]N<ĴzRp Y<<ꨣ6$B=V 4(y^f 6p|: ]˛.gq)y~N)GN;&m2={B$1.*[x @ @:@ryxEW]uUW o2w}0|tA()C~fIe/C,1xɯzYYia xI1ZKw)~>`CB@ @ hK $ 5YL‡xeBX >y!:ɾHB 9$ Fd1os_ 9(j=$'-kG/h_cnm3,,㱕+Obeуy;*+@ V@Y /Bp IEK mY(dM$^^|2ᅀ_@βP k:CC68 P{vUʀ[.Bk[N{q3A]wOftAe[-x1;0f-u!@ @ 6lRQ c(5!<|Bx]=]&yu6m^RB iI&l0ㅤaLxc2!,CD=_F| BrZya)03@ZlXCdH82*,l9Vc&TyA7,6|  ʊV[H6Kŝz&U}!@ @ &Nx!>"AY"4G9&LEz!=&cV~K24s?9?pX8P)thl!g}ګl@ @%d^x\xKHLi!%>x?HSSQ"|}vCZE+ج>"z€ttIO^/m"Xԣ̗ꓤrS^(ù!a+<{fy3kS_WYAy`~,sO_B岶~ @ ?E1^j@LhUUUyf{7m.Wnl -)a-^K%t@ @G0a0XဘKB!Ex}t@vkvE灶{b7:jz-@ @ `O♤ʮt}\[:|!.+-Mf׮z GhiQ[ }hB @  !YqxM+e^oKޜVh0N- +h+ιЈm @ 5Y"C{뽞~iWNgurbLD]۳[@ @ ?lW_/}}b h!)葆ABA-;U!+#?@ @Ld /@RyD.%5a%B@ @ B_<ݖ› kJ |"/u K-lDf@ @ A3tܸq,хN6-@>޼0!Wnvi 6"x}Z 0ΐG.@ @ Z "2+\ A8q<[!xk|5$bw @ @ h:;+\YCK_|d!@ @ @[ 0kB "~9٥^k])G@ @ PAdLe6>m?=dS3@  084[@«zh+>F{6nXyqzW_}zUWM|y-ȑ#Ӑ!C|.Fm_~y׫zl_y178-t7wZ ' 4>}E].6Btimي@ @ ho|0NxPO>,]pNw}wbY[oO>9 :ԝޓN:)=^켮QFO?==J_od#%nX`oC9ķT9Ԃ_S!$BqtRtbLY:`NZf1˂ؓ @ @ 68_8!bzGGydZveݳ:vش馛zL0.ԳgO/k 79xbC=4 80-wܑ {lZwu믟>4|p{~ߤW^9mfwz-u4{g]>fX@S.,լrbp"'mH4@ C@P|?cao}k<{)Ltf^*X|/@ @ 7q"7[qҪEuXkvK7tSz2,NZH\_cƌ޹ѣG{߽x{1kv*65di51:izsbDcN;3Rm쭮oo4B@ @ hoU ɆΠARK- zj:< b 6(>Q^z饾w'gm]k,/a- idbiy{!5=PQ]řSA[l)J@ @ ԏ<>/UnJ܉{/ X[>kM_ƨtϴ6K6 w}d|ݞ;n=کy[CZfN/R+铑SI$[T6s NM#=Nv6:v:c?$@ F$2ېnU9)++\q>!m-Pv2cLpgq|A"%hYO 5]&XU!3? 4믿>M& B-5O2&vi?ߙ8>i!*\OK'OLӧN2kaFxtͭtGR'$@ <̃@dzq#o-dY_c^jt?_cbPzpy: 5λ}hҦKJ-?F~P׌iS)֬;\-:1mռԡnKe{SH{՟yq@ s/xXYL%\2mn+2= "o.ȗ>cg{e8&a[{ymݖz_;[;~Q˯/XW_}uw[*WЌ_5ͨ%Ta7xgV+'O~pii ݣ|7=vNwG޵f@1͖f3m-/[i-HL5>mTm4ƶ@ @`]'k_xdofAVb[o )W2Am]wM,g`)+w}NpxsOcP#FH7pD:K񕾼}<^3u҃OHKv딦l?2#m3Wfk×-z'q:B`-vW֖zиKZx̆l:͜d#(@ @ &4H&ڸ7l2ьe_~ͮ@9h-^a%08E6@9<-Xrޤr%luOQJ= `$%Ȍi3_7Ky;@v6L6;sA| $|a@+<#|ޟ'aVdtGuF9>3{ {LݛE붨nl@ @}"/Ie|$qHvK>H0BhPcfS8Ν4Xj# WpFl :j!ӭ:,2M$ā ,=؂ !@ @ ~d{Q)=[hHWQ8޾%^ڠ:LTx]tH*/@ @ 6pMo 'jVg}sͭbSnx;c\&bA#3f≺Ȇ3x#a&-"ۋOʗ'WǴǍRT2@ @ 3 FY㬵Xgԥk+g-N $B ( +xl+!Oɾ[+(dveG^m @  ]{ U ^FN!E `6$мǔϦ=&?Kt9F8_|L@ @}! gkqĐʢk^Y1Ј&H @ @ !B`;˪ Fl]~fc?@ @MD!:D5Q<@ @dgN0j}y@ @ G o1.V&7@ @  b@ @ {sê I_՛lȯ,ʛY 6oƦ3'm?E)m[6%ְ=@ wIk [.kfw*XG+4ek2*zoJ^z}eҕӺ#B]>UT{MSۂƪ?EuW5{^R}L;ٶunGiv @ *!JX."Ld~ 9i--"-ǏOj0a¥H'8M<ɶ^s>i$N6-pu+(c&ki)MƉm^G!_!ܳ5nF|f#NvO>I'ѾbIsbm?@ "bi0]yt}:]s5{I/b"Nś}.~^W9knQ9*K>>J'Ntɮ>#G/ !U+‰TĈs/w[!һMҞl/RʐE:1cƤTgW_=}b%-/J{wE]x@hGy|A0-O9}<Ӿ8]'=F^sDCV3^=4#S^z[V:ڬl_C=n/?^xt6@ h ʅ"w;H\! .hZfetM .("[a?{agR~Ī3ϤZJIK>d/'<|#ٕfmS?R![nI{oM_Lw>ev7O?4P_u_N{ԭ[72^,d,q.BE+{lKAJ![ԩS[W]ux’VQ6iukwe'}ӎVt"Kov򔑎l]w((]GxГ>n8O+׿5=#@ B bxsHCp#'|2r!iV4};6]G4|)=~YnmN┉n<~_x!~aӧON& [ +a~`woD[nK[s5˘?0Bi9Oi /=}X6jԨkC 7iܘ1F:׶NaͨF?6` e}B;|$Oƌpo]ʍXϞ= u~ss*YF [ oq6@ h)5"`Wsۜr!t Zh@7I'xbzӮ$OH-K'=ibGA6v}wA$7pn9@@>˦q1Ek #Æ K<1+~9!"$"1?B2^Iڅ@!ӛo^{"i.ot(H֪8O:-~"#/C}brB )f>@zᇝHӏ_>?v"g .qgH+|ݿ:(?yL!fmV&y lMKs؁vȐ!~ i6A'z?I^ipnst7g?j.C![n3Ж`l2Fi%1N;9,a?:(CX8^3 "ܳ:+EHǶ@ @(~VEsuw;wxG:Qq$<.~x@?1pN ^_-^A{$|bB)Mӟ*dABN8 ͐/t]w^tB6!VZm.qo޷x~%\eB( I#x^y!HnVs6ttg:Q g&s;؍@@ c=wOM19|p'qŔnP""~Pvmqxe'FUxrSrOr_yg\APuopH8`>ca7hy<蠃F~3%3$Ip`]|>@nC=QxcǸۥb@ 47n v̅ ӋO-qx$!Dǹ\˅^g2@yͣ/<$|^3vÉ@).1##ܹ }1yplda|&`׿._r'Ø3tu9Mc/\cv \^sB@ D om#m\عHC*  ++E$Q0`hB _$-C6!H4eH |#]eV1x Et IԁC4!x\ Ѧ?xsL."N!Jꑇ'"%Kxu#ďZXx$ģ}Ct t~@ ʀ _Cy(ق綃מE? l01O3k1N B>9o?e%\ezX>v@! 0FN;4'&HE?01&e gVd;ZDK/, zXG`\+MfeR q j # @ ZS 7^ԋq.DA.4 {杄 Av v#<ylOC1 "a p[ωX"(}2҂G"GҮ !.GDGi+̼p }Gc= J(֔ zEnSj:8 cY8> ;p0K6n\d7۞aJl//g%ox/q|a!x!Ym*p/VQZ4ڀeKPi-r~FʠJ2q g^_9&ᤓN>bu'w 7cO7o(a(606m2"uNqMc GҶLsn>[N-^uʁn؟`7mn_)mrǍƃ;d_<`|*FX+?@ @K#7 xK!'xg%XMD_gHa%B W+Q5R EdS#9 3 ۋ@!LPC  &+A !|B$FY;^~)Ci++T?<Am!x/y9S&d3#H}k XU6kvA @ѧ{8?E gLbU =43khDT鬄U[=ĭjp!L;S&Ajx6vQ~(p)g-c8 yaL1^ @[1Ǎ'Kh^zib#ko'.B[瘛,lk3P1i y!@ @k iIk4m.<~ig'ft46iF)-ֳK}Ũ51 Q, N0B7o$ O{bV!C$Q!PAJ(O @ҙ@36t1 zxwA(|&a/iCޱ]"Ū<$ 2 a#;NPL)C X$䉌 zn#IBHG/}" #DKbXZ-[.ny3!` G? L!\a 1D<+e SMҸ'cnh3nY&$q@ [n>87Ceu) w|1NGKgU6XusS nЃp!'ù8ū`,/ 9¤5졿J @ -ÉfK&Z |]U6%^W馌/ʄ [tS@A(]X`(H36gAo#?A\3{aeϿc@ 4qiK3Wgii&f- t1H='8uj$ҋisD/p,YFs_mIj|"4O/VlҲ(Q |9tc[9FTu7Wȭ |l`X)o4$^VMߍ)3t@ iަnΦi"]Ȯ6pt-oMW&LNO=iƤCv]<5mUxȮnpG|=H:"S#z)-OrL:Qli2:*/=rg(y[˗%Mv#_?ԫ.7K2V V6_P;X=nՕ^y6XAz˗A7mJo\Qfρi/[@ @g/|ЏK#n}sco52Y<.[,ȃ"l}@P}}8T2 }rէ E4T(?VdKQHTy2E6ק(=+*OT?_N::)'Y5Ɣdډm @ 3! yF6}Ա֋ZmDjɅӢ m11Aec@ @s!ٮH @ x|$ma_" fgWOnS\@ @ 07#ś .lb["\d6,|[ ;@ @ Sq A!xQHhWo P"hq';J@ @B@10N]PHh7˒eĝ a%Wr͢@ @ 5l!{nOl$Qw:'O.͇;! 5(@ \BO 1ē;=xyq3F,m @ gpC[n1ImNf';dwK @ @#*uT3w@ @ $jYy D@ @ Z - o(@ @ hk@ @ Z - o(@ @ hk@ @ Z -/Kb֐b}ֲ1e魴^2Aϵeml=qN TGsVs`\:Zjsu,ϫRݲd L C%źm`#w/]9#U3}sdWױEslZ5~@g[žyۚ:ގ[<7X9MaҤIs[Sj~M1\%`S\Sk}k!5&po;ݨg?"Bk|pÓ'YNH3m4KcfuPG{]e>SY6qF~W^c@|O>馛n|¶/Tlj7HtPꫯ% (Mp\[sΐW_}5va囏/d_5>M=T]}Ҳ)8_cʧ"y]|tQOUZ/_~ًHt8[}|YW6z%- DK/M]Է "v>?u(̗V>Ǫ)6.K/?Ҟʫ\V!i6dܭޚR7&?7oDzYzTF.$[2USs8/?'@jR[YY]˷OY6y|VjKɇ}_d yO2gJmC*Wv)n>YȖn7ԷbehWy 7:=쳎 SK(@@4>>FJG}tbIʡA'C\}]~d8Vڒ-^KpKF _|q=z翺t8 z:GG{#G>\y(*/,}E1W_1꫼lId,fC}|K.l]mيN>𨣎J~i}SbFt}l=dctJH㘾Qߕ6nCT֤֔X{iwOl%YIȧleAuFȩz(UUUy}7]~eiw4a)]G(~n4hPZq]_JG80 şOmSY_?N ]BZ|m …~;x~(^Pإzliab'luدL}&3L>h|rKxfrCkp^}0 ;oDm_ s餽+2x6~l!O eվ׭=gGߩL$'ʏҲ1Jh/{Cvr w#gͶ#]R8}Nʩ~6)}W[mfעZl|]z9));X \=1`ñ~ 3EۑRʡ:NR4 6(۫H/);:1:CWV.xoa؆,`ެ>o8柗ĉI*EvxbiW;%±d]3eʐ}lRteճ/ok򐯿ڽ|8^9)e#i&9Vy1OڡN]tl316:u/5\W^y+Jm'lW8< #] +ҹ瞛jB *nd^{$-2`Y!:O?} W?-NDvmJs1QT .zh_rne;#3ϲ%[u9. +tfۦj;t)S ock$-Dz%)byPTp<iZ٢>|,+>uy҉OOI9:KiW9|ȧu3kK3S(SO&ўIun5TG[u@]y睗  Yd#*.̔%NJ=z}G5X#=xO?׉LOkM/|Z]/ q=.E ;h_tQZmռm2rH/;q6pцľ;_~kr AX|K,QKvx _UUg<{t9x dĈ/Ozh۷ס/>@qIj.B[nz(w}NsG/llG8 'f|_^xc=!i '|*'wu^qCnq1xhD uws K X0r;묳7|:׭0~ +8vhle~g~>J?t:ǣtuq]\;sܯ\'kĶ[{yoxdy"̟Z*_駟vL .r߀@@s=>xVاXw;m6m畾a+?|'z)إsMX6a7Տ8S*ƛnW򘵰)c\2~`O}`3_~> " Ё~>+0福q1{-oо8K>yx~o<>x8'817|*’NeoNH:x2~KG>lэ1qG9=/{[Tn ma#iZ 7: 6O`xp^㓛[n ͹?X/;SK!Fyw93OS'[d@/g?O =2~8b|ItƶhW^x|kzņ-iasOף*ID:"R"EED  VP@@,4"C@=$!=o_[n`y̙?m윽pM'^>)Ek_[-pQX0([,,,&_j[oY033aI>x&xiŘx# G 9,dȎBv.6* K8yzg0BfhM7MW_}3)@kҳسj̫:yGx);Lr{キ3!$tEcQ= p~ #yQ(>L$(͒ƂF~ ҐEt=k 6I,(lXi#Qbc/ݧ>Î;7ܟi+BçE `b@He6dfEߦC=#Vn\?nW%'Q>?GɘCkcYlx¦>ϕ?~m,Prm؀)}99M<}{}^`M{o>{N3eS)vׇv3>M 1fx/:,-pelH6Y V|-.Y] S&wa }MlNaQaiY?[ [P SX0㎅YV-e|5הa̕ά &Ogoa;}7%.a+Pşmr/{}f̚f],ӘeLA0ꗓMR)Y=K.NP&3eEٟ ҕmV”2Ma7g֮&&Z"/bE_05? zDil(l2%ilb0SL*Qv 7x<+2lSPa;!|&2 mCmUoEla|Ĕiay~"t˧Ox)))Md\Ĕ⨣x\8d8?GnOg?#do6K?7Mqmx2KVXaEV^o_Oά؞S=x@)y,Ɣ¬bxƼm;a$9D}dLFó'AȔ mSiFx&)Km4<[n4!g~Vixն Ea?k.`/ ]y!S8lcaS$Ymdʚ?cʑqy2H2@`:g<+",_2^"ld\7U̇*AE<>a”2 u2E1?^B9{).f8*La~X|EXDcTP>`ySJɪ:j41ƥt!>E_``5ryf)>Du DIdxO>Ŀ=q#`]J|yXr &yWX^!'yue>1>yS@<~tzuSuS.^4ԎƵ5P T7>3afn̈́d̄%MƸ0mUzsSI9osZd=$;ոQ;` FRqfQ_GMƸ93Po ׾CkE$T,tp&{@@:#+Gb%C/A‡ dtp-(xL 4hU"a+_y{E÷b" MJJ[JIaaQgd: 4xfAĕWhLvP_Ӡat`PK} ^J ԁ: O0:'FY|RRń>( ^ζAfI\C9«!XAR`':P>"Q~n6a&X~&gab2FbQFu/Ѣ+Vm"F.9t)WeoopBrLny1jcɟׇ1r9c󼎤|j3?OQ۩߂Y>BIN!J^s9YИ _IHq/Y`3URoʃ/i®L۸jod%IVe$X ^Ұ V]p%n*'Y.~\W%ՍoN|ljS:49/KF&x*n|PtW(l$7\>vDv%/,U򫞄Ypbm` EU-yPX? dQ{nƕկ6Oijܨȫ{T|17 Ƙ1ssr%ÀTϽ@{8 @E[YPZHڑ!L6=^I+/tL)JʳxZ #$`_0`$eXL!B${ \=c%OIy? F"cp !>cQbŇ*2xXL't(UbPٕULE^Z8 p2cfuҢu]eyP?ڈ@ay^&e&YS! 4}`2ȧ8++W!&H>\h˃4ScǦͷ_g)6jGy򽉵nP/H8xZ ~J/|Of2(2/:vCAuue^}ppU@:xF3udC| )\q!ڀ %I8ʢ)]#}Ha,ƣFu )eI)p=esV[Qǀ $d%/k6.XZ%+ihc|/w /g)*[h3_aHcGiW¸"WI3m(Lj\ nvT(s(|]卵IokTeo,~cuI9B*WuB&1糁aQ&c8%뀹7s^AyiV?uu}` T:7GF].X;ıQxPwƠLEÍ*Gŵȶ_؂(|64@S(L ` T&t&cLas uH,(LLiB`'% B9p`_jXKyu;Ŏ!Ҁ2?SV3w7LH;Q 'q B jn!҃#$2`aW”j0g0ʟN  94gNt8 #dyg$, uCl) 3ʀE⟵BԍriKwkdL?,&9ځe@m6Ԇ(%RXQڰZAiU a!W眰>em _f/( pL8NV5/֍^v#s"6 LϽ>L[Ơ䨭dNn-b@,Ќ7,YH a++źЫrLm(v|,g%((\CR㲃uTQpIu1}"Ke1.`N;/ ̅ԇʶAG6 ,(8)ӕF@}X99tO"Cu/O +}\[C_o!'1 _U17DJa}X9<2MlH}z0VyÖ[A=aj\+M*Qu|!en1؜iĒ? 6qڊ s]i'x W#7|^fbYF12Sq˼ 2rPoŒAimPxS]0`7XAg~6SqE{q ,XoSخ_X'4dSp{>WX)~@Sp pu1~H:0 Eayؤ٦O ; 2^ O?l!(ES~C7Na0%Ÿmp66 S<̬~ ݯ>̢[aSV_ә[Z,&'1 Na1K_g'7뢇&BL8@Ȕ¬ǡD;E_ 7ldWk$")WF2Cvq5l<111@IDATJo*'BǟM~Uf:FZ[ {9D P87&/]-'ȜcEéD[ TS<%Ni8djJQ۩nЮ=4|QlTʥ9H4%a8x>ȯq̒N}{~L(0mʪzoV[C\!s`/yt C>"C|MvJ+Y9PG0/s5%<$KzKxS :;1L T.u%T&W30cʵSmN/ks~ O9qNƵRj,COQLJJ<j{S^%^qWxpO_12&`1W>-Z~y:-p'l;aNTf8ɧ#yNոlS=Z#2i\?lʵdMT8!f!qg?}J4de5̼N&$ x`&CewرΟ%=;IY}B0SuѓA{J2Y٭ȳYE1E4Xhƒob5݀ݣxL;u5)<|㬞l*_~?0vGa*/rb5'u,{+ y-aX O^޸V@p4`" jF/Vdۅ2^Rژ'g#>g#+.6,jXiOӮ"ʧ`!ccXiQYʯ+eʢCAf*}qB~d,3}Qyt"DC;Q/U{ڃ>#$ }vոLx8G(+5-x5-9U,Y #xӇT'-r3WP&-Oߠ !,I*"H3яUҊ7"+rRZʥiGKo}^_Y|u+6~`μN #&0^}^vĉ 30rOP7]m@2ww:,\fC471і`Hi=R'NrK|@>Ɛ4q7va܈/s_,e:[Łtjft'CO mC&#ux/yc|Ϛ6`? :XU&AiƳN5㓧W3u£Uf|{I}sn)<]~J<]L2pגWǏ_L/Y0*]Y^tj^kfqYWVo7GX=?mvmV.*n7frE(yq+YYui{6|kOMx-ۣYp5ʑ6ZJ2uUx.!]'^ҴCQ]0jږq;<6M{BO]|rDHwtm^)CJ:[y:ݣ <)^LW:lȡ%OGFaJ&^UWj}x(לsVԗ:'cQ2N.V7ƞGUN=V7'J6sɿ @qO^ʟ[WݚgGrak3ԟۑR(=?-iO3⛋5@,]9^3TV^i$?P+umW~WW9V|+PxX%ejU _sWl/a:Ym**?U:=yYA!s]HygO(ǚjvS/s*yʵNV6{WFPn'MNy*r2/]IӊxJ#^ͮO+9Rfog'iq.OfUgU/U: WӉuV-[TViv~*,odz.OɫY:hkU{*Sf3J,!]N|cx ߼ ^ɳyuv缫e{Lt\I|;ŋG=:Lvyڪ'm'irw³4iW;E@o`R.@*Uu Ъ=[ V2Ws()G |!ྙEfl3Žj+4pk׽cq}thǐE@ @ +`o[?i5 jCOdhz8ͮγp#<)(^`_T˲zڨ=I Mˆ_x.a(sn)ڇh?|1rWsueՅK ϼy jUܹ|sR }{Ο<;)Cc__wyjRYw}w:;*za-]S>I6_ |!3 O)r/ZpwyCy2K/M7x $t9Q~$yٙgU9-XºvAO=Sz oe\J`zeqQ[pTmȷ~9 *G}B}[o[nI?3Vd_7G:a4Z<}iR5W& wM!^0)yiw/^ɐ=JgQ!< 5(\ uJsxۉDX. .;nᡴ%]m҈ȧjcq3WQ'X(3TF2It\/ cn<^|U>s~D{g2 % ?O*GiTV;9T+_z:Z>Q^ɬp\U.vm*#`+=^sY('o+*W4?w}·Mgg?'|d_)y؏`P>UnVdUi/@"0? \XJOv( Rėp-J8nT}"Y,yK*NJnS8fCO œ{ūU~+yH+y#Fp[o*uݬ!*Kyjܺ!'9NO5o2r>M!VnrE@eeF4$C|O⧾Q2_^xa׏7[:+'/}. X frHNxyUʇ7}ػ.W_zޕ]=ÇW?u:pߌH#lP?ϻ`]7#D^&?JWRaUE sCy?I~P}FT2<6WCƺvV=I^4œ8KFr1`}B;1S]FWHUVXaK,?y9Rwequ}Tށ7~GٍiQYz&OtI<'O0…aA@!0_tP?nYXc B_ Rw뮷^Z." W]urҽ{[[*nԓEԅ6yGq9_6465#eZގL$?^|Eꫯ f!c"ፅ xHehy ^}nYk5ud06FpΣY?% UW]^iS .[oY!c1}{` J+24Q~X0>h77/giiJe,pW6V[m5jç~:כ&WX_&MG\ Y xQO1N!xfc u殱RK9REƺqC8(D숧='"?xڗ>$C:ċv㖹Q/sbTQsʘcz妛nr*M6Az.7Y&o^?+ziq -"7}T}K$7k҃/#26@ `qPMJ.M67n}?”_a?Cm֯ta~waPI0g>S(T}_.nb=(L *lu~o??6z.}٧U|_p{WS S=YvN= ɫ÷r&.lQ-l1tYWGxp~5K\<7tS+lMaֆAszׯZ񖷼O;yя~ԯA$3噅O<IJ-l.~xM-!ϔ)X]wGn<xSG>3?[*d _?ޯj3 !He%z{ v>}~g[D;öj6|~z<.}iGS A7_<7W:W+ S>з.<3}x|gw1j S}(~Gc2fַ7W}z/Rkh/}ɯEfPDR{~Nf|gq^ox0~|E細 g}ˋ󢿙bTa<\ߍqC"S{ioc0{_#ƴoSy[d\u'>QIkq+X/T~%OS}{. iR8"»hfIkoJ%\Z#u~*{(sW&}O}_?㏰4z H:蠃 0CᨾYV|=?? i:k~z y bg2Yg ۅeX'tף:O=Tg2B]l;y#KWRXB3+idD$ˉ0|&gD&/veBCe"&Tg /gx$d#tvD7`@\S/HgB-f!,YZhyPT+0)Rxo:oPw%RHl{yv0!T̏Ө|;QPwdzC,(G ?%XPW-f]vZ$o,LCʥP(0~2xT1x@(UW(}i|PmC*eWʥY m4xiC=i 6 1wЀ4vDF&m::) 3 <:(C6qi6jgU۫OvaƻqG~|t=B`lJa,yAs}3|(!uq!3 uIId _/̭R~˜G ⅧƭDaf )ԛrl8 /4U1Ck~$W6x2O(Wg)Gq|>qeF (J0 G?9 OuZIKf,9PIސ Aj"p9q& Z{-\Pd'I+!RYLLI91aa )PueBPޢhX @,TPӟadCIC=YtF9i& 8kBe%( '|'V< ^XA`Q'/ ~*6۬ċ0@)*W ȪI~HmP!o?a׏6erUVrsŚeY?ۊ}ss)+[`+<~_8f! V#_' 챦71w_WյZCfڐ2Eawcǎm[>bޱy\~G~2Z}}d`7A 6qlA(_">0b4v{R W=QQNC-i qGqC[@zB{InXɏ5qBcͩn:ُ0[#d#+ ѦܲHʹg)cs?)yTd~«jrfқ3C8ucqNOnMm_s)ٸV*vdYIZړ ?@?"0(`X*| :|r¿&$óˣxwk?[kLjMg}<6!~Տ0N-*WuHl?!ҳ?d?_C&|< 1i׏_-%ʱOS)Ce; YOu5w>oKS'kHB–k]+N㙶oC|aa^Os_WtEy.HGx2wAM Ia@Ea|"}}5g>: E0 qydGUiL37–NrLۤdoْ7 W7]S= ~YToݸ%-_9D ]9A_%6'96d.`n[Z3=~*pʛhcHa6 } Akac=61^)O)/ ~^B`P(",ZhRF\5ԃ5(A/i^eC 7+O: =+[\C!-"78198MTZlW 2{b8Pb Ht|ԛr!22!KV>A0|'IkuyipV4 WL#yyFAzgq7*t9RgۦŸ?O  넃ؙbj;s, )}:?7vXd%wv3VwƎ-Sj;_bc曦6YxYYg\v65_F,XO ƒNKB񣢩lJXRbI _a:b ;;pO&&~6 n# }ȍE-¿ aD2kMfdbEH',XYIU8`"E{4(ʀuC&b`5+`BeNǏ9㥵x S(^5߽$c4bmgohhJӪy=g_3 CfCホ;wN}oլEX^i'rP3i>TW>V$U :v'vՇ)D' uķ^+iXgydx C)i:зxK;>["\1_oE7ugqD?;9j5nٷ99Y &LVEdfݔjKY9<$Hv9'Y!b,ћY?N[/c~o K޶Po_a^ vcs %Ěq3syfIC{>ԁ@pa&sؗ\R+jgXgHPS5-« 0(D k7XxŧI!G~u#aPΗ2Qʤ,wl9l˞s dUeeQ2Ň:3灒Oj]یHVU΃k=fu?^ ~y_Qvy+cU{5+YxqSWs^?OQކȋܹyN:n%V^󺑷7m>`S9iW(_{< ~~F`P)` 'g~ߌ,\]i.~઴=b!:7onY{;<#JImI3>)\jf󚮚缌>O,\i+]k3>&'G_wO$m]ٝfi/w'Qy«ӹ]:Ӫ o4"G;d@/t /0$Fqzn^|OϺCuqy)LWcE (5$VqgūzW<WQ s'yk]V(š=@ @Xl+uY-lnX\4 ɔ&;s/;ӴiҚkZk YcoO 6 -iȑozknSO 7}nHK.dzӛޔVZi%^m&iEI7|s>}zZyM7ݔ&N]wݴ뻢<ߕ @ @  a__Nnt7Yil_{n:]!5kV:s̙3KEGM|RK-?͘1VX!qON=Pzg]Aꪫy/⋻|;__|[J;+]tQ 4{lwFƎ[*U9@ @ok>3ͿwB:omYnܲ˺-WN2%}n^xmVxr)n=#=#<8uUVqYjkāXc45jTo_pzE'@ @`XXSz;˔^ 'gwr7 &{7뮴[Ҋ?b-R+een7zaK,DZ~Ә1c<+b,a]FFم2%oɥR|q @ @ ?ښ!:ԱYz>r^3 p'r،/6L:ՕUynVX|nqS@b4㞀喫=S8,ŤlUR<@ @ 0h)2̶/2BNnbsm2Bi-t%oOsf'c=Z%OSb~Ta=_[) ]z@ @ [4ui@EA|[3nw\Y{;%yAH+_fbҊ[CO?[^Qvr˕ /YYw=KpBW)ʗ DXIC@ @ @YfOz.O; R(5ͱm ;ȶ%oWCYfW[m5q[o=&{4D;8Osb|Lݤ^x?O ?|"_j/!gNG} @ @ PY2av@{o4YSx,F-J3{V>(Cӭ^XeQte lMZguҪƹ_.ލ6(-2nmClfU]2r9((殺ϒsyzm_+%F@ @ л >e\jsҭiލkbB`b)ҽpgq)O}9O˽s @ @"k!CS^H=ϴZ[aJ%a5J4cc{5jgä1T@ @ @tSxyM?ԔwK5w)icӬi459̑2@ @`v-w_z]i#ҳnNˌ]۞;te$ @ @ :ETx963ڤ&=zKiѥϐ0t4 ;.M/R[LӅ*<_-,4޹@ @ >ATxE9}f­iK< _x+ Cͷw׭wŗ,.O0A@ @ @?!0v)3MiJ{'J3Nq?-ږMd^xO-@ @ 8vVnwie>/6 u׮<\>whM^>@ @ @x?r:3%WXտ)[f]2- ]jP 4E@ @ @v)#LRf*!ewάY Ÿ /fG'B@ @ ٳfZ(OcgLwXUA]y6Pvs @ @_#MEmɟ7+ <Ԭz{5J}Y֫@ n `J!添hKW/ QU)>/ifnä zίzߪSY"򈗞օ}}?䙗S<@ w9ZsJVwSxp+Ĉ]pl4"CR. S&MJ^{m2eVxr$g4WIfΜ9Wgo?I>S줞yKQnVxsϥiƌU=2_ӦiGTQ=He饌bzy ^Upٳg("f!S VpuZCxשyVj\4}qj|CۻԴ"]wݕ>tG}sg?+(IJ*Vt=n!mfot6 o륗^J>`:u-H,Y{,=%?(?>(;Lx;w-}K/+na[<͜9sz%2=Az6ӦnmLa5G6~T#HZtE"G]Jӵ2HyY V:=WOz|'.{.b/H'<,ӽ/ Ė4:?~_;\VP@ whfmUCWu]}юcMg?Xot7[o4M *_lQFu]ESNIkok-iwL/+(5ˆC~2@XsxyߘꪴzyriP瞴j~ӻwV]Q@{wmHao~/??wK_:@Z@F .Hґv/oX9N=T_N;tgo|wOFahD¾[̜R1}ʋ/tM딉Fi,-ytG!t„ smo~iHe4xƝ+)w]BZnyy8qba;”,eO9T@IDAT~kP*v<qӬ܋. {u_L*lT[K0{5ﲛ%Xyl7VQ|(lUL𤏛b`",~%V `A+C눲0]bˣWןK=hs@T7O0馛 vM ni 9`9 ic{Pfl{‘9!0SSO]vGO|$MA}Q@ {G8C믿,ܔbw.j_[X/?;찃$d< Sl55o. _3,oB:z cM^r]X!~\_~-+^{>[,(7qqm)׾h̠8t[A z&lW: SPN:nyjP^\sMWk2WUϋ0kfao+Z*FRb l+{[eU\'pҁ9B׽LO(K-T-~L$| cO2y@ t#8$lt–œk,:GN/}{=ᬕ {\p@CN1rs@>SAZ4s/~\1`d-w 0˼,vXqAz ݳsw]wux[9/u ?qoh'/,48-f'ڕ B_< /rgv[(5yl'4]0Au?ߌ >`@5u:'p3WN9. Y7`bȑQyfWG{OjL8y<^.lq} GOUTٲW1~|P~[VaȮҦo__O}|0I`)42i#?":4!H"ꪫzsa-TI @ 0k=e+51! :XwEZwYͽ qqzyYz!j}gg>=1RWv/o6«SV,,s%{XQ6!R^ǣW}KCXGR~0hY!Zhd;dJAZxᅝ:묓o'S)|80 Lt~5˒?x98"吀{&Zx8'k?a-q&tWLYqUV)l}8|~C.bVv |-YcnV~1Ns ?eVo:PAx< ,B3kKzL: DGHg~ jo(J-V%/a< \E=s 5e"1H@(fLfuLW_%]H2xJ>:7CfvÚbXblkUY3LV/m}G yఁAa*ERp}qz m~tM-o 0C2˫w6Np,OFq=i(J ?Mq 9FY6L_f>:vX:x3n"w JH|Ko 0){dn^i3ח``n+,a,b xe^{ &n  (% mo{|TeJ cu+3꠺*N('?pS>b3?1!C۵v0-˄W]g)]^z[,P!@4d. "N~"zȬ䇨Grqq(6F|6i( VfO݁ tEW^aUOIygbO %fYa7% tPv @`!u0~a㓘Rv gm卞}9W0 2.EA4,hm7YַA@,V , (VX XBiiP.i(8XKSF'(Js \vlQ'-B!GBBQBP.*[R+b#<w&Muy7[%C$q"2٥a* x ?E ɌŜo6s5o2MÃ>$lvOՕO+ᤇ_ɯ<=/=ѷlJ Z/bxeA<П?nlG&Uw/QEc.|GOP黼1/qo iKdA@ ]'{=X0zy~Zsy;ц5o&CE¨A \KN[C*>"dc Ux>%Kz^F~7CU#K1; @  F``ז7ď3WB֣Vkvf7ڬa?Gc 4GuT *#h\ 8"ki^(Ε49`<Oz<rwqw^uƽ}* 3quavÙtU^(lbp *g*/ɵͪeU_-v%E+NTv]9@ 0мjh֪;勌SҨ\rȓq) U I^gqF|=Ky睗׿}7 . ~OSVrw/|;6- /${.[@ @ W GY+z,9W~Cyo%H,[ R뮴"UW]5vmnTe5*3T,%̭nH?{GUz%B{ E ((OD& " ]E#"^Bw!gdwwM6rϽ33w,HiTj]U3\L%'/Q&XI?l%tjK9% ˅<8-R>{dMj}H-⃈eYqub@G>S©7.}ߞmr@ @g#+WOqʇՃ v@L#AYg'AF3E#=K.I -{5¼曧? oO>_uCRBDc y뮴6ۤCz:CҐ"W 8cOxFe+vT9rdks=.5mf!Cj)K8@W^yZ;fj Kx; 7ܐ``»}`{O]wݴ˦s=Oh"N!H(5ʽ?fg'"S/r˥+"r)ٖLk.a6?WB![܂)<`=W_p>ۊyv7bĈڀ8SO=}gM @ @-b4%+ #s~gzz!--kS_N0u#0<[lK EX`rg^vmWXzcs?p'8d'?IK/4A>WI=ܓ>GuJ$sl!T|;s^(;^xa|~p on6g";Fk9> /@ .DJÔ Ń|y>T7E !dIQ> _9Il0B4큩 x\znu`wjE2֨%o+o|^E^4Tj*sf3T9*,tǷH1y_?yZ[3c']*X3@ @ !)_ ׶DVZ8 REvE&'dLgXO@YH$dT0=T<6<;d?ɫV^3ŞR,ÅwK/%FV\i']S;* Hn?d߀4@01-Xe᪫J7ؼ^Vt@F[k`.C=z/3Bj @ @Kr\Ga `["Dj*1D7C8X2Y)X7oų Y,|dq%#Lu4 5AG#NvƒP6i|z^COH @ SV̽ůa6Ts_M@/`YK[Sde/u6:^;A'@ @;`^fD|VHe(ZW@ @ c!cǎuw{GMGNzjjjrKns=W^) -uwgלۉbwܑ~iK,+w_wN|Aѵk$fHWk8LI|'`XlQ1i* cƌR>[Y9@ @ qr ZԈe]>t '_ݳwM'xb袋|-zj:ӧ~Z{>AX!9S)?cuK"@yo{?tI^Wa1zR ~ Ar <fr3r4)La`.#ʧ,;t@ @ q fqƆ'$;SꫝG)#][+yiye]6>iWN+sO>};xiV|*]^4y]wq5l[viiРAi<)7 RĕgFlE|I`a|AD:L:ewBNjĮS*J@ @ h-8 N^Ht|C){O!zpD=E]ԧ>H"+O>dj2[^HW\qEL9--Viٜ62@ vDD`Hsx=+"<@ )GAty 75Ξ)E A /\릛nJlI>|']v.$ y#,NF~6//zY%eZis]Av) H[R'B<_s\3R|nK<מ?Jԣ@ @ t+}B^qFTR:],0s1k Lu}nAz%ìwbi,.n%/y#pa$' @cdpExndB m)4-xdv|YEBL=%BZDA @ \N7#o"IGb:/G?J /{dyt9ÊTfp"s5Ɋ #GL,[ExW$ rn\hM/ni`s9gy6W~9>n1qyy%ad5\^S}Cu]# ,@ BFm\y{%\-ܲ)? &SO=$4ꫵiwb|Ygn@wټ]L~ο%d!PP_y;fdw9wg0Hӿo7@v'mI*"Dtij.`"Mzs{:Q\$ @ i+ oso1C ?l:f1 cg4|Zgᄏx!_oC(Qfd_LH;|AOӐ~lFvĴ<3Qoḯqc꾄=ׅ6'_YC-fKxyP4xd}N=OX+U@ @ Х{8Ö zܹe5+4xf"ae^c,ӔX f幢.kА,jJ_\ˆkL;[9&N_]2+::itEIcY:-hLj뭷ţwߴK<@*tx4ޔ+)t(ol@ @{#`$'P l:=-('쓏?vJZ:SBl $F:ʈpYy@ 6p\p8@p)@/Oϴq7@4/Ť2![ 5@ @ +XR(qb\ٲ쌸Ikt3u27ia+^|4ͷ26/LҒf)jK/%bfȊO1y|ĈNv!7p/|I>\q_.aY^:w}7LL,_eU7˃Љy sek{}_ ;aݽ*HNU@ @ uTBꊑWVoҠ+ˏG)|Bo3 Ƴ{^{-i=&zi}.'~ovs K_z衴8I% .W\1ygl|2FaW_mfm&vm.xy @ @ Fie_Q譗lmޱw?nQi95餓j8fy֒[ޘi| A`y^Oj.c-Uۚ`EJmmH@ Ax"|N\￈Gw\zm6ז%iz,s ),/ɳM&G^W0'̔ ,OfjÌȠA7&ɧ>:\Bv|埜9=^H@ @ 0 6eYEQa~O!o/30`pzӐx3xQe^{Ԕy;oLbe* 3e WUEDɫ)Ŵq@ @wA oIK/UJ_̈ߙ8{><^\Ël+rfX^f{1=uL:tûZ@ @"V^ԅ&ME5f}ӴkW~|f;q}\^T 'F.'N-# #<]~&BbY.5: 9ʛm<<e-F`194b?@ @ջo'~Є]#b>3g0dW:9Bh!1Հ}n4)4,A$)E3@ @ tRVD/x9L05V<&}~~L|Nt9Fz/Q@ @ НۆւenCEE7ּ}g6 ED@ @ NB ok+7@vmj@c_ef^/1ޯc/@ @`*# Ķک&@ @ t W/MQܤ#$@ @n_XkV(E@ @ @ o*@ @ ]boۆU2+2[&+lZhle _6˱jk8y)Ys)=ZJ%ӢV}hzEx @!l!H*P~m+*'+|NuHAFyR[lfWfDy']- ,zW|m=[w])G}Eai@ ^Ĕ _#0a .i:[D>C1c8"5J8/\g}|h%xMnc/H>hTϲ z6:2~4nnlPyǏmC|}Z~Wz(\vr 8n%陚ۼ; ]쟚Eف@ |oIYzꩧ3@X#'EYXO\MO;3n+ga/U,:PEx}WM޼y^ [GNp]iÇGMZyU4` )4zۋR})yoQ7ړLwHOYg7Ya7 'td:󽮐Qy2,S#"~y9ybÏ X)z"xxH7#׌ַU#\s %o{.i;N?t'Hl<*p5jTmKnFim!BVSb;cUo /u83; CefZ R.}6@hs9'-%o2; R]@li#v?#L:H59avmm7>+o7mJߣ?2cpB?fA?!fn ix(<@ @WwNhC +H~ x>Z!SN9i]vqbx{<+箺*'l{gw}}>6pᭅ(APO_p@ݩ#ɀ@A bH޿N byˮCz^a-$† W馛:!"=A ? I$"x:6BC!`G]v6@ 7SK.$󈜛:(@aFH&y~g#lA %2aH0$@4%~oVL;`-/-^^D60yB)O wE"(AdQ2p@q5ˆ.#~lLb Gǻ7z`}(-mC2E"oy)U)O\lFH5Xl" ^L삠R_@#yd>/oNDmyeWA2$yo&CNCrhglg`A^-%}J[ %FH V[~uy)]#x{t"ꂔG_ @ `ͺ7b%" B8ҋང@J `xx4$?@@$x gs4ÖeADwQ 9_C^|xM}H6ezdr!L߀83B^Ef&g" M#M7DR'ғpDZaNDi?Cd[jR$yYگ)l^G/ ln f=$TylզˏG!X#}Gz0/Ce&!l~ yx^5-C:ԍ-@\ KJ3`C]qN0HhK 3tGib@ ѨC& 7H<<=C̓Br裝Ib(o37e$0`^\xX j L@~ x ^^xb㙅,b/>l%%F02|PD[H-pδ Y1<## ""KUKpeJE\BA>/)$ԝB=@Ę!EL!k-~ҮLP83x%؄8!̏N.cf $ÛL^w0/r-g)/A0X1N̛f^-eHyMaN(0Mh7#* AޚK駼D= \2LQ#lS-\I@ %:ؤ$| F@X'Kofe^jMzl-6cͺK I|#C8qF?̼$J$z %^x< K:q+ Bx2 wx(IZ@H>H{H$ 0]xK @79a.'ܽF0 M߄4EHOZ؈ ?҈lA Gx@'N"<ϏQ6XU{ ?d6~SЗ a1v@-TSXT?<6QdA. 24Ȥ</B3`P߈6zzGyt}nSC *{kWAm޺ e0Ǐvg:6Z1> v O^Ki[E73`Llo.͓m_ _‹&PZv'l5LHnE1!|7C levĴ<3Qoc0wnS)Ǎ1d.7hP MM!S Q<KHLQ.ep* R<_& e.H&H[d]ldB\K@8H DؙQ=&:b+vv[ZlS&z' 7xAheaO=/N]fRWʠO{`CaZ˱pf[ V@9׻G2 .C}$}w|Oʠ=/|>ADꝧc rWE=>$ :~OՇrvִ<KPFTO-~ +\#V"xZk!"8694x C ƀf4|KI4G(Kq{L6S7&Qކl6?&]Ǎ9Oֶ(WaP!nExu NGyd:ӵ^ƌW>c?F4y|/0+0hq-)k?4묳w}/pKeEޒGF uWVtZYmO5*Oit|-_r-k7z)Jke+6*'HկҜsΙ=s3GӾWٖK&9_ܗ 7П'GcnROe H7ǹ(7gh#y_}jO> ?\dꤴJreI00'Rooz?|Wt ++K(m1^6>jvp &VX餇-aer6>}xP~*SbzeC1-NK\/GqccgK<ԟ H OCFyON_s mxʒ￿Xo?R,}9Y7|Zkհ.9v{{ \GE0anQ}!$Ƞlׯ[d䕨4}QG*/-aaR(m׹#{9>>_3ٟ)CI6+K:%-r8;S\ڞ|_3i<?0E9N%srIxwqv5>.~}srH_>-\?;`rmA۷ˏ?CT!'a%m$J?9tM<Dv#i 0BC=gj[T{V<|v#ZmoVZQ\]HطqO=zlxmkKvr)f;tyczK8%n<,.^-0.߬SV̗)=[\4y^v㔏<L<7ѣG+$\O/HHl\8HV.eU$k -=yE}x [P+#;sqyv< $#V1|t)v?#UIo6JKy$#7ux\wufPF +6!~Y_'N6Ny+;xڮ@^=ס4Ui6g]Sz=Z6]"xw 'x1Gh/ cI_u,*hڟ1)0;yÍM52l/_}cz#C y~ry {8}J ]s5ة^`Ofo:K{yө:q{iy{뭷h+KN?tW6Xvg ęg}:©x=no[\zg?s}OO?(s 0\s~x_7-\aL-Ywuz*|ɥS9RʝeYj:w:D?thb}9?QCO9D9Ei;Uui#̵ LF Bj28q.sR})?L?95 $6ꪫk_|ZoK_rm|u>oۇA5 uFyF9`FLΥl $yM6q}szpmlI\h>S35MxmpA?}j~vTD:4/vҹvnh~N(ѩAYo6?6Pn= 0ˆ0V_ͻf'mt؉\G'[{<6#nzo*F+6=Ïx],.B~^jhwO^v"V6۬zȀ=ܳf7k']>Z%UBЅNx}VRl OlF<62r "yz5\|bK/\ʫ@>#@Vljm٦b7UOc7~O+Vh1)$Exm#GxT&avS6oI#Eq{(ei+x㍊]hnYVWy宻`}0#fʏ9>5o7qH﫟=o/xja(mꮭXjihKKW!K8LHgnԷmzAl7Lk7|[N9U}Fkkզ8x~S-էh`i"ϓep]z]0kK,x_`}xEsLG>nu׭M_Q6&[lQ_G9-$c]u=IDץMxz Oal U}G%#ak=ixn*6#V6}[.C텨~`鯈? %3Cچ0AW~F;K[=EǓk[0SP1cfME4^z=B/}{]w^7[oӌ&?TNL;r-@tN#ke>sPx+r堃oa<5F1F?zzlܯ6#8үjc`9͋N {":UN H4Na~cntT0xx^R a6 ͔9ip@ ᤋpB:!yt1첗<8!Ni{(sBn˽JH /PE=Ҟ~\$~00a)«r*i; pV87{k6Z!!ty+ܧ/87$"#P˄v i(|[+UR}Ǘ^]k&-!LIAUwWU[}㱽^?9_ ,d?}Pc {QSk횋#j2vaOxoޠ!_7Uq4I<-au<~bJ,!Dз6+OS f8JogrsA8xȓywjDO B:.@ǜIX`dEU:F;UhW|>y 3W|Wscendr@L^NJĉͤc]@TGn6qBǪ 6yO/9?\M\qfy<.fOAP!Z!E}DLX}Ch+fPlnh~S3/@/FE!sxyfcy̫ꅩGˈۜmAm-WI/,U|EmPOqCܘ^Ã`" c'^7 O`}a }v@0AFp)<^XG4:g ̓7N󤻍/Vo^,cʜEtlB>m>yu#XRDMh#? "*#%=:wT_mg'oԯ:K㤑T<-ҳ^ٱJ+y$k=J:_VixQv^ ?L_}pBUg]-_=?뷮2F?cO|n1s}UO*]GÀg Ӗ2TN3lÆ -Wi@"ry7r? <ΰժ?cN꠼-dgO̝Bʫe}F65WL.Dw5Eƍ(#2<].^S1.(7MR˥04LG8uaE#JHlj g<,W_}}.F"؉#U]8Mat.1W.pB'8UqLaEFftss*<6=QfH"=l{i7 .$_/ Ō$)A͏$xhS^.BzAy<=#m DTzG[W F{ ɚ0ljxM:ðɅ͉ t2M>Kx^$L7P!(=t'G; +N[} +Lzm,Weq@TVxW5Q{r'bonp S5\mp3@SqleTze=Z}esc ] FdTYT\Oy҃~’z BF+"peq釐[R?oǜС:l:ċa&W{J'- ^s |'8i~ȵ1^JXd'Or씇-(N[T{n2HhB0"U6D]iHGyڈb># `\T±gua^Y&JKy*HC홫Vٕ@Mv 6u@|9hsSx9rLN4<5c±<\;Al~rsЁސ{@_y͔'śԔ%L?x?N5Gx5l;hw}0^b rNٜ\?sاCX˜M !_'\Opm`D^5rڽ*3n@WOi7U_mur?\Am6a{ vBIOEZ?>$-vmAs%ٟl!Hd*mƽ`,c2:G?'`mh#9 WA!{A`: n'8ax9cx#ϞTX"A/]k+P./뱵\5W>O~e֞ rV&"^qVAtE:w73+;;abpᡰ#AF-/Zy"|#S]В:jci? {CV1#n@wQ@79>$/_/6jKHKyr]v<83{4/Ӝf o#6`JχkxGyyT~ Z/y_6'60:i±-58>ΜzK7Е і}ۨ/me~ޛ/>u _SGhos!^U~Mu{$e!S9RWtѧPkk!y }̹r9Rn::0m?l[G<,/oWԓ-W6/+sl@wrPV~ oKA_k^b%]ogUv f`HW{ '&iF/yI(V:?u[-sK7RC{g"6ѷ:Ӯ#qW>P>?u3@ @`:C t֠Q@ @ Lsx[VG叴ukZCu_b3z6~W͍=oL~g(PGCk~G`u:[c94xJxwR<O 6LzG@W.u]VG 6vG̺iPhntz Qt#㳰^xa:9t{f{勵YPU9*mԙm%>`5jbo@Awd,|sOvfR_e};?ۙuF[ki: km6*&+TOS1SG?ף>?<kSt#thwEqI)tpЉB<0z2ru*t`cꦴycǦ8 m6}H:>O:&萞<0N*/Ӗ)L~߸{G=zg{;Ӝf+Q9eqyX*|9GQ{(-yJǶ1CљlՖtEP<[lsLA‹/+[qڪ_(mGv(htmyҟۨ<٬0v٦E uSKomkmw (^^9xJ8^leŖ߸qoG9.+Lzu/Z8}<Q񍶤=_ }cƌz/SJˀkgūg_?υ|ҢmU?궉TCaҩ㊶)Ny:wwx`2~b=ypǏo^9&jkYy}VsxY‰ŏHQ| ts1e2s{K,ް/5b_AdcqK\{m!ZGns9kvթ>>PSxƾڠOx1t.Jm:'٪}T%^:2e;7*SzB[r%Ӱa<$38?:; 3̐^zi-Ctf_xP gء ·~xꫯZpur(.gIsyz饗>xjDgsfi裏o(VYeU)]`oN&71O~+iG??}F9hVw}oFL%'dFѢ<}Dۋ?rӅRcp=*~_TS~?\耟VѝA+ye>,[d]t~{W,yV) ޖ9Ɗ^M;iQ6*ms<õ=@/xˑƤ7AQѢ wq2j'x#Chʅdr\駭Wy.UtwWN?ШdhO jm%M;xݨt\ ~GT[~%.167 /}-wk^mo{ێa,ijZkx]px_},1c)Nҗo|`Mnw2wccuqΝcm k;9@97K}B켥Hk쪼[\K_\we5莥0m𦌊N=l/eK]JA ~G'62ǚuSsS^M'spSwFm~Mowev<׷-2\;J>?u^P۸٥0t|M:7g:]W:|f_j;P ~@x/\y|=o}O}SV<]_t c>VTӪ׿[Ձ~M?&G{:4LG֚cy;0V eXw uy31q +G:K:SXM[[]x ;<m1|\z%@?ܸh WgQN uDVs7CˀalI8-1AzPiGK'Y 1pbLƈ''pQ:O|Z{R=uF΀ҁuY) Nxi00 hG.g +!:ܬMy#.Piɟp{ _x,O;Hg#4&Rd7eNK)<>0uPJ蜣6&|bΙ1D:&& +>@i)?91MOp$m'&OL$¬^ ]ޖ\}{r`IS}Ez?}:=lr#X5h!o9W:jOpt42с8骥N <{)Ǫt(OB,S\җgߖ=c`_(ew>}Nv-~J e eW<oW34 wRV^>ba?si57ߥAҤ[gh/H:wiuu*CHZz?ٟYD_j𛌗8ԶNܣ>) NŃ@2^ˑݾm&S~EV}ub(c;׹C}et2x?ߞ)wժOk"ȚO- SVaW|YuMYax]?j0)evZ}-U2ZdWrމfqFߥt\x2ڠ{d(aEs!_dR½U?v#2"~[/\oq[t\M;tKDSr>%YMVy'8r3~uU>/bϲ>_h/R7:Y`lgGq~ĥe3ŸWgHyqѥcss³WPTga~Lѧ{wG|Hí Ƿ,u]`r`[ZfS O~&nS΄&y 3<(fVD('Q ށF<1Q^muP̗f0zCj*RxQEl;Rfh0+J=f}റg/ŀIFyXu@ym6j Ν;vNEi iS=cj[`pi8cD)wP.SL~cԇ&j-'~&QCcx@X_,EHz=ϹNO{Ӈ+bkLJ q) ݞkac2PޱᥜZ5'`[tz>]ɸC|pO36jZ]7iHQwP[}A,]\FyE1mθqa@>iOo0u!)K? 8\>3j*g:DU;~h =yx&4<煰ЗO /bÍiHOYY#i5#zNHY|lK]NPmQqjއ739 lӗϓ0j) NS{;FzAf0i02ذ7uHt6宝yV M>= ?}:7=rӕ %̀<+SK9}vK cS[{:&Y $}LmTN7c>u&? + t߆"urDIQg|i ox>^h%C0s3lo:~NN} /굌⏺f` T&&A< 8 Co<m\:vM_%}Ԏ;գ#GU|&Vj;ͪ<`ȱDѯ Sdu.eӕ=ؐi@ړ\/ɐ q̩E/8PB_Er!T^~W{I;DǞ:=~ECIkj7Y2:;C]S.-(iTwٿL݅p t,_$)8{xs?_\)WŸk2,zsx$:]g+gyi;]۷I..5>Z՞쳞~ G</'~.I^]{peʷ}* gW2 Ed& }"u;FsaEz D/zlVNtOg=Ҕ7ʋu ߂'{]6Ԅqe\NU2_?u !F2X 4 F&HНo~YΨN=)3,mρ_A5di˫kfofOȁZ׺V},L7B:K9Vgy ̊-1c,2t:aR޽C*m ͲKV85ң{w,ָyyxτܪ+HQQ,ݼij1XmҧY{z9UgxwIKњnH;[ ?5e$4}{+})㬥{j@oX]Uʷ:]EIcn+]Vf'r&Tr<<ֶ-*Y-H=u7O3]ۢ!yrߓ>%[slu84.S+Q߱Oߪ2 [҇< EOkc;'fo"fuӾc-r5ixLeuGd4hW[٭tDO}'x6%l1M{|ʚZvFԩŸ S;u2 {w>='>[[KPSOڗu㚖{B_>C;>Vݧ;\SZE~+qZɓ'ݼ-įKax]o{+ṯO4<'}px'4>yaɗx)ixcvt[s K6)L4,iyդ*l3“sMÄz4y*lqgW%k6}rF}_3ݜ4??ygu`ݗro:} xoo'~Kғ7ywqw/TA Ouw''ʚI_-p}~/%ڌK[Uk\\WyҜRRVmi }p˿kfȷR_Űcǎ3Lwލ>m\4xS?׽pMו+ N\܌.==O'g$>tN=yE?c:afc)_~ 𼻯+c$ms$l^fݓӟYpK3)y|׻:%.%m?z]*%.q᠃jL)x~[xeӋk\1Vx#^ë?+GW)?NM+_ jӤ;爛ў \{R~ _͗)E9%GI~e/g ;y{hLĹ'lofgNvKzw4SOoVvҸ98>-/wWH^ڊ҂yɓ8uo9 c@` *SJ'Oڐ /:d*O>Bҗ|i:oMzpNLy *Jҗt{p+49y⧼ip.iԸ ǜ19|ޅ3KozӛO~"Hg}_?oF:?ܥ>6MdO%\&Us9HY)/_WEzP@,,}q.~>|ɏU4޻fŸ2&g?qpisF gD4v p~;i?x΀0)/~]n\ZmۮkB/=|+]0m97o7xOm)#({3y#ܾ{`ᰌ//Ng82O#?:;83$ ^p \q3"~ Tf-S3emt NRVN.玾V9>Њ ]1H&hS>ʷ7Kps!]yiopi ՠ):NGπwe6\tZ׺ps?U]y֦pЧʔF~VeW]ʝ6v8f<_ʛCd*ghLp^zW_^*GBY<Hꗺ7np: Y"#O{}IsZrxxG>,W2}mعsp\f8әp1y ȫ W)[ZFKO})+|WXx/"7紧>_1x`QxƾGCk.4/9QKogdw]Jf'}[r9Mdiͅ 8[U~:.8v'#: g=Z'*l3]%uT2 >}R_rSP7aIC M'܁3$G2'O_=< eI{ksܴ5[Lu (o (l:kxw;ǎ%0W3^?iO{ku|k_;|o;vgQ;|`(/j'?9*WX^Nxӛe(xQGAsy>ַXJӥ^^]rUH靾Vt/:׹N׷L:B߃Wb<9y#Wtv~s K|+W\ˠl.:v_;wG:MYe4:O?Ym7MVVNy]G=Q׸Xx' =Oó|+կ~_jy;9x#2Uu=yWjUާ>>6Ї)c:S]_r7񍎒<|.rt|mut~g>c-%wJ?<9YՃ|WԀҸ˻;~?}?w~w:wu{]׭&EN~7sW'>c-owVIh m~d)ן^tgo9?N?83SMJ;./Kj;ﶗDh?.8`*Rך$v]KK]R7ցµ=_Wqiu'OO++]G;я~x{{|_66n6yBލwu '_{{# z/C_4sYw>{ms=эnN{㝾C#iط06P{r;Nj ",:! `:%c9l ( Zwk_j_bD :-#T>w`ȥ[(/QC]X:Z B1pl%pa"/|aa32*q݀ 0>6P3v1v)w$Cx~;1htط7 tFYH_޷m1)ZEx;&6`VKzxK_xo=~#y&C??C<9@[NOPpv ^4 49<0nCwA| ;##o0Tv|&:?7 &F~G2x(`!!5/[4<;~m.jCN.ye dY%І{ :Q3S@@OqN #BzN#H{i:7%r}Œw1@8Nx;zы^:D1k{Tڐa&]i]?/Ҟxi1P.} ;ݓz0R>9?Cŏܞl{>Nfc(x]l&㓾1cB.:e&I3ּUUtmtq1FV_Nt- X!,OxǝNI?=>=ַv:&^_\csxٟ)'85A#K";ircb "OLBz/q/fRUȃ)/D{VEUl#,eŴɽ>oHOuCxZNį?De\LpZd)uM:ң~&:@ӹ^өʃ6; Vm]/_[]U~n]Y߸X~{OZXmkC&S73xONj1ei79EA kE X`I?L^M8i9Q:-qbee#rqKW\~dٷcoY3C͸ZjP32Ԓsס2|pzOjWjmyF{O{*e{EfYCy W8 R=TGjlWYNg\Kn^5ց:e F9Y^i<ʩxh\Z^/EeGR[4Sp_).O! MPӖjPFp_bv8KW MRr^{/{>xSPFPFZ١3ީ2CM(>C)¡w;IPJÛ__w4Ֆɞ9Q%Se8ڧ-oy]vEiW[LV{VTmM۳ XF[UU{32ں wSz(ON G}ҕx39]Y\}fP =Pv&wa%݆ojʠju(iwUG9ɇ?V뀺_j@l.+#t% :4gmm:-5ݫeW{y[ Lk(Oy('闺7KGgq%ZiZ+࡟)D" 86ֽHYezOvkljtڞpgN_d00r ῰{M@ldg ;oWzɸHj߰cǎ~w K+&?jԄuհ+׊@319.N UmP,yߝAW9Mz{x]iWax}߿{we%;|'&!29Y` ek@dCyކ˔1hf3RuKH+`s@ρttZ]Y:C:薶gh MOq9"t)j<cBtqEiw׺:N ږ2p*  ꡫVm.?$F̑eGyz(Wg%Kf=H1&9Зzv]~1hyOe9 ]u&iC;$,wY>i3mh"R-1iJDN}J43Ӻ<;|<6-?x{]%N452VO 7i-wPܞP{Zi֧w l 5Sֵ'ҟQ .*.<% cA㝜Wr#\_tM-[?< pNѴS:.t> V- r_P29F8Q 8;W=œKPtU&fM!86}_7.Vi5_)~d,Τa)p,ztIIDAT (K=Sgݟ~ϙ(9<Ϟ)}B/ʽ&/8-<*Jxxj[D;w͒Jx@=<"p=[O?1 a)Z'fG?Hgꗺx @GTZ[E1x 3v%a[G$Qh'![;^FAUle&R?_4yC]]tC6M\heTK[&t>9u}\9Iھeb;-_ Ût^^6ERnG7=- 3U$~8a a)E&:.)okq%*} ڛں>s4K>tCLxӮƎ^,^IOX3il2+y )S&<Fd(oLn.M@]ȩ,]'K&ユAC=y㒆gZI?g c[A6ד ?:`a]%2:MV'"wa$J+}cNeW|9Ck5P(V+cw%P9m}6`Iu$];xRZILq9$)O!*^W NxФ>6`,}[XCvp0PR}wB>I)]MU n}CH_J9pJV:(l_(?A&McĩLM;y7n KpDjhN7u 78XXqu`"8:Y鄰>=աq(Lu9qH3ei}%CFfɵzӾ'?jCsexGCdAv;=+͍Lh?h΢!{ŧm'A\ Oɦ%#?zIsX^i멮Z^knDÜVrg F&LgGS;NiV=r:ڵ+ڦcS_Oa32*fuyM(]gFVY'SzF8VU7VQζ ,pp`['+CnO :ZbrannD egiǡNİh IۀL;8c g˽}%g_#ƓZV\";lƥ l 1rg ?K=9:)ҙnzܪhj?CAٷubDi:EE^mNN\jNZdj7{;Iˑ/-G@>7yqξMpoݷoז D=X{,bۓrbYFױ;<l8lk?c9g=oo}% bP.,X8p` N85 q 8`9o ,X8pbs`1xOl/,ؔ[4p` WZ/X8p` l7,vkޅ ,X8p`)uOC;vT潡Wmuqw 8|dԢa'5 o=Oj:ۅ'5o,X8p`v)|fxc;<fѿ |+(;3-oyVdRO3-wO_ |+WY,X8p`?0q4/~Uz[NWҕ /y'q){ aާws}xֳ5uQ&g|;G8|M|vft w_Ƴ&$->>}_孇??W7g7ŭ K:w)$L~eMw,תMC ,X8p`IÁmn>F G>aΝ'p廋WֺI]=ψ<թN 'q36x+_WK\m1~q_ o/Mwiyyѱ'S\St?1yL'I]=rx|.0%/Y%C'P_8f;~lFC#]. ,X8p`>/Zϰ7mw;lgrCgя~4EVFI TuA7*s g8C[|`8O=|ַW韆wݽ,z3i8Mx; 5y`<Ͽ2d?6}?OWm2>Oo~{T_ׇ׽u?6Δwы^tJFO=꡾q}!>3]l}o9a\c4+r,v|#114~X2RW=4:N7Ӕq7v[/?*O__ ^ӝ4W|O}jֵ5گxe|ӟ籌N[[ƛf,=(#:GX{i/sW@VoMpկ{8>^]fM"?%/yַx{{yk/ps5ڮ2^ww;ծ6>/sˌ'=.41y) Ox까Fv6~s|7 +Np\Gsy2;}dH7эr?:͔\,X8p`}= ` \ =Ame  3wuTm;޺pTm(Pe; `ݑ22DW-O!t_|(c>_[*czئԟgt(wm+-׽umZ  xvӞ]R- @:._{{v]zC4赵q\*}mCkb4D<el}g=97ʷݤ<h ="p45/Mh_<}&HĎ _ mV,X8p`|Jdd,+(,QF!e0 cX 0Ic?&ǔ#KYpg41NxtP_n(`dАn>g?{رc3` ;Ν'F0_(p>OB| )A@w=xޘ~SyiKFd=¥aSFNskrڟo'S[/G>?oLx.vL)-yI#2Jp` l+7OwM!{b4Ĉ%9e )x=\ ƒ/0e8OQ3)-#XN%p8!_6|id+jB<b,oӼu._V)v9WT/j<5)ϦN#'&SxoGI9D(<|`NOue,a ,X8p`[bd(Oejoe2|QH'r=2GL&);Ob{'i{4Q^K<^8ܸrgLnk#L>F.Ŵ~N߰d׏۶uhi` ~ Em$y+ npPzOY '3^)uaW@-\kd5qߕ'ë8b/M :gUO:{f_nh[{{u:״NK\uw^iy}+8`mt;qmoἵ,هsum>spaOQ"C'4/bۨ ׎X. ,X8p` tX7Mnr 2J=OHKFXK^X @WZ%~M BFo3|y΀qc)C!K  #NwUp,l9ǨSaG: '6lg@?phJZƈep~.#_9o  S~G7uc0zk:Ɏ2-eSL@jAhV Q 䁷[{jo᾿˃ddQ^n5/6o<聟\"۩|]uAI-+G}eHݷyɜ^W % ,X8p` 3xc̫ya8\a!y۬3PrGs<{s$>.|3C>}8My|5u5i{$_·i:;b29],X8p`ǁmuhMScb<>c"1RyOX~JC:iI7)S O<sZ۾+#]> Mhw?!7 'Ϝiֽg2tҬeݧ % ]،ne.yxw?)&fi\` ,8vZx}>g,o<^ņ=1.c׵'l7,X8p`۝l;qVIENDB`cloud-py-api-nc_py_api-d4a32c6/examples/as_app/talk_bot_ai/HOW_TO_INSTALL.md0000664000232200023220000000140514766056032027045 0ustar debalancedebalanceHow To Install ============== 1. [Install AppAPI](https://apps.nextcloud.com/apps/app_api) 2. Create a deployment daemon according to the [instructions](https://cloud-py-api.github.io/app_api/CreationOfDeployDaemon.html#create-deploy-daemon) of the AppPI 3. php occ app_api:app:deploy talk_bot_ai "daemon_deploy_name" \ --info-xml https://raw.githubusercontent.com/cloud-py-api/nc_py_api/main/examples/as_app/talk_bot_ai/appinfo/info.xml to deploy a docker image with Bot to docker. 4. php occ app_api:app:register talk_bot_ai "daemon_deploy_name" --force-scopes \ --info-xml https://raw.githubusercontent.com/cloud-py-api/nc_py_api/main/examples/as_app/talk_bot_ai/appinfo/info.xml to call its **enable** handler and accept all required API scopes by default. cloud-py-api-nc_py_api-d4a32c6/examples/as_app/talk_bot_ai/Makefile0000664000232200023220000000555214766056032025745 0ustar debalancedebalance.DEFAULT_GOAL := help .PHONY: help help: @echo "Welcome to TalkBotAI example. Please use \`make \` where is one of" @echo " " @echo " Next commands are only for dev environment with nextcloud-docker-dev!" @echo " They should run from the host you are developing on(with activated venv) and not in the container with Nextcloud!" @echo " " @echo " build-push build image and upload to ghcr.io" @echo " " @echo " run install TalkBotAI for Nextcloud Last" @echo " run28 install TalkBotAI for Nextcloud 28" @echo " " @echo " For development of this example use PyCharm run configurations. Development is always set for last Nextcloud." @echo " First run 'TalkBotAI' and then 'make registerXX', after that you can use/debug/develop it and easy test." @echo " " @echo " register perform registration of running 'TalkBotAI' into the 'manual_install' deploy daemon." @echo " register28 perform registration of running 'TalkBotAI' into the 'manual_install' deploy daemon." .PHONY: build-push build-push: docker login ghcr.io docker buildx build --push --platform linux/arm64/v8,linux/amd64 --tag ghcr.io/cloud-py-api/talk_bot_ai:latest . .PHONY: run run: docker exec master-nextcloud-1 sudo -u www-data php occ app_api:app:unregister talk_bot_ai --silent --force || true docker exec master-nextcloud-1 sudo -u www-data php occ app_api:app:register talk_bot_ai --force-scopes \ --info-xml https://raw.githubusercontent.com/cloud-py-api/nc_py_api/main/examples/as_app/talk_bot_ai/appinfo/info.xml .PHONY: run28 run28: docker exec master-stable28-1 sudo -u www-data php occ app_api:app:unregister talk_bot_ai --silent --force || true docker exec master-stable28-1 sudo -u www-data php occ app_api:app:register talk_bot_ai --force-scopes \ --info-xml https://raw.githubusercontent.com/cloud-py-api/nc_py_api/main/examples/as_app/talk_bot_ai/appinfo/info.xml .PHONY: register register: docker exec master-nextcloud-1 sudo -u www-data php occ app_api:app:unregister talk_bot_ai --silent --force || true docker exec master-nextcloud-1 sudo -u www-data php occ app_api:app:register talk_bot_ai manual_install --json-info \ "{\"id\":\"talk_bot_ai\",\"name\":\"TalkBotAI\",\"daemon_config_name\":\"manual_install\",\"version\":\"1.0.0\",\"secret\":\"12345\",\"port\":9034,\"scopes\":[\"TALK\", \"TALK_BOT\"]}" \ --force-scopes --wait-finish .PHONY: register28 register28: docker exec master-stable28-1 sudo -u www-data php occ app_api:app:unregister talk_bot_ai --silent --force || true docker exec master-stable28-1 sudo -u www-data php occ app_api:app:register talk_bot_ai manual_install --json-info \ "{\"id\":\"talk_bot_ai\",\"name\":\"TalkBotAI\",\"daemon_config_name\":\"manual_install\",\"version\":\"1.0.0\",\"secret\":\"12345\",\"port\":9034,\"scopes\":[\"TALK\", \"TALK_BOT\"]}" \ --force-scopes --wait-finish cloud-py-api-nc_py_api-d4a32c6/examples/as_app/talk_bot_ai/Dockerfile0000664000232200023220000000044514766056032026273 0ustar debalancedebalanceFROM python:3.11-bookworm COPY requirements.txt / ADD cs[s] /app/css ADD im[g] /app/img ADD j[s] /app/js ADD l10[n] /app/l10n ADD li[b] /app/lib RUN \ python3 -m pip install -r requirements.txt && rm -rf ~/.cache && rm requirements.txt WORKDIR /app/lib ENTRYPOINT ["python3", "main.py"] cloud-py-api-nc_py_api-d4a32c6/examples/as_app/talk_bot_ai/lib/0000775000232200023220000000000014766056032025044 5ustar debalancedebalancecloud-py-api-nc_py_api-d4a32c6/examples/as_app/talk_bot_ai/lib/main.py0000664000232200023220000000343214766056032026344 0ustar debalancedebalance"""Example of an application that uses Python Transformers library with Talk Bot APIs.""" import re from contextlib import asynccontextmanager from typing import Annotated import requests from fastapi import BackgroundTasks, Depends, FastAPI from transformers import pipeline from nc_py_api import NextcloudApp, talk_bot from nc_py_api.ex_app import ( AppAPIAuthMiddleware, atalk_bot_msg, get_model_path, run_app, set_handlers, ) @asynccontextmanager async def lifespan(app: FastAPI): set_handlers(app, enabled_handler, models_to_fetch={MODEL_NAME: {}}) yield APP = FastAPI(lifespan=lifespan) APP.add_middleware(AppAPIAuthMiddleware) AI_BOT = talk_bot.TalkBot("/ai_talk_bot", "AI talk bot", "Usage: `@assistant What sounds do cats make?`") MODEL_NAME = "MBZUAI/LaMini-Flan-T5-783M" def ai_talk_bot_process_request(message: talk_bot.TalkBotMessage): r = re.search(r"@assistant\s(.*)", message.object_content["message"], re.IGNORECASE) if r is None: return model = pipeline("text2text-generation", model=get_model_path(MODEL_NAME)) response_text = model(r.group(1), max_length=64, do_sample=True)[0]["generated_text"] AI_BOT.send_message(response_text, message) @APP.post("/ai_talk_bot") async def ai_talk_bot( message: Annotated[talk_bot.TalkBotMessage, Depends(atalk_bot_msg)], background_tasks: BackgroundTasks, ): if message.object_name == "message": background_tasks.add_task(ai_talk_bot_process_request, message) return requests.Response() def enabled_handler(enabled: bool, nc: NextcloudApp) -> str: print(f"enabled={enabled}") try: AI_BOT.enabled_handler(enabled, nc) except Exception as e: return str(e) return "" if __name__ == "__main__": run_app("main:APP", log_level="trace") cloud-py-api-nc_py_api-d4a32c6/examples/as_app/to_gif/0000775000232200023220000000000014766056032023275 5ustar debalancedebalancecloud-py-api-nc_py_api-d4a32c6/examples/as_app/to_gif/appinfo/0000775000232200023220000000000014766056032024731 5ustar debalancedebalancecloud-py-api-nc_py_api-d4a32c6/examples/as_app/to_gif/appinfo/info.xml0000664000232200023220000000224314766056032026407 0ustar debalancedebalance to_gif ToGif Nextcloud To Gif Example 1.0.0 MIT Andrey Borysenko Alexander Piskun ToGifExample tools https://github.com/cloud-py-api/nc_py_api https://github.com/cloud-py-api/nc_py_api/issues https://github.com/cloud-py-api/nc_py_api ghcr.io cloud-py-api/to_gif latest .* GET,POST,PUT,DELETE USER [] cloud-py-api-nc_py_api-d4a32c6/examples/as_app/to_gif/img/0000775000232200023220000000000014766056032024051 5ustar debalancedebalancecloud-py-api-nc_py_api-d4a32c6/examples/as_app/to_gif/img/icon.svg0000664000232200023220000000373114766056032025526 0ustar debalancedebalance cloud-py-api-nc_py_api-d4a32c6/examples/as_app/to_gif/requirements.txt0000664000232200023220000000007614766056032026564 0ustar debalancedebalancenc_py_api[app]>=0.14.0 pygifsicle imageio opencv-python numpy cloud-py-api-nc_py_api-d4a32c6/examples/as_app/to_gif/Makefile0000664000232200023220000001107514766056032024741 0ustar debalancedebalance.DEFAULT_GOAL := help GITHUB_USERNAME := cloud-py-api APP_ID := to_gif APP_NAME := ToGif APP_VERSION := 1.0.0 APP_SECRET := 12345 APP_PORT := 9031 JSON_INFO := "{\"id\":\"$(APP_ID)\",\"name\":\"$(APP_NAME)\",\"daemon_config_name\":\"manual_install\",\"version\":\"$(APP_VERSION)\",\"secret\":\"$(APP_SECRET)\",\"port\":$(APP_PORT),\"routes\":[{\"url\":\".*\",\"verb\":\"GET, POST, PUT, DELETE\",\"access_level\":1,\"headers_to_exclude\":[]}]}" .PHONY: help help: @echo "Welcome to $(APP_NAME). Please use \`make \` where is one of" @echo " " @echo " Next commands are only for dev environment with nextcloud-docker-dev!" @echo " They should run from the host you are developing on(with activated venv) and not in the container with Nextcloud!" @echo " " @echo " build-push build image and upload to ghcr.io" @echo " " @echo " run28 install $(APP_NAME) for Nextcloud 28" @echo " run29 install $(APP_NAME) for Nextcloud 29" @echo " run30 install $(APP_NAME) for Nextcloud 30" @echo " run install $(APP_NAME) for Nextcloud Latest" @echo " " @echo " For development of this example use PyCharm run configurations. Development is always set to the latest version of Nextcloud." @echo " First run '$(APP_NAME)' and then 'make registerXX', after that you can use/debug/develop it and easy test." @echo " " @echo " register28 perform registration of running '$(APP_ID)' into the 'manual_install' deploy daemon." @echo " register29 perform registration of running '$(APP_ID)' into the 'manual_install' deploy daemon." @echo " register30 perform registration of running '$(APP_ID)' into the 'manual_install' deploy daemon." @echo " register perform registration of running '$(APP_ID)' into the 'manual_install' deploy daemon." .PHONY: build-push build-push: docker login ghcr.io docker buildx build --push --platform linux/arm64/v8,linux/amd64 --tag ghcr.io/$(GITHUB_USERNAME)/$(APP_ID):latest . .PHONY: run28 run28: docker exec master-stable28-1 sudo -u www-data php occ app_api:app:unregister $(APP_ID) --silent --force || true docker exec master-stable28-1 sudo -u www-data php occ app_api:app:register $(APP_ID) --force-scopes \ --info-xml https://raw.githubusercontent.com/cloud-py-api/nc_py_api/main/examples/as_app/$(APP_ID)/appinfo/info.xml .PHONY: run29 run29: docker exec master-stable29-1 sudo -u www-data php occ app_api:app:unregister $(APP_ID) --silent --force || true docker exec master-stable29-1 sudo -u www-data php occ app_api:app:register $(APP_ID) --force-scopes \ --info-xml https://raw.githubusercontent.com/cloud-py-api/nc_py_api/main/examples/as_app/$(APP_ID)/appinfo/info.xml .PHONY: run30 run30: docker exec master-stable30-1 sudo -u www-data php occ app_api:app:unregister $(APP_ID) --silent --force || true docker exec master-stable30-1 sudo -u www-data php occ app_api:app:register $(APP_ID) --force-scopes \ --info-xml https://raw.githubusercontent.com/cloud-py-api/nc_py_api/main/examples/as_app/$(APP_ID)/appinfo/info.xml .PHONY: run run: docker exec master-nextcloud-1 sudo -u www-data php occ app_api:app:unregister $(APP_ID) --silent --force || true docker exec master-nextcloud-1 sudo -u www-data php occ app_api:app:register $(APP_ID) --force-scopes \ --info-xml https://raw.githubusercontent.com/cloud-py-api/nc_py_api/main/examples/as_app/$(APP_ID)/appinfo/info.xml .PHONY: register28 register28: docker exec master-stable28-1 sudo -u www-data php occ app_api:app:unregister $(APP_ID) --silent --force || true docker exec master-stable28-1 sudo -u www-data php occ app_api:app:register $(APP_ID) manual_install --json-info $(JSON_INFO) --force-scopes --wait-finish .PHONY: register29 register29: docker exec master-stable29-1 sudo -u www-data php occ app_api:app:unregister $(APP_ID) --silent --force || true docker exec master-stable29-1 sudo -u www-data php occ app_api:app:register $(APP_ID) manual_install --json-info $(JSON_INFO) --force-scopes --wait-finish .PHONY: register30 register30: docker exec master-stable30-1 sudo -u www-data php occ app_api:app:unregister $(APP_ID) --silent --force || true docker exec master-stable30-1 sudo -u www-data php occ app_api:app:register $(APP_ID) manual_install --json-info $(JSON_INFO) --force-scopes --wait-finish .PHONY: register register: docker exec master-nextcloud-1 sudo -u www-data php occ app_api:app:unregister $(APP_ID) --silent --force || true docker exec master-nextcloud-1 sudo -u www-data php occ app_api:app:register $(APP_ID) manual_install --json-info $(JSON_INFO) --force-scopes --wait-finish cloud-py-api-nc_py_api-d4a32c6/examples/as_app/to_gif/Dockerfile0000664000232200023220000000057614766056032025277 0ustar debalancedebalanceFROM python:3-bookworm COPY requirements.txt / ADD cs[s] /app/css ADD im[g] /app/img ADD j[s] /app/js ADD l10[n] /app/l10n ADD li[b] /app/lib RUN \ apt-get update && \ apt-get install -y \ ffmpeg libsm6 libxext6 gifsicle RUN \ python3 -m pip install -r requirements.txt && rm -rf ~/.cache && rm requirements.txt WORKDIR /app/lib ENTRYPOINT ["python3", "main.py"] cloud-py-api-nc_py_api-d4a32c6/examples/as_app/to_gif/lib/0000775000232200023220000000000014766056032024043 5ustar debalancedebalancecloud-py-api-nc_py_api-d4a32c6/examples/as_app/to_gif/lib/main.py0000664000232200023220000000651214766056032025345 0ustar debalancedebalance"""Simplest example of files_dropdown_menu + notification.""" import tempfile from contextlib import asynccontextmanager from os import path from typing import Annotated import cv2 import imageio import numpy from fastapi import BackgroundTasks, Depends, FastAPI, responses from pygifsicle import optimize from nc_py_api import FsNode, NextcloudApp from nc_py_api.ex_app import AppAPIAuthMiddleware, LogLvl, nc_app, run_app, set_handlers from nc_py_api.files import ActionFileInfoEx @asynccontextmanager async def lifespan(app: FastAPI): set_handlers(app, enabled_handler) yield APP = FastAPI(lifespan=lifespan) APP.add_middleware(AppAPIAuthMiddleware) def convert_video_to_gif(input_file: FsNode, nc: NextcloudApp): save_path = path.splitext(input_file.user_path)[0] + ".gif" nc.log(LogLvl.WARNING, f"Processing:{input_file.user_path} -> {save_path}") try: with tempfile.NamedTemporaryFile(mode="w+b") as tmp_in: nc.files.download2stream(input_file, tmp_in) nc.log(LogLvl.WARNING, "File downloaded") tmp_in.flush() cap = cv2.VideoCapture(tmp_in.name) with tempfile.NamedTemporaryFile(mode="w+b", suffix=".gif") as tmp_out: image_lst = [] previous_frame = None skip = 0 while True: skip += 1 ret, frame = cap.read() if frame is None: break if skip == 2: skip = 0 continue if previous_frame is not None: diff = numpy.mean(previous_frame != frame) if diff < 0.91: continue frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) image_lst.append(frame_rgb) previous_frame = frame if len(image_lst) > 60: break cap.release() imageio.mimsave(tmp_out.name, image_lst) optimize(tmp_out.name) nc.log(LogLvl.WARNING, "GIF is ready") nc.files.upload_stream(save_path, tmp_out) nc.log(LogLvl.WARNING, "Result uploaded") nc.notifications.create(f"{input_file.name} finished!", f"{save_path} is waiting for you!") except Exception as e: nc.log(LogLvl.ERROR, str(e)) nc.notifications.create("Error occurred", "Error information was written to log file") @APP.post("/video_to_gif") async def video_to_gif( files: ActionFileInfoEx, nc: Annotated[NextcloudApp, Depends(nc_app)], background_tasks: BackgroundTasks, ): for one_file in files.files: background_tasks.add_task(convert_video_to_gif, one_file.to_fs_node(), nc) return responses.Response() def enabled_handler(enabled: bool, nc: NextcloudApp) -> str: print(f"enabled={enabled}") try: if enabled: nc.ui.files_dropdown_menu.register_ex( "to_gif", "To GIF", "/video_to_gif", mime="video", icon="img/icon.svg", ) except Exception as e: return str(e) return "" if __name__ == "__main__": run_app( "main:APP", log_level="trace", ) cloud-py-api-nc_py_api-d4a32c6/examples/COPYING0000664000232200023220000000205214766056032021615 0ustar debalancedebalance MIT License 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. cloud-py-api-nc_py_api-d4a32c6/pyproject.toml0000664000232200023220000001111014766056032021653 0ustar debalancedebalance[build-system] build-backend = "hatchling.build" requires = [ "hatchling>=1.18", ] [project] name = "nc-py-api" description = "Nextcloud Python Framework" readme = "README.md" keywords = [ "api", "client", "framework", "library", "nextcloud", ] license = "BSD-3-Clause" authors = [ { name = "Alexander Piskun", email = "bigcat88@icloud.com" }, ] requires-python = ">=3.10" classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Operating System :: MacOS :: MacOS X", "Operating System :: Microsoft :: Windows", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Software Development", "Topic :: Software Development :: Libraries", "Topic :: Software Development :: Libraries :: Application Frameworks", "Topic :: Software Development :: Libraries :: Python Modules", ] dynamic = [ "version", ] dependencies = [ "fastapi>=0.109.2", "httpx>=0.25.2", "pydantic>=2.1.1", "python-dotenv>=1", "truststore==0.10", "xmltodict>=0.13", ] optional-dependencies.app = [ "uvicorn[standard]>=0.23.2", ] optional-dependencies.bench = [ "matplotlib", "nc-py-api[app]", "numpy", "py-cpuinfo", ] optional-dependencies.calendar = [ "caldav==1.3.6", ] optional-dependencies.dev = [ "nc-py-api[bench,calendar,dev-min]", ] optional-dependencies.dev-min = [ "coverage", "huggingface-hub", "pillow", "pre-commit", "pylint", "pytest", "pytest-asyncio", ] optional-dependencies.docs = [ "autodoc-pydantic>=2.0.1", "nc-py-api[app,calendar]", "sphinx<8", "sphinx-copybutton", "sphinx-inline-tabs", "sphinx-issues>=3.0.1", "sphinx-rtd-theme<3", ] urls.Changelog = "https://github.com/cloud-py-api/nc_py_api/blob/main/CHANGELOG.md" urls.Documentation = "https://cloud-py-api.github.io/nc_py_api/" urls.Source = "https://github.com/cloud-py-api/nc_py_api" [tool.hatch.version] path = "nc_py_api/_version.py" [tool.hatch.build.targets.sdist] include = [ "/nc_py_api", "/CHANGELOG.md", "/README.md", ] exclude = [ "Makefile", ] [tool.black] line-length = 120 target-versions = [ "py310", ] preview = true [tool.ruff] target-version = "py310" line-length = 120 lint.select = [ "A", "B", "C", "D", "E", "F", "G", "I", "PIE", "Q", "RET", "RUF", "S", "SIM", "UP", "W", ] lint.extend-ignore = [ "D101", "D105", "D107", "D203", "D213", "D401", "I001", "RUF100", "S108", ] lint.per-file-ignores."nc_py_api/__init__.py" = [ "F401", ] lint.per-file-ignores."nc_py_api/ex_app/__init__.py" = [ "F401", ] lint.extend-per-file-ignores."benchmarks/**/*.py" = [ "D", "S311", "SIM", ] lint.extend-per-file-ignores."docs/**/*.py" = [ "D", ] lint.extend-per-file-ignores."examples/**/*.py" = [ "D", "S106", "S311", ] lint.extend-per-file-ignores."tests/**/*.py" = [ "D", "E402", "S", "UP", ] lint.mccabe.max-complexity = 16 [tool.isort] profile = "black" [tool.pylint] master.py-version = "3.10" master.extension-pkg-allow-list = [ "pydantic", ] design.max-locals = 20 design.max-branches = 16 design.max-returns = 8 design.max-args = 10 basic.good-names = [ "a", "b", "c", "d", "e", "f", "i", "j", "k", "r", "v", "ex", "_", "fp", "im", "nc", "ui", ] reports.output-format = "colorized" similarities.ignore-imports = "yes" similarities.min-similarity-lines = 10 messages_control.disable = [ "missing-class-docstring", "missing-function-docstring", "line-too-long", "too-few-public-methods", "too-many-public-methods", "too-many-instance-attributes", "too-many-positional-arguments", ] [tool.pytest.ini_options] minversion = "6.0" testpaths = [ "tests", ] filterwarnings = [ "ignore::DeprecationWarning", ] log_cli = true addopts = "-rs --color=yes" markers = [ "require_nc: marks a test that requires a minimum version of Nextcloud.", ] asyncio_mode = "auto" [tool.coverage.run] cover_pylib = true include = [ "*/nc_py_api/*", ] omit = [ "*/tests/*", ] [tool.coverage.paths] source = [ "nc_py_api/", "*/site-packages/nc_py_api/", ] [tool.coverage.report] exclude_lines = [ "pragma: no cover", "raise NotImplementedError", "DeprecationWarning", "DEPRECATED", ] cloud-py-api-nc_py_api-d4a32c6/docs/0000775000232200023220000000000014766056032017675 5ustar debalancedebalancecloud-py-api-nc_py_api-d4a32c6/docs/conf.py0000664000232200023220000000475114766056032021203 0ustar debalancedebalanceimport os import sys from datetime import datetime import sphinx_rtd_theme dir_path = os.path.dirname(os.path.realpath(__file__)) sys.path.insert(0, os.path.abspath(dir_path + "/_ext")) sys.path.insert(0, os.path.abspath("../.")) import nc_py_api # noqa now = datetime.now() extensions = [ "sphinx.ext.autodoc", "sphinx.ext.extlinks", "sphinx.ext.intersphinx", "sphinx.ext.viewcode", "sphinx_copybutton", "sphinx_inline_tabs", "sphinx_issues", "sphinx_rtd_theme", "sphinxcontrib.autodoc_pydantic", ] intersphinx_mapping = { "python": ("https://docs.python.org/3", None), # "sqlalchemy": ("https://docs.sqlalchemy.org/en/20/", None), # "redis": ("https://redis-py.readthedocs.io/en/stable/", None), } autodoc_pydantic_model_show_json = False # General information about the project. project = "NcPyApi" copyright = str(now.year) + f" {project} Authors" # noqa # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = nc_py_api.__version__ release = version html_theme = "sphinx_rtd_theme" html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] html_logo = "resources/logo.svg" html_theme_options = { "display_version": True, "logo_only": True, } # If true, `todos` produce output. Else they produce nothing. todo_include_todos = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" # If true, Sphinx will warn about all references where the target cannot be found. # Default is False. You can activate this mode temporarily using the -n command-line # switch. nitpicky = True nitpick_ignore_regex = [ (r"py:class", r"starlette\.requests\.Request"), (r"py:class", r"starlette\.requests\.HTTPConnection"), (r"py:class", r"ComputedFieldInfo"), (r"py:class", r"FieldInfo"), (r"py:class", r"ConfigDict"), (r"py:.*", r"httpx.*"), ] autodoc_member_order = "bysource" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["resources"] def setup(app): app.add_js_file("js/script.js") app.add_css_file("css/styles.css") app.add_css_file("css/dark.css") app.add_css_file("css/light.css") issues_github_path = "cloud-py-api/nc_py_api" cloud-py-api-nc_py_api-d4a32c6/docs/Installation.rst0000664000232200023220000000225614766056032023075 0ustar debalancedebalanceInstallation ============ First it is always a good idea to update ``pip`` to the latest version with :command:`pip`:: python -m pip install --upgrade pip To use it as a simple Nextcloud client install it without any additional dependencies with :command:`pip`:: python -m pip install --upgrade nc_py_api To use in the Nextcloud Application mode install it with additional ``app`` dependencies with :command:`pip`:: python -m pip install --upgrade "nc_py_api[app]" To use **Calendar API** just add **calendar** dependency, and command will look like this :command:`pip`:: python -m pip install --upgrade "nc_py_api[app,calendar]" To join the development of **nc_py_api** api install development dependencies with :command:`pip`:: python -m pip install --upgrade "nc_py_api[dev]" Or install last dev-version from GitHub with :command:`pip`:: python -m pip install --upgrade "nc_py_api[dev] @ git+https://github.com/cloud-py-api/nc_py_api" Congratulations, the next chapter :ref:`first-steps` awaits. .. note:: If you have any installation or building questions, you can ask them in the discussions or create a issue and we will do our best to help you. cloud-py-api-nc_py_api-d4a32c6/docs/MoreAPIs.rst0000664000232200023220000000217314766056032022051 0ustar debalancedebalance.. _more-apis: More APIs ========= All provided APIs can be accessed using instance of `Nextcloud` or `NextcloudApp` class. For example, let's print all Talk conversations for the current user: .. code-block:: python from nc_py_api import Nextcloud nc = Nextcloud(nextcloud_url="http://nextcloud.local", nc_auth_user="admin", nc_auth_pass="admin") all_conversations = nc.talk.get_user_conversations() for conversation in all_conversations: print(conversation.conversation_type.name + ": " + conversation.display_name) Or let's find only your favorite conversations and send them a sweet message containing only heart emoticons: "❤️❤️❤️" .. code-block:: python from nc_py_api import Nextcloud nc = Nextcloud(nextcloud_url="http://nextcloud.local", nc_auth_user="admin", nc_auth_pass="admin") all_conversations = nc.talk.get_user_conversations() for conversation in all_conversations: if conversation.is_favorite: print(conversation.conversation_type.name + ": " + conversation.display_name) nc.talk.send_message("❤️❤️❤️️", conversation) cloud-py-api-nc_py_api-d4a32c6/docs/benchmarks/0000775000232200023220000000000014766056032022012 5ustar debalancedebalancecloud-py-api-nc_py_api-d4a32c6/docs/benchmarks/AppAPI.rst0000664000232200023220000000365514766056032023627 0ustar debalancedebalanceAppAPI Benchmarks ================= In the current implementation, applications written and using the AppAPI so far in most cases will be authenticated at the beginning of each action. It is important to note that the AppAPI authentication type is currently the fastest among available options. Compared to traditional username/password authentication and app password authentication, both of which are considerably slower, the AppAPI provides a significant advantage in terms of speed. In summary, the AppAPI authentication offers fast and robust access to user data. Overall, the AppAPI authentication proves to be a reliable and effective method for application authentication. .. _appapi-bench-results: Detailed Benchmark Results -------------------------- Tests on MacOS (M2 CPU) are run when NC is in Docker and `nc_py_api` is in the host. Tests are run with session cache enabled and disabled to see the difference in authentication speed. | All benchmarks are run one after the other in the single thread. | Size of chunk for file stream operations = **4MB** nc-py-api version = **0.2.0** 'ocs/v1.php/cloud/USERID' endpoint ---------------------------------- .. image:: ../../benchmarks/results/ocs_user_get_details__cache0_iters100__shurik.png Downloading a 1 MB file ----------------------- .. image:: ../../benchmarks/results/dav_download_1mb__cache0_iters30__shurik.png Uploading a 1 Mb file --------------------- .. image:: ../../benchmarks/results/dav_upload_1mb__cache0_iters30__shurik.png Downloading of a 100 Mb file to the memory BytesIO python object ---------------------------------------------------------------- .. image:: ../../benchmarks/results/dav_download_stream_100mb__cache0_iters10__shurik.png Chunked uploading of a 100 Mb file from the BytesIO python object ----------------------------------------------------------------- .. image:: ../../benchmarks/results/dav_upload_stream_100mb__cache0_iters10__shurik.png cloud-py-api-nc_py_api-d4a32c6/docs/DevSetup.rst0000664000232200023220000000434214766056032022171 0ustar debalancedebalanceSetting up dev environment ========================== We highly recommend to use `Julius Haertl docker setup `_ for Nextcloud dev setup. Development of `nc-py-api` can be done on any OS as it is a **pure** Python package. .. note:: We suggest to use **PyCharm**, but of course you can use any IDE you like for this like **VS Code** or **Vim**. Steps to setup up the development environment: #. Setup Nextcloud locally or remotely. #. Install `AppAPI `_, follow it's steps to register ``deploy daemon`` if needed. #. Clone the `nc_py_api `_ with :command:`shell`:: git clone https://github.com/cloud-py-api/nc_py_api.git #. Set current working dir to the root folder of cloned **nc_py_api** with :command:`shell`:: cd nc_py_api #. Create and activate Virtual Environment with :command:`shell`:: python3 -m venv env #. Activate Python Virtual Environment with :command:`shell`:: source ./env/bin/activate #. Update ``pip`` to the last version with :command:`pip`:: python3 -m pip install --upgrade pip #. Install dev-dependencies with :command:`pip`:: pip install ".[dev]" #. Install `pre-commit` hooks with :command:`shell`:: pre-commit install #. Run `nc_py_api` with appropriate PyCharm configuration(``register_nc_py_api(xx)``) or if you are not using PyCharm execute this command in the :command:`shell`:: APP_ID=nc_py_api APP_PORT=9009 APP_SECRET=12345 APP_VERSION=1.0.0 NEXTCLOUD_URL=http://nextcloud.local APP_HOST=0.0.0.0 python3 tests/_install.py #. In a separate terminal while the ``nc_py_api`` **_install.py** script is running execute this command in the :command:`shell`:: make register28 #. In ``tests/gfixture.py`` edit ``NC_AUTH_USER`` and ``NC_AUTH_PASS``, if they are different in your setup. #. Run tests to check that everything works with :command:`shell`:: python3 -m pytest #. Install documentation dependencies if needed with :command:`pip`:: pip install ".[docs]" #. You can easy build documentation with :command:`shell`:: make docs #. **Your setup is ready for the developing nc_py_api and Applications based on it. Best of Luck!** cloud-py-api-nc_py_api-d4a32c6/docs/NextcloudApp.rst0000664000232200023220000003003514766056032023036 0ustar debalancedebalanceWriting a Nextcloud Application =============================== This chapter assumes that you are already familiar with the `concepts `_ of the AppAPI. As a first step, let's take a look at the structure of a basic Python application. Skeleton -------- .. code-block:: python from contextlib import asynccontextmanager from fastapi import FastAPI from nc_py_api import NextcloudApp from nc_py_api.ex_app import AppAPIAuthMiddleware, LogLvl, run_app, set_handlers @asynccontextmanager async def lifespan(app: FastAPI): set_handlers(app, enabled_handler) yield APP = FastAPI(lifespan=lifespan) APP.add_middleware(AppAPIAuthMiddleware) # set global AppAPI authentication middleware def enabled_handler(enabled: bool, nc: NextcloudApp) -> str: # This will be called each time application is `enabled` or `disabled` # NOTE: `user` is unavailable on this step, so all NC API calls that require it will fail as unauthorized. print(f"enabled={enabled}") if enabled: nc.log(LogLvl.WARNING, f"Hello from {nc.app_cfg.app_name} :)") else: nc.log(LogLvl.WARNING, f"Bye bye from {nc.app_cfg.app_name} :(") # In case of an error, a non-empty short string should be returned, which will be shown to the NC administrator. return "" if __name__ == "__main__": # Wrapper around `uvicorn.run`. # You are free to call it directly, with just using the `APP_HOST` and `APP_PORT` variables from the environment. run_app("main:APP", log_level="trace") What's going on in the skeleton? In `FastAPI lifespan `_ we call the ``set_handlers`` function to further process the application installation logic. Since this is a simple skeleton application, we only define the ``/enable`` endpoint. When the application receives a request at the endpoint ``/enable``, it should register all its functionalities in the cloud and wait for requests from Nextcloud. So, defining: .. code-block:: python @asynccontextmanager async def lifespan(app: FastAPI): set_handlers(app, enabled_handler) yield will register an **enabled_handler** that will be called **both when the application is enabled and disabled**. During the enablement process, you should register all the functionalities that your application offers in the **enabled_handler** and remove them during the disablement process. The AppAPI APIs is designed so that you don't have to check whether an endpoint is already registered (e.g., in case of a malfunction or if the administrator manually altered something in the Nextcloud database). The AppAPI APIs will not fail, and in such cases, it will simply re-register without error. If any error prevents your application from functioning, you should provide a brief description in the return instead of an empty string, and log comprehensive information that will assist the administrator in addressing the issue. .. code-block:: python APP = FastAPI(lifespan=lifespan) APP.add_middleware(AppAPIAuthMiddleware) With help of ``AppAPIAuthMiddleware`` you can add **global** AppAPI authentication for all future endpoints you will define. .. note:: ``AppAPIAuthMiddleware`` supports **disable_for** optional argument, where you can list all routes for which authentication should be skipped. Repository with the skeleton sources can be found here: `app-skeleton-python `_ Dockerfile ---------- We decided to keep all the examples and applications in the same format as the usual PHP applications for Nextcloud. .. code-block:: ADD cs[s] /app/css ADD im[g] /app/img ADD j[s] /app/js ADD l10[n] /app/l10n ADD li[b] /app/lib This code from dockerfile copies folders of app if they exists to the docker container. **nc_py_api** will automatically mount ``css``, ``img``, ``js``, ``l10n`` folders to the FastAPI. .. note:: If you do not want automatic mount happen, pass ``map_app_static=False`` to ``set_handlers``. Debugging --------- Debugging an application within Docker and rebuilding it from scratch each time can be cumbersome. Therefore, a manual deployment option has been specifically designed for this purpose. First register ``manual_install`` daemon: .. code-block:: shell php occ app_api:daemon:register manual_install "Manual Install" manual-install http host.docker.internal 0 Then, launch your application. Since this is a manual deployment, it's your responsibility to set minimum of the environment variables. Here they are: * APP_ID - ID of the application. * APP_PORT - Port on which application listen for the requests from the Nextcloud. * APP_HOST - "0.0.0.0"/"127.0.0.1"/other host value. * APP_SECRET - Shared secret between Nextcloud and Application. * APP_VERSION - Version of the application. * AA_VERSION - Version of the AppAPI. * NEXTCLOUD_URL - URL at which the application can access the Nextcloud API. You can find values for these environment variables in the **Skeleton** or **ToGif** run configurations. After launching your application, execute the following command in the Nextcloud container: .. code-block:: shell php occ app_api:app:register YOUR_APP_ID manual_install --json-info \ "{\"id\":\"YOUR_APP_ID\",\"name\":\"YOUR_APP_DISPLAY_NAME\",\"daemon_config_name\":\"manual_install\",\"version\":\"YOU_APP_VERSION\",\"secret\":\"YOUR_APP_SECRET\",\"scopes\":[\"ALL\"],\"port\":SELECTED_PORT}" \ --force-scopes --wait-finish You can see how **nc_py_api** registers in ``scripts/dev_register.sh``. It's advisable to write these steps as commands in a Makefile for quick use. Examples for such Makefiles can be found in this repository: `Skeleton `_ , `ToGif `_ , `TalkBot `_ , `UiExample `_ During the execution of `php occ app_api:app:register`, the **enabled_handler** will be called This is likely all you need to start debugging and developing an application for Nextcloud. Pack & Deploy ------------- Before reading this chapter, please review the basic information about deployment and the currently supported types of `deployments configurations `_ in the AppAPI documentation. Docker Deploy Daemon """""""""""""""""""" Docker images with the application can be deployed both on Docker Hub or on GitHub. All examples in this repository use GitHub for deployment. To build the application locally, if you do not have a Mac with Apple Silicon, you will need to install QEMU, to be able to build image for both **aarch64** and **x64** architectures. Of course it is always your choice and you can support only one type of CPU and not both, but it is **highly recommended to support both** of them. First login to preferred docker registry: .. code-block:: shell docker login ghcr.io After that build and push images to it: .. code-block:: shell docker buildx build --push --platform linux/arm64/v8,linux/amd64 --tag ghcr.io/REPOSITORY_OWNER/APP_ID:N_VERSION . Where APP_ID can be repository name, and it is up to you to decide. .. note:: It is not recommended to use only the ``latest`` tag for the application's image, as increasing the version of your application will overwrite the previous version, in this case, use several tags to leave the possibility of installing previous versions of your application. From skeleton to ToGif ---------------------- Now it's time to move on to something more complex than just the application skeleton. Let's consider an example of an application that performs an action with a file when you click on the drop-down context menu and reports on the work done using notification. First of all, we modernize info.ixml, add the API groups we need for this to work with **Files** and **Notifications**: .. code-block:: xml FILES NOTIFICATIONS .. note:: Full list of avalaible API scopes can be found `here `_. After that we extend the **enabled** handler and include there registration of the drop-down list element: .. code-block:: python def enabled_handler(enabled: bool, nc: NextcloudApp) -> str: try: if enabled: nc.ui.files_dropdown_menu.register_ex("to_gif", "TO GIF", "/video_to_gif", mime="video") else: nc.ui.files_dropdown_menu.unregister("to_gif") except Exception as e: return str(e) return "" After that, let's define the **"/video_to_gif"** endpoint that we had registered in previous step: .. code-block:: python @APP.post("/video_to_gif") async def video_to_gif( files: ActionFileInfoEx, nc: Annotated[NextcloudApp, Depends(nc_app)], background_tasks: BackgroundTasks, ): for one_file in files.files: background_tasks.add_task(convert_video_to_gif, one_file.to_fs_node(), nc) return responses.Response() We see two parameters ``files`` and ``BackgroundTasks``, let's start with the last one, with **BackgroundTasks**: FastAPI `BackgroundTasks `_ documentation. Since in most cases, the tasks that the application will perform will depend either on additional network calls or heavy calculations and we cannot guarantee a fast completion time, it is recommended to always try to return an empty response (which will be a status of 200) and in the background already slowly perform operations. The last parameter is a structure describing the action and the file on which it needs to be performed, which is passed by the AppAPI when clicking on the drop-down context menu of the file. We use the built-in ``to_fs_node`` method of :py:class:`~nc_py_api.files.ActionFileInfo` to get a standard :py:class:`~nc_py_api.files.FsNode` class that describes the file and pass the FsNode class instance to the background task. In the **convert_video_to_gif** function, a standard conversion using ``OpenCV`` from a video file to a GIF image occurs, and since this is not directly related to working with NextCloud, we will skip this for now. **ToGif** example `full source `_ code. Life wo AppAPIAuthMiddleware ---------------------------- If for some reason you do not want to use global AppAPI authentication **nc_py_api** provides a FastAPI Dependency for authentication your endpoints. This is a modified endpoint from ``to_gif`` example: .. code-block:: python @APP.post("/video_to_gif") async def video_to_gif( file: ActionFileInfo, nc: Annotated[NextcloudApp, Depends(nc_app)], background_tasks: BackgroundTasks, ): background_tasks.add_task(convert_video_to_gif, file.actionFile.to_fs_node(), nc) return Response() Here we see: **nc: Annotated[NextcloudApp, Depends(nc_app)]** For those who already know how FastAPI works, everything should be clear by now, and for those who have not, it is very important to understand that: It is a declaration of FastAPI `dependency `_ to be executed before the code of **video_to_gif** starts execution. And this required dependency handles authentication and returns an instance of the :py:class:`~nc_py_api.nextcloud.NextcloudApp` class that allows you to make requests to Nextcloud. .. note:: NcPyAPI is clever enough to detect whether global authentication handler is enabled, and not perform authentication twice for performance reasons. This chapter ends here, but the next topics are even more intriguing. cloud-py-api-nc_py_api-d4a32c6/docs/Makefile0000664000232200023220000000142414766056032021336 0ustar debalancedebalance# Minimal makefile for Sphinx documentation # # You can set these variables from the command line. PYTHON = python3 SPHINXOPTS = SPHINXBUILD = sphinx-build SOURCEDIR = . BUILDDIR = _build LINKCHECKDIR = _build/linkcheck # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile github: @make html @cp -a build/html/. ./docs # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: links links: @$(SPHINXBUILD) -b linkcheck "$(SOURCEDIR)" "$(LINKCHECKDIR)" $(ALLSPHINXOPTS) cloud-py-api-nc_py_api-d4a32c6/docs/resources/0000775000232200023220000000000014766056032021707 5ustar debalancedebalancecloud-py-api-nc_py_api-d4a32c6/docs/resources/js/0000775000232200023220000000000014766056032022323 5ustar debalancedebalancecloud-py-api-nc_py_api-d4a32c6/docs/resources/js/script.js0000664000232200023220000000503314766056032024166 0ustar debalancedebalancejQuery(document).ready(function ($) { setTimeout(function () { var sectionID = 'base'; var search = function ($section, $sidebarItem) { $section.children('.section, .function, .method').each(function () { if ($(this).hasClass('section')) { sectionID = $(this).attr('id'); search($(this), $sidebarItem.parent().find('[href="#'+sectionID+'"]')); } else { var $dt = $(this).children('dt'); var id = $dt.attr('id'); if (id === undefined) { return; } var $functionsUL = $sidebarItem.siblings('[data-sectionID='+sectionID+']'); if (!$functionsUL.length) { $functionsUL = $('