diff --git a/.buildinfo b/.buildinfo new file mode 100644 index 00000000..b6b02db5 --- /dev/null +++ b/.buildinfo @@ -0,0 +1,4 @@ +# Sphinx build info version 1 +# This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done. +config: 0cc48d19e4a59fe32e48b31a2f031a23 +tags: 645f666f9bcd5a90fca523b33c5a78b7 diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 00000000..e69de29b diff --git a/DevSetup.html b/DevSetup.html new file mode 100644 index 00000000..7ca1a460 --- /dev/null +++ b/DevSetup.html @@ -0,0 +1,208 @@ + + + + + + + Setting up dev environment — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Setting 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:

+
    +
  1. Setup Nextcloud locally or remotely.

  2. +
  3. Install AppAPI, follow it’s steps to register deploy daemon if needed.

  4. +
  5. Clone the nc_py_api with shell:

    +
    git clone https://github.com/cloud-py-api/nc_py_api.git
    +
    +
    +
  6. +
  7. Set current working dir to the root folder of cloned nc_py_api with shell:

    +
    cd nc_py_api
    +
    +
    +
  8. +
  9. Create and activate Virtual Environment with shell:

    +
    python3 -m venv env
    +
    +
    +
  10. +
  11. Activate Python Virtual Environment with shell:

    +
    source ./env/bin/activate
    +
    +
    +
  12. +
  13. Update pip to the last version with pip:

    +
    python3 -m pip install --upgrade pip
    +
    +
    +
  14. +
  15. Install dev-dependencies with pip:

    +
    pip install ".[dev]"
    +
    +
    +
  16. +
  17. Install pre-commit hooks with shell:

    +
    pre-commit install
    +
    +
    +
  18. +
  19. 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 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
    +
    +
    +
  20. +
  21. In a separate terminal while the nc_py_api _install.py script is running execute this command in the shell:

    +
    make register28
    +
    +
    +
  22. +
  23. In tests/gfixture.py edit NC_AUTH_USER and NC_AUTH_PASS, if they are different in your setup.

  24. +
  25. Run tests to check that everything works with shell:

    +
    python3 -m pytest
    +
    +
    +
  26. +
  27. Install documentation dependencies if needed with pip:

    +
    pip install ".[docs]"
    +
    +
    +
  28. +
  29. You can easy build documentation with shell:

    +
    make docs
    +
    +
    +
  30. +
  31. Your setup is ready for the developing nc_py_api and Applications based on it. Best of Luck!

  32. +
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/FirstSteps.html b/FirstSteps.html new file mode 100644 index 00000000..3c098533 --- /dev/null +++ b/FirstSteps.html @@ -0,0 +1,335 @@ + + + + + + + First steps — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

First steps

+

For this part, you will need an environment with nc_py_api installed and Nextcloud version 26 or higher.

+

Full support is only available from version 27.1 of Nextcloud.

+
+

Note

+

In many cases, even if you want to develop an application, +it’s a good idea to first debug and develop part of it as a client.

+
+
+

Basics

+
+

Creating Nextcloud client class

+
from nc_py_api import Nextcloud
+
+
+nc = Nextcloud(nextcloud_url="http://nextcloud.local", nc_auth_user="admin", nc_auth_pass="admin")
+
+
+

Where nc_auth_pass can be usual Nextcloud application password.

+

To test if this works, let’s print the capabilities of the Nextcloud instance:

+
from json import dumps
+
+from nc_py_api import Nextcloud
+
+
+nc = Nextcloud(nextcloud_url="http://nextcloud.local", nc_auth_user="admin", nc_auth_pass="admin")
+pretty_capabilities = dumps(nc.capabilities, indent=4, sort_keys=True)
+print(pretty_capabilities)
+
+
+
+
+

Checking Nextcloud capabilities

+

In most cases, APIs perform capability checks before invoking them and raise a NextcloudMissingCapabilities +exception if the Nextcloud instance lacks the requisite capability. +However, there are situations where this approach might not be the most convenient, +and you may wish to earlier whether a certain capability is available and active.

+

To address this need, the check_capabilities method is provided. +This method offers a straightforward way to proactively check for the existence and status of a particular capability.

+

Using this method is quite simple:

+
import nc_py_api
+
+
+nc = Nextcloud(nextcloud_url="http://nextcloud.local", nc_auth_user="admin", nc_auth_pass="admin")
+if nc.check_capabilities("files_sharing"):  # check one capability
+    print("Sharing API is not present.")
+
+# check child values in the same call
+if nc.check_capabilities("files_sharing.api_enabled"):
+    print("Sharing API is present, but is not enabled.")
+
+# check multiply capabilities at one
+missing_cap = nc.check_capabilities(["files_sharing.api_enabled", "user_status.enabled"])
+if missing_cap:
+    print(f"Missing capabilities: {missing_cap}")
+
+
+
+
+
+

Files

+
+

Getting list of files of User

+

This is a hard way to get list of all files recursively:

+
import 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)
+
+
+

This code do the same in one DAV call, but prints directories in addition to files:

+
from nc_py_api import Nextcloud
+
+
+nc = Nextcloud(nextcloud_url="http://nextcloud.local", nc_auth_user="admin", nc_auth_pass="admin")
+print("Files & folders on the instance for the selected user:")
+all_files_folders = nc.files.listdir(depth=-1)
+for obj in all_files_folders:
+    print(obj.user_path)
+
+
+

To print only files, you can use list comprehension:

+
print("Files on the instance for the selected user:")
+all_files = [i for i in nc.files.listdir(depth=-1) if not i.is_dir]
+for obj in all_files:
+    print(obj.user_path)
+
+
+
+
+

Uploading a single file

+

It is always better to use upload_stream instead of upload as it works +with chunks and in future will support multi threaded upload.

+
from 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)
+
+
+
+
+

Downloading a single file

+

A very simple example of downloading an image as one piece of data to memory and displaying it.

+
+

Note

+

For big files, it is always better to use download2stream method, as it uses chunks.

+
+
from 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)
+
+
+
+
+

Searching for a file

+

Example of using file.find() to search for file objects.

+
+

Note

+

We welcome the idea of how to make the definition of search queries more friendly.

+
+
import 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)
+
+
+
+
+
+

Conclusion

+

Once you have a good understanding of working with files, you can move on to more APIs.

+

You don’t have to learn them all at the same time, but it’s good to at least have a general idea, so let’s go with +More APIs!

+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/Installation.html b/Installation.html new file mode 100644 index 00000000..d800ab58 --- /dev/null +++ b/Installation.html @@ -0,0 +1,165 @@ + + + + + + + Installation — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Installation

+

First it is always a good idea to update pip to the latest version with pip:

+
python -m pip install --upgrade pip
+
+
+

To use it as a simple Nextcloud client install it without any additional dependencies with pip:

+
python -m pip install --upgrade nc_py_api
+
+
+

To use in the Nextcloud Application mode install it with additional app dependencies with pip:

+
python -m pip install --upgrade "nc_py_api[app]"
+
+
+

To use Calendar API just add calendar dependency, and command will look like this pip:

+
python -m pip install --upgrade "nc_py_api[app,calendar]"
+
+
+

To join the development of nc_py_api api install development dependencies with pip:

+
python -m pip install --upgrade "nc_py_api[dev]"
+
+
+

Or install last dev-version from GitHub with pip:

+
python -m pip install --upgrade "nc_py_api[dev] @ git+https://github.com/cloud-py-api/nc_py_api"
+
+
+

Congratulations, the next chapter 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.

+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/MoreAPIs.html b/MoreAPIs.html new file mode 100644 index 00000000..6f57fd52 --- /dev/null +++ b/MoreAPIs.html @@ -0,0 +1,158 @@ + + + + + + + More APIs — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

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:

+
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: “❤️❤️❤️”

+
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)
+
+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/NextcloudApp.html b/NextcloudApp.html new file mode 100644 index 00000000..75f5ea24 --- /dev/null +++ b/NextcloudApp.html @@ -0,0 +1,380 @@ + + + + + + + Writing a Nextcloud Application — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Writing 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

+
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:

+
@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.

+
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.

+
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:

+
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:

+
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:

+
docker login ghcr.io
+
+
+

After that build and push images to it:

+
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:

+
<scopes>
+    <value>FILES</value>
+    <value>NOTIFICATIONS</value>
+</scopes>
+
+
+
+

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:

+
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:

+
@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 ActionFileInfo to get a standard +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:

+
@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 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.

+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/NextcloudApp3rdParty.html b/NextcloudApp3rdParty.html new file mode 100644 index 00000000..e8468259 --- /dev/null +++ b/NextcloudApp3rdParty.html @@ -0,0 +1,209 @@ + + + + + + + Packaging 3rd party software as a Nextcloud Application — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+
    +
  • + +
  • + View page source +
  • +
+
+
+
+
+ +
+

Packaging 3rd party software as a Nextcloud Application

+

This chapter explains how you can package any 3rd party software to be compatible with Nextcloud. +You should already be familiar with Writing a Nextcloud Application before reading this part.

+

You should also have a bit of knowledge about classic PHP Nextcloud apps and how to develop them.

+
+

Architecture

+

The packaged ExApp will contain two pieces of software:

+
    +
  1. The ExApp itself which is talking to Nextcloud directly and responsible for the whole lifecycle.

  2. +
  3. The 3rd party software you want to package.

  4. +
  5. Frontend code which will be loaded by Nextcloud to display your iframe.

  6. +
+

Due to current restrictions of ExApps they can only utilize a single port, which means all requests for the 3rd part software have to be proxied through the ExApp. +This will be improved in future released, by allowing multiple ports, so that no proxying is necessary anymore.

+

Everything will be packaged into a single Docker image which will be used for deployments. +Therefore it is an advantage if the 3rd party software is already able to run inside a Docker container and has a public Docker image available.

+
+
+

Steps

+
+

Creating the ExApp

+

Please follow the instructions in Writing a Nextcloud Application and then return here.

+
+
+

Adding the frontend

+

To be able to access the 3rd party software via the browser it is necessary to embed an iframe into Nextcloud. +The frontend has to be added in the same way how you add it in a classic PHP app. +The iframe src needs to point to /apps/app_api/proxy/APP_ID, but it is necessary to use the generateUrl method to ensure the path will also work with Nextcloud instances hosted at a sub-path. +If you require some features like clipboard read/write you need to allow them for the iframe using the allow attribute.

+

To now show the frontend inside Nextcloud add the following to your enabled handler:

+
def enabled_handler(enabled: bool, nc: NextcloudApp) -> str:
+    if enabled:
+        nc.ui.resources.set_script("top_menu", "APP_ID", "js/APP_ID-main")
+        nc.ui.top_menu.register("APP_ID", "App Name", "img/app.svg")
+    else:
+        nc.ui.resources.delete_script("top_menu", "APP_ID", "js/APP_ID-main")
+        nc.ui.top_menu.unregister("APP_ID")
+    return ""
+
+
+
+
+

Proxying the requests

+

For proxying the requests to the 3rd party software you need to register a new route:

+
@APP.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH", "TRACE"])
+async def proxy_requests(request: Request, path: str):
+    pass
+
+
+

This route should have the lowest priority of all your routes, as it catches all requests that didn’t match any previous route.

+

In this request handler you need to send a new HTTP request to the 3rd party software and copy all incoming parameters like sub-path, query parameters, body and headers. +When returning the response including body, headers and status code, make sure to add or override the CSP and CORS headers if necessary.

+
+
+

Adjusting the Dockerfile

+

The Dockerfile should be based on the 3rd party software you want to package. +In case a Docker image is already available you should use that, otherwise you need to first create your own Docker image (it doesn’t have to be a separate image, it can just be a stage in the Dockerfile for your ExApp).

+

The 3rd party software needs to be adapted to be able to handle the proxied requests and generated correct URLs in the frontend. +Depending on how the software works this might only be a config option you need to set or you need to modify the source code within the Docker image (and potentially rebuild the software afterwards). +The root path of the software will be hosted at /index.php/apps/app_api/proxy/APP_ID which is the same location that was configured in the iframe src.

+

After these steps you can just continue with the normal ExApp Dockerfile steps of installing the dependencies and copying the source code. +Be aware that you will need to install Python manually in your image in case the Docker image you used so far doesn’t include it.

+

At the end you will have to add a custom entrypoint script that runs the ExApp and the 3rd party software side-by-side to allow them to live in the same container.

+
+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/NextcloudTalkBot.html b/NextcloudTalkBot.html new file mode 100644 index 00000000..07ac010e --- /dev/null +++ b/NextcloudTalkBot.html @@ -0,0 +1,190 @@ + + + + + + + Nextcloud Talk Bot API in Applications — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Nextcloud Talk Bot API in Applications

+

The AppAPI is an excellent choice for developing and deploying bots for Nextcloud Talk.

+

Bots for Nextcloud Talk, in essence, don’t differ significantly from regular external applications. +The functionality of an external application can include just the bot or provide additional functionalities as well.

+

Let’s consider a simple example of how to transform the skeleton of an external application into a Nextcloud Talk bot.

+

The first step is to add the TALK_BOT and TALK scopes to your info.xml file:

+
<scopes>
+    <value>TALK</value>
+    <value>TALK_BOT</value>
+</scopes>
+
+
+

The TALK_BOT scope enables your application to register the bot within the Nextcloud system, while the TALK scope permits access to Talk’s endpoints.

+

In the global enabled_handler, you should include a call to your bot’s enabled_handler, as shown in the bot example:

+
def enabled_handler(enabled: bool, nc: NextcloudApp) -> str:
+    try:
+        CURRENCY_BOT.enabled_handler(enabled, nc)  # registering/unregistering the bot's stuff.
+    except Exception as e:
+        return str(e)
+    return ""
+
+
+

Afterward, using FastAPI, you can define endpoints that will be invoked by Talk:

+
@APP.post("/currency_talk_bot")
+async def currency_talk_bot(
+    message: Annotated[talk_bot.TalkBotMessage, Depends(atalk_bot_msg)],
+    background_tasks: BackgroundTasks,
+):
+    return Response()
+
+
+
+

Note

+

You must include to each endpoint your bot provides the Depends(nc_app).

+

Depending on nc_app serves as an automatic authentication handler for messages from the cloud.

+
+

message: Annotated[talk_bot.TalkBotMessage, Depends(talk_bot_app)] - returns the received message from Nextcloud upon successful authentication.

+

Additionally, if your bot can provide quick and fixed execution times, you may not need to create background tasks. +However, in most cases, it’s recommended to segregate functionality and perform operations in the background, while promptly returning an empty response to Nextcloud.

+

An application can implement multiple bots concurrently, but each bot’s endpoints must be unique.

+

All authentication is facilitated by the Python SDK, ensuring you needn’t concern yourself with anything other than writing useful functionality.

+

Currently, bots have access only to three methods:

+ +
+

Note

+

The usage of system application functionality for user impersonation in bot development is strongly discouraged. +All bot messages should only be sent using the send_message method!

+
+

All other rules and algorithms remain consistent with regular external applications.

+

Full source of bot example can be found here: +TalkBot

+

Wishing success with your Nextcloud bot integration! May the force be with you!

+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/NextcloudTalkBotTransformers.html b/NextcloudTalkBotTransformers.html new file mode 100644 index 00000000..1dd3f225 --- /dev/null +++ b/NextcloudTalkBotTransformers.html @@ -0,0 +1,218 @@ + + + + + + + Talk Bot App with Transformers — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Talk Bot App with Transformers

+

Transformers provides thousands of pretrained models to perform tasks on different modalities such as text, vision, and audio.

+

In this article, we’ll demonstrate how straightforward it is to leverage the extensive capabilities +of the Transformers library in your Nextcloud application.

+

Specifically, we’ll cover:

+
    +
  • Setting the models cache path for the Transformers library

  • +
  • Downloading AI models during the application initialization step

  • +
  • Receiving messages from Nextcloud Talk Chat and sending them to a language model

  • +
  • Sending the language model’s reply back to the Nextcloud Talk Chat

  • +
+
+

Packaging the Application

+

Firstly, let’s touch upon the somewhat mundane topic of application packaging.

+

For this example, we’ve chosen Debian as the base image because it simplifies the installation of required Python packages.

+
FROM python:3.11-bookworm
+
+
+

While Alpine might be a better choice in some situations, that’s not the focus of this example.

+
+

Note

+

The selection of a suitable base image for an application is a complex topic that merits its own in-depth discussion.

+
+
+
+

Requirements

+
nc_py_api[app]>=0.14.0
+transformers>=4.33
+torch
+torchvision
+torchaudio
+
+
+

We opt for the latest version of the Transformers library. +Because the example was developed on a Mac, we ended up using Torchvision.

+

You’re free to use TensorFlow instead of PyTorch.

+

Next, we integrate the latest version of nc_py_api to minimize code redundancy and focus on the application’s logic.

+
+
+

Prepare of Language Model

+
MODEL_NAME = "MBZUAI/LaMini-Flan-T5-77M"
+
+
+

We specify the model name globally so that we can easily change the model name if necessary.

+

When Should We Download the Language Model?

+

To make process of initializing applications more robust, separate logic was introduced, with an /init endpoint.

+

This library also provides an additional functionality over this endpoint for easy downloading of models from the huggingface.

+
@asynccontextmanager
+async def lifespan(_app: FastAPI):
+    set_handlers(APP, enabled_handler, models_to_fetch={MODEL_NAME:{}})
+    yield
+
+
+

This will automatically download models specified in models_to_fetch parameter to the application persistent storage.

+

If you want write your own logic, you can always pass your own defined init_handler callback to set_handlers.

+
+
+

Working with Language Models

+

Finally, we arrive at the core aspect of the application, where we interact with the Language Model:

+
def ai_talk_bot_process_request(message: talk_bot.TalkBotMessage):
+    # Process only messages started with "@ai"
+    r = re.search(r"@ai\s(.*)", message.object_content["message"], re.IGNORECASE)
+    if r is None:
+        return
+    model = pipeline(
+        "text2text-generation",
+        model=snapshot_download(MODEL_NAME, local_files_only=True, cache_dir=persistent_storage()),
+    )
+    # Pass all text after "@ai" we to the Language model.
+    response_text = model(r.group(1), max_length=64, do_sample=True)[0]["generated_text"]
+    AI_BOT.send_message(response_text, message)
+
+
+

Simply put, AI logic is a few lines of code when using Transformers, which is incredibly efficient and cool.

+

Messages from the AI model are then sent back to Talk Chat as you would expect from a typical chatbot.

+

Full source code is here

+

That’s it for now! Stay tuned—this is merely the start of an exciting journey into the integration of AI and chat functionality in Nextcloud.

+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/NextcloudUiApp.html b/NextcloudUiApp.html new file mode 100644 index 00000000..b96419fb --- /dev/null +++ b/NextcloudUiApp.html @@ -0,0 +1,221 @@ + + + + + + + Writing Nextcloud App with UI — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Writing Nextcloud App with UI

+
+

Note

+

It is advisable to have experience writing PHP applications for Nextcloud, +since the UI of applications not written in PHP is exactly the same.

+
+

One of the most interesting features is the ability to register a page in the Nextcloud Top Menu.

+

Full source of UI example can be found here: +UiExample

+

Here we will simply describe in detail what happens in the example.

+
if enabled:
+    nc.ui.resources.set_initial_state(
+        "top_menu", "first_menu", "ui_example_state", {"initial_value": "test init value"}
+    )
+    nc.ui.resources.set_script("top_menu", "first_menu", "js/ui_example-main")
+    nc.ui.top_menu.register("first_menu", "UI example", "img/icon.svg")
+    if nc.srv_version["major"] >= 29:
+        nc.ui.settings.register_form(SETTINGS_EXAMPLE)
+
+
+

set_initial_state is analogue of PHP OCP\AppFramework\Services\IInitialState::provideInitialState

+

set_script is analogue of PHP Util::addScript

+

There is also set_style (Util::addStyle) that can be used for CSS files and works the same way as set_script.

+

Starting with Nextcloud 29 AppAPI supports declaring Settings UI, with very simple and robust API.

+

Settings values you declare will be saved to preferences_ex or appconfig_ex tables and can be retrieved using +nc_py_api._preferences_ex.PreferencesExAPI or nc_py_api._preferences_ex.AppConfigExAPI APIs.

+
+

Backend

+
class Button1Format(BaseModel):
+    initial_value: str
+
+
+@APP.post("/verify_initial_value")
+async def verify_initial_value(
+    input1: Button1Format,
+):
+    print("Old value: ", input1.initial_value)
+    return responses.JSONResponse(content={"initial_value": str(random.randint(0, 100))}, status_code=200)
+
+
+class FileInfo(BaseModel):
+    getlastmodified: str
+    getetag: str
+    getcontenttype: str
+    fileid: int
+    permissions: str
+    size: int
+    getcontentlength: int
+    favorite: int
+
+
+@APP.post("/nextcloud_file")
+async def nextcloud_file(
+    args: dict,
+):
+    print(args["file_info"])
+    return responses.Response()
+
+
+

Here is defining two endpoints for test purposes.

+

The first is to get the current initial state of the page when the button is clicked.

+

Second one is receiving a default information about file in the Nextcloud.

+
+
+

Frontend

+

The frontend part is the same as the default Nextcloud apps, with slightly different URL generation since all requests are sent through the AppAPI.

+

JS Frontend part is covered by AppAPI documentation: to_do

+
+
+

Important notes

+

We do not call top_menu.unregister or resources.delete_script as during uninstalling of application AppAPI will automatically remove this.

+
+

Note

+

Recommended way is to manually clean all stuff and probably if it was not an example, we would call all unregister and cleanup stuff during disabling.

+
+

All resources of ExApp should be avalaible and mounted to webserver(FastAPI + uvicorn are used by default for this).

+
+

Note

+

This is in case you have custom folders that Nextcloud instance should have access.

+
+

P.S.: If you are missing some required stuff for the UI part, please inform us, and we will consider adding it.

+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/Options.html b/Options.html new file mode 100644 index 00000000..e3f76349 --- /dev/null +++ b/Options.html @@ -0,0 +1,219 @@ + + + + + + + Options — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +
+

Options

+

Options to change nc_py_api’s runtime behavior.

+

Each setting only affects newly created instances of Nextcloud/NextcloudApp class, unless otherwise specified. +Specifying options in kwargs has higher priority than this.

+
+
+nc_py_api.options.XDEBUG_SESSION = ''
+

Dev option, for debugging PHP code.

+
+ +
+
+nc_py_api.options.NPA_TIMEOUT: int | None = 30
+

Default timeout for OCS API calls. Set to None to disable timeouts for development.

+
+ +
+
+nc_py_api.options.NPA_TIMEOUT_DAV: int | None = 90
+

File operations timeout, usually it is OCS timeout multiplied by 3.

+
+ +
+
+nc_py_api.options.NPA_NC_CERT: bool | str = True
+

Option to enable/disable Nextcloud certificate verification.

+

SSL certificates (a.k.a CA bundle) used to verify the identity of requested hosts. Either True (default CA bundle), +a path to an SSL certificate file, or False (which will disable verification).

+
+ +
+
+nc_py_api.options.CHUNKED_UPLOAD_V2 = True
+

Option to enable/disable version 2 chunked upload(better Object Storages support).

+

Additional information can be found in Nextcloud documentation: +Chunked file upload V2

+
+ +
+

Usage examples

+
+

Using kwargs

+
+

Note

+

The names of the options if you wish to specify it in kwargs is lowercase.

+
+
nc_client = Nextcloud(xdebug_session="PHPSTORM", npa_nc_cert=False)
+
+
+

Will set XDEBUG_SESSION to "PHPSTORM" and NPA_NC_CERT to False.

+
+
+

With .env

+

Place .env file in your project’s directory, and it will be automatically loaded using dotenv

+

Loading occurs only once, when “nc_py_api” is imported into the Python interpreter.

+
+
+

Modifying at module level

+

Import nc_py_api and modify options by setting values you need directly in nc_py_api.options, +and all newly created classes will respect that.

+
import nc_py_api
+
+nc_py_api.options.NPA_TIMEOUT = None
+nc_py_api.options.NPA_TIMEOUT_DAV = None
+
+
+
+

Note

+

In case you debugging PHP code it is always a good idea to set Timeouts to None.

+
+
+
+
+ + +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_images/dav_download_1mb__cache0_iters30__shurik.png b/_images/dav_download_1mb__cache0_iters30__shurik.png new file mode 100644 index 00000000..32d036f0 Binary files /dev/null and b/_images/dav_download_1mb__cache0_iters30__shurik.png differ diff --git a/_images/dav_download_stream_100mb__cache0_iters10__shurik.png b/_images/dav_download_stream_100mb__cache0_iters10__shurik.png new file mode 100644 index 00000000..a041a19b Binary files /dev/null and b/_images/dav_download_stream_100mb__cache0_iters10__shurik.png differ diff --git a/_images/dav_upload_1mb__cache0_iters30__shurik.png b/_images/dav_upload_1mb__cache0_iters30__shurik.png new file mode 100644 index 00000000..aa9e19a3 Binary files /dev/null and b/_images/dav_upload_1mb__cache0_iters30__shurik.png differ diff --git a/_images/dav_upload_stream_100mb__cache0_iters10__shurik.png b/_images/dav_upload_stream_100mb__cache0_iters10__shurik.png new file mode 100644 index 00000000..32083482 Binary files /dev/null and b/_images/dav_upload_stream_100mb__cache0_iters10__shurik.png differ diff --git a/_images/ocs_user_get_details__cache0_iters100__shurik.png b/_images/ocs_user_get_details__cache0_iters100__shurik.png new file mode 100644 index 00000000..487e3cf2 Binary files /dev/null and b/_images/ocs_user_get_details__cache0_iters100__shurik.png differ diff --git a/_modules/index.html b/_modules/index.html new file mode 100644 index 00000000..b452568b --- /dev/null +++ b/_modules/index.html @@ -0,0 +1,160 @@ + + + + + + Overview: module code — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + +
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/_exceptions.html b/_modules/nc_py_api/_exceptions.html new file mode 100644 index 00000000..b1119ce6 --- /dev/null +++ b/_modules/nc_py_api/_exceptions.html @@ -0,0 +1,204 @@ + + + + + + nc_py_api._exceptions — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for nc_py_api._exceptions

+"""Exceptions for the Nextcloud API."""
+
+from httpx import Response, codes
+
+
+
+[docs] +class NextcloudException(Exception): + """The base exception for all Nextcloud operation errors.""" + + status_code: int + reason: str + + def __init__(self, status_code: int = 0, reason: str = "", info: str = ""): + super(BaseException, self).__init__() + self.status_code = status_code + self.reason = reason + self.info = info + + def __str__(self): + reason = f" {self.reason}" if self.reason else "" + info = f" <{self.info}>" if self.info else "" + return f"[{self.status_code}]{reason}{info}"
+ + + +
+[docs] +class NextcloudExceptionNotModified(NextcloudException): + """The exception indicates that there is no need to retransmit the requested resources.""" + + def __init__(self, reason="Not modified", info: str = ""): + super().__init__(304, reason=reason, info=info)
+ + + +
+[docs] +class NextcloudExceptionNotFound(NextcloudException): + """The exception that is thrown during operations when the object is not found.""" + + def __init__(self, reason="Not found", info: str = ""): + super().__init__(404, reason=reason, info=info)
+ + + +
+[docs] +class NextcloudMissingCapabilities(NextcloudException): + """The exception that is thrown when required capability for API is missing.""" + + def __init__(self, reason="Missing capability", info: str = ""): + super().__init__(412, reason=reason, info=info)
+ + + +def check_error(response: Response, info: str = ""): + """Checks HTTP code from Nextcloud, and raises exception in case of error. + + For the OCS and DAV `code` be code returned by HTTP and not the status from ``ocs_meta``. + """ + status_code = response.status_code + if not info: + info = f"request: {response.request.method} {response.request.url}" + if 996 <= status_code <= 999: + if status_code == 996: + phrase = "Server error" + elif status_code == 997: + phrase = "Unauthorised" + elif status_code == 998: + phrase = "Not found" + else: + phrase = "Unknown error" + raise NextcloudException(status_code, reason=phrase, info=info) + if not codes.is_error(status_code): + return + raise NextcloudException(status_code, reason=codes(status_code).phrase, info=info) +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/_preferences.html b/_modules/nc_py_api/_preferences.html new file mode 100644 index 00000000..76de640e --- /dev/null +++ b/_modules/nc_py_api/_preferences.html @@ -0,0 +1,188 @@ + + + + + + nc_py_api._preferences — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for nc_py_api._preferences

+"""Nextcloud API for working with classics app's storage with user's context (table oc_preferences)."""
+
+from ._misc import check_capabilities, require_capabilities
+from ._session import AsyncNcSessionBasic, NcSessionBasic
+
+
+
+[docs] +class PreferencesAPI: + """API for setting/removing configuration values of applications that support it.""" + + _ep_base: str = "/ocs/v1.php/apps/provisioning_api/api/v1/config/users" + + def __init__(self, session: NcSessionBasic): + self._session = session + + @property + def available(self) -> bool: + """Returns True if the Nextcloud instance supports this feature, False otherwise.""" + return not check_capabilities("provisioning_api", self._session.capabilities) + +
+[docs] + def set_value(self, app_name: str, key: str, value: str) -> None: + """Sets the value for the key for the specific application.""" + require_capabilities("provisioning_api", self._session.capabilities) + self._session.ocs("POST", f"{self._ep_base}/{app_name}/{key}", params={"configValue": value})
+ + +
+[docs] + def delete(self, app_name: str, key: str) -> None: + """Removes a key and its value for a specific application.""" + require_capabilities("provisioning_api", self._session.capabilities) + self._session.ocs("DELETE", f"{self._ep_base}/{app_name}/{key}")
+
+ + + +class AsyncPreferencesAPI: + """Async API for setting/removing configuration values of applications that support it.""" + + _ep_base: str = "/ocs/v1.php/apps/provisioning_api/api/v1/config/users" + + def __init__(self, session: AsyncNcSessionBasic): + self._session = session + + @property + async def available(self) -> bool: + """Returns True if the Nextcloud instance supports this feature, False otherwise.""" + return not check_capabilities("provisioning_api", await self._session.capabilities) + + async def set_value(self, app_name: str, key: str, value: str) -> None: + """Sets the value for the key for the specific application.""" + require_capabilities("provisioning_api", await self._session.capabilities) + await self._session.ocs("POST", f"{self._ep_base}/{app_name}/{key}", params={"configValue": value}) + + async def delete(self, app_name: str, key: str) -> None: + """Removes a key and its value for a specific application.""" + require_capabilities("provisioning_api", await self._session.capabilities) + await self._session.ocs("DELETE", f"{self._ep_base}/{app_name}/{key}") +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/_preferences_ex.html b/_modules/nc_py_api/_preferences_ex.html new file mode 100644 index 00000000..41ee883f --- /dev/null +++ b/_modules/nc_py_api/_preferences_ex.html @@ -0,0 +1,317 @@ + + + + + + nc_py_api._preferences_ex — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for nc_py_api._preferences_ex

+"""Nextcloud API for working with apps V2's storage w/wo user context(table oc_appconfig_ex/oc_preferences_ex)."""
+
+import dataclasses
+
+from ._exceptions import NextcloudExceptionNotFound
+from ._misc import require_capabilities
+from ._session import AsyncNcSessionBasic, NcSessionBasic
+
+
+
+[docs] +@dataclasses.dataclass +class CfgRecord: + """A representation of a single key-value pair returned from the **get_values** method.""" + + key: str + value: str + + def __init__(self, raw_data: dict): + self.key = raw_data["configkey"] + self.value = raw_data["configvalue"]
+ + + +class _BasicAppCfgPref: + _url_suffix: str + + def __init__(self, session: NcSessionBasic): + self._session = session + + def get_value(self, key: str, default=None) -> str | None: + """Returns the value of the key, if found, or the specified default value.""" + if not key: + raise ValueError("`key` parameter can not be empty") + require_capabilities("app_api", self._session.capabilities) + r = self.get_values([key]) + if r: + return r[0].value + return default + + def get_values(self, keys: list[str]) -> list[CfgRecord]: + """Returns the :py:class:`CfgRecord` for each founded key.""" + if not keys: + return [] + if not all(keys): + raise ValueError("`key` parameter can not be empty") + require_capabilities("app_api", self._session.capabilities) + data = {"configKeys": keys} + results = self._session.ocs("POST", f"{self._session.ae_url}/{self._url_suffix}/get-values", json=data) + return [CfgRecord(i) for i in results] + + def delete(self, keys: str | list[str], not_fail=True) -> None: + """Deletes config/preference entries by the provided keys.""" + if isinstance(keys, str): + keys = [keys] + if not keys: + return + if not all(keys): + raise ValueError("`key` parameter can not be empty") + require_capabilities("app_api", self._session.capabilities) + try: + self._session.ocs("DELETE", f"{self._session.ae_url}/{self._url_suffix}", json={"configKeys": keys}) + except NextcloudExceptionNotFound as e: + if not not_fail: + raise e from None + + +class _AsyncBasicAppCfgPref: + _url_suffix: str + + def __init__(self, session: AsyncNcSessionBasic): + self._session = session + + async def get_value(self, key: str, default=None) -> str | None: + """Returns the value of the key, if found, or the specified default value.""" + if not key: + raise ValueError("`key` parameter can not be empty") + require_capabilities("app_api", await self._session.capabilities) + r = await self.get_values([key]) + if r: + return r[0].value + return default + + async def get_values(self, keys: list[str]) -> list[CfgRecord]: + """Returns the :py:class:`CfgRecord` for each founded key.""" + if not keys: + return [] + if not all(keys): + raise ValueError("`key` parameter can not be empty") + require_capabilities("app_api", await self._session.capabilities) + data = {"configKeys": keys} + results = await self._session.ocs("POST", f"{self._session.ae_url}/{self._url_suffix}/get-values", json=data) + return [CfgRecord(i) for i in results] + + async def delete(self, keys: str | list[str], not_fail=True) -> None: + """Deletes config/preference entries by the provided keys.""" + if isinstance(keys, str): + keys = [keys] + if not keys: + return + if not all(keys): + raise ValueError("`key` parameter can not be empty") + require_capabilities("app_api", await self._session.capabilities) + try: + await self._session.ocs("DELETE", f"{self._session.ae_url}/{self._url_suffix}", json={"configKeys": keys}) + except NextcloudExceptionNotFound as e: + if not not_fail: + raise e from None + + +
+[docs] +class PreferencesExAPI(_BasicAppCfgPref): + """User specific preferences API, avalaible as **nc.preferences_ex.<method>**.""" + + _url_suffix = "ex-app/preference" + +
+[docs] + def set_value(self, key: str, value: str) -> None: + """Sets a value for a key.""" + if not key: + raise ValueError("`key` parameter can not be empty") + require_capabilities("app_api", self._session.capabilities) + params = {"configKey": key, "configValue": value} + self._session.ocs("POST", f"{self._session.ae_url}/{self._url_suffix}", json=params)
+
+ + + +class AsyncPreferencesExAPI(_AsyncBasicAppCfgPref): + """User specific preferences API.""" + + _url_suffix = "ex-app/preference" + + async def set_value(self, key: str, value: str) -> None: + """Sets a value for a key.""" + if not key: + raise ValueError("`key` parameter can not be empty") + require_capabilities("app_api", await self._session.capabilities) + params = {"configKey": key, "configValue": value} + await self._session.ocs("POST", f"{self._session.ae_url}/{self._url_suffix}", json=params) + + +
+[docs] +class AppConfigExAPI(_BasicAppCfgPref): + """Non-user(App) specific preferences API, avalaible as **nc.appconfig_ex.<method>**.""" + + _url_suffix = "ex-app/config" + +
+[docs] + def set_value(self, key: str, value: str, sensitive: bool | None = None) -> None: + """Sets a value and if specified the sensitive flag for a key. + + .. note:: A sensitive flag ensures key values are truncated in Nextcloud logs. + Default for new records is ``False`` when sensitive is *unspecified*, if changes existing record and + sensitive is *unspecified* it will not change the existing `sensitive` flag. + """ + if not key: + raise ValueError("`key` parameter can not be empty") + require_capabilities("app_api", self._session.capabilities) + params: dict = {"configKey": key, "configValue": value} + if sensitive is not None: + params["sensitive"] = sensitive + self._session.ocs("POST", f"{self._session.ae_url}/{self._url_suffix}", json=params)
+
+ + + +class AsyncAppConfigExAPI(_AsyncBasicAppCfgPref): + """Non-user(App) specific preferences API.""" + + _url_suffix = "ex-app/config" + + async def set_value(self, key: str, value: str, sensitive: bool | None = None) -> None: + """Sets a value and if specified the sensitive flag for a key. + + .. note:: A sensitive flag ensures key values are truncated in Nextcloud logs. + Default for new records is ``False`` when sensitive is *unspecified*, if changes existing record and + sensitive is *unspecified* it will not change the existing `sensitive` flag. + """ + if not key: + raise ValueError("`key` parameter can not be empty") + require_capabilities("app_api", await self._session.capabilities) + params: dict = {"configKey": key, "configValue": value} + if sensitive is not None: + params["sensitive"] = sensitive + await self._session.ocs("POST", f"{self._session.ae_url}/{self._url_suffix}", json=params) +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/_session.html b/_modules/nc_py_api/_session.html new file mode 100644 index 00000000..374bbf40 --- /dev/null +++ b/_modules/nc_py_api/_session.html @@ -0,0 +1,686 @@ + + + + + + nc_py_api._session — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for nc_py_api._session

+"""Session represents one connection to Nextcloud. All related stuff for these live here."""
+
+import builtins
+import pathlib
+import re
+import typing
+from abc import ABC, abstractmethod
+from base64 import b64encode
+from dataclasses import dataclass
+from enum import IntEnum
+from json import loads
+from os import environ
+
+from httpx import AsyncClient, Client, Headers, Limits, ReadTimeout, Request, Response
+from starlette.requests import HTTPConnection
+
+from . import options
+from ._exceptions import (
+    NextcloudException,
+    NextcloudExceptionNotFound,
+    NextcloudExceptionNotModified,
+    check_error,
+)
+from ._misc import get_username_secret_from_headers
+
+
+class OCSRespond(IntEnum):
+    """Special Nextcloud respond statuses for OCS calls."""
+
+    RESPOND_SERVER_ERROR = 996
+    RESPOND_UNAUTHORISED = 997
+    RESPOND_NOT_FOUND = 998
+    RESPOND_UNKNOWN_ERROR = 999
+
+
+
+[docs] +class ServerVersion(typing.TypedDict): + """Nextcloud version information.""" + + major: int + """Major version""" + minor: int + """Minor version""" + micro: int + """Micro version""" + string: str + """Full version in string format""" + extended_support: bool + """Indicates if the subscription has extended support"""
+ + + +@dataclass +class RuntimeOptions: + xdebug_session: str + timeout: int | None + timeout_dav: int | None + _nc_cert: str | bool + upload_chunk_v2: bool + + def __init__(self, **kwargs): + self.xdebug_session = kwargs.get("xdebug_session", options.XDEBUG_SESSION) + self.timeout = kwargs.get("npa_timeout", options.NPA_TIMEOUT) + self.timeout_dav = kwargs.get("npa_timeout_dav", options.NPA_TIMEOUT_DAV) + self._nc_cert = kwargs.get("npa_nc_cert", options.NPA_NC_CERT) + self.upload_chunk_v2 = kwargs.get("chunked_upload_v2", options.CHUNKED_UPLOAD_V2) + + @property + def nc_cert(self) -> str | bool: + return self._nc_cert + + +@dataclass +class BasicConfig: + endpoint: str + dav_endpoint: str + dav_url_suffix: str + options: RuntimeOptions + + def __init__(self, **kwargs): + full_nc_url = self._get_config_value("nextcloud_url", **kwargs) + self.endpoint = full_nc_url.removesuffix("/index.php").removesuffix("/") + self.dav_url_suffix = self._get_config_value("dav_url_suffix", raise_not_found=False, **kwargs) + if not self.dav_url_suffix: + self.dav_url_suffix = "remote.php/dav" + self.dav_url_suffix = "/" + self.dav_url_suffix.strip("/") + self.dav_endpoint = self.endpoint + self.dav_url_suffix + self.options = RuntimeOptions(**kwargs) + + @staticmethod + def _get_config_value(value_name: str, raise_not_found=True, **kwargs): + if value_name in kwargs: + return kwargs[value_name] + value_name_upper = value_name.upper() + if value_name_upper in environ: + return environ[value_name_upper] + if raise_not_found: + raise ValueError(f"`{value_name}` is not found.") + return None + + +@dataclass +class Config(BasicConfig): + auth: tuple[str, str] = ("", "") + + def __init__(self, **kwargs): + super().__init__(**kwargs) + nc_auth_user = self._get_config_value("nc_auth_user", raise_not_found=False, **kwargs) + nc_auth_pass = self._get_config_value("nc_auth_pass", raise_not_found=False, **kwargs) + if nc_auth_user and nc_auth_pass: + self.auth = (nc_auth_user, nc_auth_pass) + + +
+[docs] +@dataclass +class AppConfig(BasicConfig): + """Application configuration.""" + + aa_version: str + """AppAPI version""" + app_name: str + """Application ID""" + app_version: str + """Application version""" + app_secret: str + """Application authentication secret""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.aa_version = self._get_config_value("aa_version", raise_not_found=False, **kwargs) + if not self.aa_version: + self.aa_version = "2.2.0" + self.app_name = self._get_config_value("app_id", **kwargs) + self.app_version = self._get_config_value("app_version", **kwargs) + self.app_secret = self._get_config_value("app_secret", **kwargs)
+ + + +class NcSessionBase(ABC): + adapter: AsyncClient | Client + adapter_dav: AsyncClient | Client + cfg: BasicConfig + custom_headers: dict + response_headers: Headers + _user: str + _capabilities: dict + + @abstractmethod + def __init__(self, **kwargs): + self._capabilities = {} + self._user = kwargs.get("user", "") + self.custom_headers = kwargs.get("headers", {}) + self.limits = Limits(max_keepalive_connections=20, max_connections=20, keepalive_expiry=60.0) + self.init_adapter() + self.init_adapter_dav() + self.response_headers = Headers() + self._ocs_regexp = re.compile(r"/ocs/v[12]\.php/|/apps/groupfolders/") + + def init_adapter(self, restart=False) -> None: + if getattr(self, "adapter", None) is None or restart: + self.adapter = self._create_adapter() + self.adapter.headers.update({"OCS-APIRequest": "true"}) + if self.custom_headers: + self.adapter.headers.update(self.custom_headers) + if options.XDEBUG_SESSION: + self.adapter.cookies.set("XDEBUG_SESSION", options.XDEBUG_SESSION) + self._capabilities = {} + + def init_adapter_dav(self, restart=False) -> None: + if getattr(self, "adapter_dav", None) is None or restart: + self.adapter_dav = self._create_adapter(dav=True) + if self.custom_headers: + self.adapter_dav.headers.update(self.custom_headers) + if options.XDEBUG_SESSION: + self.adapter_dav.cookies.set("XDEBUG_SESSION", options.XDEBUG_SESSION) + + @abstractmethod + def _create_adapter(self, dav: bool = False) -> AsyncClient | Client: + pass # pragma: no cover + + @property + def ae_url(self) -> str: + """Return base url for the AppAPI endpoints.""" + return "/ocs/v1.php/apps/app_api/api/v1" + + @property + def ae_url_v2(self) -> str: + """Return base url for the AppAPI endpoints(version 2).""" + return "/ocs/v1.php/apps/app_api/api/v2" + + +
+[docs] +class NcSessionBasic(NcSessionBase, ABC): + adapter: Client + adapter_dav: Client + + def ocs( + self, + method: str, + path: str, + *, + content: bytes | str | typing.Iterable[bytes] | typing.AsyncIterable[bytes] | None = None, + json: dict | list | None = None, + params: dict | None = None, + files: dict | None = None, + **kwargs, + ): + self.init_adapter() + info = f"request: {method} {path}" + nested_req = kwargs.pop("nested_req", False) + try: + response = self.adapter.request( + method, path, content=content, json=json, params=params, files=files, **kwargs + ) + except ReadTimeout: + raise NextcloudException(408, info=info) from None + + check_error(response, info) + if response.status_code == 204: # NO_CONTENT + return [] + response_data = loads(response.text) + ocs_meta = response_data["ocs"]["meta"] + if ocs_meta["status"] != "ok": + if ( + not nested_req + and ocs_meta["statuscode"] == 403 + and str(ocs_meta["message"]).lower().find("password confirmation is required") != -1 + ): + self.adapter.close() + self.init_adapter(restart=True) + return self.ocs(method, path, **kwargs, content=content, json=json, params=params, nested_req=True) + if ocs_meta["statuscode"] in (404, OCSRespond.RESPOND_NOT_FOUND): + raise NextcloudExceptionNotFound(reason=ocs_meta["message"], info=info) + if ocs_meta["statuscode"] == 304: + raise NextcloudExceptionNotModified(reason=ocs_meta["message"], info=info) + raise NextcloudException(status_code=ocs_meta["statuscode"], reason=ocs_meta["message"], info=info) + return response_data["ocs"]["data"] + + def update_server_info(self) -> None: + self._capabilities = self.ocs("GET", "/ocs/v1.php/cloud/capabilities") + + @property + def capabilities(self) -> dict: + if not self._capabilities: + self.update_server_info() + return self._capabilities["capabilities"] + + @property + def nc_version(self) -> ServerVersion: + if not self._capabilities: + self.update_server_info() + v = self._capabilities["version"] + return ServerVersion( + major=v["major"], + minor=v["minor"], + micro=v["micro"], + string=v["string"], + extended_support=v["extendedSupport"], + ) + + @property + def user(self) -> str: + """Current user ID. Can be different from the login name.""" + if isinstance(self, NcSession) and not self._user: # do not trigger for NextcloudApp + self._user = self.ocs("GET", "/ocs/v1.php/cloud/user")["id"] + return self._user + + def set_user(self, user_id: str) -> None: + self._user = user_id + + def download2stream(self, url_path: str, fp, dav: bool = False, **kwargs): + if isinstance(fp, str | pathlib.Path): + with builtins.open(fp, "wb") as f: + self.download2fp(url_path, f, dav, **kwargs) + elif hasattr(fp, "write"): + self.download2fp(url_path, fp, dav, **kwargs) + else: + raise TypeError("`fp` must be a path to file or an object with `write` method.") + + def _get_adapter_kwargs(self, dav: bool) -> dict[str, typing.Any]: + if dav: + return { + "base_url": self.cfg.dav_endpoint, + "timeout": self.cfg.options.timeout_dav, + "event_hooks": {"request": [], "response": [self._response_event]}, + } + return { + "base_url": self.cfg.endpoint, + "timeout": self.cfg.options.timeout, + "event_hooks": {"request": [self._request_event_ocs], "response": [self._response_event]}, + } + + def _request_event_ocs(self, request: Request) -> None: + str_url = str(request.url) + if re.search(self._ocs_regexp, str_url) is not None: # this is OCS call + request.url = request.url.copy_merge_params({"format": "json"}) + request.headers["Accept"] = "application/json" + + def _response_event(self, response: Response) -> None: + str_url = str(response.request.url) + # we do not want ResponseHeaders for those two endpoints, as call to them can occur during DAV calls. + for i in ("/ocs/v1.php/cloud/capabilities?format=json", "/ocs/v1.php/cloud/user?format=json"): + if str_url.endswith(i): + return + self.response_headers = response.headers + + def download2fp(self, url_path: str, fp, dav: bool, params=None, **kwargs): + adapter = self.adapter_dav if dav else self.adapter + with adapter.stream("GET", url_path, params=params) as response: + check_error(response) + for data_chunk in response.iter_raw(chunk_size=kwargs.get("chunk_size", 5 * 1024 * 1024)): + fp.write(data_chunk)
+ + + +class AsyncNcSessionBasic(NcSessionBase, ABC): + adapter: AsyncClient + adapter_dav: AsyncClient + + async def ocs( + self, + method: str, + path: str, + *, + content: bytes | str | typing.Iterable[bytes] | typing.AsyncIterable[bytes] | None = None, + json: dict | list | None = None, + params: dict | None = None, + files: dict | None = None, + **kwargs, + ): + self.init_adapter() + info = f"request: {method} {path}" + nested_req = kwargs.pop("nested_req", False) + try: + response = await self.adapter.request( + method, path, content=content, json=json, params=params, files=files, **kwargs + ) + except ReadTimeout: + raise NextcloudException(408, info=info) from None + + check_error(response, info) + if response.status_code == 204: # NO_CONTENT + return [] + response_data = loads(response.text) + ocs_meta = response_data["ocs"]["meta"] + if ocs_meta["status"] != "ok": + if ( + not nested_req + and ocs_meta["statuscode"] == 403 + and str(ocs_meta["message"]).lower().find("password confirmation is required") != -1 + ): + await self.adapter.aclose() + self.init_adapter(restart=True) + return await self.ocs( + method, path, **kwargs, content=content, json=json, params=params, nested_req=True + ) + if ocs_meta["statuscode"] in (404, OCSRespond.RESPOND_NOT_FOUND): + raise NextcloudExceptionNotFound(reason=ocs_meta["message"], info=info) + if ocs_meta["statuscode"] == 304: + raise NextcloudExceptionNotModified(reason=ocs_meta["message"], info=info) + raise NextcloudException(status_code=ocs_meta["statuscode"], reason=ocs_meta["message"], info=info) + return response_data["ocs"]["data"] + + async def update_server_info(self) -> None: + self._capabilities = await self.ocs("GET", "/ocs/v1.php/cloud/capabilities") + + @property + async def capabilities(self) -> dict: + if not self._capabilities: + await self.update_server_info() + return self._capabilities["capabilities"] + + @property + async def nc_version(self) -> ServerVersion: + if not self._capabilities: + await self.update_server_info() + v = self._capabilities["version"] + return ServerVersion( + major=v["major"], + minor=v["minor"], + micro=v["micro"], + string=v["string"], + extended_support=v["extendedSupport"], + ) + + @property + async def user(self) -> str: + """Current user ID. Can be different from the login name.""" + if isinstance(self, AsyncNcSession) and not self._user: # do not trigger for NextcloudApp + self._user = (await self.ocs("GET", "/ocs/v1.php/cloud/user"))["id"] + return self._user + + def set_user(self, user: str) -> None: + self._user = user + + async def download2stream(self, url_path: str, fp, dav: bool = False, **kwargs): + if isinstance(fp, str | pathlib.Path): + with builtins.open(fp, "wb") as f: + await self.download2fp(url_path, f, dav, **kwargs) + elif hasattr(fp, "write"): + await self.download2fp(url_path, fp, dav, **kwargs) + else: + raise TypeError("`fp` must be a path to file or an object with `write` method.") + + def _get_adapter_kwargs(self, dav: bool) -> dict[str, typing.Any]: + if dav: + return { + "base_url": self.cfg.dav_endpoint, + "timeout": self.cfg.options.timeout_dav, + "event_hooks": {"request": [], "response": [self._response_event]}, + } + return { + "base_url": self.cfg.endpoint, + "timeout": self.cfg.options.timeout, + "event_hooks": {"request": [self._request_event_ocs], "response": [self._response_event]}, + } + + async def _request_event_ocs(self, request: Request) -> None: + str_url = str(request.url) + if re.search(self._ocs_regexp, str_url) is not None: # this is OCS call + request.url = request.url.copy_merge_params({"format": "json"}) + request.headers["Accept"] = "application/json" + + async def _response_event(self, response: Response) -> None: + str_url = str(response.request.url) + # we do not want ResponseHeaders for those two endpoints, as call to them can occur during DAV calls. + for i in ("/ocs/v1.php/cloud/capabilities?format=json", "/ocs/v1.php/cloud/user?format=json"): + if str_url.endswith(i): + return + self.response_headers = response.headers + + async def download2fp(self, url_path: str, fp, dav: bool, params=None, **kwargs): + adapter = self.adapter_dav if dav else self.adapter + async with adapter.stream("GET", url_path, params=params) as response: + check_error(response) + async for data_chunk in response.aiter_raw(chunk_size=kwargs.get("chunk_size", 5 * 1024 * 1024)): + fp.write(data_chunk) + + +
+[docs] +class NcSession(NcSessionBasic): + cfg: Config + + def __init__(self, **kwargs): + self.cfg = Config(**kwargs) + super().__init__() + + def _create_adapter(self, dav: bool = False) -> AsyncClient | Client: + return Client( + follow_redirects=True, + limits=self.limits, + verify=self.cfg.options.nc_cert, + **self._get_adapter_kwargs(dav), + auth=self.cfg.auth, + )
+ + + +class AsyncNcSession(AsyncNcSessionBasic): + cfg: Config + + def __init__(self, **kwargs): + self.cfg = Config(**kwargs) + super().__init__() + + def _create_adapter(self, dav: bool = False) -> AsyncClient | Client: + return AsyncClient( + follow_redirects=True, + limits=self.limits, + verify=self.cfg.options.nc_cert, + **self._get_adapter_kwargs(dav), + auth=self.cfg.auth, + ) + + +class NcSessionAppBasic(ABC): + cfg: AppConfig + _user: str + adapter: AsyncClient | Client + adapter_dav: AsyncClient | Client + + def __init__(self, **kwargs): + self.cfg = AppConfig(**kwargs) + super().__init__(**kwargs) + + def sign_check(self, request: HTTPConnection) -> str: + headers = { + "AA-VERSION": request.headers.get("AA-VERSION", ""), + "EX-APP-ID": request.headers.get("EX-APP-ID", ""), + "EX-APP-VERSION": request.headers.get("EX-APP-VERSION", ""), + "AUTHORIZATION-APP-API": request.headers.get("AUTHORIZATION-APP-API", ""), + } + + empty_headers = [k for k, v in headers.items() if not v] + if empty_headers: + raise ValueError(f"Missing required headers:{empty_headers}") + + if headers["EX-APP-ID"] != self.cfg.app_name: + raise ValueError(f"Invalid EX-APP-ID:{headers['EX-APP-ID']} != {self.cfg.app_name}") + + username, app_secret = get_username_secret_from_headers(headers) + if app_secret != self.cfg.app_secret: + raise ValueError(f"Invalid App secret:{app_secret} != {self.cfg.app_secret}") + return username + + +
+[docs] +class NcSessionApp(NcSessionAppBasic, NcSessionBasic): + cfg: AppConfig + + def _create_adapter(self, dav: bool = False) -> AsyncClient | Client: + r = self._get_adapter_kwargs(dav) + r["event_hooks"]["request"].append(self._add_auth) + return Client( + follow_redirects=True, + limits=self.limits, + verify=self.cfg.options.nc_cert, + **r, + headers={ + "AA-VERSION": self.cfg.aa_version, + "EX-APP-ID": self.cfg.app_name, + "EX-APP-VERSION": self.cfg.app_version, + }, + ) + + def _add_auth(self, request: Request): + request.headers.update( + {"AUTHORIZATION-APP-API": b64encode(f"{self._user}:{self.cfg.app_secret}".encode("UTF=8"))} + )
+ + + +class AsyncNcSessionApp(NcSessionAppBasic, AsyncNcSessionBasic): + cfg: AppConfig + + def _create_adapter(self, dav: bool = False) -> AsyncClient | Client: + r = self._get_adapter_kwargs(dav) + r["event_hooks"]["request"].append(self._add_auth) + return AsyncClient( + follow_redirects=True, + limits=self.limits, + verify=self.cfg.options.nc_cert, + **r, + headers={ + "AA-VERSION": self.cfg.aa_version, + "EX-APP-ID": self.cfg.app_name, + "EX-APP-VERSION": self.cfg.app_version, + }, + ) + + async def _add_auth(self, request: Request): + request.headers.update( + {"AUTHORIZATION-APP-API": b64encode(f"{self._user}:{self.cfg.app_secret}".encode("UTF=8"))} + ) +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/_talk_api.html b/_modules/nc_py_api/_talk_api.html new file mode 100644 index 00000000..de9fdd3f --- /dev/null +++ b/_modules/nc_py_api/_talk_api.html @@ -0,0 +1,1203 @@ + + + + + + nc_py_api._talk_api — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for nc_py_api._talk_api

+"""Nextcloud Talk API implementation."""
+
+import hashlib
+
+from ._exceptions import check_error
+from ._misc import (
+    check_capabilities,
+    clear_from_params_empty,
+    random_string,
+    require_capabilities,
+)
+from ._session import AsyncNcSessionBasic, NcSessionBasic
+from .files import FsNode, Share, ShareType
+from .talk import (
+    BotInfo,
+    BotInfoBasic,
+    Conversation,
+    ConversationType,
+    MessageReactions,
+    NotificationLevel,
+    Participant,
+    Poll,
+    TalkFileMessage,
+    TalkMessage,
+)
+
+
+
+[docs] +class _TalkAPI: + """Class provides API to work with Nextcloud Talk, avalaible as **nc.talk.<method>**.""" + + _ep_base: str = "/ocs/v2.php/apps/spreed" + config_sha: str + """Sha1 value over Talk config. After receiving a different value on subsequent requests, settings got refreshed.""" + modified_since: int + """Used by ``get_user_conversations``, when **modified_since** param is ``True``.""" + + def __init__(self, session: NcSessionBasic): + self._session = session + self.config_sha = "" + self.modified_since = 0 + + @property + def available(self) -> bool: + """Returns True if the Nextcloud instance supports this feature, False otherwise.""" + return not check_capabilities("spreed", self._session.capabilities) + + @property + def bots_available(self) -> bool: + """Returns True if the Nextcloud instance supports this feature, False otherwise.""" + return not check_capabilities("spreed.features.bots-v1", self._session.capabilities) + +
+[docs] + def get_user_conversations( + self, no_status_update: bool = True, include_status: bool = False, modified_since: int | bool = 0 + ) -> list[Conversation]: + """Returns the list of the user's conversations. + + :param no_status_update: When the user status should not be automatically set to the online. + :param include_status: Whether the user status information of all one-to-one conversations should be loaded. + :param modified_since: When provided only conversations with a newer **lastActivity** + (and one-to-one conversations when includeStatus is provided) are returned. + Can be set to ``True`` to automatically use last ``modified_since`` from previous calls. Default = **0**. + + .. note:: In rare cases, when a request arrives between seconds, it is possible that return data + will contain part of the conversations from the last call that was not modified( + their `last_activity` will be the same as ``talk.modified_since``). + """ + params: dict = {} + if no_status_update: + params["noStatusUpdate"] = 1 + if include_status: + params["includeStatus"] = 1 + if modified_since: + params["modifiedSince"] = self.modified_since if modified_since is True else modified_since + + result = self._session.ocs("GET", self._ep_base + "/api/v4/room", params=params) + self.modified_since = int(self._session.response_headers["X-Nextcloud-Talk-Modified-Before"]) + self._update_config_sha() + return [Conversation(i) for i in result]
+ + +
+[docs] + def list_participants(self, conversation: Conversation | str, include_status: bool = False) -> list[Participant]: + """Returns a list of conversation participants. + + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + :param include_status: Whether the user status information of all one-to-one conversations should be loaded. + """ + token = conversation.token if isinstance(conversation, Conversation) else conversation + result = self._session.ocs( + "GET", self._ep_base + f"/api/v4/room/{token}/participants", params={"includeStatus": int(include_status)} + ) + return [Participant(i) for i in result]
+ + +
+[docs] + def create_conversation( + self, + conversation_type: ConversationType, + invite: str = "", + source: str = "", + room_name: str = "", + object_type: str = "", + object_id: str = "", + ) -> Conversation: + """Creates a new conversation. + + .. note:: Creating a conversation as a child breakout room will automatically set the lobby when breakout + rooms are not started and will always overwrite the room type with the parent room type. + Also, moderators of the parent conversation will be automatically added as moderators. + + :param conversation_type: type of the conversation to create. + :param invite: User ID(roomType=ONE_TO_ONE), Group ID(roomType=GROUP - optional), + Circle ID(roomType=GROUP, source='circles', only available with the ``circles-support`` capability). + :param source: The source for the invite, only supported on roomType = GROUP for groups and circles. + :param room_name: Conversation name up to 255 characters(``not available for roomType=ONE_TO_ONE``). + :param object_type: Type of object this room references, currently only allowed + value is **"room"** to indicate the parent of a breakout room. + :param object_id: ID of an object this room references, room token is used for the parent of a breakout room. + """ + params: dict = { + "roomType": int(conversation_type), + "invite": invite, + "source": source, + "roomName": room_name, + "objectType": object_type, + "objectId": object_id, + } + clear_from_params_empty(["invite", "source", "roomName", "objectType", "objectId"], params) + return Conversation(self._session.ocs("POST", self._ep_base + "/api/v4/room", json=params))
+ + +
+[docs] + def rename_conversation(self, conversation: Conversation | str, new_name: str) -> None: + """Renames a conversation.""" + token = conversation.token if isinstance(conversation, Conversation) else conversation + self._session.ocs("PUT", self._ep_base + f"/api/v4/room/{token}", params={"roomName": new_name})
+ + +
+[docs] + def set_conversation_description(self, conversation: Conversation | str, description: str) -> None: + """Sets conversation description.""" + token = conversation.token if isinstance(conversation, Conversation) else conversation + self._session.ocs( + "PUT", self._ep_base + f"/api/v4/room/{token}/description", params={"description": description} + )
+ + +
+[docs] + def set_conversation_fav(self, conversation: Conversation | str, favorite: bool) -> None: + """Changes conversation **favorite** state.""" + token = conversation.token if isinstance(conversation, Conversation) else conversation + self._session.ocs("POST" if favorite else "DELETE", self._ep_base + f"/api/v4/room/{token}/favorite")
+ + +
+[docs] + def set_conversation_password(self, conversation: Conversation | str, password: str) -> None: + """Sets password for a conversation. + + Currently, it is only allowed to have a password for ``public`` conversations. + + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + :param password: new password for the conversation. + + .. note:: Password should match the password policy. + """ + token = conversation.token if isinstance(conversation, Conversation) else conversation + self._session.ocs("PUT", self._ep_base + f"/api/v4/room/{token}/password", params={"password": password})
+ + +
+[docs] + def set_conversation_readonly(self, conversation: Conversation | str, read_only: bool) -> None: + """Changes conversation **read_only** state.""" + token = conversation.token if isinstance(conversation, Conversation) else conversation + self._session.ocs("PUT", self._ep_base + f"/api/v4/room/{token}/read-only", params={"state": int(read_only)})
+ + +
+[docs] + def set_conversation_public(self, conversation: Conversation | str, public: bool) -> None: + """Changes conversation **public** state.""" + token = conversation.token if isinstance(conversation, Conversation) else conversation + self._session.ocs("POST" if public else "DELETE", self._ep_base + f"/api/v4/room/{token}/public")
+ + +
+[docs] + def set_conversation_notify_lvl(self, conversation: Conversation | str, new_lvl: NotificationLevel) -> None: + """Sets new notification level for user in the conversation.""" + token = conversation.token if isinstance(conversation, Conversation) else conversation + self._session.ocs("POST", self._ep_base + f"/api/v4/room/{token}/notify", json={"level": int(new_lvl)})
+ + +
+[docs] + def get_conversation_by_token(self, conversation: Conversation | str) -> Conversation: + """Gets conversation by token.""" + token = conversation.token if isinstance(conversation, Conversation) else conversation + result = self._session.ocs("GET", self._ep_base + f"/api/v4/room/{token}") + self._update_config_sha() + return Conversation(result)
+ + +
+[docs] + def delete_conversation(self, conversation: Conversation | str) -> None: + """Deletes a conversation. + + .. note:: Deleting a conversation that is the parent of breakout rooms, will also delete them. + ``ONE_TO_ONE`` conversations cannot be deleted for them + :py:class:`~nc_py_api._talk_api._TalkAPI.leave_conversation` should be used. + """ + token = conversation.token if isinstance(conversation, Conversation) else conversation + self._session.ocs("DELETE", self._ep_base + f"/api/v4/room/{token}")
+ + +
+[docs] + def leave_conversation(self, conversation: Conversation | str) -> None: + """Removes yourself from the conversation. + + .. note:: When the participant is a moderator or owner and there are no other moderators or owners left, + participant cannot leave conversation. + """ + token = conversation.token if isinstance(conversation, Conversation) else conversation + self._session.ocs("DELETE", self._ep_base + f"/api/v4/room/{token}/participants/self")
+ + +
+[docs] + def send_message( + self, + message: str, + conversation: Conversation | str = "", + reply_to_message: int | TalkMessage = 0, + silent: bool = False, + actor_display_name: str = "", + ) -> TalkMessage: + """Send a message to the conversation. + + :param message: The message the user wants to say. + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + Need only if **reply_to_message** is not :py:class:`~nc_py_api.talk.TalkMessage` + :param reply_to_message: The message ID this message is a reply to. + + .. note:: Only allowed when the message type is not ``system`` or ``command``. + The message you are replying to should be from the same conversation. + :param silent: Flag controlling if the message should create a chat notifications for the users. + :param actor_display_name: Guest display name (**ignored for the logged-in users**). + :raises ValueError: in case of an invalid usage. + """ + params = _send_message(message, actor_display_name, silent, reply_to_message) + token = _get_token(message, conversation) + r = self._session.ocs("POST", self._ep_base + f"/api/v1/chat/{token}", json=params) + return TalkMessage(r)
+ + +
+[docs] + def send_file(self, path: str | FsNode, conversation: Conversation | str = "") -> tuple[Share, str]: + """Sends a file to the conversation.""" + reference_id, params = _send_file(path, conversation) + require_capabilities("files_sharing.api_enabled", self._session.capabilities) + r = self._session.ocs("POST", "/ocs/v1.php/apps/files_sharing/api/v1/shares", json=params) + return Share(r), reference_id
+ + +
+[docs] + def receive_messages( + self, + conversation: Conversation | str, + look_in_future: bool = False, + limit: int = 100, + timeout: int = 30, + no_status_update: bool = True, + ) -> list[TalkMessage]: + """Receive chat messages of a conversation. + + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + :param look_in_future: ``True`` to poll and wait for the new message or ``False`` to get history. + :param limit: Number of chat messages to receive (``100`` by default, ``200`` at most). + :param timeout: ``look_in_future=1`` only: seconds to wait for the new messages (60 secs at most). + :param no_status_update: When the user status should not be automatically set to the online. + """ + token = conversation.token if isinstance(conversation, Conversation) else conversation + params = { + "lookIntoFuture": int(look_in_future), + "limit": limit, + "timeout": timeout, + "noStatusUpdate": int(no_status_update), + } + r = self._session.ocs("GET", self._ep_base + f"/api/v1/chat/{token}", params=params) + return [TalkFileMessage(i, self._session.user) if i["message"] == "{file}" else TalkMessage(i) for i in r]
+ + +
+[docs] + def delete_message(self, message: TalkMessage | str, conversation: Conversation | str = "") -> TalkMessage: + """Delete a chat message. + + :param message: Message ID or :py:class:`~nc_py_api.talk.TalkMessage` to delete. + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + + .. note:: **Conversation** needed only if **message** is not :py:class:`~nc_py_api.talk.TalkMessage` + """ + token = _get_token(message, conversation) + message_id = message.message_id if isinstance(message, TalkMessage) else message + result = self._session.ocs("DELETE", self._ep_base + f"/api/v1/chat/{token}/{message_id}") + return TalkMessage(result)
+ + +
+[docs] + def react_to_message( + self, message: TalkMessage | str, reaction: str, conversation: Conversation | str = "" + ) -> dict[str, list[MessageReactions]]: + """React to a chat message. + + :param message: Message ID or :py:class:`~nc_py_api.talk.TalkMessage` to react to. + :param reaction: A single emoji. + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + + .. note:: **Conversation** needed only if **message** is not :py:class:`~nc_py_api.talk.TalkMessage` + """ + token = _get_token(message, conversation) + message_id = message.message_id if isinstance(message, TalkMessage) else message + params = { + "reaction": reaction, + } + r = self._session.ocs("POST", self._ep_base + f"/api/v1/reaction/{token}/{message_id}", params=params) + return {k: [MessageReactions(i) for i in v] for k, v in r.items()} if r else {}
+ + +
+[docs] + def delete_reaction( + self, message: TalkMessage | str, reaction: str, conversation: Conversation | str = "" + ) -> dict[str, list[MessageReactions]]: + """Remove reaction from a chat message. + + :param message: Message ID or :py:class:`~nc_py_api.talk.TalkMessage` to remove reaction from. + :param reaction: A single emoji. + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + + .. note:: **Conversation** needed only if **message** is not :py:class:`~nc_py_api.talk.TalkMessage` + """ + token = _get_token(message, conversation) + message_id = message.message_id if isinstance(message, TalkMessage) else message + params = { + "reaction": reaction, + } + r = self._session.ocs("DELETE", self._ep_base + f"/api/v1/reaction/{token}/{message_id}", params=params) + return {k: [MessageReactions(i) for i in v] for k, v in r.items()} if r else {}
+ + +
+[docs] + def get_message_reactions( + self, message: TalkMessage | str, reaction_filter: str = "", conversation: Conversation | str = "" + ) -> dict[str, list[MessageReactions]]: + """Get reactions information for a chat message. + + :param message: Message ID or :py:class:`~nc_py_api.talk.TalkMessage` to get reactions from. + :param reaction_filter: A single emoji to get reaction information only for it. + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + + .. note:: **Conversation** needed only if **message** is not :py:class:`~nc_py_api.talk.TalkMessage` + """ + token = _get_token(message, conversation) + message_id = message.message_id if isinstance(message, TalkMessage) else message + params = {"reaction": reaction_filter} if reaction_filter else {} + r = self._session.ocs("GET", self._ep_base + f"/api/v1/reaction/{token}/{message_id}", params=params) + return {k: [MessageReactions(i) for i in v] for k, v in r.items()} if r else {}
+ + +
+[docs] + def list_bots(self) -> list[BotInfo]: + """Lists the bots that are installed on the server.""" + require_capabilities("spreed.features.bots-v1", self._session.capabilities) + return [BotInfo(i) for i in self._session.ocs("GET", self._ep_base + "/api/v1/bot/admin")]
+ + +
+[docs] + def conversation_list_bots(self, conversation: Conversation | str) -> list[BotInfoBasic]: + """Lists the bots that are enabled and can be enabled for the conversation. + + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + """ + require_capabilities("spreed.features.bots-v1", self._session.capabilities) + token = conversation.token if isinstance(conversation, Conversation) else conversation + return [BotInfoBasic(i) for i in self._session.ocs("GET", self._ep_base + f"/api/v1/bot/{token}")]
+ + +
+[docs] + def enable_bot(self, conversation: Conversation | str, bot: BotInfoBasic | int) -> None: + """Enable a bot for a conversation as a moderator. + + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + :param bot: bot ID or :py:class:`~nc_py_api.talk.BotInfoBasic`. + """ + require_capabilities("spreed.features.bots-v1", self._session.capabilities) + token = conversation.token if isinstance(conversation, Conversation) else conversation + bot_id = bot.bot_id if isinstance(bot, BotInfoBasic) else bot + self._session.ocs("POST", self._ep_base + f"/api/v1/bot/{token}/{bot_id}")
+ + +
+[docs] + def disable_bot(self, conversation: Conversation | str, bot: BotInfoBasic | int) -> None: + """Disable a bot for a conversation as a moderator. + + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + :param bot: bot ID or :py:class:`~nc_py_api.talk.BotInfoBasic`. + """ + require_capabilities("spreed.features.bots-v1", self._session.capabilities) + token = conversation.token if isinstance(conversation, Conversation) else conversation + bot_id = bot.bot_id if isinstance(bot, BotInfoBasic) else bot + self._session.ocs("DELETE", self._ep_base + f"/api/v1/bot/{token}/{bot_id}")
+ + +
+[docs] + def create_poll( + self, + conversation: Conversation | str, + question: str, + options: list[str], + hidden_results: bool = True, + max_votes: int = 1, + ) -> Poll: + """Creates a poll in a conversation. + + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + :param question: The question of the poll. + :param options: Array of strings with the voting options. + :param hidden_results: Should results be hidden until the poll is closed and then only the summary is published. + :param max_votes: The maximum amount of options a participant can vote for. + """ + token = conversation.token if isinstance(conversation, Conversation) else conversation + params = { + "question": question, + "options": options, + "resultMode": int(hidden_results), + "maxVotes": max_votes, + } + return Poll(self._session.ocs("POST", self._ep_base + f"/api/v1/poll/{token}", json=params), token)
+ + +
+[docs] + def get_poll(self, poll: Poll | int, conversation: Conversation | str = "") -> Poll: + """Get state or result of a poll. + + :param poll: Poll ID or :py:class:`~nc_py_api.talk.Poll`. + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + """ + if isinstance(poll, Poll): + poll_id = poll.poll_id + token = poll.conversation_token + else: + poll_id = poll + token = conversation.token if isinstance(conversation, Conversation) else conversation + return Poll(self._session.ocs("GET", self._ep_base + f"/api/v1/poll/{token}/{poll_id}"), token)
+ + +
+[docs] + def vote_poll(self, options_ids: list[int], poll: Poll | int, conversation: Conversation | str = "") -> Poll: + """Vote on a poll. + + :param options_ids: The option IDs the participant wants to vote for. + :param poll: Poll ID or :py:class:`~nc_py_api.talk.Poll`. + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + """ + if isinstance(poll, Poll): + poll_id = poll.poll_id + token = poll.conversation_token + else: + poll_id = poll + token = conversation.token if isinstance(conversation, Conversation) else conversation + r = self._session.ocs( + "POST", self._ep_base + f"/api/v1/poll/{token}/{poll_id}", json={"optionIds": options_ids} + ) + return Poll(r, token)
+ + +
+[docs] + def close_poll(self, poll: Poll | int, conversation: Conversation | str = "") -> Poll: + """Close a poll. + + :param poll: Poll ID or :py:class:`~nc_py_api.talk.Poll`. + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + """ + if isinstance(poll, Poll): + poll_id = poll.poll_id + token = poll.conversation_token + else: + poll_id = poll + token = conversation.token if isinstance(conversation, Conversation) else conversation + return Poll(self._session.ocs("DELETE", self._ep_base + f"/api/v1/poll/{token}/{poll_id}"), token)
+ + +
+[docs] + def set_conversation_avatar( + self, conversation: Conversation | str, avatar: bytes | tuple[str, str | None] + ) -> Conversation: + """Set image or emoji as avatar for the conversation. + + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + :param avatar: Squared image with mimetype equal to PNG or JPEG or a tuple with emoji and optional + HEX color code(6 times ``0-9A-F``) without the leading ``#`` character. + + .. note:: When color omitted, fallback will be to the default bright/dark mode icon background color. + """ + require_capabilities("spreed.features.avatar", self._session.capabilities) + token = conversation.token if isinstance(conversation, Conversation) else conversation + if isinstance(avatar, bytes): + r = self._session.ocs("POST", self._ep_base + f"/api/v1/room/{token}/avatar", files={"file": avatar}) + else: + r = self._session.ocs( + "POST", + self._ep_base + f"/api/v1/room/{token}/avatar/emoji", + json={ + "emoji": avatar[0], + "color": avatar[1], + }, + ) + return Conversation(r)
+ + +
+[docs] + def delete_conversation_avatar(self, conversation: Conversation | str) -> Conversation: + """Delete conversation avatar. + + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + """ + require_capabilities("spreed.features.avatar", self._session.capabilities) + token = conversation.token if isinstance(conversation, Conversation) else conversation + return Conversation(self._session.ocs("DELETE", self._ep_base + f"/api/v1/room/{token}/avatar"))
+ + +
+[docs] + def get_conversation_avatar(self, conversation: Conversation | str, dark=False) -> bytes: + """Get conversation avatar (binary). + + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + :param dark: boolean indicating should be or not avatar fetched for dark theme. + """ + require_capabilities("spreed.features.avatar", self._session.capabilities) + token = conversation.token if isinstance(conversation, Conversation) else conversation + ep_suffix = "/dark" if dark else "" + response = self._session.adapter.get(self._ep_base + f"/api/v1/room/{token}/avatar" + ep_suffix) + check_error(response) + return response.content
+ + + def _update_config_sha(self): + config_sha = self._session.response_headers["X-Nextcloud-Talk-Hash"] + if self.config_sha != config_sha: + self._session.update_server_info() + self.config_sha = config_sha
+ + + +class _AsyncTalkAPI: + """Class provides API to work with Nextcloud Talk.""" + + _ep_base: str = "/ocs/v2.php/apps/spreed" + config_sha: str + """Sha1 value over Talk config. After receiving a different value on subsequent requests, settings got refreshed.""" + modified_since: int + """Used by ``get_user_conversations``, when **modified_since** param is ``True``.""" + + def __init__(self, session: AsyncNcSessionBasic): + self._session = session + self.config_sha = "" + self.modified_since = 0 + + @property + async def available(self) -> bool: + """Returns True if the Nextcloud instance supports this feature, False otherwise.""" + return not check_capabilities("spreed", await self._session.capabilities) + + @property + async def bots_available(self) -> bool: + """Returns True if the Nextcloud instance supports this feature, False otherwise.""" + return not check_capabilities("spreed.features.bots-v1", await self._session.capabilities) + + async def get_user_conversations( + self, no_status_update: bool = True, include_status: bool = False, modified_since: int | bool = 0 + ) -> list[Conversation]: + """Returns the list of the user's conversations. + + :param no_status_update: When the user status should not be automatically set to the online. + :param include_status: Whether the user status information of all one-to-one conversations should be loaded. + :param modified_since: When provided only conversations with a newer **lastActivity** + (and one-to-one conversations when includeStatus is provided) are returned. + Can be set to ``True`` to automatically use last ``modified_since`` from previous calls. Default = **0**. + + .. note:: In rare cases, when a request arrives between seconds, it is possible that return data + will contain part of the conversations from the last call that was not modified( + their `last_activity` will be the same as ``talk.modified_since``). + """ + params: dict = {} + if no_status_update: + params["noStatusUpdate"] = 1 + if include_status: + params["includeStatus"] = 1 + if modified_since: + params["modifiedSince"] = self.modified_since if modified_since is True else modified_since + + result = await self._session.ocs("GET", self._ep_base + "/api/v4/room", params=params) + self.modified_since = int(self._session.response_headers["X-Nextcloud-Talk-Modified-Before"]) + await self._update_config_sha() + return [Conversation(i) for i in result] + + async def list_participants( + self, conversation: Conversation | str, include_status: bool = False + ) -> list[Participant]: + """Returns a list of conversation participants. + + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + :param include_status: Whether the user status information of all one-to-one conversations should be loaded. + """ + token = conversation.token if isinstance(conversation, Conversation) else conversation + result = await self._session.ocs( + "GET", self._ep_base + f"/api/v4/room/{token}/participants", params={"includeStatus": int(include_status)} + ) + return [Participant(i) for i in result] + + async def create_conversation( + self, + conversation_type: ConversationType, + invite: str = "", + source: str = "", + room_name: str = "", + object_type: str = "", + object_id: str = "", + ) -> Conversation: + """Creates a new conversation. + + .. note:: Creating a conversation as a child breakout room will automatically set the lobby when breakout + rooms are not started and will always overwrite the room type with the parent room type. + Also, moderators of the parent conversation will be automatically added as moderators. + + :param conversation_type: type of the conversation to create. + :param invite: User ID(roomType=ONE_TO_ONE), Group ID(roomType=GROUP - optional), + Circle ID(roomType=GROUP, source='circles', only available with the ``circles-support`` capability). + :param source: The source for the invite, only supported on roomType = GROUP for groups and circles. + :param room_name: Conversation name up to 255 characters(``not available for roomType=ONE_TO_ONE``). + :param object_type: Type of object this room references, currently only allowed + value is **"room"** to indicate the parent of a breakout room. + :param object_id: ID of an object this room references, room token is used for the parent of a breakout room. + """ + params: dict = { + "roomType": int(conversation_type), + "invite": invite, + "source": source, + "roomName": room_name, + "objectType": object_type, + "objectId": object_id, + } + clear_from_params_empty(["invite", "source", "roomName", "objectType", "objectId"], params) + return Conversation(await self._session.ocs("POST", self._ep_base + "/api/v4/room", json=params)) + + async def rename_conversation(self, conversation: Conversation | str, new_name: str) -> None: + """Renames a conversation.""" + token = conversation.token if isinstance(conversation, Conversation) else conversation + await self._session.ocs("PUT", self._ep_base + f"/api/v4/room/{token}", params={"roomName": new_name}) + + async def set_conversation_description(self, conversation: Conversation | str, description: str) -> None: + """Sets conversation description.""" + token = conversation.token if isinstance(conversation, Conversation) else conversation + await self._session.ocs( + "PUT", self._ep_base + f"/api/v4/room/{token}/description", params={"description": description} + ) + + async def set_conversation_fav(self, conversation: Conversation | str, favorite: bool) -> None: + """Changes conversation **favorite** state.""" + token = conversation.token if isinstance(conversation, Conversation) else conversation + await self._session.ocs("POST" if favorite else "DELETE", self._ep_base + f"/api/v4/room/{token}/favorite") + + async def set_conversation_password(self, conversation: Conversation | str, password: str) -> None: + """Sets password for a conversation. + + Currently, it is only allowed to have a password for ``public`` conversations. + + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + :param password: new password for the conversation. + + .. note:: Password should match the password policy. + """ + token = conversation.token if isinstance(conversation, Conversation) else conversation + await self._session.ocs("PUT", self._ep_base + f"/api/v4/room/{token}/password", params={"password": password}) + + async def set_conversation_readonly(self, conversation: Conversation | str, read_only: bool) -> None: + """Changes conversation **read_only** state.""" + token = conversation.token if isinstance(conversation, Conversation) else conversation + await self._session.ocs( + "PUT", self._ep_base + f"/api/v4/room/{token}/read-only", params={"state": int(read_only)} + ) + + async def set_conversation_public(self, conversation: Conversation | str, public: bool) -> None: + """Changes conversation **public** state.""" + token = conversation.token if isinstance(conversation, Conversation) else conversation + await self._session.ocs("POST" if public else "DELETE", self._ep_base + f"/api/v4/room/{token}/public") + + async def set_conversation_notify_lvl(self, conversation: Conversation | str, new_lvl: NotificationLevel) -> None: + """Sets new notification level for user in the conversation.""" + token = conversation.token if isinstance(conversation, Conversation) else conversation + await self._session.ocs("POST", self._ep_base + f"/api/v4/room/{token}/notify", json={"level": int(new_lvl)}) + + async def get_conversation_by_token(self, conversation: Conversation | str) -> Conversation: + """Gets conversation by token.""" + token = conversation.token if isinstance(conversation, Conversation) else conversation + result = await self._session.ocs("GET", self._ep_base + f"/api/v4/room/{token}") + await self._update_config_sha() + return Conversation(result) + + async def delete_conversation(self, conversation: Conversation | str) -> None: + """Deletes a conversation. + + .. note:: Deleting a conversation that is the parent of breakout rooms, will also delete them. + ``ONE_TO_ONE`` conversations cannot be deleted for them + :py:class:`~nc_py_api._talk_api._TalkAPI.leave_conversation` should be used. + """ + token = conversation.token if isinstance(conversation, Conversation) else conversation + await self._session.ocs("DELETE", self._ep_base + f"/api/v4/room/{token}") + + async def leave_conversation(self, conversation: Conversation | str) -> None: + """Removes yourself from the conversation. + + .. note:: When the participant is a moderator or owner and there are no other moderators or owners left, + participant cannot leave conversation. + """ + token = conversation.token if isinstance(conversation, Conversation) else conversation + await self._session.ocs("DELETE", self._ep_base + f"/api/v4/room/{token}/participants/self") + + async def send_message( + self, + message: str, + conversation: Conversation | str = "", + reply_to_message: int | TalkMessage = 0, + silent: bool = False, + actor_display_name: str = "", + ) -> TalkMessage: + """Send a message to the conversation. + + :param message: The message the user wants to say. + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + Need only if **reply_to_message** is not :py:class:`~nc_py_api.talk.TalkMessage` + :param reply_to_message: The message ID this message is a reply to. + + .. note:: Only allowed when the message type is not ``system`` or ``command``. + The message you are replying to should be from the same conversation. + :param silent: Flag controlling if the message should create a chat notifications for the users. + :param actor_display_name: Guest display name (**ignored for the logged-in users**). + :raises ValueError: in case of an invalid usage. + """ + params = _send_message(message, actor_display_name, silent, reply_to_message) + token = _get_token(message, conversation) + r = await self._session.ocs("POST", self._ep_base + f"/api/v1/chat/{token}", json=params) + return TalkMessage(r) + + async def send_file(self, path: str | FsNode, conversation: Conversation | str = "") -> tuple[Share, str]: + """Sends a file to the conversation.""" + reference_id, params = _send_file(path, conversation) + require_capabilities("files_sharing.api_enabled", await self._session.capabilities) + r = await self._session.ocs("POST", "/ocs/v1.php/apps/files_sharing/api/v1/shares", json=params) + return Share(r), reference_id + + async def receive_messages( + self, + conversation: Conversation | str, + look_in_future: bool = False, + limit: int = 100, + timeout: int = 30, + no_status_update: bool = True, + ) -> list[TalkMessage]: + """Receive chat messages of a conversation. + + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + :param look_in_future: ``True`` to poll and wait for the new message or ``False`` to get history. + :param limit: Number of chat messages to receive (``100`` by default, ``200`` at most). + :param timeout: ``look_in_future=1`` only: seconds to wait for the new messages (60 secs at most). + :param no_status_update: When the user status should not be automatically set to the online. + """ + token = conversation.token if isinstance(conversation, Conversation) else conversation + params = { + "lookIntoFuture": int(look_in_future), + "limit": limit, + "timeout": timeout, + "noStatusUpdate": int(no_status_update), + } + r = await self._session.ocs("GET", self._ep_base + f"/api/v1/chat/{token}", params=params) + return [TalkFileMessage(i, await self._session.user) if i["message"] == "{file}" else TalkMessage(i) for i in r] + + async def delete_message(self, message: TalkMessage | str, conversation: Conversation | str = "") -> TalkMessage: + """Delete a chat message. + + :param message: Message ID or :py:class:`~nc_py_api.talk.TalkMessage` to delete. + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + + .. note:: **Conversation** needed only if **message** is not :py:class:`~nc_py_api.talk.TalkMessage` + """ + token = _get_token(message, conversation) + message_id = message.message_id if isinstance(message, TalkMessage) else message + result = await self._session.ocs("DELETE", self._ep_base + f"/api/v1/chat/{token}/{message_id}") + return TalkMessage(result) + + async def react_to_message( + self, message: TalkMessage | str, reaction: str, conversation: Conversation | str = "" + ) -> dict[str, list[MessageReactions]]: + """React to a chat message. + + :param message: Message ID or :py:class:`~nc_py_api.talk.TalkMessage` to react to. + :param reaction: A single emoji. + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + + .. note:: **Conversation** needed only if **message** is not :py:class:`~nc_py_api.talk.TalkMessage` + """ + token = _get_token(message, conversation) + message_id = message.message_id if isinstance(message, TalkMessage) else message + params = { + "reaction": reaction, + } + r = await self._session.ocs("POST", self._ep_base + f"/api/v1/reaction/{token}/{message_id}", params=params) + return {k: [MessageReactions(i) for i in v] for k, v in r.items()} if r else {} + + async def delete_reaction( + self, message: TalkMessage | str, reaction: str, conversation: Conversation | str = "" + ) -> dict[str, list[MessageReactions]]: + """Remove reaction from a chat message. + + :param message: Message ID or :py:class:`~nc_py_api.talk.TalkMessage` to remove reaction from. + :param reaction: A single emoji. + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + + .. note:: **Conversation** needed only if **message** is not :py:class:`~nc_py_api.talk.TalkMessage` + """ + token = _get_token(message, conversation) + message_id = message.message_id if isinstance(message, TalkMessage) else message + params = { + "reaction": reaction, + } + r = await self._session.ocs("DELETE", self._ep_base + f"/api/v1/reaction/{token}/{message_id}", params=params) + return {k: [MessageReactions(i) for i in v] for k, v in r.items()} if r else {} + + async def get_message_reactions( + self, message: TalkMessage | str, reaction_filter: str = "", conversation: Conversation | str = "" + ) -> dict[str, list[MessageReactions]]: + """Get reactions information for a chat message. + + :param message: Message ID or :py:class:`~nc_py_api.talk.TalkMessage` to get reactions from. + :param reaction_filter: A single emoji to get reaction information only for it. + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + + .. note:: **Conversation** needed only if **message** is not :py:class:`~nc_py_api.talk.TalkMessage` + """ + token = _get_token(message, conversation) + message_id = message.message_id if isinstance(message, TalkMessage) else message + params = {"reaction": reaction_filter} if reaction_filter else {} + r = await self._session.ocs("GET", self._ep_base + f"/api/v1/reaction/{token}/{message_id}", params=params) + return {k: [MessageReactions(i) for i in v] for k, v in r.items()} if r else {} + + async def list_bots(self) -> list[BotInfo]: + """Lists the bots that are installed on the server.""" + require_capabilities("spreed.features.bots-v1", await self._session.capabilities) + return [BotInfo(i) for i in await self._session.ocs("GET", self._ep_base + "/api/v1/bot/admin")] + + async def conversation_list_bots(self, conversation: Conversation | str) -> list[BotInfoBasic]: + """Lists the bots that are enabled and can be enabled for the conversation. + + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + """ + require_capabilities("spreed.features.bots-v1", await self._session.capabilities) + token = conversation.token if isinstance(conversation, Conversation) else conversation + return [BotInfoBasic(i) for i in await self._session.ocs("GET", self._ep_base + f"/api/v1/bot/{token}")] + + async def enable_bot(self, conversation: Conversation | str, bot: BotInfoBasic | int) -> None: + """Enable a bot for a conversation as a moderator. + + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + :param bot: bot ID or :py:class:`~nc_py_api.talk.BotInfoBasic`. + """ + require_capabilities("spreed.features.bots-v1", await self._session.capabilities) + token = conversation.token if isinstance(conversation, Conversation) else conversation + bot_id = bot.bot_id if isinstance(bot, BotInfoBasic) else bot + await self._session.ocs("POST", self._ep_base + f"/api/v1/bot/{token}/{bot_id}") + + async def disable_bot(self, conversation: Conversation | str, bot: BotInfoBasic | int) -> None: + """Disable a bot for a conversation as a moderator. + + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + :param bot: bot ID or :py:class:`~nc_py_api.talk.BotInfoBasic`. + """ + require_capabilities("spreed.features.bots-v1", await self._session.capabilities) + token = conversation.token if isinstance(conversation, Conversation) else conversation + bot_id = bot.bot_id if isinstance(bot, BotInfoBasic) else bot + await self._session.ocs("DELETE", self._ep_base + f"/api/v1/bot/{token}/{bot_id}") + + async def create_poll( + self, + conversation: Conversation | str, + question: str, + options: list[str], + hidden_results: bool = True, + max_votes: int = 1, + ) -> Poll: + """Creates a poll in a conversation. + + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + :param question: The question of the poll. + :param options: Array of strings with the voting options. + :param hidden_results: Should results be hidden until the poll is closed and then only the summary is published. + :param max_votes: The maximum amount of options a participant can vote for. + """ + token = conversation.token if isinstance(conversation, Conversation) else conversation + params = { + "question": question, + "options": options, + "resultMode": int(hidden_results), + "maxVotes": max_votes, + } + return Poll(await self._session.ocs("POST", self._ep_base + f"/api/v1/poll/{token}", json=params), token) + + async def get_poll(self, poll: Poll | int, conversation: Conversation | str = "") -> Poll: + """Get state or result of a poll. + + :param poll: Poll ID or :py:class:`~nc_py_api.talk.Poll`. + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + """ + if isinstance(poll, Poll): + poll_id = poll.poll_id + token = poll.conversation_token + else: + poll_id = poll + token = conversation.token if isinstance(conversation, Conversation) else conversation + return Poll(await self._session.ocs("GET", self._ep_base + f"/api/v1/poll/{token}/{poll_id}"), token) + + async def vote_poll(self, options_ids: list[int], poll: Poll | int, conversation: Conversation | str = "") -> Poll: + """Vote on a poll. + + :param options_ids: The option IDs the participant wants to vote for. + :param poll: Poll ID or :py:class:`~nc_py_api.talk.Poll`. + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + """ + if isinstance(poll, Poll): + poll_id = poll.poll_id + token = poll.conversation_token + else: + poll_id = poll + token = conversation.token if isinstance(conversation, Conversation) else conversation + r = await self._session.ocs( + "POST", self._ep_base + f"/api/v1/poll/{token}/{poll_id}", json={"optionIds": options_ids} + ) + return Poll(r, token) + + async def close_poll(self, poll: Poll | int, conversation: Conversation | str = "") -> Poll: + """Close a poll. + + :param poll: Poll ID or :py:class:`~nc_py_api.talk.Poll`. + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + """ + if isinstance(poll, Poll): + poll_id = poll.poll_id + token = poll.conversation_token + else: + poll_id = poll + token = conversation.token if isinstance(conversation, Conversation) else conversation + return Poll(await self._session.ocs("DELETE", self._ep_base + f"/api/v1/poll/{token}/{poll_id}"), token) + + async def set_conversation_avatar( + self, conversation: Conversation | str, avatar: bytes | tuple[str, str | None] + ) -> Conversation: + """Set image or emoji as avatar for the conversation. + + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + :param avatar: Squared image with mimetype equal to PNG or JPEG or a tuple with emoji and optional + HEX color code(6 times ``0-9A-F``) without the leading ``#`` character. + + .. note:: When color omitted, fallback will be to the default bright/dark mode icon background color. + """ + require_capabilities("spreed.features.avatar", await self._session.capabilities) + token = conversation.token if isinstance(conversation, Conversation) else conversation + if isinstance(avatar, bytes): + r = await self._session.ocs("POST", self._ep_base + f"/api/v1/room/{token}/avatar", files={"file": avatar}) + else: + r = await self._session.ocs( + "POST", + self._ep_base + f"/api/v1/room/{token}/avatar/emoji", + json={ + "emoji": avatar[0], + "color": avatar[1], + }, + ) + return Conversation(r) + + async def delete_conversation_avatar(self, conversation: Conversation | str) -> Conversation: + """Delete conversation avatar. + + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + """ + require_capabilities("spreed.features.avatar", await self._session.capabilities) + token = conversation.token if isinstance(conversation, Conversation) else conversation + return Conversation(await self._session.ocs("DELETE", self._ep_base + f"/api/v1/room/{token}/avatar")) + + async def get_conversation_avatar(self, conversation: Conversation | str, dark=False) -> bytes: + """Get conversation avatar (binary). + + :param conversation: conversation token or :py:class:`~nc_py_api.talk.Conversation`. + :param dark: boolean indicating should be or not avatar fetched for dark theme. + """ + require_capabilities("spreed.features.avatar", await self._session.capabilities) + token = conversation.token if isinstance(conversation, Conversation) else conversation + ep_suffix = "/dark" if dark else "" + response = await self._session.adapter.get(self._ep_base + f"/api/v1/room/{token}/avatar" + ep_suffix) + check_error(response) + return response.content + + async def _update_config_sha(self): + config_sha = self._session.response_headers["X-Nextcloud-Talk-Hash"] + if self.config_sha != config_sha: + await self._session.update_server_info() + self.config_sha = config_sha + + +def _send_message(message: str, actor_display_name: str, silent: bool, reply_to_message: int | TalkMessage): + return { + "message": message, + "actorDisplayName": actor_display_name, + "replyTo": reply_to_message.message_id if isinstance(reply_to_message, TalkMessage) else reply_to_message, + "referenceId": hashlib.sha256(random_string(32).encode("UTF-8")).hexdigest(), + "silent": silent, + } + + +def _send_file(path: str | FsNode, conversation: Conversation | str): + token = conversation.token if isinstance(conversation, Conversation) else conversation + reference_id = hashlib.sha256(random_string(32).encode("UTF-8")).hexdigest() + params = { + "shareType": ShareType.TYPE_ROOM, + "shareWith": token, + "path": path.user_path if isinstance(path, FsNode) else path, + "referenceId": reference_id, + } + return reference_id, params + + +def _get_token(message: TalkMessage | str, conversation: Conversation | str) -> str: + if not conversation and not isinstance(message, TalkMessage): + raise ValueError("Either specify 'conversation' or provide 'TalkMessage'.") + + return ( + message.token + if isinstance(message, TalkMessage) + else conversation.token if isinstance(conversation, Conversation) else conversation + ) +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/_theming.html b/_modules/nc_py_api/_theming.html new file mode 100644 index 00000000..29771e22 --- /dev/null +++ b/_modules/nc_py_api/_theming.html @@ -0,0 +1,184 @@ + + + + + + nc_py_api._theming — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for nc_py_api._theming

+"""Nextcloud stuff for work with Theming app."""
+
+from typing import TypedDict
+
+
+
+[docs] +class ThemingInfo(TypedDict): + """Nextcloud Theme information.""" + + name: str + """Name of the Nextcloud instance""" + url: str + """Url that set in Theme app""" + slogan: str + """Slogan, e.g. 'a safe home for all your data'""" + color: tuple[int, int, int] + color_text: tuple[int, int, int] + color_element: tuple[int, int, int] + color_element_bright: tuple[int, int, int] + color_element_dark: tuple[int, int, int] + logo: str + """Url of the instance's logo""" + background: str + """Either an URL of the background image or a hex color value""" + background_plain: bool + background_default: bool
+ + + +def convert_str_color(theming_capability: dict, key: str) -> tuple[int, int, int]: + """Returns a tuple of integers representing the RGB color for the specified theme key.""" + if key not in theming_capability: + return 0, 0, 0 + value = theming_capability[key] + if not value or value == "#": + return 0, 0, 0 + return int(value[1:3], 16), int(value[3:5], 16), int(value[5:7], 16) + + +def get_parsed_theme(theming_capability: dict) -> ThemingInfo: + """Returns parsed ``theme`` information.""" + i = theming_capability + return ThemingInfo( + name=i["name"], + url=i["url"], + slogan=i["slogan"], + color=convert_str_color(i, "color"), + color_text=convert_str_color(i, "color-text"), + color_element=convert_str_color(i, "color-element"), + color_element_bright=convert_str_color(i, "color-element-bright"), + color_element_dark=convert_str_color(i, "color-element-dark"), + logo=i["logo"], + background=i.get("background", ""), + background_plain=i.get("background-plain", False), + background_default=i.get("background-default", False), + ) +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/activity.html b/_modules/nc_py_api/activity.html new file mode 100644 index 00000000..0ecd3bc4 --- /dev/null +++ b/_modules/nc_py_api/activity.html @@ -0,0 +1,401 @@ + + + + + + nc_py_api.activity — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for nc_py_api.activity

+"""API for working with Activity App."""
+
+import dataclasses
+import datetime
+import typing
+
+from ._exceptions import NextcloudExceptionNotModified
+from ._misc import check_capabilities, nc_iso_time_to_datetime
+from ._session import AsyncNcSessionBasic, NcSessionBasic
+
+
+
+[docs] +@dataclasses.dataclass +class ActivityFilter: + """Activity filter description.""" + + def __init__(self, raw_data: dict): + self._raw_data = raw_data + + @property + def icon(self) -> str: + """Icon for filter.""" + return self._raw_data["icon"] + + @property + def filter_id(self) -> str: + """Filter ID.""" + return self._raw_data["id"] + + @property + def name(self) -> str: + """Filter name.""" + return self._raw_data["name"] + + @property + def priority(self) -> int: + """Arrangement priority in ascending order. Values from 0 to 99.""" + return self._raw_data["priority"] + + def __repr__(self): + return f"<{self.__class__.__name__} id={self.filter_id}, name={self.name}, priority={self.priority}>"
+ + + +
+[docs] +@dataclasses.dataclass +class Activity: + """Description of one activity.""" + + def __init__(self, raw_data: dict): + self._raw_data = raw_data + + @property + def activity_id(self) -> int: + """Unique for one Nextcloud instance activity ID.""" + return self._raw_data["activity_id"] + + @property + def app(self) -> str: + """App that created the activity (e.g. 'files', 'files_sharing', etc.).""" + return self._raw_data["app"] + + @property + def activity_type(self) -> str: + """String describing the activity type, depends on the **app** field.""" + return self._raw_data["type"] + + @property + def actor_id(self) -> str: + """User ID of the user that triggered/created this activity. + + .. note:: Can be empty in case of public link/remote share action. + """ + return self._raw_data["user"] + + @property + def subject(self) -> str: + """Translated simple subject without markup, ready for use (e.g. 'You created hello.jpg').""" + return self._raw_data["subject"] + + @property + def subject_rich(self) -> list: + """`0` is the string subject including placeholders, `1` is an array with the placeholders.""" + return self._raw_data["subject_rich"] + + @property + def message(self) -> str: + """Translated message without markup, ready for use (longer text, unused by core apps).""" + return self._raw_data["message"] + + @property + def message_rich(self) -> list: + """See description of **subject_rich**.""" + return self._raw_data["message_rich"] + + @property + def object_type(self) -> str: + """The Type of the object this activity is about (e.g. 'files' is used for files and folders).""" + return self._raw_data["object_type"] + + @property + def object_id(self) -> int: + """ID of the object this activity is about (e.g., ID in the file cache is used for files and folders).""" + return self._raw_data["object_id"] + + @property + def object_name(self) -> str: + """The name of the object this activity is about (e.g., for files it's the relative path to the user's root).""" + return self._raw_data["object_name"] + + @property + def objects(self) -> dict: + """Contains the objects involved in multi-object activities, like editing multiple files in a folder. + + .. note:: They are stored in objects as key-value pairs of the object_id and the object_name: + { object_id: object_name} + """ + return self._raw_data["objects"] if isinstance(self._raw_data["objects"], dict) else {} + + @property + def link(self) -> str: + """A full URL pointing to a suitable location (e.g. 'http://localhost/apps/files/?dir=%2Ffolder' for folder).""" + return self._raw_data["link"] + + @property + def icon(self) -> str: + """URL of the icon.""" + return self._raw_data["icon"] + + @property + def time(self) -> datetime.datetime: + """Time when the activity occurred.""" + return nc_iso_time_to_datetime(self._raw_data["datetime"]) + + def __repr__(self): + return ( + f"<{self.__class__.__name__} id={self.activity_id}, app={self.app}, type={self.activity_type}," + f" time={self.time}>" + )
+ + + +
+[docs] +class _ActivityAPI: + """The class provides the Activity Application API.""" + + _ep_base: str = "/ocs/v1.php/apps/activity" + last_given: int + """Used by ``get_activities``, when **since** param is ``True``.""" + + def __init__(self, session: NcSessionBasic): + self._session = session + self.last_given = 0 + + @property + def available(self) -> bool: + """Returns True if the Nextcloud instance supports this feature, False otherwise.""" + return not check_capabilities("activity.apiv2", self._session.capabilities) + +
+[docs] + def get_activities( + self, + filter_id: ActivityFilter | str = "", + since: int | bool = 0, + limit: int = 50, + object_type: str = "", + object_id: int = 0, + sort: str = "desc", + ) -> list[Activity]: + """Returns activities for the current user. + + :param filter_id: Filter to apply, if needed. + :param since: Last activity ID you have seen. When specified, only activities after provided are returned. + Can be set to ``True`` to automatically use last ``last_given`` from previous calls. Default = **0**. + :param limit: Max number of activities to be returned. + :param object_type: Filter the activities to a given object. + :param object_id: Filter the activities to a given object. + :param sort: Sort activities ascending or descending. Default is ``desc``. + + .. note:: ``object_type`` and ``object_id`` should only appear together with ``filter_id`` unset. + """ + if since is True: + since = self.last_given + url, params = _get_activities(filter_id, since, limit, object_type, object_id, sort) + try: + result = self._session.ocs("GET", self._ep_base + url, params=params) + except NextcloudExceptionNotModified: + return [] + self.last_given = int(self._session.response_headers["X-Activity-Last-Given"]) + return [Activity(i) for i in result]
+ + +
+[docs] + def get_filters(self) -> list[ActivityFilter]: + """Returns avalaible activity filters.""" + return [ActivityFilter(i) for i in self._session.ocs("GET", self._ep_base + "/api/v2/activity/filters")]
+
+ + + +class _AsyncActivityAPI: + """The class provides the async Activity Application API.""" + + _ep_base: str = "/ocs/v1.php/apps/activity" + last_given: int + """Used by ``get_activities``, when **since** param is ``True``.""" + + def __init__(self, session: AsyncNcSessionBasic): + self._session = session + self.last_given = 0 + + @property + async def available(self) -> bool: + """Returns True if the Nextcloud instance supports this feature, False otherwise.""" + return not check_capabilities("activity.apiv2", await self._session.capabilities) + + async def get_activities( + self, + filter_id: ActivityFilter | str = "", + since: int | bool = 0, + limit: int = 50, + object_type: str = "", + object_id: int = 0, + sort: str = "desc", + ) -> list[Activity]: + """Returns activities for the current user. + + :param filter_id: Filter to apply, if needed. + :param since: Last activity ID you have seen. When specified, only activities after provided are returned. + Can be set to ``True`` to automatically use last ``last_given`` from previous calls. Default = **0**. + :param limit: Max number of activities to be returned. + :param object_type: Filter the activities to a given object. + :param object_id: Filter the activities to a given object. + :param sort: Sort activities ascending or descending. Default is ``desc``. + + .. note:: ``object_type`` and ``object_id`` should only appear together with ``filter_id`` unset. + """ + if since is True: + since = self.last_given + url, params = _get_activities(filter_id, since, limit, object_type, object_id, sort) + try: + result = await self._session.ocs("GET", self._ep_base + url, params=params) + except NextcloudExceptionNotModified: + return [] + self.last_given = int(self._session.response_headers["X-Activity-Last-Given"]) + return [Activity(i) for i in result] + + async def get_filters(self) -> list[ActivityFilter]: + """Returns avalaible activity filters.""" + return [ActivityFilter(i) for i in await self._session.ocs("GET", self._ep_base + "/api/v2/activity/filters")] + + +def _get_activities( + filter_id: ActivityFilter | str, since: int | bool, limit: int, object_type: str, object_id: int, sort: str +) -> tuple[str, dict[str, typing.Any]]: + if bool(object_id) != bool(object_type): + raise ValueError("Either specify both `object_type` and `object_id`, or don't specify any at all.") + filter_id = filter_id.filter_id if isinstance(filter_id, ActivityFilter) else filter_id + params = { + "since": since, + "limit": limit, + "object_type": object_type, + "object_id": object_id, + "sort": sort, + } + url = ( + f"/api/v2/activity/{filter_id}" if filter_id else "/api/v2/activity/filter" if object_id else "/api/v2/activity" + ) + return url, params +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/apps.html b/_modules/nc_py_api/apps.html new file mode 100644 index 00000000..582f34a1 --- /dev/null +++ b/_modules/nc_py_api/apps.html @@ -0,0 +1,408 @@ + + + + + + nc_py_api.apps — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for nc_py_api.apps

+"""Nextcloud API for working with applications."""
+
+import dataclasses
+import datetime
+
+from ._misc import require_capabilities
+from ._session import AsyncNcSessionBasic, NcSessionBasic
+
+
+
+[docs] +@dataclasses.dataclass +class ExAppInfo: + """Information about the External Application.""" + + def __init__(self, raw_data: dict): + self._raw_data = raw_data + + @property + def app_id(self) -> str: + """`ID` of the application.""" + return self._raw_data["id"] + + @property + def name(self) -> str: + """Display name.""" + return self._raw_data["name"] + + @property + def version(self) -> str: + """Version of the application.""" + return self._raw_data["version"] + + @property + def enabled(self) -> bool: + """Flag indicating if the application enabled.""" + return bool(self._raw_data["enabled"]) + + @property + def last_check_time(self) -> datetime.datetime: + """Time of the last successful application check.""" + return datetime.datetime.utcfromtimestamp(int(self._raw_data["last_check_time"])).replace( + tzinfo=datetime.timezone.utc + ) + + @property + def system(self) -> bool: + """**DEPRECATED** Flag indicating if the application is a system application.""" + return True + + def __repr__(self): + return f"<{self.__class__.__name__} id={self.app_id}, ver={self.version}>"
+ + + +
+[docs] +class _AppsAPI: + """The class provides the application management API on the Nextcloud server.""" + + _ep_base: str = "/ocs/v1.php/cloud/apps" + + def __init__(self, session: NcSessionBasic): + self._session = session + +
+[docs] + def disable(self, app_id: str) -> None: + """Disables the application. + + .. note:: Does not work in NextcloudApp mode, only for Nextcloud client mode. + """ + if not app_id: + raise ValueError("`app_id` parameter can not be empty") + self._session.ocs("DELETE", f"{self._ep_base}/{app_id}")
+ + +
+[docs] + def enable(self, app_id: str) -> None: + """Enables the application. + + .. note:: Does not work in NextcloudApp mode, only for Nextcloud client mode. + """ + if not app_id: + raise ValueError("`app_id` parameter can not be empty") + self._session.ocs("POST", f"{self._ep_base}/{app_id}")
+ + +
+[docs] + def get_list(self, enabled: bool | None = None) -> list[str]: + """Get the list of installed applications. + + :param enabled: filter to list all/only enabled/only disabled applications. + """ + params = None + if enabled is not None: + params = {"filter": "enabled" if enabled else "disabled"} + result = self._session.ocs("GET", self._ep_base, params=params) + return list(result["apps"].values()) if isinstance(result["apps"], dict) else result["apps"]
+ + +
+[docs] + def is_installed(self, app_id: str) -> bool: + """Returns ``True`` if specified application is installed.""" + if not app_id: + raise ValueError("`app_id` parameter can not be empty") + return app_id in self.get_list()
+ + +
+[docs] + def is_enabled(self, app_id: str) -> bool: + """Returns ``True`` if specified application is enabled.""" + if not app_id: + raise ValueError("`app_id` parameter can not be empty") + return app_id in self.get_list(enabled=True)
+ + +
+[docs] + def is_disabled(self, app_id: str) -> bool: + """Returns ``True`` if specified application is disabled.""" + if not app_id: + raise ValueError("`app_id` parameter can not be empty") + return app_id in self.get_list(enabled=False)
+ + +
+[docs] + def ex_app_disable(self, app_id: str) -> None: + """Disables the external application. + + .. note:: Does not work in NextcloudApp mode, only for Nextcloud client mode. + """ + if not app_id: + raise ValueError("`app_id` parameter can not be empty") + self._session.ocs("PUT", f"{self._session.ae_url}/ex-app/{app_id}/enabled", json={"enabled": 0})
+ + +
+[docs] + def ex_app_enable(self, app_id: str) -> None: + """Enables the external application. + + .. note:: Does not work in NextcloudApp mode, only for Nextcloud client mode. + """ + if not app_id: + raise ValueError("`app_id` parameter can not be empty") + self._session.ocs("PUT", f"{self._session.ae_url}/ex-app/{app_id}/enabled", json={"enabled": 1})
+ + +
+[docs] + def ex_app_get_list(self, enabled: bool = False) -> list[ExAppInfo]: + """Gets information of the enabled external applications installed on the server. + + :param enabled: Flag indicating whether to return only enabled applications or all applications. + """ + require_capabilities("app_api", self._session.capabilities) + url_param = "enabled" if enabled else "all" + r = self._session.ocs("GET", f"{self._session.ae_url}/ex-app/{url_param}") + return [ExAppInfo(i) for i in r]
+ + +
+[docs] + def ex_app_is_enabled(self, app_id: str) -> bool: + """Returns ``True`` if specified external application is enabled.""" + if not app_id: + raise ValueError("`app_id` parameter can not be empty") + return app_id in [i.app_id for i in self.ex_app_get_list(True)]
+ + +
+[docs] + def ex_app_is_disabled(self, app_id: str) -> bool: + """Returns ``True`` if specified external application is disabled.""" + if not app_id: + raise ValueError("`app_id` parameter can not be empty") + return app_id in [i.app_id for i in self.ex_app_get_list() if not i.enabled]
+
+ + + +class _AsyncAppsAPI: + """The class provides the async application management API on the Nextcloud server.""" + + _ep_base: str = "/ocs/v1.php/cloud/apps" + + def __init__(self, session: AsyncNcSessionBasic): + self._session = session + + async def disable(self, app_id: str) -> None: + """Disables the application. + + .. note:: Does not work in NextcloudApp mode, only for Nextcloud client mode. + """ + if not app_id: + raise ValueError("`app_id` parameter can not be empty") + await self._session.ocs("DELETE", f"{self._ep_base}/{app_id}") + + async def enable(self, app_id: str) -> None: + """Enables the application. + + .. note:: Does not work in NextcloudApp mode, only for Nextcloud client mode. + """ + if not app_id: + raise ValueError("`app_id` parameter can not be empty") + await self._session.ocs("POST", f"{self._ep_base}/{app_id}") + + async def get_list(self, enabled: bool | None = None) -> list[str]: + """Get the list of installed applications. + + :param enabled: filter to list all/only enabled/only disabled applications. + """ + params = None + if enabled is not None: + params = {"filter": "enabled" if enabled else "disabled"} + result = await self._session.ocs("GET", self._ep_base, params=params) + return list(result["apps"].values()) if isinstance(result["apps"], dict) else result["apps"] + + async def is_installed(self, app_id: str) -> bool: + """Returns ``True`` if specified application is installed.""" + if not app_id: + raise ValueError("`app_id` parameter can not be empty") + return app_id in await self.get_list() + + async def is_enabled(self, app_id: str) -> bool: + """Returns ``True`` if specified application is enabled.""" + if not app_id: + raise ValueError("`app_id` parameter can not be empty") + return app_id in await self.get_list(enabled=True) + + async def is_disabled(self, app_id: str) -> bool: + """Returns ``True`` if specified application is disabled.""" + if not app_id: + raise ValueError("`app_id` parameter can not be empty") + return app_id in await self.get_list(enabled=False) + + async def ex_app_disable(self, app_id: str) -> None: + """Disables the external application. + + .. note:: Does not work in NextcloudApp mode, only for Nextcloud client mode. + """ + if not app_id: + raise ValueError("`app_id` parameter can not be empty") + await self._session.ocs("PUT", f"{self._session.ae_url}/ex-app/{app_id}/enabled", json={"enabled": 0}) + + async def ex_app_enable(self, app_id: str) -> None: + """Enables the external application. + + .. note:: Does not work in NextcloudApp mode, only for Nextcloud client mode. + """ + if not app_id: + raise ValueError("`app_id` parameter can not be empty") + await self._session.ocs("PUT", f"{self._session.ae_url}/ex-app/{app_id}/enabled", json={"enabled": 1}) + + async def ex_app_get_list(self, enabled: bool = False) -> list[ExAppInfo]: + """Gets information of the enabled external applications installed on the server. + + :param enabled: Flag indicating whether to return only enabled applications or all applications. + """ + require_capabilities("app_api", await self._session.capabilities) + url_param = "enabled" if enabled else "all" + r = await self._session.ocs("GET", f"{self._session.ae_url}/ex-app/{url_param}") + return [ExAppInfo(i) for i in r] + + async def ex_app_is_enabled(self, app_id: str) -> bool: + """Returns ``True`` if specified external application is enabled.""" + if not app_id: + raise ValueError("`app_id` parameter can not be empty") + return app_id in [i.app_id for i in await self.ex_app_get_list(True)] + + async def ex_app_is_disabled(self, app_id: str) -> bool: + """Returns ``True`` if specified external application is disabled.""" + if not app_id: + raise ValueError("`app_id` parameter can not be empty") + return app_id in [i.app_id for i in await self.ex_app_get_list() if not i.enabled] +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/calendar.html b/_modules/nc_py_api/calendar.html new file mode 100644 index 00000000..18090bf2 --- /dev/null +++ b/_modules/nc_py_api/calendar.html @@ -0,0 +1,171 @@ + + + + + + nc_py_api.calendar — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for nc_py_api.calendar

+"""Nextcloud Calendar DAV API."""
+
+from ._session import NcSessionBasic
+
+try:
+    from caldav.davclient import DAVClient, DAVResponse
+
+    class _CalendarAPI(DAVClient):
+        """Class that encapsulates ``caldav.DAVClient`` to work with the Nextcloud calendar."""
+
+        def __init__(self, session: NcSessionBasic):
+            self._session = session
+            super().__init__(session.cfg.dav_endpoint)
+
+        @property
+        def available(self) -> bool:
+            """Returns True if ``caldav`` package is avalaible, False otherwise."""
+            return True
+
+        def request(self, url, method="GET", body="", headers={}):  # noqa pylint: disable=dangerous-default-value
+            if isinstance(body, str):
+                body = body.encode("UTF-8")
+            if body:
+                body = body.replace(b"\n", b"\r\n").replace(b"\r\r\n", b"\r\n")
+            r = self._session.adapter_dav.request(
+                method, url if isinstance(url, str) else str(url), content=body, headers=headers
+            )
+            return DAVResponse(r)
+
+except ImportError:
+
+
+[docs] + class _CalendarAPI: # type: ignore + """A stub class in case **caldav** is missing.""" + + def __init__(self, session: NcSessionBasic): + self._session = session + + @property + def available(self) -> bool: + """Returns True if ``caldav`` package is avalaible, False otherwise.""" + return False
+ +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/ex_app/defs.html b/_modules/nc_py_api/ex_app/defs.html new file mode 100644 index 00000000..cc22a0f4 --- /dev/null +++ b/_modules/nc_py_api/ex_app/defs.html @@ -0,0 +1,167 @@ + + + + + + nc_py_api.ex_app.defs — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for nc_py_api.ex_app.defs

+"""Additional definitions for NextcloudApp."""
+
+import enum
+
+from pydantic import BaseModel
+
+from ..files import ActionFileInfo
+
+
+
+[docs] +class LogLvl(enum.IntEnum): + """Log levels.""" + + DEBUG = 0 + """Debug log level""" + INFO = 1 + """Informational log level""" + WARNING = 2 + """Warning log level. ``Default``""" + ERROR = 3 + """Error log level""" + FATAL = 4 + """Fatal log level"""
+ + + +class FileSystemEventData(BaseModel): + """FileSystem events format.""" + + target: ActionFileInfo + source: ActionFileInfo | None = None + + +class FileSystemEventNotification(BaseModel): + """AppAPI event notification common data.""" + + event_type: str + event_subtype: str + event_data: FileSystemEventData +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/ex_app/events_listener.html b/_modules/nc_py_api/ex_app/events_listener.html new file mode 100644 index 00000000..e74d60c5 --- /dev/null +++ b/_modules/nc_py_api/ex_app/events_listener.html @@ -0,0 +1,279 @@ + + + + + + nc_py_api.ex_app.events_listener — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+
    +
  • + + +
  • +
  • +
+
+
+
+
+ +

Source code for nc_py_api.ex_app.events_listener

+"""Nextcloud API for registering Events listeners for ExApps."""
+
+import dataclasses
+
+from .._exceptions import NextcloudExceptionNotFound
+from .._misc import require_capabilities
+from .._session import AsyncNcSessionApp, NcSessionApp
+
+_EP_SUFFIX: str = "events_listener"
+
+
+
+[docs] +@dataclasses.dataclass +class EventsListener: + """EventsListener description.""" + + def __init__(self, raw_data: dict): + self._raw_data = raw_data + + @property + def event_type(self) -> str: + """Main type of event, e.g. ``node_event``.""" + return self._raw_data["event_type"] + + @property + def event_subtypes(self) -> str: + """Subtypes for which fire event, e.g. ``NodeCreatedEvent``, ``NodeDeletedEvent``.""" + return self._raw_data["event_subtypes"] + + @property + def action_handler(self) -> str: + """Relative ExApp url which will be called by Nextcloud.""" + return self._raw_data["action_handler"] + + def __repr__(self): + return f"<{self.__class__.__name__} event_type={self.event_type}, handler={self.action_handler}>"
+ + + +
+[docs] +class EventsListenerAPI: + """API for registering Events listeners, avalaible as **nc.events_handler.<method>**.""" + + def __init__(self, session: NcSessionApp): + self._session = session + +
+[docs] + def register( + self, + event_type: str, + callback_url: str, + event_subtypes: list[str] | None = None, + ) -> None: + """Registers or edits the events listener.""" + if event_subtypes is None: + event_subtypes = [] + require_capabilities("app_api", self._session.capabilities) + params = { + "eventType": event_type, + "actionHandler": callback_url, + "eventSubtypes": event_subtypes, + } + self._session.ocs("POST", f"{self._session.ae_url}/{_EP_SUFFIX}", json=params)
+ + +
+[docs] + def unregister(self, event_type: str, not_fail=True) -> None: + """Removes the events listener.""" + require_capabilities("app_api", self._session.capabilities) + try: + self._session.ocs( + "DELETE", + f"{self._session.ae_url}/{_EP_SUFFIX}", + params={"eventType": event_type}, + ) + except NextcloudExceptionNotFound as e: + if not not_fail: + raise e from None
+ + +
+[docs] + def get_entry(self, event_type: str) -> EventsListener | None: + """Get information about the event listener.""" + require_capabilities("app_api", self._session.capabilities) + try: + return EventsListener( + self._session.ocs( + "GET", + f"{self._session.ae_url}/{_EP_SUFFIX}", + params={"eventType": event_type}, + ) + ) + except NextcloudExceptionNotFound: + return None
+
+ + + +class AsyncEventsListenerAPI: + """API for registering Events listeners, avalaible as **nc.events_handler.<method>**.""" + + def __init__(self, session: AsyncNcSessionApp): + self._session = session + + async def register( + self, + event_type: str, + callback_url: str, + event_subtypes: list[str] | None = None, + ) -> None: + """Registers or edits the events listener.""" + if event_subtypes is None: + event_subtypes = [] + require_capabilities("app_api", await self._session.capabilities) + params = { + "eventType": event_type, + "actionHandler": callback_url, + "eventSubtypes": event_subtypes, + } + await self._session.ocs("POST", f"{self._session.ae_url}/{_EP_SUFFIX}", json=params) + + async def unregister(self, event_type: str, not_fail=True) -> None: + """Removes the events listener.""" + require_capabilities("app_api", await self._session.capabilities) + try: + await self._session.ocs( + "DELETE", + f"{self._session.ae_url}/{_EP_SUFFIX}", + params={"eventType": event_type}, + ) + except NextcloudExceptionNotFound as e: + if not not_fail: + raise e from None + + async def get_entry(self, event_type: str) -> EventsListener | None: + """Get information about the event listener.""" + require_capabilities("app_api", await self._session.capabilities) + try: + return EventsListener( + await self._session.ocs( + "GET", + f"{self._session.ae_url}/{_EP_SUFFIX}", + params={"eventType": event_type}, + ) + ) + except NextcloudExceptionNotFound: + return None +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/ex_app/misc.html b/_modules/nc_py_api/ex_app/misc.html new file mode 100644 index 00000000..f03747d4 --- /dev/null +++ b/_modules/nc_py_api/ex_app/misc.html @@ -0,0 +1,190 @@ + + + + + + nc_py_api.ex_app.misc — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for nc_py_api.ex_app.misc

+"""Different miscellaneous optimization/helper functions for the Nextcloud Applications."""
+
+import os
+from sys import platform
+
+
+
+[docs] +def persistent_storage() -> str: + """Returns the path to directory, which is permanent storage and is not deleted when the application is updated.""" + return os.getenv("APP_PERSISTENT_STORAGE", _get_app_cache_dir())
+ + + +def _get_app_cache_dir() -> str: + sys_platform = platform.lower() + root_cache_path = ( + os.path.normpath(os.environ["LOCALAPPDATA"]) + if sys_platform == "win32" + else ( + os.path.expanduser("~/Library/Caches") + if sys_platform == "darwin" + else os.getenv("XDG_CACHE_HOME", os.path.expanduser("~/.cache")) + ) + ) + r = os.path.join(root_cache_path, os.environ["APP_ID"]) + os.makedirs(r, exist_ok=True) + return r + + +
+[docs] +def verify_version(finalize_update: bool = True) -> tuple[str, str] | None: + """Returns tuple with an old version and new version or ``None`` if there was no update taken. + + :param finalize_update: Flag indicating whether update information should be updated. + If ``True``, all subsequent calls to this function will return that there is no update. + """ + version_file_path = os.path.join(persistent_storage(), "_version.info") + r = None + with open(version_file_path, "a+t", encoding="UTF-8") as version_file: + version_file.seek(0) + old_version = version_file.read() + if old_version != os.environ["APP_VERSION"]: + r = (old_version, os.environ["APP_VERSION"]) + if finalize_update: + version_file.seek(0) + version_file.write(os.environ["APP_VERSION"]) + version_file.truncate() + return r
+ + + +def get_model_path(model_name: str) -> str: + """Wrapper around hugging_face's ``snapshot_download`` to return path to downloaded model directory.""" + from huggingface_hub import snapshot_download # noqa isort:skip pylint: disable=C0415 disable=E0401 + + return snapshot_download(model_name, local_files_only=True, cache_dir=persistent_storage()) + + +def get_computation_device() -> str: + """Returns computation device(`ROCM` or `CUDA`) if it is defined in the environment variable.""" + return str(os.environ.get("COMPUTE_DEVICE", "")).upper() +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/ex_app/occ_commands.html b/_modules/nc_py_api/ex_app/occ_commands.html new file mode 100644 index 00000000..e90449bf --- /dev/null +++ b/_modules/nc_py_api/ex_app/occ_commands.html @@ -0,0 +1,295 @@ + + + + + + nc_py_api.ex_app.occ_commands — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+
    +
  • + + +
  • +
  • +
+
+
+
+
+ +

Source code for nc_py_api.ex_app.occ_commands

+"""Nextcloud API for registering OCC commands for ExApps."""
+
+import dataclasses
+
+from .._exceptions import NextcloudExceptionNotFound
+from .._misc import clear_from_params_empty, require_capabilities
+from .._session import AsyncNcSessionApp, NcSessionApp
+
+_EP_SUFFIX: str = "occ_command"
+
+
+
+[docs] +@dataclasses.dataclass +class OccCommand: + """OccCommand description.""" + + def __init__(self, raw_data: dict): + self._raw_data = raw_data + + @property + def name(self) -> str: + """Unique ID for the command.""" + return self._raw_data["name"] + + @property + def description(self) -> str: + """Command description.""" + return self._raw_data["description"] + + @property + def hidden(self) -> bool: + """Flag determining ss command hidden or not.""" + return bool(self._raw_data["hidden"]) + + @property + def arguments(self) -> dict: + """Look at PHP Symfony framework for details.""" + return self._raw_data["arguments"] + + @property + def options(self) -> str: + """Look at PHP Symfony framework for details.""" + return self._raw_data["options"] + + @property + def usages(self) -> str: + """Look at PHP Symfony framework for details.""" + return self._raw_data["usages"] + + @property + def action_handler(self) -> str: + """Relative ExApp url which will be called by Nextcloud.""" + return self._raw_data["execute_handler"] + + def __repr__(self): + return f"<{self.__class__.__name__} name={self.name}, handler={self.action_handler}>"
+ + + +
+[docs] +class OccCommandsAPI: + """API for registering OCC commands, avalaible as **nc.occ_command.<method>**.""" + + def __init__(self, session: NcSessionApp): + self._session = session + +
+[docs] + def register( + self, + name: str, + callback_url: str, + arguments: list | None = None, + options: list | None = None, + usages: list | None = None, + description: str = "", + hidden: bool = False, + ) -> None: + """Registers or edit the OCC command.""" + require_capabilities("app_api", self._session.capabilities) + params = { + "name": name, + "description": description, + "arguments": arguments, + "hidden": int(hidden), + "options": options, + "usages": usages, + "execute_handler": callback_url, + } + clear_from_params_empty(["arguments", "options", "usages"], params) + self._session.ocs("POST", f"{self._session.ae_url}/{_EP_SUFFIX}", json=params)
+ + +
+[docs] + def unregister(self, name: str, not_fail=True) -> None: + """Removes the OCC command.""" + require_capabilities("app_api", self._session.capabilities) + try: + self._session.ocs("DELETE", f"{self._session.ae_url}/{_EP_SUFFIX}", params={"name": name}) + except NextcloudExceptionNotFound as e: + if not not_fail: + raise e from None
+ + +
+[docs] + def get_entry(self, name: str) -> OccCommand | None: + """Get information of the OCC command.""" + require_capabilities("app_api", self._session.capabilities) + try: + return OccCommand(self._session.ocs("GET", f"{self._session.ae_url}/{_EP_SUFFIX}", params={"name": name})) + except NextcloudExceptionNotFound: + return None
+
+ + + +class AsyncOccCommandsAPI: + """Async API for registering OCC commands, avalaible as **nc.occ_command.<method>**.""" + + def __init__(self, session: AsyncNcSessionApp): + self._session = session + + async def register( + self, + name: str, + callback_url: str, + arguments: list | None = None, + options: list | None = None, + usages: list | None = None, + description: str = "", + hidden: bool = False, + ) -> None: + """Registers or edit the OCC command.""" + require_capabilities("app_api", await self._session.capabilities) + params = { + "name": name, + "description": description, + "arguments": arguments, + "hidden": int(hidden), + "options": options, + "usages": usages, + "execute_handler": callback_url, + } + clear_from_params_empty(["arguments", "options", "usages"], params) + await self._session.ocs("POST", f"{self._session.ae_url}/{_EP_SUFFIX}", json=params) + + async def unregister(self, name: str, not_fail=True) -> None: + """Removes the OCC command.""" + require_capabilities("app_api", await self._session.capabilities) + try: + await self._session.ocs("DELETE", f"{self._session.ae_url}/{_EP_SUFFIX}", params={"name": name}) + except NextcloudExceptionNotFound as e: + if not not_fail: + raise e from None + + async def get_entry(self, name: str) -> OccCommand | None: + """Get information of the OCC command.""" + require_capabilities("app_api", await self._session.capabilities) + try: + return OccCommand( + await self._session.ocs("GET", f"{self._session.ae_url}/{_EP_SUFFIX}", params={"name": name}) + ) + except NextcloudExceptionNotFound: + return None +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/ex_app/providers/providers.html b/_modules/nc_py_api/ex_app/providers/providers.html new file mode 100644 index 00000000..f1202691 --- /dev/null +++ b/_modules/nc_py_api/ex_app/providers/providers.html @@ -0,0 +1,154 @@ + + + + + + nc_py_api.ex_app.providers.providers — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+
    +
  • + + +
  • +
  • +
+
+
+
+
+ +

Source code for nc_py_api.ex_app.providers.providers

+"""Nextcloud API for AI Providers."""
+
+from ..._session import AsyncNcSessionApp, NcSessionApp
+from .task_processing import _AsyncTaskProcessingProviderAPI, _TaskProcessingProviderAPI
+
+
+
+[docs] +class ProvidersApi: + """Class that encapsulates all AI Providers functionality.""" + + task_processing: _TaskProcessingProviderAPI + """TaskProcessing Provider API.""" + + def __init__(self, session: NcSessionApp): + self.task_processing = _TaskProcessingProviderAPI(session)
+ + + +class AsyncProvidersApi: + """Class that encapsulates all AI Providers functionality.""" + + task_processing: _AsyncTaskProcessingProviderAPI + """TaskProcessing Provider API.""" + + def __init__(self, session: AsyncNcSessionApp): + self.task_processing = _AsyncTaskProcessingProviderAPI(session) +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/ex_app/providers/task_processing.html b/_modules/nc_py_api/ex_app/providers/task_processing.html new file mode 100644 index 00000000..016645d5 --- /dev/null +++ b/_modules/nc_py_api/ex_app/providers/task_processing.html @@ -0,0 +1,424 @@ + + + + + + nc_py_api.ex_app.providers.task_processing — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+
    +
  • + + +
  • +
  • +
+
+
+
+
+ +

Source code for nc_py_api.ex_app.providers.task_processing

+"""Nextcloud API for declaring TaskProcessing provider."""
+
+import contextlib
+import dataclasses
+import typing
+from enum import IntEnum
+
+from pydantic import RootModel
+from pydantic.dataclasses import dataclass
+
+from ..._exceptions import NextcloudException, NextcloudExceptionNotFound
+from ..._misc import require_capabilities
+from ..._session import AsyncNcSessionApp, NcSessionApp
+
+_EP_SUFFIX: str = "ai_provider/task_processing"
+
+
+
+[docs] +class ShapeType(IntEnum): + """Enum for shape types.""" + + NUMBER = 0 + TEXT = 1 + IMAGE = 2 + AUDIO = 3 + VIDEO = 4 + FILE = 5 + ENUM = 6 + LIST_OF_NUMBERS = 10 + LIST_OF_TEXTS = 11 + LIST_OF_IMAGES = 12 + LIST_OF_AUDIOS = 13 + LIST_OF_VIDEOS = 14 + LIST_OF_FILES = 15
+ + + +
+[docs] +@dataclass +class ShapeEnumValue: + """Data object for input output shape enum slot value.""" + + name: str + """Name of the enum slot value which will be displayed in the UI""" + value: str + """Value of the enum slot value"""
+ + + +
+[docs] +@dataclass +class ShapeDescriptor: + """Data object for input output shape entries.""" + + name: str + """Name of the shape entry""" + description: str + """Description of the shape entry""" + shape_type: ShapeType + """Type of the shape entry"""
+ + + +
+[docs] +@dataclass +class TaskType: + """TaskType description for the provider.""" + + id: str + """The unique ID for the task type.""" + name: str + """The localized name of the task type.""" + description: str + """The localized description of the task type.""" + input_shape: list[ShapeDescriptor] + """The input shape of the task.""" + output_shape: list[ShapeDescriptor] + """The output shape of the task."""
+ + + +
+[docs] +@dataclass +class TaskProcessingProvider: + + id: str + """Unique ID for the provider.""" + name: str + """The localized name of this provider""" + task_type: str + """The TaskType provided by this provider.""" + expected_runtime: int = dataclasses.field(default=0) + """Expected runtime of the task in seconds.""" + optional_input_shape: list[ShapeDescriptor] = dataclasses.field(default_factory=list) + """Optional input shape of the task.""" + optional_output_shape: list[ShapeDescriptor] = dataclasses.field(default_factory=list) + """Optional output shape of the task.""" + input_shape_enum_values: dict[str, list[ShapeEnumValue]] = dataclasses.field(default_factory=dict) + """The option dict for each input shape ENUM slot.""" + input_shape_defaults: dict[str, str | int | float] = dataclasses.field(default_factory=dict) + """The default values for input shape slots.""" + optional_input_shape_enum_values: dict[str, list[ShapeEnumValue]] = dataclasses.field(default_factory=dict) + """The option list for each optional input shape ENUM slot.""" + optional_input_shape_defaults: dict[str, str | int | float] = dataclasses.field(default_factory=dict) + """The default values for optional input shape slots.""" + output_shape_enum_values: dict[str, list[ShapeEnumValue]] = dataclasses.field(default_factory=dict) + """The option list for each output shape ENUM slot.""" + optional_output_shape_enum_values: dict[str, list[ShapeEnumValue]] = dataclasses.field(default_factory=dict) + """The option list for each optional output shape ENUM slot.""" + + def __repr__(self): + return f"<{self.__class__.__name__} name={self.name}, type={self.task_type}>"
+ + + +
+[docs] +class _TaskProcessingProviderAPI: + """API for TaskProcessing providers, available as **nc.providers.task_processing.<method>**.""" + + def __init__(self, session: NcSessionApp): + self._session = session + +
+[docs] + def register( + self, + provider: TaskProcessingProvider, + custom_task_type: TaskType | None = None, + ) -> None: + """Registers or edit the TaskProcessing provider.""" + require_capabilities("app_api", self._session.capabilities) + params = { + "provider": RootModel(provider).model_dump(), + **({"customTaskType": RootModel(custom_task_type).model_dump()} if custom_task_type else {}), + } + self._session.ocs("POST", f"{self._session.ae_url}/{_EP_SUFFIX}", json=params)
+ + +
+[docs] + def unregister(self, name: str, not_fail=True) -> None: + """Removes TaskProcessing provider.""" + require_capabilities("app_api", self._session.capabilities) + try: + self._session.ocs("DELETE", f"{self._session.ae_url}/{_EP_SUFFIX}", params={"name": name}) + except NextcloudExceptionNotFound as e: + if not not_fail: + raise e from None
+ + +
+[docs] + def next_task(self, provider_ids: list[str], task_types: list[str]) -> dict[str, typing.Any]: + """Get the next task processing task from Nextcloud.""" + with contextlib.suppress(NextcloudException): + if r := self._session.ocs( + "GET", + "/ocs/v2.php/taskprocessing/tasks_provider/next", + json={"providerIds": provider_ids, "taskTypeIds": task_types}, + ): + return r + return {}
+ + +
+[docs] + def set_progress(self, task_id: int, progress: float) -> dict[str, typing.Any]: + """Report new progress value of the task to Nextcloud. Progress should be in range from 0.0 to 100.0.""" + with contextlib.suppress(NextcloudException): + if r := self._session.ocs( + "POST", + f"/ocs/v2.php/taskprocessing/tasks_provider/{task_id}/progress", + json={"taskId": task_id, "progress": progress / 100.0}, + ): + return r + return {}
+ + +
+[docs] + def upload_result_file(self, task_id: int, file: bytes | str | typing.Any) -> int: + """Uploads file and returns fileID that should be used in the ``report_result`` function. + + .. note:: ``file`` can be any file-like object. + """ + return self._session.ocs( + "POST", + f"/ocs/v2.php/taskprocessing/tasks_provider/{task_id}/file", + files={"file": file}, + )["fileId"]
+ + +
+[docs] + def report_result( + self, + task_id: int, + output: dict[str, typing.Any] | None = None, + error_message: str | None = None, + ) -> dict[str, typing.Any]: + """Report result of the task processing to Nextcloud.""" + with contextlib.suppress(NextcloudException): + if r := self._session.ocs( + "POST", + f"/ocs/v2.php/taskprocessing/tasks_provider/{task_id}/result", + json={"taskId": task_id, "output": output, "errorMessage": error_message}, + ): + return r + return {}
+
+ + + +class _AsyncTaskProcessingProviderAPI: + """Async API for TaskProcessing providers.""" + + def __init__(self, session: AsyncNcSessionApp): + self._session = session + + async def register( + self, + provider: TaskProcessingProvider, + custom_task_type: TaskType | None = None, + ) -> None: + """Registers or edit the TaskProcessing provider.""" + require_capabilities("app_api", await self._session.capabilities) + params = { + "provider": RootModel(provider).model_dump(), + **({"customTaskType": RootModel(custom_task_type).model_dump()} if custom_task_type else {}), + } + await self._session.ocs("POST", f"{self._session.ae_url}/{_EP_SUFFIX}", json=params) + + async def unregister(self, name: str, not_fail=True) -> None: + """Removes TaskProcessing provider.""" + require_capabilities("app_api", await self._session.capabilities) + try: + await self._session.ocs("DELETE", f"{self._session.ae_url}/{_EP_SUFFIX}", params={"name": name}) + except NextcloudExceptionNotFound as e: + if not not_fail: + raise e from None + + async def next_task(self, provider_ids: list[str], task_types: list[str]) -> dict[str, typing.Any]: + """Get the next task processing task from Nextcloud.""" + with contextlib.suppress(NextcloudException): + if r := await self._session.ocs( + "GET", + "/ocs/v2.php/taskprocessing/tasks_provider/next", + json={"providerIds": provider_ids, "taskTypeIds": task_types}, + ): + return r + return {} + + async def set_progress(self, task_id: int, progress: float) -> dict[str, typing.Any]: + """Report new progress value of the task to Nextcloud. Progress should be in range from 0.0 to 100.0.""" + with contextlib.suppress(NextcloudException): + if r := await self._session.ocs( + "POST", + f"/ocs/v2.php/taskprocessing/tasks_provider/{task_id}/progress", + json={"taskId": task_id, "progress": progress / 100.0}, + ): + return r + return {} + + async def upload_result_file(self, task_id: int, file: bytes | str | typing.Any) -> int: + """Uploads file and returns fileID that should be used in the ``report_result`` function. + + .. note:: ``file`` can be any file-like object. + """ + return ( + await self._session.ocs( + "POST", + f"/ocs/v2.php/taskprocessing/tasks_provider/{task_id}/file", + files={"file": file}, + ) + )["fileId"] + + async def report_result( + self, + task_id: int, + output: dict[str, typing.Any] | None = None, + error_message: str | None = None, + ) -> dict[str, typing.Any]: + """Report result of the task processing to Nextcloud.""" + with contextlib.suppress(NextcloudException): + if r := await self._session.ocs( + "POST", + f"/ocs/v2.php/taskprocessing/tasks_provider/{task_id}/result", + json={"taskId": task_id, "output": output, "errorMessage": error_message}, + ): + return r + return {} +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/ex_app/ui/files_actions.html b/_modules/nc_py_api/ex_app/ui/files_actions.html new file mode 100644 index 00000000..b2821c3f --- /dev/null +++ b/_modules/nc_py_api/ex_app/ui/files_actions.html @@ -0,0 +1,331 @@ + + + + + + nc_py_api.ex_app.ui.files_actions — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+
    +
  • + + +
  • +
  • +
+
+
+
+
+ +

Source code for nc_py_api.ex_app.ui.files_actions

+"""Nextcloud API for working with drop-down file's menu."""
+
+import dataclasses
+import warnings
+
+from ..._exceptions import NextcloudExceptionNotFound
+from ..._misc import require_capabilities
+from ..._session import AsyncNcSessionApp, NcSessionApp
+
+
+
+[docs] +@dataclasses.dataclass +class UiFileActionEntry: + """Files app, right click file action entry description.""" + + def __init__(self, raw_data: dict): + self._raw_data = raw_data + + @property + def appid(self) -> str: + """App ID for which this entry is.""" + return self._raw_data["appid"] + + @property + def name(self) -> str: + """File action name, acts like ID.""" + return self._raw_data["name"] + + @property + def display_name(self) -> str: + """Display name of the entry.""" + return self._raw_data["display_name"] + + @property + def mime(self) -> str: + """For which file types this entry applies.""" + return self._raw_data["mime"] + + @property + def permissions(self) -> int: + """For which file permissions this entry applies.""" + return int(self._raw_data["permissions"]) + + @property + def order(self) -> int: + """Order of the entry in the file action list.""" + return int(self._raw_data["order"]) + + @property + def icon(self) -> str: + """Relative to the ExApp url with icon or empty value to use the default one icon.""" + return self._raw_data["icon"] if self._raw_data["icon"] else "" + + @property + def action_handler(self) -> str: + """Relative ExApp url which will be called if user click on the entry.""" + return self._raw_data["action_handler"] + + @property + def version(self) -> str: + """AppAPI `2.6.0` supports new version of UiActions(https://github.com/cloud-py-api/app_api/pull/284).""" + return self._raw_data.get("version", "1.0") + + def __repr__(self): + return f"<{self.__class__.__name__} name={self.name}, mime={self.mime}, handler={self.action_handler}>"
+ + + +
+[docs] +class _UiFilesActionsAPI: + """API for the drop-down menu in Nextcloud **Files app**, avalaible as **nc.ui.files_dropdown_menu.<method>**.""" + + _ep_suffix: str = "ui/files-actions-menu" + + def __init__(self, session: NcSessionApp): + self._session = session + +
+[docs] + def register(self, name: str, display_name: str, callback_url: str, **kwargs) -> None: + """Registers the files dropdown menu element.""" + warnings.warn( + "register() is deprecated and will be removed in a future version. Use register_ex() instead.", + DeprecationWarning, + stacklevel=2, + ) + require_capabilities("app_api", self._session.capabilities) + params = { + "name": name, + "displayName": display_name, + "actionHandler": callback_url, + "icon": kwargs.get("icon", ""), + "mime": kwargs.get("mime", "file"), + "permissions": kwargs.get("permissions", 31), + "order": kwargs.get("order", 0), + } + self._session.ocs("POST", f"{self._session.ae_url}/{self._ep_suffix}", json=params)
+ + +
+[docs] + def register_ex(self, name: str, display_name: str, callback_url: str, **kwargs) -> None: + """Registers the files dropdown menu element(extended version that receives ``ActionFileInfoEx``).""" + require_capabilities("app_api", self._session.capabilities) + params = { + "name": name, + "displayName": display_name, + "actionHandler": callback_url, + "icon": kwargs.get("icon", ""), + "mime": kwargs.get("mime", "file"), + "permissions": kwargs.get("permissions", 31), + "order": kwargs.get("order", 0), + } + self._session.ocs("POST", f"{self._session.ae_url_v2}/{self._ep_suffix}", json=params)
+ + +
+[docs] + def unregister(self, name: str, not_fail=True) -> None: + """Removes files dropdown menu element.""" + require_capabilities("app_api", self._session.capabilities) + try: + self._session.ocs("DELETE", f"{self._session.ae_url}/{self._ep_suffix}", json={"name": name}) + except NextcloudExceptionNotFound as e: + if not not_fail: + raise e from None
+ + +
+[docs] + def get_entry(self, name: str) -> UiFileActionEntry | None: + """Get information of the file action meny entry.""" + require_capabilities("app_api", self._session.capabilities) + try: + return UiFileActionEntry( + self._session.ocs("GET", f"{self._session.ae_url}/{self._ep_suffix}", params={"name": name}) + ) + except NextcloudExceptionNotFound: + return None
+
+ + + +class _AsyncUiFilesActionsAPI: + """Async API for the drop-down menu in Nextcloud **Files app**.""" + + _ep_suffix: str = "ui/files-actions-menu" + + def __init__(self, session: AsyncNcSessionApp): + self._session = session + + async def register(self, name: str, display_name: str, callback_url: str, **kwargs) -> None: + """Registers the files a dropdown menu element.""" + warnings.warn( + "register() is deprecated and will be removed in a future version. Use register_ex() instead.", + DeprecationWarning, + stacklevel=2, + ) + require_capabilities("app_api", await self._session.capabilities) + params = { + "name": name, + "displayName": display_name, + "actionHandler": callback_url, + "icon": kwargs.get("icon", ""), + "mime": kwargs.get("mime", "file"), + "permissions": kwargs.get("permissions", 31), + "order": kwargs.get("order", 0), + } + await self._session.ocs("POST", f"{self._session.ae_url}/{self._ep_suffix}", json=params) + + async def register_ex(self, name: str, display_name: str, callback_url: str, **kwargs) -> None: + """Registers the files dropdown menu element(extended version that receives ``ActionFileInfoEx``).""" + require_capabilities("app_api", await self._session.capabilities) + params = { + "name": name, + "displayName": display_name, + "actionHandler": callback_url, + "icon": kwargs.get("icon", ""), + "mime": kwargs.get("mime", "file"), + "permissions": kwargs.get("permissions", 31), + "order": kwargs.get("order", 0), + } + await self._session.ocs("POST", f"{self._session.ae_url_v2}/{self._ep_suffix}", json=params) + + async def unregister(self, name: str, not_fail=True) -> None: + """Removes files dropdown menu element.""" + require_capabilities("app_api", await self._session.capabilities) + try: + await self._session.ocs("DELETE", f"{self._session.ae_url}/{self._ep_suffix}", json={"name": name}) + except NextcloudExceptionNotFound as e: + if not not_fail: + raise e from None + + async def get_entry(self, name: str) -> UiFileActionEntry | None: + """Get information of the file action meny entry for current app.""" + require_capabilities("app_api", await self._session.capabilities) + try: + return UiFileActionEntry( + await self._session.ocs("GET", f"{self._session.ae_url}/{self._ep_suffix}", params={"name": name}) + ) + except NextcloudExceptionNotFound: + return None +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/ex_app/ui/resources.html b/_modules/nc_py_api/ex_app/ui/resources.html new file mode 100644 index 00000000..f5eb3771 --- /dev/null +++ b/_modules/nc_py_api/ex_app/ui/resources.html @@ -0,0 +1,490 @@ + + + + + + nc_py_api.ex_app.ui.resources — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+
    +
  • + + +
  • +
  • +
+
+
+
+
+ +

Source code for nc_py_api.ex_app.ui.resources

+"""API for adding scripts, styles, initial-states to the Nextcloud UI."""
+
+import dataclasses
+
+from ..._exceptions import NextcloudExceptionNotFound
+from ..._misc import require_capabilities
+from ..._session import AsyncNcSessionApp, NcSessionApp
+
+
+@dataclasses.dataclass
+class UiBase:
+    """Basic class for InitialStates, Scripts, Styles."""
+
+    def __init__(self, raw_data: dict):
+        self._raw_data = raw_data
+
+    @property
+    def appid(self) -> str:
+        """The App ID of the owner of this UI."""
+        return self._raw_data["appid"]
+
+    @property
+    def ui_type(self) -> str:
+        """UI type. Possible values: 'top_menu'."""
+        return self._raw_data["type"]
+
+    @property
+    def name(self) -> str:
+        """UI page name, acts like ID."""
+        return self._raw_data["name"]
+
+
+
+[docs] +class UiInitState(UiBase): + """One Initial State description.""" + + @property + def key(self) -> str: + """Name of the object.""" + return self._raw_data["key"] + + @property + def value(self) -> dict | list: + """Object for the page(template).""" + return self._raw_data["value"] + + def __repr__(self): + return f"<{self.__class__.__name__} type={self.ui_type}, name={self.name}, key={self.key}>"
+ + + +
+[docs] +class UiScript(UiBase): + """One Script description.""" + + @property + def path(self) -> str: + """Url to script relative to the ExApp.""" + return self._raw_data["path"] + + @property + def after_app_id(self) -> str: + """Optional AppID after which script should be injected.""" + return self._raw_data["after_app_id"] if self._raw_data["after_app_id"] else "" + + def __repr__(self): + return f"<{self.__class__.__name__} type={self.ui_type}, name={self.name}, path={self.path}>"
+ + + +
+[docs] +class UiStyle(UiBase): + """One Style description.""" + + @property + def path(self) -> str: + """Url to style relative to the ExApp.""" + return self._raw_data["path"] + + def __repr__(self): + return f"<{self.__class__.__name__} type={self.ui_type}, name={self.name}, path={self.path}>"
+ + + +
+[docs] +class _UiResources: + """API for adding scripts, styles, initial-states to the pages, avalaible as **nc.ui.resources.<method>**.""" + + _ep_suffix_init_state: str = "ui/initial-state" + _ep_suffix_js: str = "ui/script" + _ep_suffix_css: str = "ui/style" + + def __init__(self, session: NcSessionApp): + self._session = session + +
+[docs] + def set_initial_state(self, ui_type: str, name: str, key: str, value: dict | list) -> None: + """Add or update initial state for the page(template).""" + require_capabilities("app_api", self._session.capabilities) + params = { + "type": ui_type, + "name": name, + "key": key, + "value": value, + } + self._session.ocs("POST", f"{self._session.ae_url}/{self._ep_suffix_init_state}", json=params)
+ + +
+[docs] + def delete_initial_state(self, ui_type: str, name: str, key: str, not_fail=True) -> None: + """Removes initial state for the page(template) by object name.""" + require_capabilities("app_api", self._session.capabilities) + try: + self._session.ocs( + "DELETE", + f"{self._session.ae_url}/{self._ep_suffix_init_state}", + params={"type": ui_type, "name": name, "key": key}, + ) + except NextcloudExceptionNotFound as e: + if not not_fail: + raise e from None
+ + +
+[docs] + def get_initial_state(self, ui_type: str, name: str, key: str) -> UiInitState | None: + """Get information about initial state for the page(template) by object name.""" + require_capabilities("app_api", self._session.capabilities) + try: + return UiInitState( + self._session.ocs( + "GET", + f"{self._session.ae_url}/{self._ep_suffix_init_state}", + params={"type": ui_type, "name": name, "key": key}, + ) + ) + except NextcloudExceptionNotFound: + return None
+ + +
+[docs] + def set_script(self, ui_type: str, name: str, path: str, after_app_id: str = "") -> None: + """Add or update script for the page(template).""" + require_capabilities("app_api", self._session.capabilities) + params = { + "type": ui_type, + "name": name, + "path": path, + "afterAppId": after_app_id, + } + self._session.ocs("POST", f"{self._session.ae_url}/{self._ep_suffix_js}", json=params)
+ + +
+[docs] + def delete_script(self, ui_type: str, name: str, path: str, not_fail=True) -> None: + """Removes script for the page(template) by object name.""" + require_capabilities("app_api", self._session.capabilities) + try: + self._session.ocs( + "DELETE", + f"{self._session.ae_url}/{self._ep_suffix_js}", + params={"type": ui_type, "name": name, "path": path}, + ) + except NextcloudExceptionNotFound as e: + if not not_fail: + raise e from None
+ + +
+[docs] + def get_script(self, ui_type: str, name: str, path: str) -> UiScript | None: + """Get information about script for the page(template) by object name.""" + require_capabilities("app_api", self._session.capabilities) + try: + return UiScript( + self._session.ocs( + "GET", + f"{self._session.ae_url}/{self._ep_suffix_js}", + params={"type": ui_type, "name": name, "path": path}, + ) + ) + except NextcloudExceptionNotFound: + return None
+ + +
+[docs] + def set_style(self, ui_type: str, name: str, path: str) -> None: + """Add or update style(css) for the page(template).""" + require_capabilities("app_api", self._session.capabilities) + params = { + "type": ui_type, + "name": name, + "path": path, + } + self._session.ocs("POST", f"{self._session.ae_url}/{self._ep_suffix_css}", json=params)
+ + +
+[docs] + def delete_style(self, ui_type: str, name: str, path: str, not_fail=True) -> None: + """Removes style(css) for the page(template) by object name.""" + require_capabilities("app_api", self._session.capabilities) + try: + self._session.ocs( + "DELETE", + f"{self._session.ae_url}/{self._ep_suffix_css}", + params={"type": ui_type, "name": name, "path": path}, + ) + except NextcloudExceptionNotFound as e: + if not not_fail: + raise e from None
+ + +
+[docs] + def get_style(self, ui_type: str, name: str, path: str) -> UiStyle | None: + """Get information about style(css) for the page(template) by object name.""" + require_capabilities("app_api", self._session.capabilities) + try: + return UiStyle( + self._session.ocs( + "GET", + f"{self._session.ae_url}/{self._ep_suffix_css}", + params={"type": ui_type, "name": name, "path": path}, + ) + ) + except NextcloudExceptionNotFound: + return None
+
+ + + +class _AsyncUiResources: + """Async API for adding scripts, styles, initial-states to the TopMenu pages.""" + + _ep_suffix_init_state: str = "ui/initial-state" + _ep_suffix_js: str = "ui/script" + _ep_suffix_css: str = "ui/style" + + def __init__(self, session: AsyncNcSessionApp): + self._session = session + + async def set_initial_state(self, ui_type: str, name: str, key: str, value: dict | list) -> None: + """Add or update initial state for the page(template).""" + require_capabilities("app_api", await self._session.capabilities) + params = { + "type": ui_type, + "name": name, + "key": key, + "value": value, + } + await self._session.ocs("POST", f"{self._session.ae_url}/{self._ep_suffix_init_state}", json=params) + + async def delete_initial_state(self, ui_type: str, name: str, key: str, not_fail=True) -> None: + """Removes initial state for the page(template) by object name.""" + require_capabilities("app_api", await self._session.capabilities) + try: + await self._session.ocs( + "DELETE", + f"{self._session.ae_url}/{self._ep_suffix_init_state}", + params={"type": ui_type, "name": name, "key": key}, + ) + except NextcloudExceptionNotFound as e: + if not not_fail: + raise e from None + + async def get_initial_state(self, ui_type: str, name: str, key: str) -> UiInitState | None: + """Get information about initial state for the page(template) by object name.""" + require_capabilities("app_api", await self._session.capabilities) + try: + return UiInitState( + await self._session.ocs( + "GET", + f"{self._session.ae_url}/{self._ep_suffix_init_state}", + params={"type": ui_type, "name": name, "key": key}, + ) + ) + except NextcloudExceptionNotFound: + return None + + async def set_script(self, ui_type: str, name: str, path: str, after_app_id: str = "") -> None: + """Add or update script for the page(template).""" + require_capabilities("app_api", await self._session.capabilities) + params = { + "type": ui_type, + "name": name, + "path": path, + "afterAppId": after_app_id, + } + await self._session.ocs("POST", f"{self._session.ae_url}/{self._ep_suffix_js}", json=params) + + async def delete_script(self, ui_type: str, name: str, path: str, not_fail=True) -> None: + """Removes script for the page(template) by object name.""" + require_capabilities("app_api", await self._session.capabilities) + try: + await self._session.ocs( + "DELETE", + f"{self._session.ae_url}/{self._ep_suffix_js}", + params={"type": ui_type, "name": name, "path": path}, + ) + except NextcloudExceptionNotFound as e: + if not not_fail: + raise e from None + + async def get_script(self, ui_type: str, name: str, path: str) -> UiScript | None: + """Get information about script for the page(template) by object name.""" + require_capabilities("app_api", await self._session.capabilities) + try: + return UiScript( + await self._session.ocs( + "GET", + f"{self._session.ae_url}/{self._ep_suffix_js}", + params={"type": ui_type, "name": name, "path": path}, + ) + ) + except NextcloudExceptionNotFound: + return None + + async def set_style(self, ui_type: str, name: str, path: str) -> None: + """Add or update style(css) for the page(template).""" + require_capabilities("app_api", await self._session.capabilities) + params = { + "type": ui_type, + "name": name, + "path": path, + } + await self._session.ocs("POST", f"{self._session.ae_url}/{self._ep_suffix_css}", json=params) + + async def delete_style(self, ui_type: str, name: str, path: str, not_fail=True) -> None: + """Removes style(css) for the page(template) by object name.""" + require_capabilities("app_api", await self._session.capabilities) + try: + await self._session.ocs( + "DELETE", + f"{self._session.ae_url}/{self._ep_suffix_css}", + params={"type": ui_type, "name": name, "path": path}, + ) + except NextcloudExceptionNotFound as e: + if not not_fail: + raise e from None + + async def get_style(self, ui_type: str, name: str, path: str) -> UiStyle | None: + """Get information about style(css) for the page(template) by object name.""" + require_capabilities("app_api", await self._session.capabilities) + try: + return UiStyle( + await self._session.ocs( + "GET", + f"{self._session.ae_url}/{self._ep_suffix_css}", + params={"type": ui_type, "name": name, "path": path}, + ) + ) + except NextcloudExceptionNotFound: + return None +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/ex_app/ui/settings.html b/_modules/nc_py_api/ex_app/ui/settings.html new file mode 100644 index 00000000..5b33fe98 --- /dev/null +++ b/_modules/nc_py_api/ex_app/ui/settings.html @@ -0,0 +1,338 @@ + + + + + + nc_py_api.ex_app.ui.settings — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for nc_py_api.ex_app.ui.settings

+"""Nextcloud API for declaring UI for settings."""
+
+import dataclasses
+import enum
+import typing
+
+from ..._exceptions import NextcloudExceptionNotFound
+from ..._misc import require_capabilities
+from ..._session import AsyncNcSessionApp, NcSessionApp
+
+
+
+[docs] +class SettingsFieldType(enum.Enum): # StrEnum + """Declarative Settings Field Type.""" + + TEXT = "text" + """NcInputField type text""" + PASSWORD = "password" # noqa + """NcInputField type password""" + EMAIL = "email" + """NcInputField type email""" + TEL = "tel" + """NcInputField type tel""" + URL = "url" + """NcInputField type url""" + NUMBER = "number" + """NcInputField type number""" + CHECKBOX = "checkbox" + """NcCheckboxRadioSwitch type checkbox""" + MULTI_CHECKBOX = "multi-checkbox" + """Multiple NcCheckboxRadioSwitch type checkbox representing a one config value (saved as JSON object)""" + RADIO = "radio" + """NcCheckboxRadioSwitch type radio""" + SELECT = "select" + """NcSelect""" + MULTI_SELECT = "multi-select" + """Multiple NcSelect representing a one config value (saved as JSON array)"""
+ + + +
+[docs] +@dataclasses.dataclass +class SettingsField: + """Section field.""" + + id: str + title: str + type: SettingsFieldType + default: bool | int | float | str | list[bool | int | float | str] | dict[str, typing.Any] + options: dict | list = dataclasses.field(default_factory=dict) + description: str = "" + placeholder: str = "" + label: str = "" + notify = False # to be supported in future + +
+[docs] + @classmethod + def from_dict(cls, data: dict) -> "SettingsField": + """Creates instance of class from dict, ignoring unknown keys.""" + filtered_data = { + k: SettingsFieldType(v) if k == "type" else v for k, v in data.items() if k in cls.__annotations__ + } + return cls(**filtered_data)
+ + +
+[docs] + def to_dict(self) -> dict: + """Returns data in format that is accepted by AppAPI.""" + return { + "id": self.id, + "title": self.title, + "type": self.type.value, + "default": self.default, + "description": self.description, + "options": ( + [{"name": key, "value": value} for key, value in self.options.items()] + if isinstance(self.options, dict) + else self.options + ), + "placeholder": self.placeholder, + "label": self.label, + "notify": self.notify, + }
+
+ + + +
+[docs] +@dataclasses.dataclass +class SettingsForm: + """Settings Form and Section.""" + + id: str + section_id: str + title: str + fields: list[SettingsField] = dataclasses.field(default_factory=list) + description: str = "" + priority: int = 50 + doc_url: str = "" + section_type: str = "personal" + +
+[docs] + @classmethod + def from_dict(cls, data: dict) -> "SettingsForm": + """Creates instance of class from dict, ignoring unknown keys.""" + filtered_data = {k: v for k, v in data.items() if k in cls.__annotations__} + filtered_data["fields"] = [SettingsField.from_dict(i) for i in filtered_data.get("fields", [])] + return cls(**filtered_data)
+ + +
+[docs] + def to_dict(self) -> dict: + """Returns data in format that is accepted by AppAPI.""" + return { + "id": self.id, + "priority": self.priority, + "section_type": self.section_type, + "section_id": self.section_id, + "title": self.title, + "description": self.description, + "doc_url": self.doc_url, + "fields": [i.to_dict() for i in self.fields], + }
+
+ + + +_EP_SUFFIX: str = "ui/settings" + + +
+[docs] +class _DeclarativeSettingsAPI: + """Class providing API for creating UI for the ExApp settings, avalaible as **nc.ui.settings.<method>**.""" + + def __init__(self, session: NcSessionApp): + self._session = session + +
+[docs] + def register_form(self, form_schema: SettingsForm | dict[str, typing.Any]) -> None: + """Registers or edit the Settings UI Form.""" + require_capabilities("app_api", self._session.capabilities) + param = {"formScheme": form_schema.to_dict() if isinstance(form_schema, SettingsForm) else form_schema} + self._session.ocs("POST", f"{self._session.ae_url}/{_EP_SUFFIX}", json=param)
+ + +
+[docs] + def unregister_form(self, form_id: str, not_fail=True) -> None: + """Removes Settings UI Form.""" + require_capabilities("app_api", self._session.capabilities) + try: + self._session.ocs("DELETE", f"{self._session.ae_url}/{_EP_SUFFIX}", params={"formId": form_id}) + except NextcloudExceptionNotFound as e: + if not not_fail: + raise e from None
+ + +
+[docs] + def get_entry(self, form_id: str) -> SettingsForm | None: + """Get information of the Settings UI Form.""" + require_capabilities("app_api", self._session.capabilities) + try: + return SettingsForm.from_dict( + self._session.ocs("GET", f"{self._session.ae_url}/{_EP_SUFFIX}", params={"formId": form_id}) + ) + except NextcloudExceptionNotFound: + return None
+
+ + + +class _AsyncDeclarativeSettingsAPI: + """Class providing async API for creating UI for the ExApp settings.""" + + def __init__(self, session: AsyncNcSessionApp): + self._session = session + + async def register_form(self, form_schema: SettingsForm | dict[str, typing.Any]) -> None: + """Registers or edit the Settings UI Form.""" + require_capabilities("app_api", await self._session.capabilities) + param = {"formScheme": form_schema.to_dict() if isinstance(form_schema, SettingsForm) else form_schema} + await self._session.ocs("POST", f"{self._session.ae_url}/{_EP_SUFFIX}", json=param) + + async def unregister_form(self, form_id: str, not_fail=True) -> None: + """Removes Settings UI Form.""" + require_capabilities("app_api", await self._session.capabilities) + try: + await self._session.ocs("DELETE", f"{self._session.ae_url}/{_EP_SUFFIX}", params={"formId": form_id}) + except NextcloudExceptionNotFound as e: + if not not_fail: + raise e from None + + async def get_entry(self, form_id: str) -> SettingsForm | None: + """Get information of the Settings UI Form.""" + require_capabilities("app_api", await self._session.capabilities) + try: + return SettingsForm.from_dict( + await self._session.ocs("GET", f"{self._session.ae_url}/{_EP_SUFFIX}", params={"formId": form_id}) + ) + except NextcloudExceptionNotFound: + return None +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/ex_app/ui/top_menu.html b/_modules/nc_py_api/ex_app/ui/top_menu.html new file mode 100644 index 00000000..d0ec5094 --- /dev/null +++ b/_modules/nc_py_api/ex_app/ui/top_menu.html @@ -0,0 +1,275 @@ + + + + + + nc_py_api.ex_app.ui.top_menu — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for nc_py_api.ex_app.ui.top_menu

+"""Nextcloud API for working with Top App menu."""
+
+import dataclasses
+
+from ..._exceptions import NextcloudExceptionNotFound
+from ..._misc import require_capabilities
+from ..._session import AsyncNcSessionApp, NcSessionApp
+
+
+
+[docs] +@dataclasses.dataclass +class UiTopMenuEntry: + """App top menu entry description.""" + + def __init__(self, raw_data: dict): + self._raw_data = raw_data + + @property + def appid(self) -> str: + """App ID for which this entry is.""" + return self._raw_data["appid"] + + @property + def name(self) -> str: + """Top Menu entry name, acts like ID.""" + return self._raw_data["name"] + + @property + def display_name(self) -> str: + """Display name of the entry.""" + return self._raw_data["display_name"] + + @property + def icon(self) -> str: + """Relative to the ExApp url with icon or empty value to use the default one icon.""" + return self._raw_data["icon"] if self._raw_data["icon"] else "" + + @property + def admin_required(self) -> bool: + """Flag that determines whether the entry menu is displayed only for administrators.""" + return bool(int(self._raw_data["admin_required"])) + + def __repr__(self): + return f"<{self.__class__.__name__} name={self.name}, admin_required={self.admin_required}>"
+ + + +
+[docs] +class _UiTopMenuAPI: + """API for the top menu app nav bar in Nextcloud, avalaible as **nc.ui.top_menu.<method>**.""" + + _ep_suffix: str = "ui/top-menu" + + def __init__(self, session: NcSessionApp): + self._session = session + +
+[docs] + def register(self, name: str, display_name: str, icon: str = "", admin_required=False) -> None: + """Registers or edit the App entry in Top Meny. + + :param name: Unique name for the menu entry. + :param display_name: Display name of the menu entry. + :param icon: Optional, url relative to the ExApp, like: "img/icon.svg" + :param admin_required: Boolean value indicating should be Entry visible to all or only to admins. + """ + require_capabilities("app_api", self._session.capabilities) + params = { + "name": name, + "displayName": display_name, + "icon": icon, + "adminRequired": int(admin_required), + } + self._session.ocs("POST", f"{self._session.ae_url}/{self._ep_suffix}", json=params)
+ + +
+[docs] + def unregister(self, name: str, not_fail=True) -> None: + """Removes App entry in Top Menu.""" + require_capabilities("app_api", self._session.capabilities) + try: + self._session.ocs("DELETE", f"{self._session.ae_url}/{self._ep_suffix}", params={"name": name}) + except NextcloudExceptionNotFound as e: + if not not_fail: + raise e from None
+ + +
+[docs] + def get_entry(self, name: str) -> UiTopMenuEntry | None: + """Get information of the top meny entry for current app.""" + require_capabilities("app_api", self._session.capabilities) + try: + return UiTopMenuEntry( + self._session.ocs("GET", f"{self._session.ae_url}/{self._ep_suffix}", params={"name": name}) + ) + except NextcloudExceptionNotFound: + return None
+
+ + + +class _AsyncUiTopMenuAPI: + """Async API for the top menu app nav bar in Nextcloud.""" + + _ep_suffix: str = "ui/top-menu" + + def __init__(self, session: AsyncNcSessionApp): + self._session = session + + async def register(self, name: str, display_name: str, icon: str = "", admin_required=False) -> None: + """Registers or edit the App entry in Top Meny. + + :param name: Unique name for the menu entry. + :param display_name: Display name of the menu entry. + :param icon: Optional, url relative to the ExApp, like: "img/icon.svg" + :param admin_required: Boolean value indicating should be Entry visible to all or only to admins. + """ + require_capabilities("app_api", await self._session.capabilities) + params = { + "name": name, + "displayName": display_name, + "icon": icon, + "adminRequired": int(admin_required), + } + await self._session.ocs("POST", f"{self._session.ae_url}/{self._ep_suffix}", json=params) + + async def unregister(self, name: str, not_fail=True) -> None: + """Removes App entry in Top Menu.""" + require_capabilities("app_api", await self._session.capabilities) + try: + await self._session.ocs("DELETE", f"{self._session.ae_url}/{self._ep_suffix}", params={"name": name}) + except NextcloudExceptionNotFound as e: + if not not_fail: + raise e from None + + async def get_entry(self, name: str) -> UiTopMenuEntry | None: + """Get information of the top meny entry for current app.""" + require_capabilities("app_api", await self._session.capabilities) + try: + return UiTopMenuEntry( + await self._session.ocs("GET", f"{self._session.ae_url}/{self._ep_suffix}", params={"name": name}) + ) + except NextcloudExceptionNotFound: + return None +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/ex_app/ui/ui.html b/_modules/nc_py_api/ex_app/ui/ui.html new file mode 100644 index 00000000..0e048cb7 --- /dev/null +++ b/_modules/nc_py_api/ex_app/ui/ui.html @@ -0,0 +1,175 @@ + + + + + + nc_py_api.ex_app.ui.ui — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for nc_py_api.ex_app.ui.ui

+"""Nextcloud API for User Interface."""
+
+from ..._session import AsyncNcSessionApp, NcSessionApp
+from .files_actions import _AsyncUiFilesActionsAPI, _UiFilesActionsAPI
+from .resources import _AsyncUiResources, _UiResources
+from .settings import _AsyncDeclarativeSettingsAPI, _DeclarativeSettingsAPI
+from .top_menu import _AsyncUiTopMenuAPI, _UiTopMenuAPI
+
+
+
+[docs] +class UiApi: + """Class that encapsulates all UI functionality.""" + + files_dropdown_menu: _UiFilesActionsAPI + """File dropdown menu API.""" + top_menu: _UiTopMenuAPI + """Top App menu API.""" + resources: _UiResources + """Page(Template) resources API.""" + settings: _DeclarativeSettingsAPI + """API for ExApp settings UI""" + + def __init__(self, session: NcSessionApp): + self.files_dropdown_menu = _UiFilesActionsAPI(session) + self.top_menu = _UiTopMenuAPI(session) + self.resources = _UiResources(session) + self.settings = _DeclarativeSettingsAPI(session)
+ + + +class AsyncUiApi: + """Class that encapsulates all UI functionality(async).""" + + files_dropdown_menu: _AsyncUiFilesActionsAPI + """File dropdown menu API.""" + top_menu: _AsyncUiTopMenuAPI + """Top App menu API.""" + resources: _AsyncUiResources + """Page(Template) resources API.""" + settings: _AsyncDeclarativeSettingsAPI + """API for ExApp settings UI""" + + def __init__(self, session: AsyncNcSessionApp): + self.files_dropdown_menu = _AsyncUiFilesActionsAPI(session) + self.top_menu = _AsyncUiTopMenuAPI(session) + self.resources = _AsyncUiResources(session) + self.settings = _AsyncDeclarativeSettingsAPI(session) +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/files.html b/_modules/nc_py_api/files.html new file mode 100644 index 00000000..a64c2fc3 --- /dev/null +++ b/_modules/nc_py_api/files.html @@ -0,0 +1,687 @@ + + + + + + nc_py_api.files — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for nc_py_api.files

+"""APIs related to Files and Shares."""
+
+import dataclasses
+import datetime
+import email.utils
+import enum
+import os
+import warnings
+
+from pydantic import BaseModel
+
+from .. import _misc
+
+
+
+[docs] +class LockType(enum.IntEnum): + """Nextcloud File Locks types.""" + + MANUAL_LOCK = 0 + COLLABORATIVE_LOCK = 1 + WEBDAV_TOKEN = 2
+ + + +
+[docs] +@dataclasses.dataclass +class FsNodeLockInfo: + """File Lock information if Nextcloud `files_lock` is enabled.""" + + def __init__(self, **kwargs): + self._is_locked = bool(int(kwargs.get("is_locked", False))) + self._lock_owner_type = LockType(int(kwargs.get("lock_owner_type", 0))) + self._lock_owner = kwargs.get("lock_owner", "") + self._owner_display_name = kwargs.get("owner_display_name", "") + self._owner_editor = kwargs.get("lock_owner_editor", "") + self._lock_time = int(kwargs.get("lock_time", 0)) + self._lock_ttl = int(kwargs.get("_lock_ttl", 0)) + + @property + def is_locked(self) -> bool: + """Returns ``True`` if the file is locked, ``False`` otherwise.""" + return self._is_locked + + @property + def type(self) -> LockType: + """Type of the lock.""" + return LockType(self._lock_owner_type) + + @property + def owner(self) -> str: + """User id of the lock owner.""" + return self._lock_owner + + @property + def owner_display_name(self) -> str: + """Display name of the lock owner.""" + return self._owner_display_name + + @property + def owner_editor(self) -> str: + """App id of an app owned lock to allow clients to suggest joining the collaborative editing session.""" + return self._owner_editor + + @property + def lock_creation_time(self) -> datetime.datetime: + """Lock creation time.""" + return datetime.datetime.utcfromtimestamp(self._lock_time).replace(tzinfo=datetime.timezone.utc) + + @property + def lock_ttl(self) -> int: + """TTL of the lock in seconds staring from the creation time. A value of 0 means the timeout is infinite.""" + return self._lock_ttl
+ + + +
+[docs] +@dataclasses.dataclass +class FsNodeInfo: + """Extra FS object attributes from Nextcloud.""" + + fileid: int + """Clear file ID without Nextcloud instance ID.""" + favorite: bool + """Flag indicating if the object is marked as favorite.""" + is_version: bool + """Flag indicating if the object is File Version representation""" + _last_modified: datetime.datetime + _trashbin: dict + + def __init__(self, **kwargs): + self._raw_data = { + "content_length": kwargs.get("content_length", 0), + "size": kwargs.get("size", 0), + "permissions": kwargs.get("permissions", ""), + "mimetype": kwargs.get("mimetype", ""), + } + self.favorite = kwargs.get("favorite", False) + self.is_version = False + self.fileid = kwargs.get("fileid", 0) + try: + self.last_modified = kwargs.get("last_modified", datetime.datetime(1970, 1, 1)) + except (ValueError, TypeError): + self.last_modified = datetime.datetime(1970, 1, 1) + self._trashbin: dict[str, str | int] = {} + for i in ("trashbin_filename", "trashbin_original_location", "trashbin_deletion_time"): + if i in kwargs: + self._trashbin[i] = kwargs[i] + + @property + def content_length(self) -> int: + """Length of file in bytes, zero for directories.""" + return self._raw_data["content_length"] + + @property + def size(self) -> int: + """In the case of directories it is the size of all content, for files it is equal to ``content_length``.""" + return self._raw_data["size"] + + @property + def mimetype(self) -> str: + """Mimetype of a file. Empty for the directories.""" + return self._raw_data["mimetype"] + + @property + def permissions(self) -> str: + """Permissions for the object.""" + return self._raw_data["permissions"] + + @property + def last_modified(self) -> datetime.datetime: + """Time when the object was last modified. + + .. note:: ETag is a more preferable way to check if the object was changed. + """ + return self._last_modified + + @last_modified.setter + def last_modified(self, value: str | datetime.datetime): + if isinstance(value, str): + self._last_modified = email.utils.parsedate_to_datetime(value) + else: + self._last_modified = value + + @property + def in_trash(self) -> bool: + """Returns ``True`` if the object is in trash.""" + return bool(self._trashbin) + + @property + def trashbin_filename(self) -> str: + """Returns the name of the object in the trashbin.""" + return self._trashbin.get("trashbin_filename", "") + + @property + def trashbin_original_location(self) -> str: + """Returns the original path of the object.""" + return self._trashbin.get("trashbin_original_location", "") + + @property + def trashbin_deletion_time(self) -> int: + """Returns deletion time of the object.""" + return int(self._trashbin.get("trashbin_deletion_time", 0))
+ + + +
+[docs] +@dataclasses.dataclass +class FsNode: + """A class that represents a Nextcloud file object. + + Acceptable itself as a ``path`` parameter for the most file APIs. + """ + + full_path: str + """Path to the object, including the username. Does not include `dav` prefix""" + + file_id: str + """File ID + NC instance ID""" + + etag: str + """An entity tag (ETag) of the object""" + + info: FsNodeInfo + """Additional extra information for the object""" + + lock_info: FsNodeLockInfo + """Class describing `lock` information if any.""" + + def __init__(self, full_path: str, **kwargs): + self.full_path = full_path + self.file_id = kwargs.get("file_id", "") + self.etag = kwargs.get("etag", "") + self.info = FsNodeInfo(**kwargs) + self.lock_info = FsNodeLockInfo(**kwargs) + + @property + def is_dir(self) -> bool: + """Returns ``True`` for the directories, ``False`` otherwise.""" + return self.full_path.endswith("/") + + def __str__(self): + if self.info.is_version: + return ( + f"File version: `{self.name}` for FileID={self.file_id}" + f" last modified at {self.info.last_modified} with {self.info.content_length} bytes size." + ) + return ( + f"{'Dir' if self.is_dir else 'File'}: `{self.name}` with id={self.file_id}" + f" last modified at {self.info.last_modified} and {self.info.permissions} permissions." + ) + + def __eq__(self, other): + return bool(self.file_id and self.file_id == other.file_id) + + @property + def has_extra(self) -> bool: + """Flag showing that this "FsNode" originates from the mkdir/upload methods and lacks extended information.""" + return bool(self.info.permissions) + + @property + def name(self) -> str: + """Returns last ``pathname`` component.""" + return self.full_path.rstrip("/").rsplit("/", maxsplit=1)[-1] + + @property + def user(self) -> str: + """Returns user ID extracted from the `full_path`.""" + return self.full_path.lstrip("/").split("/", maxsplit=2)[1] + + @property + def user_path(self) -> str: + """Returns path relative to the user's root directory.""" + return self.full_path.lstrip("/").split("/", maxsplit=2)[-1] + + @property + def is_shared(self) -> bool: + """Check if a file or folder is shared.""" + return self.info.permissions.find("S") != -1 + + @property + def is_shareable(self) -> bool: + """Check if a file or folder can be shared.""" + return self.info.permissions.find("R") != -1 + + @property + def is_mounted(self) -> bool: + """Check if a file or folder is mounted.""" + return self.info.permissions.find("M") != -1 + + @property + def is_readable(self) -> bool: + """Check if the file or folder is readable.""" + return self.info.permissions.find("G") != -1 + + @property + def is_deletable(self) -> bool: + """Check if a file or folder can be deleted.""" + return self.info.permissions.find("D") != -1 + + @property + def is_updatable(self) -> bool: + """Check if file/directory is writable.""" + if self.is_dir: + return self.info.permissions.find("NV") != -1 + return self.info.permissions.find("W") != -1 + + @property + def is_creatable(self) -> bool: + """Check whether new files or folders can be created inside this folder.""" + if not self.is_dir: + return False + return self.info.permissions.find("CK") != -1
+ + + +
+[docs] +class FilePermissions(enum.IntFlag): + """List of available permissions for files/directories.""" + + PERMISSION_READ = 1 + """Access to read""" + PERMISSION_UPDATE = 2 + """Access to write""" + PERMISSION_CREATE = 4 + """Access to create new objects in the directory""" + PERMISSION_DELETE = 8 + """Access to remove object(s)""" + PERMISSION_SHARE = 16 + """Access to re-share object(s)"""
+ + + +def permissions_to_str(permissions: int | str, is_dir: bool = False) -> str: + """Converts integer permissions to string permissions. + + :param permissions: concatenation of ``FilePermissions`` integer flags. + :param is_dir: Flag indicating is permissions related to the directory object or not. + """ + permissions = int(permissions) if not isinstance(permissions, int) else permissions + r = "" + if permissions & FilePermissions.PERMISSION_SHARE: + r += "R" + if permissions & FilePermissions.PERMISSION_READ: + r += "G" + if permissions & FilePermissions.PERMISSION_DELETE: + r += "D" + if permissions & FilePermissions.PERMISSION_UPDATE: + r += "NV" if is_dir else "NVW" + if is_dir and permissions & FilePermissions.PERMISSION_CREATE: + r += "CK" + return r + + +
+[docs] +@dataclasses.dataclass +class SystemTag: + """Nextcloud System Tag.""" + + def __init__(self, raw_data: dict): + self._raw_data = raw_data + + @property + def tag_id(self) -> int: + """Unique numeric identifier of the Tag.""" + return int(self._raw_data["oc:id"]) + + @property + def display_name(self) -> str: + """The visible Tag name.""" + return self._raw_data.get("oc:display-name", str(self.tag_id)) + + @property + def user_visible(self) -> bool: + """Flag indicating if the Tag is visible in the UI.""" + return bool(self._raw_data.get("oc:user-visible", "false").lower() == "true") + + @property + def user_assignable(self) -> bool: + """Flag indicating if User can assign this Tag.""" + return bool(self._raw_data.get("oc:user-assignable", "false").lower() == "true") + + def __repr__(self): + return f"<{self.__class__.__name__} id={self.tag_id}, name={self.display_name}>"
+ + + +
+[docs] +class ShareType(enum.IntEnum): + """Type of the object that will receive share.""" + + TYPE_USER = 0 + """Share to the user""" + TYPE_GROUP = 1 + """Share to the group""" + TYPE_LINK = 3 + """Share by link""" + TYPE_EMAIL = 4 + """Share by the email""" + TYPE_REMOTE = 6 + """Share to the Federation""" + TYPE_CIRCLE = 7 + """Share to the Nextcloud Circle""" + TYPE_GUEST = 8 + """Share to `Guest`""" + TYPE_REMOTE_GROUP = 9 + """Share to the Federation group""" + TYPE_ROOM = 10 + """Share to the Talk room""" + TYPE_DECK = 11 + """Share to the Nextcloud Deck""" + TYPE_SCIENCE_MESH = 15 + """Share to the Reva instance(Science Mesh)"""
+ + + +
+[docs] +class Share: + """Information about Share.""" + + def __init__(self, raw_data: dict): + self._raw_data = raw_data + + def __getattr__(self, name): + if name == "raw_data": + warnings.warn( + f"{name} is deprecated and will be removed in 0.10.0 version.", DeprecationWarning, stacklevel=2 + ) + return self._raw_data + return getattr(self, name) + + @property + def share_id(self) -> int: + """Unique ID of the share.""" + return int(self._raw_data["id"]) + + @property + def share_type(self) -> ShareType: + """Type of the share.""" + return ShareType(int(self._raw_data["share_type"])) + + @property + def share_with(self) -> str: + """To whom Share was created.""" + return self._raw_data["share_with"] + + @property + def permissions(self) -> FilePermissions: + """Recipient permissions.""" + return FilePermissions(int(self._raw_data["permissions"])) + + @property + def url(self) -> str: + """URL at which Share is avalaible.""" + return self._raw_data.get("url", "") + + @property + def path(self) -> str: + """Share path relative to the user's root directory.""" + return self._raw_data.get("path", "").lstrip("/") + + @property + def label(self) -> str: + """Label for the Shared object.""" + return self._raw_data.get("label", "") + + @property + def note(self) -> str: + """Note for the Shared object.""" + return self._raw_data.get("note", "") + + @property + def mimetype(self) -> str: + """Mimetype of the Shared object.""" + return self._raw_data.get("mimetype", "") + + @property + def share_owner(self) -> str: + """Share's creator ID.""" + return self._raw_data.get("uid_owner", "") + + @property + def file_owner(self) -> str: + """File/directory owner ID.""" + return self._raw_data.get("uid_file_owner", "") + + @property + def password(self) -> str: + """Password to access share.""" + return self._raw_data.get("password", "") + + @property + def send_password_by_talk(self) -> bool: + """Flag indicating was password send by Talk.""" + return self._raw_data.get("send_password_by_talk", False) + + @property + def expire_date(self) -> datetime.datetime: + """Share expiration time.""" + return _misc.nc_iso_time_to_datetime(self._raw_data.get("expiration", "")) + + @property + def file_source_id(self) -> int: + """File source ID.""" + return self._raw_data.get("file_source", 0) + + @property + def can_edit(self) -> bool: + """Does caller have ``write`` permissions.""" + return self._raw_data.get("can_edit", False) + + @property + def can_delete(self) -> bool: + """Does caller have ``delete`` permissions.""" + return self._raw_data.get("can_delete", False) + + def __str__(self): + return ( + f"{self.share_type.name}: `{self.path}` with id={self.share_id}" + f" from {self.share_owner} to {self.share_with}" + )
+ + + +
+[docs] +class ActionFileInfo(BaseModel): + """Information Nextcloud sends to the External Application about File Nodes affected in action.""" + + fileId: int + """FileID without Nextcloud instance ID""" + name: str + """Name of the file/directory""" + directory: str + """Directory relative to the user's home directory""" + etag: str + mime: str + fileType: str + """**file** or **dir**""" + size: int + """size of file/directory""" + favorite: str + """**true** or **false**""" + permissions: int + """Combination of :py:class:`~nc_py_api.files.FilePermissions` values""" + mtime: int + """Last modified time""" + userId: str + """The ID of the user performing the action.""" + shareOwner: str | None = None + """If the object is shared, this is a display name of the share owner.""" + shareOwnerId: str | None = None + """If the object is shared, this is the owner ID of the share.""" + instanceId: str | None = None + """Nextcloud instance ID.""" + +
+[docs] + def to_fs_node(self) -> FsNode: + """Returns usual :py:class:`~nc_py_api.files.FsNode` created from this class.""" + user_path = os.path.join(self.directory, self.name).rstrip("/") + is_dir = bool(self.fileType.lower() == "dir") + if is_dir: + user_path += "/" + full_path = os.path.join(f"files/{self.userId}", user_path.lstrip("/")) + file_id = str(self.fileId).rjust(8, "0") + + permissions = "S" if self.shareOwnerId else "" + permissions += permissions_to_str(self.permissions, is_dir) + return FsNode( + full_path, + etag=self.etag, + size=self.size, + content_length=0 if is_dir else self.size, + permissions=permissions, + favorite=bool(self.favorite.lower() == "true"), + file_id=file_id + self.instanceId if self.instanceId else file_id, + fileid=self.fileId, + last_modified=datetime.datetime.utcfromtimestamp(self.mtime).replace(tzinfo=datetime.timezone.utc), + mimetype=self.mime, + )
+
+ + + +
+[docs] +class ActionFileInfoEx(BaseModel): + """New ``register_ex`` uses new data format which allowing receiving multiple NC Nodes in one request.""" + + files: list[ActionFileInfo] + """Always list of ``ActionFileInfo`` with one element minimum."""
+ +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/files/files.html b/_modules/nc_py_api/files/files.html new file mode 100644 index 00000000..b14437f9 --- /dev/null +++ b/_modules/nc_py_api/files/files.html @@ -0,0 +1,741 @@ + + + + + + nc_py_api.files.files — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for nc_py_api.files.files

+"""Nextcloud API for working with the file system."""
+
+import builtins
+import os
+from pathlib import Path
+from urllib.parse import quote
+
+from httpx import Headers
+
+from .._exceptions import NextcloudException, NextcloudExceptionNotFound, check_error
+from .._misc import random_string, require_capabilities
+from .._session import NcSessionBasic
+from . import FsNode, LockType, SystemTag
+from ._files import (
+    PROPFIND_PROPERTIES,
+    PropFindType,
+    build_find_request,
+    build_list_by_criteria_req,
+    build_list_tag_req,
+    build_list_tags_response,
+    build_listdir_req,
+    build_listdir_response,
+    build_setfav_req,
+    build_tags_ids_for_object,
+    build_update_tag_req,
+    dav_get_obj_path,
+    element_tree_as_str,
+    etag_fileid_from_response,
+    get_propfind_properties,
+    lf_parse_webdav_response,
+)
+from .sharing import _FilesSharingAPI
+
+
+
+[docs] +class FilesAPI: + """Class that encapsulates file system and file sharing API, avalaible as **nc.files.<method>**.""" + + sharing: _FilesSharingAPI + """API for managing Files Shares""" + + def __init__(self, session: NcSessionBasic): + self._session = session + self.sharing = _FilesSharingAPI(session) + +
+[docs] + def listdir(self, path: str | FsNode = "", depth: int = 1, exclude_self=True) -> list[FsNode]: + """Returns a list of all entries in the specified directory. + + :param path: path to the directory to get the list. + :param depth: how many directory levels should be included in output. Default = **1** (only specified directory) + :param exclude_self: boolean value indicating whether the `path` itself should be excluded from the list or not. + Default = **True**. + """ + if exclude_self and not depth: + raise ValueError("Wrong input parameters, query will return nothing.") + properties = get_propfind_properties(self._session.capabilities) + path = path.user_path if isinstance(path, FsNode) else path + return self._listdir(self._session.user, path, properties=properties, depth=depth, exclude_self=exclude_self)
+ + +
+[docs] + def by_id(self, file_id: int | str | FsNode) -> FsNode | None: + """Returns :py:class:`~nc_py_api.files.FsNode` by file_id if any. + + :param file_id: can be full file ID with Nextcloud instance ID or only clear file ID. + """ + file_id = file_id.file_id if isinstance(file_id, FsNode) else file_id + result = self.find(req=["eq", "fileid", file_id]) + return result[0] if result else None
+ + +
+[docs] + def by_path(self, path: str | FsNode) -> FsNode | None: + """Returns :py:class:`~nc_py_api.files.FsNode` by exact path if any.""" + path = path.user_path if isinstance(path, FsNode) else path + result = self.listdir(path, depth=0, exclude_self=False) + return result[0] if result else None
+ + +
+[docs] + def find(self, req: list, path: str | FsNode = "") -> list[FsNode]: + """Searches a directory for a file or subdirectory with a name. + + :param req: list of conditions to search for. Detailed description here... + :param path: path where to search from. Default = **""**. + """ + # `req` possible keys: "name", "mime", "last_modified", "size", "favorite", "fileid" + root = build_find_request(req, path, self._session.user, self._session.capabilities) + webdav_response = self._session.adapter_dav.request( + "SEARCH", "", content=element_tree_as_str(root), headers={"Content-Type": "text/xml"} + ) + request_info = f"find: {self._session.user}, {req}, {path}" + return lf_parse_webdav_response(self._session.cfg.dav_url_suffix, webdav_response, request_info)
+ + +
+[docs] + def download(self, path: str | FsNode) -> bytes: + """Downloads and returns the content of a file.""" + path = path.user_path if isinstance(path, FsNode) else path + response = self._session.adapter_dav.get(quote(dav_get_obj_path(self._session.user, path))) + check_error(response, f"download: user={self._session.user}, path={path}") + return response.content
+ + +
+[docs] + def download2stream(self, path: str | FsNode, fp, **kwargs) -> None: + """Downloads file to the given `fp` object. + + :param path: path to download file. + :param fp: filename (string), pathlib.Path object or a file object. + The object must implement the ``file.write`` method and be able to write binary data. + :param kwargs: **chunk_size** an int value specifying chunk size to write. Default = **5Mb** + """ + path = quote(dav_get_obj_path(self._session.user, path.user_path if isinstance(path, FsNode) else path)) + self._session.download2stream(path, fp, dav=True, **kwargs)
+ + +
+[docs] + def download_directory_as_zip(self, path: str | FsNode, local_path: str | Path | None = None, **kwargs) -> Path: + """Downloads a remote directory as zip archive. + + :param path: path to directory to download. + :param local_path: relative or absolute file path to save zip file. + :returns: Path to the saved zip archive. + + .. note:: This works only for directories, you should not use this to download a file. + """ + path = path.user_path if isinstance(path, FsNode) else path + result_path = local_path if local_path else os.path.basename(path) + with open(result_path, "wb") as fp: + self._session.download2fp( + "/index.php/apps/files/ajax/download.php", fp, dav=False, params={"dir": path}, **kwargs + ) + return Path(result_path)
+ + +
+[docs] + def upload(self, path: str | FsNode, content: bytes | str) -> FsNode: + """Creates a file with the specified content at the specified path. + + :param path: file's upload path. + :param content: content to create the file. If it is a string, it will be encoded into bytes using UTF-8. + """ + path = path.user_path if isinstance(path, FsNode) else path + full_path = dav_get_obj_path(self._session.user, path) + response = self._session.adapter_dav.put(quote(full_path), content=content) + check_error(response, f"upload: user={self._session.user}, path={path}, size={len(content)}") + return FsNode(full_path.strip("/"), **etag_fileid_from_response(response))
+ + +
+[docs] + def upload_stream(self, path: str | FsNode, fp, **kwargs) -> FsNode: + """Creates a file with content provided by `fp` object at the specified path. + + :param path: file's upload path. + :param fp: filename (string), pathlib.Path object or a file object. + The object must implement the ``file.read`` method providing data with str or bytes type. + :param kwargs: **chunk_size** an int value specifying chunk size to read. Default = **5Mb** + """ + path = path.user_path if isinstance(path, FsNode) else path + chunk_size = kwargs.get("chunk_size", 5 * 1024 * 1024) + if isinstance(fp, str | Path): + with builtins.open(fp, "rb") as f: + return self.__upload_stream(path, f, chunk_size) + elif hasattr(fp, "read"): + return self.__upload_stream(path, fp, chunk_size) + else: + raise TypeError("`fp` must be a path to file or an object with `read` method.")
+ + +
+[docs] + def mkdir(self, path: str | FsNode) -> FsNode: + """Creates a new directory. + + :param path: path of the directory to be created. + """ + path = path.user_path if isinstance(path, FsNode) else path + full_path = dav_get_obj_path(self._session.user, path) + response = self._session.adapter_dav.request("MKCOL", quote(full_path)) + check_error(response) + full_path += "/" if not full_path.endswith("/") else "" + return FsNode(full_path.lstrip("/"), **etag_fileid_from_response(response))
+ + +
+[docs] + def makedirs(self, path: str | FsNode, exist_ok=False) -> FsNode | None: + """Creates a new directory and subdirectories. + + :param path: path of the directories to be created. + :param exist_ok: ignore error if any of pathname components already exists. + :returns: `FsNode` if directory was created or ``None`` if it was already created. + """ + _path = "" + path = path.user_path if isinstance(path, FsNode) else path + path = path.lstrip("/") + result = None + for i in Path(path).parts: + _path = f"{_path}/{i}" + if not exist_ok: + result = self.mkdir(_path) + else: + try: + result = self.mkdir(_path) + except NextcloudException as e: + if e.status_code != 405: + raise e from None + return result
+ + +
+[docs] + def delete(self, path: str | FsNode, not_fail=False) -> None: + """Deletes a file/directory (moves to trash if trash is enabled). + + :param path: path to delete. + :param not_fail: if set to ``True`` and the object is not found, it does not raise an exception. + """ + path = path.user_path if isinstance(path, FsNode) else path + response = self._session.adapter_dav.delete(quote(dav_get_obj_path(self._session.user, path))) + if response.status_code == 404 and not_fail: + return + check_error(response)
+ + +
+[docs] + def move(self, path_src: str | FsNode, path_dest: str | FsNode, overwrite=False) -> FsNode: + """Moves an existing file or a directory. + + :param path_src: path of an existing file/directory. + :param path_dest: name of the new one. + :param overwrite: if ``True`` and the destination object already exists, it gets overwritten. + Default = **False**. + """ + path_src = path_src.user_path if isinstance(path_src, FsNode) else path_src + full_dest_path = dav_get_obj_path( + self._session.user, path_dest.user_path if isinstance(path_dest, FsNode) else path_dest + ) + dest = self._session.cfg.dav_endpoint + quote(full_dest_path) + headers = Headers({"Destination": dest, "Overwrite": "T" if overwrite else "F"}, encoding="utf-8") + response = self._session.adapter_dav.request( + "MOVE", + quote(dav_get_obj_path(self._session.user, path_src)), + headers=headers, + ) + check_error(response, f"move: user={self._session.user}, src={path_src}, dest={dest}, {overwrite}") + return self.find(req=["eq", "fileid", response.headers["OC-FileId"]])[0]
+ + +
+[docs] + def copy(self, path_src: str | FsNode, path_dest: str | FsNode, overwrite=False) -> FsNode: + """Copies an existing file/directory. + + :param path_src: path of an existing file/directory. + :param path_dest: name of the new one. + :param overwrite: if ``True`` and the destination object already exists, it gets overwritten. + Default = **False**. + """ + path_src = path_src.user_path if isinstance(path_src, FsNode) else path_src + full_dest_path = dav_get_obj_path( + self._session.user, path_dest.user_path if isinstance(path_dest, FsNode) else path_dest + ) + dest = self._session.cfg.dav_endpoint + quote(full_dest_path) + headers = Headers({"Destination": dest, "Overwrite": "T" if overwrite else "F"}, encoding="utf-8") + response = self._session.adapter_dav.request( + "COPY", + quote(dav_get_obj_path(self._session.user, path_src)), + headers=headers, + ) + check_error(response, f"copy: user={self._session.user}, src={path_src}, dest={dest}, {overwrite}") + return self.find(req=["eq", "fileid", response.headers["OC-FileId"]])[0]
+ + +
+[docs] + def list_by_criteria( + self, properties: list[str] | None = None, tags: list[int | SystemTag] | None = None + ) -> list[FsNode]: + """Returns a list of all files/directories for the current user filtered by the specified values. + + :param properties: List of ``properties`` that should have been set for the file. + Supported values: **favorite** + :param tags: List of ``tags ids`` or ``SystemTag`` that should have been set for the file. + """ + root = build_list_by_criteria_req(properties, tags, self._session.capabilities) + webdav_response = self._session.adapter_dav.request( + "REPORT", dav_get_obj_path(self._session.user), content=element_tree_as_str(root) + ) + request_info = f"list_files_by_criteria: {self._session.user}" + check_error(webdav_response, request_info) + return lf_parse_webdav_response(self._session.cfg.dav_url_suffix, webdav_response, request_info)
+ + +
+[docs] + def setfav(self, path: str | FsNode, value: int | bool) -> None: + """Sets or unsets favourite flag for specific file. + + :param path: path to the object to set the state. + :param value: value to set for the ``favourite`` state. + """ + path = path.user_path if isinstance(path, FsNode) else path + root = build_setfav_req(value) + webdav_response = self._session.adapter_dav.request( + "PROPPATCH", quote(dav_get_obj_path(self._session.user, path)), content=element_tree_as_str(root) + ) + check_error(webdav_response, f"setfav: path={path}, value={value}")
+ + +
+[docs] + def trashbin_list(self) -> list[FsNode]: + """Returns a list of all entries in the TrashBin.""" + properties = PROPFIND_PROPERTIES + properties += ["nc:trashbin-filename", "nc:trashbin-original-location", "nc:trashbin-deletion-time"] + return self._listdir( + self._session.user, "", properties=properties, depth=1, exclude_self=False, prop_type=PropFindType.TRASHBIN + )
+ + +
+[docs] + def trashbin_restore(self, path: str | FsNode) -> None: + """Restore a file/directory from the TrashBin. + + :param path: path to delete, e.g., the ``user_path`` field from ``FsNode`` or the **FsNode** class itself. + """ + restore_name = path.name if isinstance(path, FsNode) else path.split("/", maxsplit=1)[-1] + path = path.user_path if isinstance(path, FsNode) else path + + dest = self._session.cfg.dav_endpoint + f"/trashbin/{self._session.user}/restore/{restore_name}" + headers = Headers({"Destination": dest}, encoding="utf-8") + response = self._session.adapter_dav.request( + "MOVE", + quote(f"/trashbin/{self._session.user}/{path}"), + headers=headers, + ) + check_error(response, f"trashbin_restore: user={self._session.user}, src={path}, dest={dest}")
+ + +
+[docs] + def trashbin_delete(self, path: str | FsNode, not_fail=False) -> None: + """Deletes a file/directory permanently from the TrashBin. + + :param path: path to delete, e.g., the ``user_path`` field from ``FsNode`` or the **FsNode** class itself. + :param not_fail: if set to ``True`` and the object is not found, it does not raise an exception. + """ + path = path.user_path if isinstance(path, FsNode) else path + response = self._session.adapter_dav.delete(quote(f"/trashbin/{self._session.user}/{path}")) + if response.status_code == 404 and not_fail: + return + check_error(response)
+ + +
+[docs] + def trashbin_cleanup(self) -> None: + """Empties the TrashBin.""" + check_error(self._session.adapter_dav.delete(f"/trashbin/{self._session.user}/trash"))
+ + +
+[docs] + def get_versions(self, file_object: FsNode) -> list[FsNode]: + """Returns a list of all file versions if any.""" + require_capabilities("files.versioning", self._session.capabilities) + return self._listdir( + self._session.user, + str(file_object.info.fileid) if file_object.info.fileid else file_object.file_id, + properties=PROPFIND_PROPERTIES, + depth=1, + exclude_self=False, + prop_type=PropFindType.VERSIONS_FILEID if file_object.info.fileid else PropFindType.VERSIONS_FILE_ID, + )
+ + +
+[docs] + def restore_version(self, file_object: FsNode) -> None: + """Restore a file with specified version. + + :param file_object: The **FsNode** class from :py:meth:`~nc_py_api.files.files.FilesAPI.get_versions`. + """ + require_capabilities("files.versioning", self._session.capabilities) + dest = self._session.cfg.dav_endpoint + f"/versions/{self._session.user}/restore/{file_object.name}" + headers = Headers({"Destination": dest}, encoding="utf-8") + response = self._session.adapter_dav.request( + "MOVE", + quote(f"/versions/{self._session.user}/{file_object.user_path}"), + headers=headers, + ) + check_error(response, f"restore_version: user={self._session.user}, src={file_object.user_path}")
+ + +
+[docs] + def list_tags(self) -> list[SystemTag]: + """Returns list of the avalaible Tags.""" + root = build_list_tag_req() + response = self._session.adapter_dav.request("PROPFIND", "/systemtags", content=element_tree_as_str(root)) + return build_list_tags_response(response)
+ + +
+[docs] + def get_tags(self, file_id: FsNode | int) -> list[SystemTag]: + """Returns list of Tags assigned to the File or Directory.""" + fs_object = file_id.info.fileid if isinstance(file_id, FsNode) else file_id + url_to_fetch = f"/systemtags-relations/files/{fs_object}/" + response = self._session.adapter_dav.request("PROPFIND", url_to_fetch) + object_tags_ids = build_tags_ids_for_object(self._session.cfg.dav_url_suffix + url_to_fetch, response) + if not object_tags_ids: + return [] + all_tags = self.list_tags() + return [tag for tag in all_tags if tag.tag_id in object_tags_ids]
+ + +
+[docs] + def create_tag(self, name: str, user_visible: bool = True, user_assignable: bool = True) -> None: + """Creates a new Tag. + + :param name: Name of the tag. + :param user_visible: Should be Tag visible in the UI. + :param user_assignable: Can Tag be assigned from the UI. + """ + response = self._session.adapter_dav.post( + "/systemtags", + json={ + "name": name, + "userVisible": user_visible, + "userAssignable": user_assignable, + }, + ) + check_error(response, info=f"create_tag({name})")
+ + +
+[docs] + def update_tag( + self, + tag_id: int | SystemTag, + name: str | None = None, + user_visible: bool | None = None, + user_assignable: bool | None = None, + ) -> None: + """Updates the Tag information.""" + tag_id = tag_id.tag_id if isinstance(tag_id, SystemTag) else tag_id + root = build_update_tag_req(name, user_visible, user_assignable) + response = self._session.adapter_dav.request( + "PROPPATCH", f"/systemtags/{tag_id}", content=element_tree_as_str(root) + ) + check_error(response)
+ + +
+[docs] + def delete_tag(self, tag_id: int | SystemTag) -> None: + """Deletes the tag.""" + tag_id = tag_id.tag_id if isinstance(tag_id, SystemTag) else tag_id + response = self._session.adapter_dav.delete(f"/systemtags/{tag_id}") + check_error(response)
+ + +
+[docs] + def tag_by_name(self, tag_name: str) -> SystemTag: + """Returns Tag info by its name if found or ``None`` otherwise.""" + r = [i for i in self.list_tags() if i.display_name == tag_name] + if not r: + raise NextcloudExceptionNotFound(f"Tag with name='{tag_name}' not found.") + return r[0]
+ + +
+[docs] + def assign_tag(self, file_id: FsNode | int, tag_id: SystemTag | int) -> None: + """Assigns Tag to a file/directory.""" + self._file_change_tag_state(file_id, tag_id, True)
+ + +
+[docs] + def unassign_tag(self, file_id: FsNode | int, tag_id: SystemTag | int) -> None: + """Removes Tag from a file/directory.""" + self._file_change_tag_state(file_id, tag_id, False)
+ + +
+[docs] + def lock(self, path: FsNode | str, lock_type: LockType = LockType.MANUAL_LOCK) -> None: + """Locks the file. + + .. note:: Exception codes: 423 - existing lock present. + """ + require_capabilities("files.locking", self._session.capabilities) + full_path = dav_get_obj_path(self._session.user, path.user_path if isinstance(path, FsNode) else path) + response = self._session.adapter_dav.request( + "LOCK", + quote(full_path), + headers={"X-User-Lock": "1", "X-User-Lock-Type": str(lock_type.value)}, + ) + check_error(response, f"lock: user={self._session.user}, path={full_path}")
+ + +
+[docs] + def unlock(self, path: FsNode | str) -> None: + """Unlocks the file. + + .. note:: Exception codes: 412 - the file is not locked, 423 - the lock is owned by another user. + """ + require_capabilities("files.locking", self._session.capabilities) + full_path = dav_get_obj_path(self._session.user, path.user_path if isinstance(path, FsNode) else path) + response = self._session.adapter_dav.request( + "UNLOCK", + quote(full_path), + headers={"X-User-Lock": "1"}, + ) + check_error(response, f"unlock: user={self._session.user}, path={full_path}")
+ + + def _file_change_tag_state(self, file_id: FsNode | int, tag_id: SystemTag | int, tag_state: bool) -> None: + fs_object = file_id.info.fileid if isinstance(file_id, FsNode) else file_id + tag = tag_id.tag_id if isinstance(tag_id, SystemTag) else tag_id + response = self._session.adapter_dav.request( + "PUT" if tag_state else "DELETE", f"/systemtags-relations/files/{fs_object}/{tag}" + ) + check_error( + response, + info=f"({'Adding' if tag_state else 'Removing'} `{tag}` {'to' if tag_state else 'from'} {fs_object})", + ) + + def _listdir( + self, + user: str, + path: str, + properties: list[str], + depth: int, + exclude_self: bool, + prop_type: PropFindType = PropFindType.DEFAULT, + ) -> list[FsNode]: + root, dav_path = build_listdir_req(user, path, properties, prop_type) + webdav_response = self._session.adapter_dav.request( + "PROPFIND", + quote(dav_path), + content=element_tree_as_str(root), + headers={"Depth": "infinity" if depth == -1 else str(depth)}, + ) + return build_listdir_response( + self._session.cfg.dav_url_suffix, webdav_response, user, path, properties, exclude_self, prop_type + ) + + def __upload_stream(self, path: str, fp, chunk_size: int) -> FsNode: + _tmp_path = "nc-py-api-" + random_string(56) + _dav_path = quote(dav_get_obj_path(self._session.user, _tmp_path, root_path="/uploads")) + _v2 = bool(self._session.cfg.options.upload_chunk_v2 and chunk_size >= 5 * 1024 * 1024) + full_path = dav_get_obj_path(self._session.user, path) + headers = Headers({"Destination": self._session.cfg.dav_endpoint + quote(full_path)}, encoding="utf-8") + if _v2: + response = self._session.adapter_dav.request("MKCOL", _dav_path, headers=headers) + else: + response = self._session.adapter_dav.request("MKCOL", _dav_path) + check_error(response) + try: + start_bytes = end_bytes = chunk_number = 0 + while True: + piece = fp.read(chunk_size) + if not piece: + break + end_bytes = start_bytes + len(piece) + if _v2: + response = self._session.adapter_dav.put( + _dav_path + "/" + str(chunk_number), content=piece, headers=headers + ) + else: + _filename = str(start_bytes).rjust(15, "0") + "-" + str(end_bytes).rjust(15, "0") + response = self._session.adapter_dav.put(_dav_path + "/" + _filename, content=piece) + check_error( + response, + f"upload_stream(v={_v2}): user={self._session.user}, path={path}, cur_size={end_bytes}", + ) + start_bytes = end_bytes + chunk_number += 1 + + response = self._session.adapter_dav.request( + "MOVE", + _dav_path + "/.file", + headers=headers, + ) + check_error( + response, + f"upload_stream(v={_v2}): user={self._session.user}, path={path}, total_size={end_bytes}", + ) + return FsNode(full_path.strip("/"), **etag_fileid_from_response(response)) + finally: + self._session.adapter_dav.delete(_dav_path)
+ +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/files/sharing.html b/_modules/nc_py_api/files/sharing.html new file mode 100644 index 00000000..0a35be33 --- /dev/null +++ b/_modules/nc_py_api/files/sharing.html @@ -0,0 +1,461 @@ + + + + + + nc_py_api.files.sharing — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for nc_py_api.files.sharing

+"""Nextcloud API for working with the files shares."""
+
+from .. import _misc, _session
+from . import FilePermissions, FsNode, Share, ShareType
+
+
+
+[docs] +class _FilesSharingAPI: + """Class provides all File Sharing functionality, avalaible as **nc.files.sharing.<method>**.""" + + _ep_base: str = "/ocs/v1.php/apps/files_sharing/api/v1" + + def __init__(self, session: _session.NcSessionBasic): + self._session = session + + @property + def available(self) -> bool: + """Returns True if the Nextcloud instance supports this feature, False otherwise.""" + return not _misc.check_capabilities("files_sharing.api_enabled", self._session.capabilities) + +
+[docs] + def get_list(self, shared_with_me=False, reshares=False, subfiles=False, path: str | FsNode = "") -> list[Share]: + """Returns lists of shares. + + :param shared_with_me: Shares should be with the current user. + :param reshares: Only get shares by the current user and reshares. + :param subfiles: Only get all sub shares in a folder. + :param path: Get shares for a specific path. + """ + _misc.require_capabilities("files_sharing.api_enabled", self._session.capabilities) + path = path.user_path if isinstance(path, FsNode) else path + params = { + "shared_with_me": "true" if shared_with_me else "false", + "reshares": "true" if reshares else "false", + "subfiles": "true" if subfiles else "false", + } + if path: + params["path"] = path + result = self._session.ocs("GET", f"{self._ep_base}/shares", params=params) + return [Share(i) for i in result]
+ + +
+[docs] + def get_by_id(self, share_id: int) -> Share: + """Get Share by share ID.""" + _misc.require_capabilities("files_sharing.api_enabled", self._session.capabilities) + result = self._session.ocs("GET", f"{self._ep_base}/shares/{share_id}") + return Share(result[0] if isinstance(result, list) else result)
+ + +
+[docs] + def get_inherited(self, path: str) -> list[Share]: + """Get all shares relative to a file, e.g., parent folders shares.""" + _misc.require_capabilities("files_sharing.api_enabled", self._session.capabilities) + result = self._session.ocs("GET", f"{self._ep_base}/shares/inherited", params={"path": path}) + return [Share(i) for i in result]
+ + +
+[docs] + def create( + self, + path: str | FsNode, + share_type: ShareType, + permissions: FilePermissions | None = None, + share_with: str = "", + **kwargs, + ) -> Share: + """Creates a new share. + + :param path: The path of an existing file/directory. + :param share_type: :py:class:`~nc_py_api.files.sharing.ShareType` value. + :param permissions: combination of the :py:class:`~nc_py_api.files.FilePermissions` values. + :param share_with: the recipient of the shared object. + :param kwargs: See below. + + Additionally supported arguments: + + * ``public_upload`` - indicating should share be available for upload for non-registered users. + default = ``False`` + * ``password`` - string with password to protect share. default = ``""`` + * ``send_password_by_talk`` - boolean indicating should password be automatically delivered using Talk. + default = ``False`` + * ``expire_date`` - :py:class:`~datetime.datetime` time when share should expire. + `hours, minutes, seconds` are ignored. default = ``None`` + * ``note`` - string with note, if any. default = ``""`` + * ``label`` - string with label, if any. default = ``""`` + """ + params = _create(path, share_type, permissions, share_with, **kwargs) + _misc.require_capabilities("files_sharing.api_enabled", self._session.capabilities) + return Share(self._session.ocs("POST", f"{self._ep_base}/shares", params=params))
+ + +
+[docs] + def update(self, share_id: int | Share, **kwargs) -> Share: + """Updates the share options. + + :param share_id: ID of the Share to update. + :param kwargs: Available for update: ``permissions``, ``password``, ``send_password_by_talk``, + ``public_upload``, ``expire_date``, ``note``, ``label``. + """ + params = _update(**kwargs) + _misc.require_capabilities("files_sharing.api_enabled", self._session.capabilities) + share_id = share_id.share_id if isinstance(share_id, Share) else share_id + return Share(self._session.ocs("PUT", f"{self._ep_base}/shares/{share_id}", params=params))
+ + +
+[docs] + def delete(self, share_id: int | Share) -> None: + """Removes the given share.""" + _misc.require_capabilities("files_sharing.api_enabled", self._session.capabilities) + share_id = share_id.share_id if isinstance(share_id, Share) else share_id + self._session.ocs("DELETE", f"{self._ep_base}/shares/{share_id}")
+ + +
+[docs] + def get_pending(self) -> list[Share]: + """Returns all pending shares for current user.""" + return [Share(i) for i in self._session.ocs("GET", f"{self._ep_base}/shares/pending")]
+ + +
+[docs] + def accept_share(self, share_id: int | Share) -> None: + """Accept pending share.""" + _misc.require_capabilities("files_sharing.api_enabled", self._session.capabilities) + share_id = share_id.share_id if isinstance(share_id, Share) else share_id + self._session.ocs("POST", f"{self._ep_base}/pending/{share_id}")
+ + +
+[docs] + def decline_share(self, share_id: int | Share) -> None: + """Decline pending share.""" + _misc.require_capabilities("files_sharing.api_enabled", self._session.capabilities) + share_id = share_id.share_id if isinstance(share_id, Share) else share_id + self._session.ocs("DELETE", f"{self._ep_base}/pending/{share_id}")
+ + +
+[docs] + def get_deleted(self) -> list[Share]: + """Get a list of deleted shares.""" + _misc.require_capabilities("files_sharing.api_enabled", self._session.capabilities) + return [Share(i) for i in self._session.ocs("GET", f"{self._ep_base}/deletedshares")]
+ + +
+[docs] + def undelete(self, share_id: int | Share) -> None: + """Undelete a deleted share.""" + _misc.require_capabilities("files_sharing.api_enabled", self._session.capabilities) + share_id = share_id.share_id if isinstance(share_id, Share) else share_id + self._session.ocs("POST", f"{self._ep_base}/deletedshares/{share_id}")
+
+ + + +class _AsyncFilesSharingAPI: + """Class provides all Async File Sharing functionality.""" + + _ep_base: str = "/ocs/v1.php/apps/files_sharing/api/v1" + + def __init__(self, session: _session.AsyncNcSessionBasic): + self._session = session + + @property + async def available(self) -> bool: + """Returns True if the Nextcloud instance supports this feature, False otherwise.""" + return not _misc.check_capabilities("files_sharing.api_enabled", await self._session.capabilities) + + async def get_list( + self, shared_with_me=False, reshares=False, subfiles=False, path: str | FsNode = "" + ) -> list[Share]: + """Returns lists of shares. + + :param shared_with_me: Shares should be with the current user. + :param reshares: Only get shares by the current user and reshares. + :param subfiles: Only get all sub shares in a folder. + :param path: Get shares for a specific path. + """ + _misc.require_capabilities("files_sharing.api_enabled", await self._session.capabilities) + path = path.user_path if isinstance(path, FsNode) else path + params = { + "shared_with_me": "true" if shared_with_me else "false", + "reshares": "true" if reshares else "false", + "subfiles": "true" if subfiles else "false", + } + if path: + params["path"] = path + result = await self._session.ocs("GET", f"{self._ep_base}/shares", params=params) + return [Share(i) for i in result] + + async def get_by_id(self, share_id: int) -> Share: + """Get Share by share ID.""" + _misc.require_capabilities("files_sharing.api_enabled", await self._session.capabilities) + result = await self._session.ocs("GET", f"{self._ep_base}/shares/{share_id}") + return Share(result[0] if isinstance(result, list) else result) + + async def get_inherited(self, path: str) -> list[Share]: + """Get all shares relative to a file, e.g., parent folders shares.""" + _misc.require_capabilities("files_sharing.api_enabled", await self._session.capabilities) + result = await self._session.ocs("GET", f"{self._ep_base}/shares/inherited", params={"path": path}) + return [Share(i) for i in result] + + async def create( + self, + path: str | FsNode, + share_type: ShareType, + permissions: FilePermissions | None = None, + share_with: str = "", + **kwargs, + ) -> Share: + """Creates a new share. + + :param path: The path of an existing file/directory. + :param share_type: :py:class:`~nc_py_api.files.sharing.ShareType` value. + :param permissions: combination of the :py:class:`~nc_py_api.files.FilePermissions` values. + :param share_with: the recipient of the shared object. + :param kwargs: See below. + + Additionally supported arguments: + + * ``public_upload`` - indicating should share be available for upload for non-registered users. + default = ``False`` + * ``password`` - string with password to protect share. default = ``""`` + * ``send_password_by_talk`` - boolean indicating should password be automatically delivered using Talk. + default = ``False`` + * ``expire_date`` - :py:class:`~datetime.datetime` time when share should expire. + `hours, minutes, seconds` are ignored. default = ``None`` + * ``note`` - string with note, if any. default = ``""`` + * ``label`` - string with label, if any. default = ``""`` + """ + params = _create(path, share_type, permissions, share_with, **kwargs) + _misc.require_capabilities("files_sharing.api_enabled", await self._session.capabilities) + return Share(await self._session.ocs("POST", f"{self._ep_base}/shares", params=params)) + + async def update(self, share_id: int | Share, **kwargs) -> Share: + """Updates the share options. + + :param share_id: ID of the Share to update. + :param kwargs: Available for update: ``permissions``, ``password``, ``send_password_by_talk``, + ``public_upload``, ``expire_date``, ``note``, ``label``. + """ + params = _update(**kwargs) + _misc.require_capabilities("files_sharing.api_enabled", await self._session.capabilities) + share_id = share_id.share_id if isinstance(share_id, Share) else share_id + return Share(await self._session.ocs("PUT", f"{self._ep_base}/shares/{share_id}", params=params)) + + async def delete(self, share_id: int | Share) -> None: + """Removes the given share.""" + _misc.require_capabilities("files_sharing.api_enabled", await self._session.capabilities) + share_id = share_id.share_id if isinstance(share_id, Share) else share_id + await self._session.ocs("DELETE", f"{self._ep_base}/shares/{share_id}") + + async def get_pending(self) -> list[Share]: + """Returns all pending shares for current user.""" + return [Share(i) for i in await self._session.ocs("GET", f"{self._ep_base}/shares/pending")] + + async def accept_share(self, share_id: int | Share) -> None: + """Accept pending share.""" + _misc.require_capabilities("files_sharing.api_enabled", await self._session.capabilities) + share_id = share_id.share_id if isinstance(share_id, Share) else share_id + await self._session.ocs("POST", f"{self._ep_base}/pending/{share_id}") + + async def decline_share(self, share_id: int | Share) -> None: + """Decline pending share.""" + _misc.require_capabilities("files_sharing.api_enabled", await self._session.capabilities) + share_id = share_id.share_id if isinstance(share_id, Share) else share_id + await self._session.ocs("DELETE", f"{self._ep_base}/pending/{share_id}") + + async def get_deleted(self) -> list[Share]: + """Get a list of deleted shares.""" + _misc.require_capabilities("files_sharing.api_enabled", await self._session.capabilities) + return [Share(i) for i in await self._session.ocs("GET", f"{self._ep_base}/deletedshares")] + + async def undelete(self, share_id: int | Share) -> None: + """Undelete a deleted share.""" + _misc.require_capabilities("files_sharing.api_enabled", await self._session.capabilities) + share_id = share_id.share_id if isinstance(share_id, Share) else share_id + await self._session.ocs("POST", f"{self._ep_base}/deletedshares/{share_id}") + + +def _create( + path: str | FsNode, share_type: ShareType, permissions: FilePermissions | None, share_with: str, **kwargs +) -> dict: + params = { + "path": path.user_path if isinstance(path, FsNode) else path, + "shareType": int(share_type), + } + if permissions is not None: + params["permissions"] = int(permissions) + if share_with: + params["shareWith"] = share_with + if kwargs.get("public_upload", False): + params["publicUpload"] = "true" + if "password" in kwargs: + params["password"] = kwargs["password"] + if kwargs.get("send_password_by_talk", False): + params["sendPasswordByTalk"] = "true" + if "expire_date" in kwargs: + params["expireDate"] = kwargs["expire_date"].isoformat() + if "note" in kwargs: + params["note"] = kwargs["note"] + if "label" in kwargs: + params["label"] = kwargs["label"] + return params + + +def _update(**kwargs) -> dict: + params: dict = {} + if "permissions" in kwargs: + params["permissions"] = int(kwargs["permissions"]) + if "password" in kwargs: + params["password"] = kwargs["password"] + if kwargs.get("send_password_by_talk", False): + params["sendPasswordByTalk"] = "true" + if kwargs.get("public_upload", False): + params["publicUpload"] = "true" + if "expire_date" in kwargs: + params["expireDate"] = kwargs["expire_date"].isoformat() + if "note" in kwargs: + params["note"] = kwargs["note"] + if "label" in kwargs: + params["label"] = kwargs["label"] + return params +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/loginflow_v2.html b/_modules/nc_py_api/loginflow_v2.html new file mode 100644 index 00000000..360694ce --- /dev/null +++ b/_modules/nc_py_api/loginflow_v2.html @@ -0,0 +1,303 @@ + + + + + + nc_py_api.loginflow_v2 — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for nc_py_api.loginflow_v2

+"""Login flow v2 API wrapper."""
+
+import asyncio
+import json
+import time
+from dataclasses import dataclass
+
+import httpx
+
+from ._exceptions import check_error
+from ._session import AsyncNcSession, NcSession
+
+MAX_TIMEOUT = 60 * 20
+
+
+
+[docs] +@dataclass +class LoginFlow: + """The Nextcloud Login flow v2 initialization response representation.""" + + def __init__(self, raw_data: dict) -> None: + self.raw_data = raw_data + + @property + def login(self) -> str: + """The URL for user authorization. + + Should be opened by the user in the default browser to authorize in Nextcloud. + """ + return self.raw_data["login"] + + @property + def token(self) -> str: + """Token for a polling for confirmation of user authorization.""" + return self.raw_data["poll"]["token"] + + @property + def endpoint(self) -> str: + """Endpoint for polling.""" + return self.raw_data["poll"]["endpoint"] + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} login_url={self.login}>"
+ + + +
+[docs] +@dataclass +class Credentials: + """The Nextcloud Login flow v2 response with app credentials representation.""" + + def __init__(self, raw_data: dict) -> None: + self.raw_data = raw_data + + @property + def server(self) -> str: + """The address of Nextcloud to connect to. + + The server may specify a protocol (http or https). If no protocol is specified https will be used. + """ + return self.raw_data["server"] + + @property + def login_name(self) -> str: + """The username for authenticating with Nextcloud.""" + return self.raw_data["loginName"] + + @property + def app_password(self) -> str: + """The application password generated for authenticating with Nextcloud.""" + return self.raw_data["appPassword"] + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} login={self.login_name} app_password={self.app_password}>"
+ + + +
+[docs] +class _LoginFlowV2API: + """Class implementing Nextcloud Login flow v2.""" + + _ep_init: str = "/index.php/login/v2" + _ep_poll: str = "/index.php/login/v2/poll" + + def __init__(self, session: NcSession) -> None: + self._session = session + +
+[docs] + def init(self, user_agent: str = "nc_py_api") -> LoginFlow: + """Init a Login flow v2. + + :param user_agent: Application name. Application password will be associated with this name. + """ + r = self._session.adapter.post(self._ep_init, headers={"user-agent": user_agent}) + return LoginFlow(_res_to_json(r))
+ + +
+[docs] + def poll(self, token: str, timeout: int = MAX_TIMEOUT, step: int = 1, overwrite_auth: bool = True) -> Credentials: + """Poll the Login flow v2 credentials. + + :param token: Token for a polling for confirmation of user authorization. + :param timeout: Maximum time to wait for polling in seconds, defaults to MAX_TIMEOUT. + :param step: Interval for polling in seconds, defaults to 1. + :param overwrite_auth: If True current session will be overwritten with new credentials, defaults to True. + :raises ValueError: If timeout more than 20 minutes. + """ + if timeout > MAX_TIMEOUT: + msg = "Timeout can't be more than 20 minutes." + raise ValueError(msg) + for _ in range(timeout // step): + r = self._session.adapter.post(self._ep_poll, data={"token": token}) + if r.status_code == 200: + break + time.sleep(step) + r_model = Credentials(_res_to_json(r)) + if overwrite_auth: + self._session.cfg.auth = (r_model.login_name, r_model.app_password) + self._session.init_adapter(restart=True) + self._session.init_adapter_dav(restart=True) + return r_model
+
+ + + +class _AsyncLoginFlowV2API: + """Class implementing Async Nextcloud Login flow v2.""" + + _ep_init: str = "/index.php/login/v2" + _ep_poll: str = "/index.php/login/v2/poll" + + def __init__(self, session: AsyncNcSession) -> None: + self._session = session + + async def init(self, user_agent: str = "nc_py_api") -> LoginFlow: + """Init a Login flow v2. + + :param user_agent: Application name. Application password will be associated with this name. + """ + r = await self._session.adapter.post(self._ep_init, headers={"user-agent": user_agent}) + return LoginFlow(_res_to_json(r)) + + async def poll( + self, token: str, timeout: int = MAX_TIMEOUT, step: int = 1, overwrite_auth: bool = True + ) -> Credentials: + """Poll the Login flow v2 credentials. + + :param token: Token for a polling for confirmation of user authorization. + :param timeout: Maximum time to wait for polling in seconds, defaults to MAX_TIMEOUT. + :param step: Interval for polling in seconds, defaults to 1. + :param overwrite_auth: If True current session will be overwritten with new credentials, defaults to True. + :raises ValueError: If timeout more than 20 minutes. + """ + if timeout > MAX_TIMEOUT: + raise ValueError("Timeout can't be more than 20 minutes.") + for _ in range(timeout // step): + r = await self._session.adapter.post(self._ep_poll, data={"token": token}) + if r.status_code == 200: + break + await asyncio.sleep(step) + r_model = Credentials(_res_to_json(r)) + if overwrite_auth: + self._session.cfg.auth = (r_model.login_name, r_model.app_password) + self._session.init_adapter(restart=True) + self._session.init_adapter_dav(restart=True) + return r_model + + +def _res_to_json(response: httpx.Response) -> dict: + check_error(response) + return json.loads(response.text) +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/nextcloud.html b/_modules/nc_py_api/nextcloud.html new file mode 100644 index 00000000..2e13006c --- /dev/null +++ b/_modules/nc_py_api/nextcloud.html @@ -0,0 +1,717 @@ + + + + + + nc_py_api.nextcloud — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for nc_py_api.nextcloud

+"""Nextcloud class providing access to all API endpoints."""
+
+import contextlib
+import typing
+from abc import ABC
+
+from httpx import Headers
+
+from ._exceptions import NextcloudExceptionNotFound
+from ._misc import check_capabilities, require_capabilities
+from ._preferences import AsyncPreferencesAPI, PreferencesAPI
+from ._preferences_ex import (
+    AppConfigExAPI,
+    AsyncAppConfigExAPI,
+    AsyncPreferencesExAPI,
+    PreferencesExAPI,
+)
+from ._session import (
+    AppConfig,
+    AsyncNcSession,
+    AsyncNcSessionApp,
+    AsyncNcSessionBasic,
+    NcSession,
+    NcSessionApp,
+    NcSessionBasic,
+    ServerVersion,
+)
+from ._talk_api import _AsyncTalkAPI, _TalkAPI
+from ._theming import ThemingInfo, get_parsed_theme
+from .activity import _ActivityAPI, _AsyncActivityAPI
+from .apps import _AppsAPI, _AsyncAppsAPI
+from .calendar import _CalendarAPI
+from .ex_app.defs import LogLvl
+from .ex_app.events_listener import AsyncEventsListenerAPI, EventsListenerAPI
+from .ex_app.occ_commands import AsyncOccCommandsAPI, OccCommandsAPI
+from .ex_app.providers.providers import AsyncProvidersApi, ProvidersApi
+from .ex_app.ui.ui import AsyncUiApi, UiApi
+from .files.files import FilesAPI
+from .files.files_async import AsyncFilesAPI
+from .loginflow_v2 import _AsyncLoginFlowV2API, _LoginFlowV2API
+from .notes import _AsyncNotesAPI, _NotesAPI
+from .notifications import _AsyncNotificationsAPI, _NotificationsAPI
+from .user_status import _AsyncUserStatusAPI, _UserStatusAPI
+from .users import _AsyncUsersAPI, _UsersAPI
+from .users_groups import _AsyncUsersGroupsAPI, _UsersGroupsAPI
+from .weather_status import _AsyncWeatherStatusAPI, _WeatherStatusAPI
+from .webhooks import _AsyncWebhooksAPI, _WebhooksAPI
+
+
+class _NextcloudBasic(ABC):  # pylint: disable=too-many-instance-attributes
+    apps: _AppsAPI
+    """Nextcloud API for App management"""
+    activity: _ActivityAPI
+    """Activity Application API"""
+    cal: _CalendarAPI
+    """Nextcloud Calendar API"""
+    files: FilesAPI
+    """Nextcloud API for File System and Files Sharing"""
+    preferences: PreferencesAPI
+    """Nextcloud User Preferences API"""
+    notes: _NotesAPI
+    """Nextcloud Notes API"""
+    notifications: _NotificationsAPI
+    """Nextcloud API for managing user notifications"""
+    talk: _TalkAPI
+    """Nextcloud Talk API"""
+    users: _UsersAPI
+    """Nextcloud API for managing users."""
+    users_groups: _UsersGroupsAPI
+    """Nextcloud API for managing user groups."""
+    user_status: _UserStatusAPI
+    """Nextcloud API for managing users statuses"""
+    weather_status: _WeatherStatusAPI
+    """Nextcloud API for managing user weather statuses"""
+    webhooks: _WebhooksAPI
+    """Nextcloud API for managing webhooks"""
+    _session: NcSessionBasic
+
+    def __init__(self, session: NcSessionBasic):
+        self.apps = _AppsAPI(session)
+        self.activity = _ActivityAPI(session)
+        self.cal = _CalendarAPI(session)
+        self.files = FilesAPI(session)
+        self.preferences = PreferencesAPI(session)
+        self.notes = _NotesAPI(session)
+        self.notifications = _NotificationsAPI(session)
+        self.talk = _TalkAPI(session)
+        self.users = _UsersAPI(session)
+        self.users_groups = _UsersGroupsAPI(session)
+        self.user_status = _UserStatusAPI(session)
+        self.weather_status = _WeatherStatusAPI(session)
+        self.webhooks = _WebhooksAPI(session)
+
+    @property
+    def capabilities(self) -> dict:
+        """Returns the capabilities of the Nextcloud instance."""
+        return self._session.capabilities
+
+    @property
+    def srv_version(self) -> ServerVersion:
+        """Returns dictionary with the server version."""
+        return self._session.nc_version
+
+    def check_capabilities(self, capabilities: str | list[str]) -> list[str]:
+        """Returns the list with missing capabilities if any."""
+        return check_capabilities(capabilities, self.capabilities)
+
+    def update_server_info(self) -> None:
+        """Updates the capabilities and the Nextcloud version.
+
+        *In normal cases, it is called automatically and there is no need to call it manually.*
+        """
+        self._session.update_server_info()
+
+    @property
+    def response_headers(self) -> Headers:
+        """Returns the `HTTPX headers <https://www.python-httpx.org/api/#headers>`_ from the last response."""
+        return self._session.response_headers
+
+    @property
+    def theme(self) -> ThemingInfo | None:
+        """Returns Theme information."""
+        return get_parsed_theme(self.capabilities["theming"]) if "theming" in self.capabilities else None
+
+    def perform_login(self) -> bool:
+        """Performs login into Nextcloud if not already logged in; manual invocation of this method is unnecessary."""
+        try:
+            self.update_server_info()
+        except Exception:  # noqa pylint: disable=broad-exception-caught
+            return False
+        return True
+
+    def ocs(
+        self,
+        method: str,
+        path: str,
+        *,
+        content: bytes | str | typing.Iterable[bytes] | typing.AsyncIterable[bytes] | None = None,
+        json: dict | list | None = None,
+        params: dict | None = None,
+        **kwargs,
+    ):
+        """Performs OCS call and returns OCS response payload data."""
+        return self._session.ocs(method, path, content=content, json=json, params=params, **kwargs)
+
+    def download_log(self, fp) -> None:
+        """Downloads Nextcloud log file. Requires Admin privileges."""
+        self._session.download2stream("/index.php/settings/admin/log/download", fp)
+
+
+class _AsyncNextcloudBasic(ABC):  # pylint: disable=too-many-instance-attributes
+    apps: _AsyncAppsAPI
+    """Nextcloud API for App management"""
+    activity: _AsyncActivityAPI
+    """Activity Application API"""
+    # cal: _CalendarAPI
+    # """Nextcloud Calendar API"""
+    files: AsyncFilesAPI
+    """Nextcloud API for File System and Files Sharing"""
+    preferences: AsyncPreferencesAPI
+    """Nextcloud User Preferences API"""
+    notes: _AsyncNotesAPI
+    """Nextcloud Notes API"""
+    notifications: _AsyncNotificationsAPI
+    """Nextcloud API for managing user notifications"""
+    talk: _AsyncTalkAPI
+    """Nextcloud Talk API"""
+    users: _AsyncUsersAPI
+    """Nextcloud API for managing users."""
+    users_groups: _AsyncUsersGroupsAPI
+    """Nextcloud API for managing user groups."""
+    user_status: _AsyncUserStatusAPI
+    """Nextcloud API for managing users statuses"""
+    weather_status: _AsyncWeatherStatusAPI
+    """Nextcloud API for managing user weather statuses"""
+    webhooks: _AsyncWebhooksAPI
+    """Nextcloud API for managing webhooks"""
+    _session: AsyncNcSessionBasic
+
+    def __init__(self, session: AsyncNcSessionBasic):
+        self.apps = _AsyncAppsAPI(session)
+        self.activity = _AsyncActivityAPI(session)
+        # self.cal = _CalendarAPI(session)
+        self.files = AsyncFilesAPI(session)
+        self.preferences = AsyncPreferencesAPI(session)
+        self.notes = _AsyncNotesAPI(session)
+        self.notifications = _AsyncNotificationsAPI(session)
+        self.talk = _AsyncTalkAPI(session)
+        self.users = _AsyncUsersAPI(session)
+        self.users_groups = _AsyncUsersGroupsAPI(session)
+        self.user_status = _AsyncUserStatusAPI(session)
+        self.weather_status = _AsyncWeatherStatusAPI(session)
+        self.webhooks = _AsyncWebhooksAPI(session)
+
+    @property
+    async def capabilities(self) -> dict:
+        """Returns the capabilities of the Nextcloud instance."""
+        return await self._session.capabilities
+
+    @property
+    async def srv_version(self) -> ServerVersion:
+        """Returns dictionary with the server version."""
+        return await self._session.nc_version
+
+    async def check_capabilities(self, capabilities: str | list[str]) -> list[str]:
+        """Returns the list with missing capabilities if any."""
+        return check_capabilities(capabilities, await self.capabilities)
+
+    async def update_server_info(self) -> None:
+        """Updates the capabilities and the Nextcloud version.
+
+        *In normal cases, it is called automatically and there is no need to call it manually.*
+        """
+        await self._session.update_server_info()
+
+    @property
+    def response_headers(self) -> Headers:
+        """Returns the `HTTPX headers <https://www.python-httpx.org/api/#headers>`_ from the last response."""
+        return self._session.response_headers
+
+    @property
+    async def theme(self) -> ThemingInfo | None:
+        """Returns Theme information."""
+        return get_parsed_theme((await self.capabilities)["theming"]) if "theming" in await self.capabilities else None
+
+    async def perform_login(self) -> bool:
+        """Performs login into Nextcloud if not already logged in; manual invocation of this method is unnecessary."""
+        try:
+            await self.update_server_info()
+        except Exception:  # noqa pylint: disable=broad-exception-caught
+            return False
+        return True
+
+    async def ocs(
+        self,
+        method: str,
+        path: str,
+        *,
+        content: bytes | str | typing.Iterable[bytes] | typing.AsyncIterable[bytes] | None = None,
+        json: dict | list | None = None,
+        params: dict | None = None,
+        **kwargs,
+    ):
+        """Performs OCS call and returns OCS response payload data."""
+        return await self._session.ocs(method, path, content=content, json=json, params=params, **kwargs)
+
+    async def download_log(self, fp) -> None:
+        """Downloads Nextcloud log file. Requires Admin privileges."""
+        await self._session.download2stream("/index.php/settings/admin/log/download", fp)
+
+
+
+[docs] +class Nextcloud(_NextcloudBasic): + """Nextcloud client class. + + Allows you to connect to Nextcloud and perform operations on files, shares, users, and everything else. + """ + + _session: NcSession + loginflow_v2: _LoginFlowV2API + """Nextcloud Login flow v2.""" + +
+[docs] + def __init__(self, **kwargs): + """If the parameters are not specified, they will be taken from the environment. + + :param nextcloud_url: url of the nextcloud instance. + :param nc_auth_user: login username. Optional. + :param nc_auth_pass: password or app-password for the username. Optional. + """ + self._session = NcSession(**kwargs) + self.loginflow_v2 = _LoginFlowV2API(self._session) + super().__init__(self._session)
+ + + @property + def user(self) -> str: + """Returns current user ID.""" + return self._session.user
+ + + +class AsyncNextcloud(_AsyncNextcloudBasic): + """Async Nextcloud client class. + + Allows you to connect to Nextcloud and perform operations on files, shares, users, and everything else. + """ + + _session: AsyncNcSession + loginflow_v2: _AsyncLoginFlowV2API + """Nextcloud Login flow v2.""" + + def __init__(self, **kwargs): + """If the parameters are not specified, they will be taken from the environment. + + :param nextcloud_url: url of the nextcloud instance. + :param nc_auth_user: login username. Optional. + :param nc_auth_pass: password or app-password for the username. Optional. + """ + self._session = AsyncNcSession(**kwargs) + self.loginflow_v2 = _AsyncLoginFlowV2API(self._session) + super().__init__(self._session) + + @property + async def user(self) -> str: + """Returns current user ID.""" + return await self._session.user + + +
+[docs] +class NextcloudApp(_NextcloudBasic): + """Class for communication with Nextcloud in Nextcloud applications. + + Provides additional API required for applications such as user impersonation, + endpoint registration, new authentication method, etc. + + .. note:: Instance of this class should not be created directly in ``normal`` applications, + it will be provided for each app endpoint call. + """ + + _session: NcSessionApp + appconfig_ex: AppConfigExAPI + """Nextcloud App Preferences API for ExApps""" + preferences_ex: PreferencesExAPI + """Nextcloud User Preferences API for ExApps""" + ui: UiApi + """Nextcloud UI API for ExApps""" + providers: ProvidersApi + """API for registering providers for Nextcloud""" + events_listener: EventsListenerAPI + """API for registering Events listeners for ExApps""" + occ_commands: OccCommandsAPI + """API for registering OCC command for ExApps""" + + def __init__(self, **kwargs): + """The parameters will be taken from the environment. + + They can be overridden by specifying them in **kwargs**, but this behavior is highly discouraged. + """ + self._session = NcSessionApp(**kwargs) + super().__init__(self._session) + self.appconfig_ex = AppConfigExAPI(self._session) + self.preferences_ex = PreferencesExAPI(self._session) + self.ui = UiApi(self._session) + self.providers = ProvidersApi(self._session) + self.events_listener = EventsListenerAPI(self._session) + self.occ_commands = OccCommandsAPI(self._session) + + @property + def enabled_state(self) -> bool: + """Returns ``True`` if ExApp is enabled, ``False`` otherwise.""" + with contextlib.suppress(Exception): + return bool(self._session.ocs("GET", "/ocs/v1.php/apps/app_api/ex-app/state")) + return False + +
+[docs] + def log(self, log_lvl: LogLvl, content: str) -> None: + """Writes log to the Nextcloud log file.""" + if self.check_capabilities("app_api"): + return + if int(log_lvl) < self.capabilities["app_api"].get("loglevel", 0): + return + self._session.ocs("POST", f"{self._session.ae_url}/log", json={"level": int(log_lvl), "message": content})
+ + +
+[docs] + def users_list(self) -> list[str]: + """Returns list of users on the Nextcloud instance.""" + return self._session.ocs("GET", f"{self._session.ae_url}/users")
+ + + @property + def user(self) -> str: + """Property containing the current user ID. + + **ExApps** can change user ID they impersonate with **set_user** method. + """ + return self._session.user + +
+[docs] + def set_user(self, user_id: str): + """Changes current User ID.""" + if self._session.user != user_id: + self._session.set_user(user_id) + self.talk.config_sha = "" + self.talk.modified_since = 0 + self.activity.last_given = 0 + self.notes.last_etag = "" + self._session.update_server_info()
+ + + @property + def app_cfg(self) -> AppConfig: + """Returns deploy config, with AppAPI version, Application version and name.""" + return self._session.cfg + +
+[docs] + def register_talk_bot(self, callback_url: str, display_name: str, description: str = "") -> tuple[str, str]: + """Registers Talk BOT. + + .. note:: AppAPI will add a record in a case of successful registration to the ``appconfig_ex`` table. + + :param callback_url: URL suffix for fetching new messages. MUST be ``UNIQ`` for each bot the app provides. + :param display_name: The name under which the messages will be posted. + :param description: Optional description shown in the admin settings. + :return: Tuple with ID and the secret used for signing requests. + """ + require_capabilities("app_api", self._session.capabilities) + require_capabilities("spreed.features.bots-v1", self._session.capabilities) + params = { + "name": display_name, + "route": callback_url, + "description": description, + } + result = self._session.ocs("POST", f"{self._session.ae_url}/talk_bot", json=params) + return result["id"], result["secret"]
+ + +
+[docs] + def unregister_talk_bot(self, callback_url: str) -> bool: + """Unregisters Talk BOT.""" + require_capabilities("app_api", self._session.capabilities) + require_capabilities("spreed.features.bots-v1", self._session.capabilities) + params = { + "route": callback_url, + } + try: + self._session.ocs("DELETE", f"{self._session.ae_url}/talk_bot", json=params) + except NextcloudExceptionNotFound: + return False + return True
+ + +
+[docs] + def set_init_status(self, progress: int, error: str = "") -> None: + """Sets state of the app initialization. + + :param progress: a number from ``0`` to ``100`` indicating the percentage of application readiness for work. + After sending ``100`` AppAPI will enable the application. + :param error: if non-empty, signals to AppAPI that the application cannot be initialized successfully. + """ + self._session.ocs( + "PUT", + f"/ocs/v1.php/apps/app_api/apps/status/{self._session.cfg.app_name}", + json={ + "progress": progress, + "error": error, + }, + )
+
+ + + +class AsyncNextcloudApp(_AsyncNextcloudBasic): + """Class for communication with Nextcloud in Async Nextcloud applications. + + Provides additional API required for applications such as user impersonation, + endpoint registration, new authentication method, etc. + + .. note:: Instance of this class should not be created directly in ``normal`` applications, + it will be provided for each app endpoint call. + """ + + _session: AsyncNcSessionApp + appconfig_ex: AsyncAppConfigExAPI + """Nextcloud App Preferences API for ExApps""" + preferences_ex: AsyncPreferencesExAPI + """Nextcloud User Preferences API for ExApps""" + ui: AsyncUiApi + """Nextcloud UI API for ExApps""" + providers: AsyncProvidersApi + """API for registering providers for Nextcloud""" + events_listener: AsyncEventsListenerAPI + """API for registering Events listeners for ExApps""" + occ_commands: AsyncOccCommandsAPI + """API for registering OCC command for ExApps""" + + def __init__(self, **kwargs): + """The parameters will be taken from the environment. + + They can be overridden by specifying them in **kwargs**, but this behavior is highly discouraged. + """ + self._session = AsyncNcSessionApp(**kwargs) + super().__init__(self._session) + self.appconfig_ex = AsyncAppConfigExAPI(self._session) + self.preferences_ex = AsyncPreferencesExAPI(self._session) + self.ui = AsyncUiApi(self._session) + self.providers = AsyncProvidersApi(self._session) + self.events_listener = AsyncEventsListenerAPI(self._session) + self.occ_commands = AsyncOccCommandsAPI(self._session) + + @property + async def enabled_state(self) -> bool: + """Returns ``True`` if ExApp is enabled, ``False`` otherwise.""" + with contextlib.suppress(Exception): + return bool(await self._session.ocs("GET", "/ocs/v1.php/apps/app_api/ex-app/state")) + return False + + async def log(self, log_lvl: LogLvl, content: str) -> None: + """Writes log to the Nextcloud log file.""" + if await self.check_capabilities("app_api"): + return + if int(log_lvl) < (await self.capabilities)["app_api"].get("loglevel", 0): + return + await self._session.ocs("POST", f"{self._session.ae_url}/log", json={"level": int(log_lvl), "message": content}) + + async def users_list(self) -> list[str]: + """Returns list of users on the Nextcloud instance.""" + return await self._session.ocs("GET", f"{self._session.ae_url}/users") + + @property + async def user(self) -> str: + """Property containing the current user ID. + + **ExApps** can change user ID they impersonate with **set_user** method. + """ + return await self._session.user + + async def set_user(self, user_id: str): + """Changes current User ID.""" + if await self._session.user != user_id: + self._session.set_user(user_id) + self.talk.config_sha = "" + self.talk.modified_since = 0 + self.activity.last_given = 0 + self.notes.last_etag = "" + await self._session.update_server_info() + + @property + def app_cfg(self) -> AppConfig: + """Returns deploy config, with AppAPI version, Application version and name.""" + return self._session.cfg + + async def register_talk_bot(self, callback_url: str, display_name: str, description: str = "") -> tuple[str, str]: + """Registers Talk BOT. + + .. note:: AppAPI will add a record in a case of successful registration to the ``appconfig_ex`` table. + + :param callback_url: URL suffix for fetching new messages. MUST be ``UNIQ`` for each bot the app provides. + :param display_name: The name under which the messages will be posted. + :param description: Optional description shown in the admin settings. + :return: Tuple with ID and the secret used for signing requests. + """ + require_capabilities("app_api", await self._session.capabilities) + require_capabilities("spreed.features.bots-v1", await self._session.capabilities) + params = { + "name": display_name, + "route": callback_url, + "description": description, + } + result = await self._session.ocs("POST", f"{self._session.ae_url}/talk_bot", json=params) + return result["id"], result["secret"] + + async def unregister_talk_bot(self, callback_url: str) -> bool: + """Unregisters Talk BOT.""" + require_capabilities("app_api", await self._session.capabilities) + require_capabilities("spreed.features.bots-v1", await self._session.capabilities) + params = { + "route": callback_url, + } + try: + await self._session.ocs("DELETE", f"{self._session.ae_url}/talk_bot", json=params) + except NextcloudExceptionNotFound: + return False + return True + + async def set_init_status(self, progress: int, error: str = "") -> None: + """Sets state of the app initialization. + + :param progress: a number from ``0`` to ``100`` indicating the percentage of application readiness for work. + After sending ``100`` AppAPI will enable the application. + :param error: if non-empty, signals to AppAPI that the application cannot be initialized successfully. + """ + await self._session.ocs( + "PUT", + f"/ocs/v1.php/apps/app_api/apps/status/{self._session.cfg.app_name}", + json={ + "progress": progress, + "error": error, + }, + ) +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/notes.html b/_modules/nc_py_api/notes.html new file mode 100644 index 00000000..42debf7f --- /dev/null +++ b/_modules/nc_py_api/notes.html @@ -0,0 +1,529 @@ + + + + + + nc_py_api.notes — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for nc_py_api.notes

+"""Notes App API wrapper."""
+
+import dataclasses
+import datetime
+import json
+import typing
+
+import httpx
+
+from ._exceptions import check_error
+from ._misc import check_capabilities, clear_from_params_empty, require_capabilities
+from ._session import AsyncNcSessionBasic, NcSessionBasic
+
+
+
+[docs] +@dataclasses.dataclass +class Note: + """Class representing one **Note**.""" + + def __init__(self, raw_data: dict): + self._raw_data = raw_data + + @property + def note_id(self) -> int: + """Unique identifier which is created by the server.""" + return self._raw_data["id"] + + @property + def etag(self) -> str: + """The note's entity tag (ETag) indicates if a note's attribute has changed.""" + return self._raw_data.get("etag", "") + + @property + def readonly(self) -> bool: + """Indicates if the note is read-only.""" + return self._raw_data.get("readonly", False) + + @property + def title(self) -> str: + """The note's title is also used as filename for the note's file. + + .. note :: Some special characters are automatically removed and a sequential number is added + if a note with the same title in the same category exists. + """ + return self._raw_data.get("title", "") + + @property + def content(self) -> str: + """Notes can contain arbitrary text. + + .. note:: Formatting should be done using Markdown, but not every markup can be supported by every client. + """ + return self._raw_data.get("content", "") + + @property + def category(self) -> str: + """Every note is assigned to a category. + + By default, the category is an empty string (not null), which means the note is uncategorized. + + .. note:: Categories are mapped to folders in the file backend. + Illegal characters are automatically removed and the respective folder is automatically created. + Sub-categories (mapped to sub-folders) can be created by using ``/`` as delimiter. + """ + return self._raw_data.get("category", "") + + @property + def favorite(self) -> bool: + """If a note is marked as favorite, it is displayed at the top of the notes' list.""" + return self._raw_data.get("favorite", False) + + @property + def last_modified(self) -> datetime.datetime: + """Last modified date/time of the note. + + If not provided on note creation or content update, the current time is used. + """ + modified = self._raw_data.get("modified", 0) + return datetime.datetime.utcfromtimestamp(modified).replace(tzinfo=datetime.timezone.utc) + + def __repr__(self): + return f"<{self.__class__.__name__} id={self.note_id}, title={self.title}, last_modified={self.last_modified}>"
+ + + +
+[docs] +class NotesSettings(typing.TypedDict): + """Settings of Notes App.""" + + notes_path: str + """Path to the folder, where note's files are stored in Nextcloud. + The path must be relative to the user folder. Default is the localized string **Notes**.""" + file_suffix: str + """Newly created note's files will have this file suffix. Default is **.txt**."""
+ + + +
+[docs] +class _NotesAPI: + """Class implementing Nextcloud Notes API.""" + + _ep_base: str = "/index.php/apps/notes/api/v1" # without `index.php` we will get 405 error. + last_etag: str + """Used by ``get_list``, when **etag** param is ``True``.""" + + def __init__(self, session: NcSessionBasic): + self._session = session + self.last_etag = "" + + @property + def available(self) -> bool: + """Returns True if the Nextcloud instance supports this feature, False otherwise.""" + return not check_capabilities("notes", self._session.capabilities) + +
+[docs] + def get_list( + self, + category: str | None = None, + modified_since: int | None = None, + limit: int | None = None, + cursor: str | None = None, + no_content: bool = False, + etag: bool = False, + ) -> list[Note]: + """Get information of all Notes. + + :param category: Filter the result by category name. Notes with another category are not included in the result. + :param modified_since: When provided only results newer than given Unix timestamp are returned. + :param limit: Limit response to contain no more than the given number of notes. + If there are more notes, then the result is chunked and the HTTP response header + **X-Notes-Chunk-Cursor** is sent with a string value. + + .. note:: Use :py:attr:`~nc_py_api.nextcloud.Nextcloud.response_headers` property to achieve that. + :param cursor: You should use the string value from the last request's HTTP response header + ``X-Notes-Chunk-Cursor`` in order to get the next chunk of notes. + :param no_content: Flag indicating should ``content`` field be excluded from response. + :param etag: Flag indicating should ``ETag`` from last call be used. Default = **False**. + """ + require_capabilities("notes", self._session.capabilities) + params = { + "category": category, + "pruneBefore": modified_since, + "exclude": "content" if no_content else None, + "chunkSize": limit, + "chunkCursor": cursor, + } + clear_from_params_empty(list(params.keys()), params) + headers = {"If-None-Match": self.last_etag} if self.last_etag and etag else {} + r = _res_to_json(self._session.adapter.get(self._ep_base + "/notes", params=params, headers=headers)) + self.last_etag = self._session.response_headers["ETag"] + return [Note(i) for i in r]
+ + +
+[docs] + def by_id(self, note: Note) -> Note: + """Get updated information about :py:class:`~nc_py_api.notes.Note`.""" + require_capabilities("notes", self._session.capabilities) + r = _res_to_json( + self._session.adapter.get( + self._ep_base + f"/notes/{note.note_id}", headers={"If-None-Match": f'"{note.etag}"'} + ) + ) + return Note(r) if r else note
+ + +
+[docs] + def create( + self, + title: str, + content: str | None = None, + category: str | None = None, + favorite: bool | None = None, + last_modified: int | str | datetime.datetime | None = None, + ) -> Note: + """Create new Note.""" + require_capabilities("notes", self._session.capabilities) + params = { + "title": title, + "content": content, + "category": category, + "favorite": favorite, + "modified": last_modified, + } + clear_from_params_empty(list(params.keys()), params) + return Note(_res_to_json(self._session.adapter.post(self._ep_base + "/notes", json=params)))
+ + +
+[docs] + def update( + self, + note: Note, + title: str | None = None, + content: str | None = None, + category: str | None = None, + favorite: bool | None = None, + overwrite: bool = False, + ) -> Note: + """Updates Note. + + ``overwrite`` specifies should be or not the Note updated even if it was changed on server(has different ETag). + """ + require_capabilities("notes", self._session.capabilities) + headers = {"If-Match": f'"{note.etag}"'} if not overwrite else {} + params = { + "title": title, + "content": content, + "category": category, + "favorite": favorite, + } + clear_from_params_empty(list(params.keys()), params) + if not params: + raise ValueError("Nothing to update.") + return Note( + _res_to_json( + self._session.adapter.put(self._ep_base + f"/notes/{note.note_id}", json=params, headers=headers) + ) + )
+ + +
+[docs] + def delete(self, note: int | Note) -> None: + """Deletes a Note.""" + require_capabilities("notes", self._session.capabilities) + note_id = note.note_id if isinstance(note, Note) else note + check_error(self._session.adapter.delete(self._ep_base + f"/notes/{note_id}"))
+ + +
+[docs] + def get_settings(self) -> NotesSettings: + """Returns Notes App settings.""" + require_capabilities("notes", self._session.capabilities) + r = _res_to_json(self._session.adapter.get(self._ep_base + "/settings")) + return {"notes_path": r["notesPath"], "file_suffix": r["fileSuffix"]}
+ + +
+[docs] + def set_settings(self, notes_path: str | None = None, file_suffix: str | None = None) -> None: + """Change specified setting(s).""" + if notes_path is None and file_suffix is None: + raise ValueError("No setting to change.") + require_capabilities("notes", self._session.capabilities) + params = { + "notesPath": notes_path, + "fileSuffix": file_suffix, + } + clear_from_params_empty(list(params.keys()), params) + check_error(self._session.adapter.put(self._ep_base + "/settings", json=params))
+
+ + + +class _AsyncNotesAPI: + """Class implements Async Nextcloud Notes API.""" + + _ep_base: str = "/index.php/apps/notes/api/v1" # without `index.php` we will get 405 error. + last_etag: str + """Used by ``get_list``, when **etag** param is ``True``.""" + + def __init__(self, session: AsyncNcSessionBasic): + self._session = session + self.last_etag = "" + + @property + async def available(self) -> bool: + """Returns True if the Nextcloud instance supports this feature, False otherwise.""" + return not check_capabilities("notes", await self._session.capabilities) + + async def get_list( + self, + category: str | None = None, + modified_since: int | None = None, + limit: int | None = None, + cursor: str | None = None, + no_content: bool = False, + etag: bool = False, + ) -> list[Note]: + """Get information of all Notes. + + :param category: Filter the result by category name. Notes with another category are not included in the result. + :param modified_since: When provided only results newer than given Unix timestamp are returned. + :param limit: Limit response to contain no more than the given number of notes. + If there are more notes, then the result is chunked and the HTTP response header + **X-Notes-Chunk-Cursor** is sent with a string value. + + .. note:: Use :py:attr:`~nc_py_api.nextcloud.Nextcloud.response_headers` property to achieve that. + :param cursor: You should use the string value from the last request's HTTP response header + ``X-Notes-Chunk-Cursor`` in order to get the next chunk of notes. + :param no_content: Flag indicating should ``content`` field be excluded from response. + :param etag: Flag indicating should ``ETag`` from last call be used. Default = **False**. + """ + require_capabilities("notes", await self._session.capabilities) + params = { + "category": category, + "pruneBefore": modified_since, + "exclude": "content" if no_content else None, + "chunkSize": limit, + "chunkCursor": cursor, + } + clear_from_params_empty(list(params.keys()), params) + headers = {"If-None-Match": self.last_etag} if self.last_etag and etag else {} + r = _res_to_json(await self._session.adapter.get(self._ep_base + "/notes", params=params, headers=headers)) + self.last_etag = self._session.response_headers["ETag"] + return [Note(i) for i in r] + + async def by_id(self, note: Note) -> Note: + """Get updated information about :py:class:`~nc_py_api.notes.Note`.""" + require_capabilities("notes", await self._session.capabilities) + r = _res_to_json( + await self._session.adapter.get( + self._ep_base + f"/notes/{note.note_id}", headers={"If-None-Match": f'"{note.etag}"'} + ) + ) + return Note(r) if r else note + + async def create( + self, + title: str, + content: str | None = None, + category: str | None = None, + favorite: bool | None = None, + last_modified: int | str | datetime.datetime | None = None, + ) -> Note: + """Create new Note.""" + require_capabilities("notes", await self._session.capabilities) + params = { + "title": title, + "content": content, + "category": category, + "favorite": favorite, + "modified": last_modified, + } + clear_from_params_empty(list(params.keys()), params) + return Note(_res_to_json(await self._session.adapter.post(self._ep_base + "/notes", json=params))) + + async def update( + self, + note: Note, + title: str | None = None, + content: str | None = None, + category: str | None = None, + favorite: bool | None = None, + overwrite: bool = False, + ) -> Note: + """Updates Note. + + ``overwrite`` specifies should be or not the Note updated even if it was changed on server(has different ETag). + """ + require_capabilities("notes", await self._session.capabilities) + headers = {"If-Match": f'"{note.etag}"'} if not overwrite else {} + params = { + "title": title, + "content": content, + "category": category, + "favorite": favorite, + } + clear_from_params_empty(list(params.keys()), params) + if not params: + raise ValueError("Nothing to update.") + return Note( + _res_to_json( + await self._session.adapter.put(self._ep_base + f"/notes/{note.note_id}", json=params, headers=headers) + ) + ) + + async def delete(self, note: int | Note) -> None: + """Deletes a Note.""" + require_capabilities("notes", await self._session.capabilities) + note_id = note.note_id if isinstance(note, Note) else note + check_error(await self._session.adapter.delete(self._ep_base + f"/notes/{note_id}")) + + async def get_settings(self) -> NotesSettings: + """Returns Notes App settings.""" + require_capabilities("notes", await self._session.capabilities) + r = _res_to_json(await self._session.adapter.get(self._ep_base + "/settings")) + return {"notes_path": r["notesPath"], "file_suffix": r["fileSuffix"]} + + async def set_settings(self, notes_path: str | None = None, file_suffix: str | None = None) -> None: + """Change specified setting(s).""" + if notes_path is None and file_suffix is None: + raise ValueError("No setting to change.") + require_capabilities("notes", await self._session.capabilities) + params = { + "notesPath": notes_path, + "fileSuffix": file_suffix, + } + clear_from_params_empty(list(params.keys()), params) + check_error(await self._session.adapter.put(self._ep_base + "/settings", json=params)) + + +def _res_to_json(response: httpx.Response) -> dict: + check_error(response) + return json.loads(response.text) if response.status_code != 304 else {} +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/notifications.html b/_modules/nc_py_api/notifications.html new file mode 100644 index 00000000..5567d0ec --- /dev/null +++ b/_modules/nc_py_api/notifications.html @@ -0,0 +1,396 @@ + + + + + + nc_py_api.notifications — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for nc_py_api.notifications

+"""Nextcloud API for working with Notifications."""
+
+import dataclasses
+import datetime
+
+from ._misc import (
+    check_capabilities,
+    nc_iso_time_to_datetime,
+    random_string,
+    require_capabilities,
+)
+from ._session import (
+    AsyncNcSessionApp,
+    AsyncNcSessionBasic,
+    NcSessionApp,
+    NcSessionBasic,
+)
+
+
+
+[docs] +@dataclasses.dataclass +class Notification: + """Class representing information about Nextcloud notification.""" + + def __init__(self, raw_data: dict): + self._raw_data = raw_data + + @property + def notification_id(self) -> int: + """ID of the notification.""" + return self._raw_data["notification_id"] + + @property + def object_id(self) -> str: + """Randomly generated unique object ID.""" + return self._raw_data["object_id"] + + @property + def object_type(self) -> str: + """Currently not used.""" + return self._raw_data["object_type"] + + @property + def app_name(self) -> str: + """Application name that generated notification.""" + return self._raw_data["app"] + + @property + def user_id(self) -> str: + """User ID of user for which this notification is.""" + return self._raw_data["user"] + + @property + def subject(self) -> str: + """Subject of the notification.""" + return self._raw_data["subject"] + + @property + def message(self) -> str: + """Message of the notification.""" + return self._raw_data["message"] + + @property + def time(self) -> datetime.datetime: + """Time when the notification was created.""" + return nc_iso_time_to_datetime(self._raw_data["datetime"]) + + @property + def link(self) -> str: + """Link, which will be opened when user clicks on notification.""" + return self._raw_data.get("link", "") + + @property + def icon(self) -> str: + """Relative to instance url of the icon image.""" + return self._raw_data.get("icon", "") + + def __repr__(self): + return ( + f"<{self.__class__.__name__} id={self.notification_id}, app_name={self.app_name}, user_id={self.user_id}," + f" time={self.time}>" + )
+ + + +
+[docs] +class _NotificationsAPI: + """Class providing an API for managing user notifications on the Nextcloud server.""" + + _ep_base: str = "/ocs/v2.php/apps/notifications/api/v2/notifications" + + def __init__(self, session: NcSessionBasic): + self._session = session + + @property + def available(self) -> bool: + """Returns True if the Nextcloud instance supports this feature, False otherwise.""" + return not check_capabilities("notifications", self._session.capabilities) + +
+[docs] + def create( + self, + subject: str, + message: str = "", + subject_params: dict | None = None, + message_params: dict | None = None, + link: str = "", + ) -> str: + """Create a Notification for the current user and returns it's ObjectID. + + .. note:: Does not work in Nextcloud client mode, only for NextcloudApp mode. + """ + params = _create(subject, message, subject_params, message_params, link) + if not isinstance(self._session, NcSessionApp): + raise NotImplementedError("Sending notifications is only supported for `App` mode.") + require_capabilities(["app_api", "notifications"], self._session.capabilities) + return self._session.ocs("POST", f"{self._session.ae_url}/notification", json=params)["object_id"]
+ + +
+[docs] + def get_all(self) -> list[Notification]: + """Gets all notifications for a current user.""" + require_capabilities("notifications", self._session.capabilities) + return [Notification(i) for i in self._session.ocs("GET", self._ep_base)]
+ + +
+[docs] + def get_one(self, notification_id: int) -> Notification: + """Gets a single notification for a current user.""" + require_capabilities("notifications", self._session.capabilities) + return Notification(self._session.ocs("GET", f"{self._ep_base}/{notification_id}"))
+ + +
+[docs] + def by_object_id(self, object_id: str) -> Notification | None: + """Returns Notification if any by its object ID. + + .. note:: this method is a temporary workaround until `create` can return `notification_id`. + """ + for i in self.get_all(): + if i.object_id == object_id: + return i + return None
+ + +
+[docs] + def delete(self, notification_id: int) -> None: + """Deletes a notification for the current user.""" + require_capabilities("notifications", self._session.capabilities) + self._session.ocs("DELETE", f"{self._ep_base}/{notification_id}")
+ + +
+[docs] + def delete_all(self) -> None: + """Deletes all notifications for the current user.""" + require_capabilities("notifications", self._session.capabilities) + self._session.ocs("DELETE", self._ep_base)
+ + +
+[docs] + def exists(self, notification_ids: list[int]) -> list[int]: + """Checks the existence of notifications for the current user.""" + require_capabilities("notifications", self._session.capabilities) + return self._session.ocs("POST", f"{self._ep_base}/exists", json={"ids": notification_ids})
+
+ + + +class _AsyncNotificationsAPI: + """Class provides async API for managing user notifications on the Nextcloud server.""" + + _ep_base: str = "/ocs/v2.php/apps/notifications/api/v2/notifications" + + def __init__(self, session: AsyncNcSessionBasic): + self._session = session + + @property + async def available(self) -> bool: + """Returns True if the Nextcloud instance supports this feature, False otherwise.""" + return not check_capabilities("notifications", await self._session.capabilities) + + async def create( + self, + subject: str, + message: str = "", + subject_params: dict | None = None, + message_params: dict | None = None, + link: str = "", + ) -> str: + """Create a Notification for the current user and returns it's ObjectID. + + .. note:: Does not work in Nextcloud client mode, only for NextcloudApp mode. + """ + params = _create(subject, message, subject_params, message_params, link) + if not isinstance(self._session, AsyncNcSessionApp): + raise NotImplementedError("Sending notifications is only supported for `App` mode.") + require_capabilities(["app_api", "notifications"], await self._session.capabilities) + return (await self._session.ocs("POST", f"{self._session.ae_url}/notification", json=params))["object_id"] + + async def get_all(self) -> list[Notification]: + """Gets all notifications for a current user.""" + require_capabilities("notifications", await self._session.capabilities) + return [Notification(i) for i in await self._session.ocs("GET", self._ep_base)] + + async def get_one(self, notification_id: int) -> Notification: + """Gets a single notification for a current user.""" + require_capabilities("notifications", await self._session.capabilities) + return Notification(await self._session.ocs("GET", f"{self._ep_base}/{notification_id}")) + + async def by_object_id(self, object_id: str) -> Notification | None: + """Returns Notification if any by its object ID. + + .. note:: this method is a temporary workaround until `create` can return `notification_id`. + """ + for i in await self.get_all(): + if i.object_id == object_id: + return i + return None + + async def delete(self, notification_id: int) -> None: + """Deletes a notification for the current user.""" + require_capabilities("notifications", await self._session.capabilities) + await self._session.ocs("DELETE", f"{self._ep_base}/{notification_id}") + + async def delete_all(self) -> None: + """Deletes all notifications for the current user.""" + require_capabilities("notifications", await self._session.capabilities) + await self._session.ocs("DELETE", self._ep_base) + + async def exists(self, notification_ids: list[int]) -> list[int]: + """Checks the existence of notifications for the current user.""" + require_capabilities("notifications", await self._session.capabilities) + return await self._session.ocs("POST", f"{self._ep_base}/exists", json={"ids": notification_ids}) + + +def _create( + subject: str, message: str, subject_params: dict | None, message_params: dict | None, link: str +) -> dict[str, str | dict]: + if not subject: + raise ValueError("`subject` cannot be empty string.") + if subject_params is None: + subject_params = {} + if message_params is None: + message_params = {} + params: dict = { + "params": { + "object": "app_api", + "object_id": random_string(56), + "subject_type": "app_api_ex_app", + "subject_params": { + "rich_subject": subject, + "rich_subject_params": subject_params, + "rich_message": message, + "rich_message_params": message_params, + }, + } + } + if link: + params["params"]["subject_params"]["link"] = link + return params +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/talk.html b/_modules/nc_py_api/talk.html new file mode 100644 index 00000000..4a5e9c14 --- /dev/null +++ b/_modules/nc_py_api/talk.html @@ -0,0 +1,1074 @@ + + + + + + nc_py_api.talk — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for nc_py_api.talk

+"""Nextcloud Talk API definitions."""
+
+import dataclasses
+import datetime
+import enum
+import os
+
+from . import files as _files
+
+
+
+[docs] +class ConversationType(enum.IntEnum): + """Talk conversation types.""" + + ONE_TO_ONE = 1 + """Direct One to One""" + GROUP = 2 + """Group conversation(group chat)""" + PUBLIC = 3 + """Group conversation opened to all""" + CHANGELOG = 4 + """Conversation that some App start to inform about new features, changes, e.g. changelog.""" + FORMER = 5 + """Former "One to one" + (When a user is deleted from the server or removed from all their conversations, + "One to one" rooms are converted to this type)"""
+ + + +
+[docs] +class ParticipantType(enum.IntEnum): + """Permissions level of the current user.""" + + OWNER = 1 + """Creator of the conversation""" + MODERATOR = 2 + """Moderator of the conversation""" + USER = 3 + """Conversation participant""" + GUEST = 4 + """Conversation participant, with no account on NC instance""" + USER_SELF_JOINED = 5 + """User following a public link""" + GUEST_MODERATOR = 6 + """Conversation moderator, with no account on NC instance"""
+ + + +
+[docs] +class AttendeePermissions(enum.IntFlag): + """Final permissions for the current participant. + + .. note:: Permissions are picked in order of attendee then call, then default, + and the first which is ``Custom`` will apply. + """ + + DEFAULT = 0 + """Default permissions (will pick the one from the next level of: ``user``, ``call``, ``conversation``)""" + CUSTOM = 1 + """Custom permissions (this is required to be able to remove all other permissions)""" + START_CALL = 2 + """Start call""" + JOIN_CALL = 4 + """Join call""" + IGNORE = 8 + """Can ignore lobby""" + AUDIO = 16 + """Can publish audio stream""" + VIDEO = 32 + """Can publish video stream""" + SHARE_SCREEN = 64 + """Can publish screen sharing stream""" + OTHER = 128 + """Can post chat message, share items and do reactions"""
+ + + +
+[docs] +class InCallFlags(enum.IntFlag): + """Participant in-call flags.""" + + DISCONNECTED = 0 + IN_CALL = 1 + PROVIDES_AUDIO = 2 + PROVIDES_VIDEO = 4 + USES_SIP_DIAL_IN = 8
+ + + +
+[docs] +class ListableScope(enum.IntEnum): + """Listable scope for the room.""" + + PARTICIPANTS_ONLY = 0 + ONLY_REGULAR_USERS = 1 + EVERYONE = 2
+ + + +
+[docs] +class NotificationLevel(enum.IntEnum): + """The notification level for the user. + + .. note:: Default: ``1`` for ``one-to-one`` conversations, ``2`` for other conversations. + """ + + DEFAULT = 0 + ALWAYS_NOTIFY = 1 + NOTIFY_ON_MENTION = 2 + NEVER_NOTIFY = 3
+ + + +
+[docs] +class WebinarLobbyStates(enum.IntEnum): + """Webinar lobby restriction (0-1), if the participant is a moderator, they can always join the conversation.""" + + NO_LOBBY = 0 + NON_MODERATORS = 1
+ + + +
+[docs] +class SipEnabledStatus(enum.IntEnum): + """SIP enable status.""" + + DISABLED = 0 + ENABLED = 1 + """Each participant needs a unique PIN.""" + ENABLED_NO_PIN = 2 + """Only the conversation token is required."""
+ + + +
+[docs] +class CallRecordingStatus(enum.IntEnum): + """Type of call recording.""" + + NO_RECORDING = 0 + VIDEO = 1 + AUDIO = 2 + STARTING_VIDEO = 3 + STARTING_AUDIO = 4 + RECORDING_FAILED = 5
+ + + +
+[docs] +class BreakoutRoomMode(enum.IntEnum): + """Breakout room modes.""" + + NOT_CONFIGURED = 0 + AUTOMATIC = 1 + """ Attendees are unsorted and then distributed over the rooms, so they all have the same participant count.""" + MANUAL = 2 + """A map with attendee to room number specifies the participants.""" + FREE = 3 + """Each attendee picks their own breakout room."""
+ + + +
+[docs] +class BreakoutRoomStatus(enum.IntEnum): + """Breakout room status.""" + + STOPPED = 0 + """Breakout rooms lobbies are disabled.""" + STARTED = 1 + """Breakout rooms lobbies are enabled."""
+ + + +
+[docs] +@dataclasses.dataclass +class MessageReactions: + """One reaction for a message, retrieved with :py:meth:`~nc_py_api._talk_api._TalkAPI.get_message_reactions`.""" + + def __init__(self, raw_data: dict): + self._raw_data = raw_data + + @property + def actor_type(self) -> str: + """Actor types of the chat message: **users**, **guests**.""" + return self._raw_data["actorType"] + + @property + def actor_id(self) -> str: + """Actor id of the message author.""" + return self._raw_data["actorId"] + + @property + def actor_display_name(self) -> str: + """A display name of the message author.""" + return self._raw_data["actorDisplayName"] + + @property + def timestamp(self) -> int: + """Timestamp in seconds and UTC time zone.""" + return self._raw_data["timestamp"]
+ + + +
+[docs] +@dataclasses.dataclass +class TalkMessage: + """Talk message.""" + + def __init__(self, raw_data: dict): + self._raw_data = raw_data + + @property + def message_id(self) -> int: + """Numeric identifier of the message. Most methods that require this should accept this class itself.""" + return self._raw_data["id"] + + @property + def token(self) -> str: + """Token identifier of the conversation which is used for further interaction.""" + return self._raw_data["token"] + + @property + def actor_type(self) -> str: + """Actor types of the chat message: **users**, **guests**, **bots**, **bridged**.""" + return self._raw_data["actorType"] + + @property + def actor_id(self) -> str: + """Actor id of the message author.""" + return self._raw_data["actorId"] + + @property + def actor_display_name(self) -> str: + """A display name of the message author.""" + return self._raw_data["actorDisplayName"] + + @property + def timestamp(self) -> int: + """Timestamp in seconds and UTC time zone.""" + return self._raw_data["timestamp"] + + @property + def system_message(self) -> str: + """Empty for the normal chat message or the type of the system message (untranslated).""" + return self._raw_data["systemMessage"] + + @property + def message_type(self) -> str: + """Currently known types are "comment", "comment_deleted", "system" and "command".""" + return self._raw_data["messageType"] + + @property + def is_replyable(self) -> bool: + """True if the user can post a reply to this message. + + .. note:: Only available with ``chat-replies`` capability. + """ + return self._raw_data["isReplyable"] + + @property + def reference_id(self) -> str: + """A reference string that was given while posting the message to be able to identify the sent message again. + + .. note:: Only available with ``chat-reference-id`` capability. + """ + return self._raw_data["referenceId"] + + @property + def message(self) -> str: + """Message string with placeholders. + + See `Rich Object String <https://nextcloud-talk.readthedocs.io/en/latest/chat/#parent-data>`_. + """ + return self._raw_data["message"] + + @property + def message_parameters(self) -> dict: + """Message parameters for the ``message``.""" + return self._raw_data["messageParameters"] + + @property + def expiration_timestamp(self) -> int: + """Unix time stamp when the message expires and show be removed from the client's UI without further note. + + .. note:: Only available with ``message-expiration`` capability. + """ + return self._raw_data["expirationTimestamp"] + + @property + def parent(self) -> list: + """To be refactored: `Description here <https://nextcloud-talk.readthedocs.io/en/latest/chat/#parent-data>`_.""" + return self._raw_data.get("parent", []) + + @property + def reactions(self) -> dict: + """An array map with relation between reaction emoji and total count of reactions with this emoji.""" + return self._raw_data.get("reactions", {}) + + @property + def reactions_self(self) -> list[str]: + """When the user reacted, this is the list of emojis the user reacted with.""" + return self._raw_data.get("reactionsSelf", []) + + @property + def markdown(self) -> bool: + """Whether the message should be rendered as markdown or shown as plain text.""" + return self._raw_data.get("markdown", False) + + def __repr__(self): + return ( + f"<{self.__class__.__name__} id={self.message_id}, author={self.actor_display_name}," + f" time={datetime.datetime.utcfromtimestamp(self.timestamp).replace(tzinfo=datetime.timezone.utc)}>" + )
+ + + +
+[docs] +class TalkFileMessage(TalkMessage): + """Subclass of Talk Message representing message-containing file.""" + + def __init__(self, raw_data: dict, user_id: str): + super().__init__(raw_data) + self._user_id = user_id + +
+[docs] + def to_fs_node(self) -> _files.FsNode: + """Returns usual :py:class:`~nc_py_api.files.FsNode` created from this class.""" + _file_params: dict = self.message_parameters["file"] + user_path = _file_params["path"].rstrip("/") + is_dir = bool(_file_params["mimetype"].lower() == "httpd/unix-directory") + if is_dir: + user_path += "/" + full_path = os.path.join(f"files/{self._user_id}", user_path.lstrip("/")) + permissions = _files.permissions_to_str(_file_params["permissions"], is_dir) + return _files.FsNode( + full_path, + etag=_file_params["etag"], + size=_file_params["size"], + content_length=0 if is_dir else _file_params["size"], + permissions=permissions, + fileid=_file_params["id"], + mimetype=_file_params["mimetype"], + )
+
+ + + +@dataclasses.dataclass +class _TalkUserStatus: + def __init__(self, raw_data: dict): + self._raw_data = raw_data + + @property + def status_message(self) -> str: + """Message of the status.""" + return str(self._raw_data.get("statusMessage", "") or "") + + @property + def status_icon(self) -> str: + """The icon picked by the user (must be one emoji).""" + return str(self._raw_data.get("statusIcon", "") or "") + + @property + def status_type(self) -> str: + """Status type, on of the: online, away, dnd, invisible, offline.""" + return str(self._raw_data.get("status", "") or "") + + +
+[docs] +@dataclasses.dataclass(init=False) +class Conversation(_TalkUserStatus): + """Talk conversation.""" + + @property + def conversation_id(self) -> int: + """Numeric identifier of the conversation. Most methods that require this should accept this class itself.""" + return self._raw_data["id"] + + @property + def token(self) -> str: + """Token identifier of the conversation which is used for further interaction.""" + return self._raw_data["token"] + + @property + def conversation_type(self) -> ConversationType: + """Type of the conversation, see: :py:class:`~nc_py_api.talk.ConversationType`.""" + return ConversationType(self._raw_data["type"]) + + @property + def name(self) -> str: + """Name of the conversation (can also be empty).""" + return self._raw_data.get("name", "") + + @property + def display_name(self) -> str: + """``name`` if non-empty, otherwise it falls back to a list of participants.""" + return self._raw_data["displayName"] + + @property + def description(self) -> str: + """Description of the conversation (can also be empty) (only available with ``room-description`` capability).""" + return self._raw_data.get("description", "") + + @property + def participant_type(self) -> ParticipantType: + """Permissions level of the current user, see: :py:class:`~nc_py_api.talk.ParticipantType`.""" + return ParticipantType(self._raw_data["participantType"]) + + @property + def attendee_id(self) -> int: + """Unique attendee id.""" + return self._raw_data["attendeeId"] + + @property + def attendee_pin(self) -> str: + """Unique dial-in authentication code for this user when the conversation has SIP enabled.""" + return self._raw_data["attendeePin"] + + @property + def actor_type(self) -> str: + """Actor types of chat messages: **users**, **guests**, **bots**, **bridged**.""" + return self._raw_data["actorType"] + + @property + def actor_id(self) -> str: + """The unique identifier for the given actor type.""" + return self._raw_data["actorId"] + + @property + def permissions(self) -> AttendeePermissions: + """Final permissions, combined :py:class:`~nc_py_api.talk.AttendeePermissions` values.""" + return AttendeePermissions(self._raw_data["permissions"]) + + @property + def attendee_permissions(self) -> AttendeePermissions: + """Dedicated permissions for the current participant, if not ``Custom``, they are not the resulting ones.""" + return AttendeePermissions(self._raw_data["attendeePermissions"]) + + @property + def call_permissions(self) -> AttendeePermissions: + """Call permissions, if not ``Custom``, these are not the resulting permissions. + + .. note:: If set, they will reset after the end of the call. + """ + return AttendeePermissions(self._raw_data["callPermissions"]) + + @property + def default_permissions(self) -> AttendeePermissions: + """Default permissions for new participants.""" + return AttendeePermissions(self._raw_data["defaultPermissions"]) + + @property + def participant_flags(self) -> InCallFlags: + """``In call`` flags of the user's session making the request. + + .. note:: Available with ``in-call-flags`` capability. + """ + return InCallFlags(self._raw_data.get("participantFlags", InCallFlags.DISCONNECTED)) + + @property + def read_only(self) -> bool: + """Read-only state for the current user (only available with ``read-only-rooms`` capability).""" + return bool(self._raw_data.get("readOnly", False)) + + @property + def listable(self) -> ListableScope: + """Listable scope for the room (only available with ``listable-rooms`` capability).""" + return ListableScope(self._raw_data.get("listable", ListableScope.PARTICIPANTS_ONLY)) + + @property + def message_expiration(self) -> int: + """The message expiration time in seconds in this chat. Zero if disabled. + + .. note:: Only available with ``message-expiration`` capability. + """ + return self._raw_data.get("messageExpiration", 0) + + @property + def has_password(self) -> bool: + """Flag if the conversation has a password.""" + return bool(self._raw_data["hasPassword"]) + + @property + def has_call(self) -> bool: + """Flag if the conversation has call.""" + return bool(self._raw_data["hasCall"]) + + @property + def call_flag(self) -> InCallFlags: + """Combined flag of all participants in the current call. + + .. note:: Only available with ``conversation-call-flags`` capability. + """ + return InCallFlags(self._raw_data.get("callFlag", InCallFlags.DISCONNECTED)) + + @property + def can_start_call(self) -> bool: + """Flag if the user can start a new call in this conversation (joining is always possible). + + .. note:: Only available with start-call-flag capability. + """ + return bool(self._raw_data.get("canStartCall", False)) + + @property + def can_delete_conversation(self) -> bool: + """Flag if the user can delete the conversation for everyone. + + .. note: Not possible without moderator permissions or in ``one-to-one`` conversations. + """ + return bool(self._raw_data.get("canDeleteConversation", False)) + + @property + def can_leave_conversation(self) -> bool: + """Flag if the user can leave the conversation (not possible for the last user with moderator permissions).""" + return bool(self._raw_data.get("canLeaveConversation", False)) + + @property + def last_activity(self) -> int: + """Timestamp of the last activity in the conversation, in seconds and UTC time zone.""" + return self._raw_data["lastActivity"] + + @property + def is_favorite(self) -> bool: + """Flag if the conversation is favorite for the user.""" + return self._raw_data["isFavorite"] + + @property + def notification_level(self) -> NotificationLevel: + """The notification level for the user.""" + return NotificationLevel(self._raw_data["notificationLevel"]) + + @property + def lobby_state(self) -> WebinarLobbyStates: + """Webinar lobby restriction (0-1). + + .. note:: Only available with ``webinary-lobby`` capability. + """ + return WebinarLobbyStates(self._raw_data["lobbyState"]) + + @property + def lobby_timer(self) -> int: + """Timestamp when the lobby will be automatically disabled. + + .. note:: Only available with ``webinary-lobby`` capability. + """ + return self._raw_data["lobbyTimer"] + + @property + def sip_enabled(self) -> SipEnabledStatus: + """Status of the SIP for the conversation.""" + return SipEnabledStatus(self._raw_data["sipEnabled"]) + + @property + def can_enable_sip(self) -> bool: + """Whether the given user can enable SIP for this conversation. + + .. note:: When the token is not-numeric only, SIP can not be enabled even + if the user is permitted and a moderator of the conversation. + """ + return bool(self._raw_data["canEnableSIP"]) + + @property + def unread_messages_count(self) -> int: + """Number of unread chat messages in the conversation. + + .. note: Only available with chat-v2 capability. + """ + return self._raw_data["unreadMessages"] + + @property + def unread_mention(self) -> bool: + """Flag if the user was mentioned since their last visit.""" + return self._raw_data["unreadMention"] + + @property + def unread_mention_direct(self) -> bool: + """Flag if the user was mentioned directly (ignoring **@all** mentions) since their last visit. + + .. note:: Only available with ``direct-mention-flag`` capability. + """ + return self._raw_data["unreadMentionDirect"] + + @property + def last_read_message(self) -> int: + """ID of the last read message in a room. + + .. note:: only available with ``chat-read-marker`` capability. + """ + return self._raw_data["lastReadMessage"] + + @property + def last_common_read_message(self) -> int: + """``ID`` of the last message read by every user that has read privacy set to public in a room. + + When the user himself has it set to ``private`` the value is ``0``. + + .. note:: Only available with ``chat-read-status`` capability. + """ + return self._raw_data["lastCommonReadMessage"] + + @property + def last_message(self) -> TalkMessage | None: + """Last message in a conversation if available, otherwise ``empty``. + + .. note:: Even when given, the message will not contain the ``parent`` or ``reactionsSelf`` + attribute due to performance reasons + """ + return TalkMessage(self._raw_data["lastMessage"]) if self._raw_data["lastMessage"] else None + + @property + def breakout_room_mode(self) -> BreakoutRoomMode: + """Breakout room configuration mode. + + .. note:: Only available with ``breakout-rooms-v1`` capability. + """ + return BreakoutRoomMode(self._raw_data.get("breakoutRoomMode", BreakoutRoomMode.NOT_CONFIGURED)) + + @property + def breakout_room_status(self) -> BreakoutRoomStatus: + """Breakout room status. + + .. note:: Only available with ``breakout-rooms-v1`` capability. + """ + return BreakoutRoomStatus(self._raw_data.get("breakoutRoomStatus", BreakoutRoomStatus.STOPPED)) + + @property + def avatar_version(self) -> str: + """Version of conversation avatar used to easier expiration of the avatar in case a moderator updates it. + + .. note:: Only available with ``avatar`` capability. + """ + return self._raw_data["avatarVersion"] + + @property + def is_custom_avatar(self) -> bool: + """Flag if the conversation has a custom avatar. + + .. note:: Only available with ``avatar`` capability. + """ + return self._raw_data.get("isCustomAvatar", False) + + @property + def call_start_time(self) -> int: + """Timestamp when the call was started. + + .. note:: Only available with ``recording-v1`` capability. + """ + return self._raw_data["callStartTime"] + + @property + def recording_status(self) -> CallRecordingStatus: + """Call recording status.. + + .. note:: Only available with ``recording-v1`` capability. + """ + return CallRecordingStatus(self._raw_data.get("callRecording", CallRecordingStatus.NO_RECORDING)) + + @property + def status_clear_at(self) -> int | None: + """Unix Timestamp representing the time to clear the status. + + .. note:: Available only for ``one-to-one`` conversations. + """ + return self._raw_data.get("statusClearAt", None) + + def __repr__(self): + return ( + f"<{self.__class__.__name__} id={self.conversation_id}, name={self.display_name}," + f" type={self.conversation_type.name}>" + )
+ + + +
+[docs] +@dataclasses.dataclass(init=False) +class Participant(_TalkUserStatus): + """Conversation participant information.""" + + @property + def attendee_id(self) -> int: + """Unique attendee id.""" + return self._raw_data["attendeeId"] + + @property + def actor_type(self) -> str: + """The actor type of the participant that voted: **users**, **groups**, **circles**, **guests**, **emails**.""" + return self._raw_data["actorType"] + + @property + def actor_id(self) -> str: + """The unique identifier for the given actor type.""" + return self._raw_data["actorId"] + + @property + def display_name(self) -> str: + """Can be empty for guests.""" + return self._raw_data["displayName"] + + @property + def participant_type(self) -> ParticipantType: + """Permissions level, see: :py:class:`~nc_py_api.talk.ParticipantType`.""" + return ParticipantType(self._raw_data["participantType"]) + + @property + def last_ping(self) -> int: + """Timestamp of the last ping. Should be used for sorting.""" + return self._raw_data["lastPing"] + + @property + def participant_flags(self) -> InCallFlags: + """Current call flags.""" + return InCallFlags(self._raw_data.get("inCall", InCallFlags.DISCONNECTED)) + + @property + def permissions(self) -> AttendeePermissions: + """Final permissions, combined :py:class:`~nc_py_api.talk.AttendeePermissions` values.""" + return AttendeePermissions(self._raw_data["permissions"]) + + @property + def attendee_permissions(self) -> AttendeePermissions: + """Dedicated permissions for the current participant, if not ``Custom``, they are not the resulting ones.""" + return AttendeePermissions(self._raw_data["attendeePermissions"]) + + @property + def session_ids(self) -> list[str]: + """A list of session IDs, each one 512 characters long, or empty if there is no session.""" + return self._raw_data["sessionIds"] + + @property + def breakout_token(self) -> str: + """Only available with breakout-rooms-v1 capability.""" + return self._raw_data.get("roomToken", "") + + def __repr__(self): + return ( + f"<{self.__class__.__name__} id={self.attendee_id}, name={self.display_name}, last_ping={self.last_ping}>" + )
+ + + +
+[docs] +@dataclasses.dataclass +class BotInfoBasic: + """Basic information about the Nextcloud Talk Bot.""" + + def __init__(self, raw_data: dict): + self._raw_data = raw_data + + @property + def bot_id(self) -> int: + """Unique numeric identifier of the bot on this server.""" + return self._raw_data["id"] + + @property + def bot_name(self) -> str: + """The display name of the bot shown as author when it posts a message or reaction.""" + return self._raw_data["name"] + + @property + def description(self) -> str: + """A longer description of the bot helping moderators to decide if they want to enable this bot.""" + return self._raw_data["description"] + + @property + def state(self) -> int: + """One of the Bot states: ``0`` - Disabled, ``1`` - enabled, ``2`` - **No setup**.""" + return self._raw_data["state"] + + def __repr__(self): + return f"<{self.__class__.__name__} id={self.bot_id}, name={self.bot_name}>"
+ + + +
+[docs] +@dataclasses.dataclass(init=False) +class BotInfo(BotInfoBasic): + """Full information about the Nextcloud Talk Bot.""" + + @property + def url(self) -> str: + """URL endpoint that is triggered by this bot.""" + return self._raw_data["url"] + + @property + def url_hash(self) -> str: + """Hash of the URL prefixed with ``bot-`` serves as ``actor_id``.""" + return self._raw_data["url_hash"] + + @property + def error_count(self) -> int: + """Number of consecutive errors.""" + return self._raw_data["error_count"] + + @property + def last_error_date(self) -> int: + """UNIX timestamp of the last error.""" + return self._raw_data["last_error_date"] + + @property + def last_error_message(self) -> str | None: + """The last exception message or error response information when trying to reach the bot.""" + return self._raw_data["last_error_message"]
+ + + +
+[docs] +@dataclasses.dataclass +class PollDetail: + """Detail about who voted for option.""" + + def __init__(self, raw_data: dict): + self._raw_data = raw_data + + @property + def actor_type(self) -> str: + """The actor type of the participant that voted: **users**, **groups**, **circles**, **guests**, **emails**.""" + return self._raw_data["actorType"] + + @property + def actor_id(self) -> str: + """The actor id of the participant that voted.""" + return self._raw_data["actorId"] + + @property + def actor_display_name(self) -> str: + """The display name of the participant that voted.""" + return self._raw_data["actorDisplayName"] + + @property + def option(self) -> int: + """The option that was voted for.""" + return self._raw_data["optionId"] + + def __repr__(self): + return f"<{self.__class__.__name__} actor={self.actor_display_name}, voted_for={self.option}>"
+ + + +
+[docs] +@dataclasses.dataclass +class Poll: + """Conversation Poll information.""" + + def __init__(self, raw_data: dict, conversation_token: str): + self._raw_data = raw_data + self._conversation_token = conversation_token + + @property + def conversation_token(self) -> str: + """Token identifier of the conversation to which poll belongs.""" + return self._conversation_token + + @property + def poll_id(self) -> int: + """ID of the poll.""" + return self._raw_data["id"] + + @property + def question(self) -> str: + """The question of the poll.""" + return self._raw_data["question"] + + @property + def options(self) -> list[str]: + """Options participants can vote for.""" + return self._raw_data["options"] + + @property + def votes(self) -> dict[str, int]: + """Map with 'option-' + optionId => number of votes. + + .. note:: Only available for when the actor voted on the public poll or the poll is closed. + """ + return self._raw_data.get("votes", {}) + + @property + def actor_type(self) -> str: + """Actor type of the poll author: **users**, **groups**, **circles**, **guests**, **emails**.""" + return self._raw_data["actorType"] + + @property + def actor_id(self) -> str: + """Actor ID identifying the poll author.""" + return self._raw_data["actorId"] + + @property + def actor_display_name(self) -> str: + """The display name of the poll author.""" + return self._raw_data["actorDisplayName"] + + @property + def closed(self) -> bool: + """Participants can no longer cast votes and the result is displayed.""" + return bool(self._raw_data["status"] == 1) + + @property + def hidden_results(self) -> bool: + """The results are hidden until the poll is closed.""" + return bool(self._raw_data["resultMode"] == 1) + + @property + def max_votes(self) -> int: + """The maximum amount of options a user can vote for, ``0`` means unlimited.""" + return self._raw_data["maxVotes"] + + @property + def voted_self(self) -> list[int]: + """Array of option ids the participant voted for.""" + return self._raw_data["votedSelf"] + + @property + def num_voters(self) -> int: + """The number of unique voters that voted. + + .. note:: only available when the actor voted on the public poll or the + poll is closed unless for the creator and moderators. + """ + return self._raw_data.get("numVoters", 0) + + @property + def details(self) -> list[PollDetail]: + """Detailed list who voted for which option (only available for public closed polls).""" + return [PollDetail(i) for i in self._raw_data.get("details", [])] + + def __repr__(self): + return f"<{self.__class__.__name__} id={self.poll_id}, author={self.actor_display_name}>"
+ +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/talk_bot.html b/_modules/nc_py_api/talk_bot.html new file mode 100644 index 00000000..c9110c03 --- /dev/null +++ b/_modules/nc_py_api/talk_bot.html @@ -0,0 +1,498 @@ + + + + + + nc_py_api.talk_bot — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for nc_py_api.talk_bot

+"""Nextcloud Talk API for bots."""
+
+import dataclasses
+import hashlib
+import hmac
+import json
+import os
+import typing
+
+import httpx
+
+from . import options
+from ._misc import random_string
+from ._session import BasicConfig
+from .nextcloud import AsyncNextcloudApp, NextcloudApp
+
+
+
+[docs] +class ObjectContent(typing.TypedDict): + """Object content of :py:class:`~nc_py_api.talk_bot.TalkBotMessage`.""" + + message: str + parameters: dict
+ + + +
+[docs] +@dataclasses.dataclass +class TalkBotMessage: + """Talk message received by bots.""" + + def __init__(self, raw_data: dict): + self._raw_data = raw_data + + @property + def actor_id(self) -> str: + """One of the attendee types followed by the ``/`` character and a unique identifier within the given type. + + For the users it is the Nextcloud user ID, for guests a **sha1** value. + """ + return self._raw_data["actor"]["id"] + + @property + def actor_display_name(self) -> str: + """The display name of the attendee sending the message.""" + return self._raw_data["actor"]["name"] + + @property + def object_id(self) -> int: + """The message ID of the given message on the origin server. + + It can be used to react or reply to the given message. + """ + return self._raw_data["object"]["id"] + + @property + def object_name(self) -> str: + """For normal written messages ``message``, otherwise one of the known ``system message identifiers``.""" + return self._raw_data["object"]["name"] + + @property + def object_content(self) -> ObjectContent: + """Dictionary with a ``message`` and ``parameters`` keys.""" + return json.loads(self._raw_data["object"]["content"]) + + @property + def object_media_type(self) -> str: + """``text/markdown`` when the message should be interpreted as **Markdown**, otherwise ``text/plain``.""" + return self._raw_data["object"]["mediaType"] + + @property + def conversation_token(self) -> str: + """The token of the conversation in which the message was posted. + + It can be used to react or reply to the given message. + """ + return self._raw_data["target"]["id"] + + @property + def conversation_name(self) -> str: + """The name of the conversation in which the message was posted.""" + return self._raw_data["target"]["name"] + + def __repr__(self): + return f"<{self.__class__.__name__} conversation={self.conversation_name}, actor={self.actor_display_name}>"
+ + + +
+[docs] +class TalkBot: + """A class that implements the TalkBot functionality.""" + + _ep_base: str = "/ocs/v2.php/apps/spreed/api/v1/bot" + + def __init__(self, callback_url: str, display_name: str, description: str = ""): + """Class implementing Nextcloud Talk Bot functionality. + + :param callback_url: FastAPI endpoint which will be assigned to bot. + :param display_name: The display name of the bot that is shown as author when it posts a message or reaction. + :param description: Description of the bot helping moderators to decide if they want to enable this bot. + """ + self.callback_url = callback_url.lstrip("/") + self.display_name = display_name + self.description = description + +
+[docs] + def enabled_handler(self, enabled: bool, nc: NextcloudApp) -> None: + """Handles the app ``on``/``off`` event in the context of the bot. + + :param enabled: Value that was passed to ``/enabled`` handler. + :param nc: **NextcloudApp** class that was passed ``/enabled`` handler. + """ + if enabled: + bot_id, bot_secret = nc.register_talk_bot(self.callback_url, self.display_name, self.description) + os.environ[bot_id] = bot_secret + else: + nc.unregister_talk_bot(self.callback_url)
+ + +
+[docs] + def send_message( + self, message: str, reply_to_message: int | TalkBotMessage, silent: bool = False, token: str = "" + ) -> tuple[httpx.Response, str]: + """Send a message and returns a "reference string" to identify the message again in a "get messages" request. + + :param message: The message to say. + :param reply_to_message: The message ID this message is a reply to. + + .. note:: Only allowed when the message type is not ``system`` or ``command``. + :param silent: Flag controlling if the message should create a chat notifications for the users. + :param token: Token of the conversation. + Can be empty if ``reply_to_message`` is :py:class:`~nc_py_api.talk_bot.TalkBotMessage`. + :returns: Tuple, where fist element is :py:class:`httpx.Response` and second is a "reference string". + :raises ValueError: in case of an invalid usage. + :raises RuntimeError: in case of a broken installation. + """ + if not token and not isinstance(reply_to_message, TalkBotMessage): + raise ValueError("Either specify 'token' value or provide 'TalkBotMessage'.") + token = reply_to_message.conversation_token if isinstance(reply_to_message, TalkBotMessage) else token + reference_id = hashlib.sha256(random_string(32).encode("UTF-8")).hexdigest() + params = { + "message": message, + "replyTo": reply_to_message.object_id if isinstance(reply_to_message, TalkBotMessage) else reply_to_message, + "referenceId": reference_id, + "silent": silent, + } + return self._sign_send_request("POST", f"/{token}/message", params, message), reference_id
+ + +
+[docs] + def react_to_message(self, message: int | TalkBotMessage, reaction: str, token: str = "") -> httpx.Response: + """React to a message. + + :param message: Message ID or :py:class:`~nc_py_api.talk_bot.TalkBotMessage` to react to. + :param reaction: A single emoji. + :param token: Token of the conversation. + Can be empty if ``message`` is :py:class:`~nc_py_api.talk_bot.TalkBotMessage`. + :raises ValueError: in case of an invalid usage. + :raises RuntimeError: in case of a broken installation. + """ + if not token and not isinstance(message, TalkBotMessage): + raise ValueError("Either specify 'token' value or provide 'TalkBotMessage'.") + message_id = message.object_id if isinstance(message, TalkBotMessage) else message + token = message.conversation_token if isinstance(message, TalkBotMessage) else token + params = { + "reaction": reaction, + } + return self._sign_send_request("POST", f"/{token}/reaction/{message_id}", params, reaction)
+ + +
+[docs] + def delete_reaction(self, message: int | TalkBotMessage, reaction: str, token: str = "") -> httpx.Response: + """Removes reaction from a message. + + :param message: Message ID or :py:class:`~nc_py_api.talk_bot.TalkBotMessage` to remove reaction from. + :param reaction: A single emoji. + :param token: Token of the conversation. + Can be empty if ``message`` is :py:class:`~nc_py_api.talk_bot.TalkBotMessage`. + :raises ValueError: in case of an invalid usage. + :raises RuntimeError: in case of a broken installation. + """ + if not token and not isinstance(message, TalkBotMessage): + raise ValueError("Either specify 'token' value or provide 'TalkBotMessage'.") + message_id = message.object_id if isinstance(message, TalkBotMessage) else message + token = message.conversation_token if isinstance(message, TalkBotMessage) else token + params = { + "reaction": reaction, + } + return self._sign_send_request("DELETE", f"/{token}/reaction/{message_id}", params, reaction)
+ + + def _sign_send_request(self, method: str, url_suffix: str, data: dict, data_to_sign: str) -> httpx.Response: + secret = get_bot_secret(self.callback_url) + if secret is None: + raise RuntimeError("Can't find the 'secret' of the bot. Has the bot been installed?") + talk_bot_random = random_string(32) + hmac_sign = hmac.new(secret, talk_bot_random.encode("UTF-8"), digestmod=hashlib.sha256) + hmac_sign.update(data_to_sign.encode("UTF-8")) + nc_app_cfg = BasicConfig() + with httpx.Client(verify=nc_app_cfg.options.nc_cert) as client: + return client.request( + method, + url=nc_app_cfg.endpoint + "/ocs/v2.php/apps/spreed/api/v1/bot" + url_suffix, + json=data, + headers={ + "X-Nextcloud-Talk-Bot-Random": talk_bot_random, + "X-Nextcloud-Talk-Bot-Signature": hmac_sign.hexdigest(), + "OCS-APIRequest": "true", + }, + cookies={"XDEBUG_SESSION": options.XDEBUG_SESSION} if options.XDEBUG_SESSION else {}, + timeout=nc_app_cfg.options.timeout, + )
+ + + +class AsyncTalkBot: + """A class that implements the async TalkBot functionality.""" + + _ep_base: str = "/ocs/v2.php/apps/spreed/api/v1/bot" + + def __init__(self, callback_url: str, display_name: str, description: str = ""): + """Class implementing Nextcloud Talk Bot functionality. + + :param callback_url: FastAPI endpoint which will be assigned to bot. + :param display_name: The display name of the bot that is shown as author when it posts a message or reaction. + :param description: Description of the bot helping moderators to decide if they want to enable this bot. + """ + self.callback_url = callback_url.lstrip("/") + self.display_name = display_name + self.description = description + + async def enabled_handler(self, enabled: bool, nc: AsyncNextcloudApp) -> None: + """Handles the app ``on``/``off`` event in the context of the bot. + + :param enabled: Value that was passed to ``/enabled`` handler. + :param nc: **NextcloudApp** class that was passed ``/enabled`` handler. + """ + if enabled: + bot_id, bot_secret = await nc.register_talk_bot(self.callback_url, self.display_name, self.description) + os.environ[bot_id] = bot_secret + else: + await nc.unregister_talk_bot(self.callback_url) + + async def send_message( + self, message: str, reply_to_message: int | TalkBotMessage, silent: bool = False, token: str = "" + ) -> tuple[httpx.Response, str]: + """Send a message and returns a "reference string" to identify the message again in a "get messages" request. + + :param message: The message to say. + :param reply_to_message: The message ID this message is a reply to. + + .. note:: Only allowed when the message type is not ``system`` or ``command``. + :param silent: Flag controlling if the message should create a chat notifications for the users. + :param token: Token of the conversation. + Can be empty if ``reply_to_message`` is :py:class:`~nc_py_api.talk_bot.TalkBotMessage`. + :returns: Tuple, where fist element is :py:class:`httpx.Response` and second is a "reference string". + :raises ValueError: in case of an invalid usage. + :raises RuntimeError: in case of a broken installation. + """ + if not token and not isinstance(reply_to_message, TalkBotMessage): + raise ValueError("Either specify 'token' value or provide 'TalkBotMessage'.") + token = reply_to_message.conversation_token if isinstance(reply_to_message, TalkBotMessage) else token + reference_id = hashlib.sha256(random_string(32).encode("UTF-8")).hexdigest() + params = { + "message": message, + "replyTo": reply_to_message.object_id if isinstance(reply_to_message, TalkBotMessage) else reply_to_message, + "referenceId": reference_id, + "silent": silent, + } + return await self._sign_send_request("POST", f"/{token}/message", params, message), reference_id + + async def react_to_message(self, message: int | TalkBotMessage, reaction: str, token: str = "") -> httpx.Response: + """React to a message. + + :param message: Message ID or :py:class:`~nc_py_api.talk_bot.TalkBotMessage` to react to. + :param reaction: A single emoji. + :param token: Token of the conversation. + Can be empty if ``message`` is :py:class:`~nc_py_api.talk_bot.TalkBotMessage`. + :raises ValueError: in case of an invalid usage. + :raises RuntimeError: in case of a broken installation. + """ + if not token and not isinstance(message, TalkBotMessage): + raise ValueError("Either specify 'token' value or provide 'TalkBotMessage'.") + message_id = message.object_id if isinstance(message, TalkBotMessage) else message + token = message.conversation_token if isinstance(message, TalkBotMessage) else token + params = { + "reaction": reaction, + } + return await self._sign_send_request("POST", f"/{token}/reaction/{message_id}", params, reaction) + + async def delete_reaction(self, message: int | TalkBotMessage, reaction: str, token: str = "") -> httpx.Response: + """Removes reaction from a message. + + :param message: Message ID or :py:class:`~nc_py_api.talk_bot.TalkBotMessage` to remove reaction from. + :param reaction: A single emoji. + :param token: Token of the conversation. + Can be empty if ``message`` is :py:class:`~nc_py_api.talk_bot.TalkBotMessage`. + :raises ValueError: in case of an invalid usage. + :raises RuntimeError: in case of a broken installation. + """ + if not token and not isinstance(message, TalkBotMessage): + raise ValueError("Either specify 'token' value or provide 'TalkBotMessage'.") + message_id = message.object_id if isinstance(message, TalkBotMessage) else message + token = message.conversation_token if isinstance(message, TalkBotMessage) else token + params = { + "reaction": reaction, + } + return await self._sign_send_request("DELETE", f"/{token}/reaction/{message_id}", params, reaction) + + async def _sign_send_request(self, method: str, url_suffix: str, data: dict, data_to_sign: str) -> httpx.Response: + secret = await aget_bot_secret(self.callback_url) + if secret is None: + raise RuntimeError("Can't find the 'secret' of the bot. Has the bot been installed?") + talk_bot_random = random_string(32) + hmac_sign = hmac.new(secret, talk_bot_random.encode("UTF-8"), digestmod=hashlib.sha256) + hmac_sign.update(data_to_sign.encode("UTF-8")) + nc_app_cfg = BasicConfig() + async with httpx.AsyncClient(verify=nc_app_cfg.options.nc_cert) as aclient: + return await aclient.request( + method, + url=nc_app_cfg.endpoint + "/ocs/v2.php/apps/spreed/api/v1/bot" + url_suffix, + json=data, + headers={ + "X-Nextcloud-Talk-Bot-Random": talk_bot_random, + "X-Nextcloud-Talk-Bot-Signature": hmac_sign.hexdigest(), + "OCS-APIRequest": "true", + }, + cookies={"XDEBUG_SESSION": options.XDEBUG_SESSION} if options.XDEBUG_SESSION else {}, + timeout=nc_app_cfg.options.timeout, + ) + + +def __get_bot_secret(callback_url: str) -> str: + sha_1 = hashlib.sha1(usedforsecurity=False) + string_to_hash = os.environ["APP_ID"] + "_" + callback_url.lstrip("/") + sha_1.update(string_to_hash.encode("UTF-8")) + return sha_1.hexdigest() + + +
+[docs] +def get_bot_secret(callback_url: str) -> bytes | None: + """Returns the bot's secret from an environment variable or from the application's configuration on the server.""" + secret_key = __get_bot_secret(callback_url) + if secret_key in os.environ: + return os.environ[secret_key].encode("UTF-8") + secret_value = NextcloudApp().appconfig_ex.get_value(secret_key) + if secret_value is not None: + os.environ[secret_key] = secret_value + return secret_value.encode("UTF-8") + return None
+ + + +async def aget_bot_secret(callback_url: str) -> bytes | None: + """Returns the bot's secret from an environment variable or from the application's configuration on the server.""" + secret_key = __get_bot_secret(callback_url) + if secret_key in os.environ: + return os.environ[secret_key].encode("UTF-8") + secret_value = await AsyncNextcloudApp().appconfig_ex.get_value(secret_key) + if secret_value is not None: + os.environ[secret_key] = secret_value + return secret_value.encode("UTF-8") + return None +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/user_status.html b/_modules/nc_py_api/user_status.html new file mode 100644 index 00000000..e6136200 --- /dev/null +++ b/_modules/nc_py_api/user_status.html @@ -0,0 +1,468 @@ + + + + + + nc_py_api.user_status — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for nc_py_api.user_status

+"""Nextcloud API for working with user statuses."""
+
+import dataclasses
+import typing
+
+from ._exceptions import NextcloudExceptionNotFound
+from ._misc import check_capabilities, kwargs_to_params, require_capabilities
+from ._session import AsyncNcSessionBasic, NcSessionBasic
+
+
+
+[docs] +@dataclasses.dataclass +class ClearAt: + """Determination when a user's predefined status will be cleared.""" + + clear_type: str + """Possible values: ``period``, ``end-of``""" + time: str | int + """Depending of ``type`` it can be number of seconds relative to ``now`` or one of the next values: ``day``""" + + def __init__(self, raw_data: dict): + self.clear_type = raw_data["type"] + self.time = raw_data["time"]
+ + + +
+[docs] +@dataclasses.dataclass +class PredefinedStatus: + """Definition of the predefined status.""" + + status_id: str + """ID of the predefined status""" + icon: str + """Icon in string(UTF) format""" + message: str + """The message defined for this status. It is translated, so it depends on the user's language setting.""" + clear_at: ClearAt | None + """When the default, if not override, the predefined status will be cleared.""" + + def __init__(self, raw_status: dict): + self.status_id = raw_status["id"] + self.icon = raw_status["icon"] + self.message = raw_status["message"] + clear_at_raw = raw_status.get("clearAt") + if clear_at_raw: + self.clear_at = ClearAt(clear_at_raw) + else: + self.clear_at = None
+ + + +
+[docs] +@dataclasses.dataclass +class UserStatus: + """Information about user status.""" + + user_id: str + """The ID of the user this status is for""" + + def __init__(self, raw_data: dict): + self._raw_data = raw_data + self.user_id = raw_data["userId"] + + @property + def status_message(self) -> str: + """Message of the status.""" + return self._raw_data.get("message", "") + + @property + def status_icon(self) -> str: + """The icon picked by the user (must be one emoji).""" + return self._raw_data.get("icon", "") + + @property + def status_clear_at(self) -> int | None: + """Unix Timestamp representing the time to clear the status.""" + return self._raw_data.get("clearAt", None) + + @property + def status_type(self) -> str: + """Status type, on of the: online, away, dnd, invisible, offline.""" + return self._raw_data.get("status", "") + + def __repr__(self): + return f"<{self.__class__.__name__} user_id={self.user_id}, status_type={self.status_type}>"
+ + + +
+[docs] +@dataclasses.dataclass(init=False) +class CurrentUserStatus(UserStatus): + """Information about current user status.""" + + @property + def status_id(self) -> str | None: + """ID of the predefined status.""" + return self._raw_data["messageId"] + + @property + def message_predefined(self) -> bool: + """*True* if the status is predefined, *False* otherwise.""" + return self._raw_data["messageIsPredefined"] + + @property + def status_type_defined(self) -> bool: + """*True* if :py:attr:`UserStatus.status_type` is set by user, *False* otherwise.""" + return self._raw_data["statusIsUserDefined"] + + def __repr__(self): + return ( + f"<{self.__class__.__name__} user_id={self.user_id}, status_type={self.status_type}," + f" status_id={self.status_id}>" + )
+ + + +
+[docs] +class _UserStatusAPI: + """Class providing the user status management API on the Nextcloud server.""" + + _ep_base: str = "/ocs/v1.php/apps/user_status/api/v1" + + def __init__(self, session: NcSessionBasic): + self._session = session + + @property + def available(self) -> bool: + """Returns True if the Nextcloud instance supports this feature, False otherwise.""" + return not check_capabilities("user_status.enabled", self._session.capabilities) + +
+[docs] + def get_list(self, limit: int | None = None, offset: int | None = None) -> list[UserStatus]: + """Returns statuses for all users.""" + require_capabilities("user_status.enabled", self._session.capabilities) + data = kwargs_to_params(["limit", "offset"], limit=limit, offset=offset) + result = self._session.ocs("GET", f"{self._ep_base}/statuses", params=data) + return [UserStatus(i) for i in result]
+ + +
+[docs] + def get_current(self) -> CurrentUserStatus: + """Returns the current user status.""" + require_capabilities("user_status.enabled", self._session.capabilities) + return CurrentUserStatus(self._session.ocs("GET", f"{self._ep_base}/user_status"))
+ + +
+[docs] + def get(self, user_id: str) -> UserStatus | None: + """Returns the user status for the specified user.""" + require_capabilities("user_status.enabled", self._session.capabilities) + try: + return UserStatus(self._session.ocs("GET", f"{self._ep_base}/statuses/{user_id}")) + except NextcloudExceptionNotFound: + return None
+ + +
+[docs] + def get_predefined(self) -> list[PredefinedStatus]: + """Returns a list of predefined statuses available for installation on this Nextcloud instance.""" + if self._session.nc_version["major"] < 27: + return [] + require_capabilities("user_status.enabled", self._session.capabilities) + result = self._session.ocs("GET", f"{self._ep_base}/predefined_statuses") + return [PredefinedStatus(i) for i in result]
+ + +
+[docs] + def set_predefined(self, status_id: str, clear_at: int = 0) -> None: + """Set predefined status for the current user. + + :param status_id: ``predefined`` status ID. + :param clear_at: *optional* time in seconds before the status is cleared. + """ + if self._session.nc_version["major"] < 27: + return + require_capabilities("user_status.enabled", self._session.capabilities) + params: dict[str, int | str] = {"messageId": status_id} + if clear_at: + params["clearAt"] = clear_at + self._session.ocs("PUT", f"{self._ep_base}/user_status/message/predefined", params=params)
+ + +
+[docs] + def set_status_type(self, value: typing.Literal["online", "away", "dnd", "invisible", "offline"]) -> None: + """Sets the status type for the current user.""" + require_capabilities("user_status.enabled", self._session.capabilities) + self._session.ocs("PUT", f"{self._ep_base}/user_status/status", params={"statusType": value})
+ + +
+[docs] + def set_status(self, message: str | None = None, clear_at: int = 0, status_icon: str = "") -> None: + """Sets current user status. + + :param message: Message text to set in the status. + :param clear_at: Unix Timestamp, representing the time to clear the status. + :param status_icon: The icon picked by the user (must be one emoji) + """ + require_capabilities("user_status.enabled", self._session.capabilities) + if message is None: + self._session.ocs("DELETE", f"{self._ep_base}/user_status/message") + return + if status_icon: + require_capabilities("user_status.supports_emoji", self._session.capabilities) + params: dict[str, int | str] = {"message": message} + if clear_at: + params["clearAt"] = clear_at + if status_icon: + params["statusIcon"] = status_icon + self._session.ocs("PUT", f"{self._ep_base}/user_status/message/custom", params=params)
+ + +
+[docs] + def get_backup_status(self, user_id: str = "") -> UserStatus | None: + """Get the backup status of the user if any.""" + require_capabilities("user_status.enabled", self._session.capabilities) + user_id = user_id if user_id else self._session.user + if not user_id: + raise ValueError("user_id can not be empty.") + return self.get(f"_{user_id}")
+ + +
+[docs] + def restore_backup_status(self, status_id: str) -> CurrentUserStatus | None: + """Restores the backup state as current for the current user.""" + require_capabilities("user_status.enabled", self._session.capabilities) + require_capabilities("user_status.restore", self._session.capabilities) + result = self._session.ocs("DELETE", f"{self._ep_base}/user_status/revert/{status_id}") + return result if result else None
+
+ + + +class _AsyncUserStatusAPI: + """Class provides async user status management API on the Nextcloud server.""" + + _ep_base: str = "/ocs/v1.php/apps/user_status/api/v1" + + def __init__(self, session: AsyncNcSessionBasic): + self._session = session + + @property + async def available(self) -> bool: + """Returns True if the Nextcloud instance supports this feature, False otherwise.""" + return not check_capabilities("user_status.enabled", await self._session.capabilities) + + async def get_list(self, limit: int | None = None, offset: int | None = None) -> list[UserStatus]: + """Returns statuses for all users.""" + require_capabilities("user_status.enabled", await self._session.capabilities) + data = kwargs_to_params(["limit", "offset"], limit=limit, offset=offset) + result = await self._session.ocs("GET", f"{self._ep_base}/statuses", params=data) + return [UserStatus(i) for i in result] + + async def get_current(self) -> CurrentUserStatus: + """Returns the current user status.""" + require_capabilities("user_status.enabled", await self._session.capabilities) + return CurrentUserStatus(await self._session.ocs("GET", f"{self._ep_base}/user_status")) + + async def get(self, user_id: str) -> UserStatus | None: + """Returns the user status for the specified user.""" + require_capabilities("user_status.enabled", await self._session.capabilities) + try: + return UserStatus(await self._session.ocs("GET", f"{self._ep_base}/statuses/{user_id}")) + except NextcloudExceptionNotFound: + return None + + async def get_predefined(self) -> list[PredefinedStatus]: + """Returns a list of predefined statuses available for installation on this Nextcloud instance.""" + if (await self._session.nc_version)["major"] < 27: + return [] + require_capabilities("user_status.enabled", await self._session.capabilities) + result = await self._session.ocs("GET", f"{self._ep_base}/predefined_statuses") + return [PredefinedStatus(i) for i in result] + + async def set_predefined(self, status_id: str, clear_at: int = 0) -> None: + """Set predefined status for the current user. + + :param status_id: ``predefined`` status ID. + :param clear_at: *optional* time in seconds before the status is cleared. + """ + if (await self._session.nc_version)["major"] < 27: + return + require_capabilities("user_status.enabled", await self._session.capabilities) + params: dict[str, int | str] = {"messageId": status_id} + if clear_at: + params["clearAt"] = clear_at + await self._session.ocs("PUT", f"{self._ep_base}/user_status/message/predefined", params=params) + + async def set_status_type(self, value: typing.Literal["online", "away", "dnd", "invisible", "offline"]) -> None: + """Sets the status type for the current user.""" + require_capabilities("user_status.enabled", await self._session.capabilities) + await self._session.ocs("PUT", f"{self._ep_base}/user_status/status", params={"statusType": value}) + + async def set_status(self, message: str | None = None, clear_at: int = 0, status_icon: str = "") -> None: + """Sets current user status. + + :param message: Message text to set in the status. + :param clear_at: Unix Timestamp, representing the time to clear the status. + :param status_icon: The icon picked by the user (must be one emoji) + """ + require_capabilities("user_status.enabled", await self._session.capabilities) + if message is None: + await self._session.ocs("DELETE", f"{self._ep_base}/user_status/message") + return + if status_icon: + require_capabilities("user_status.supports_emoji", await self._session.capabilities) + params: dict[str, int | str] = {"message": message} + if clear_at: + params["clearAt"] = clear_at + if status_icon: + params["statusIcon"] = status_icon + await self._session.ocs("PUT", f"{self._ep_base}/user_status/message/custom", params=params) + + async def get_backup_status(self, user_id: str = "") -> UserStatus | None: + """Get the backup status of the user if any.""" + require_capabilities("user_status.enabled", await self._session.capabilities) + user_id = user_id if user_id else await self._session.user + if not user_id: + raise ValueError("user_id can not be empty.") + return await self.get(f"_{user_id}") + + async def restore_backup_status(self, status_id: str) -> CurrentUserStatus | None: + """Restores the backup state as current for the current user.""" + require_capabilities("user_status.enabled", await self._session.capabilities) + require_capabilities("user_status.restore", await self._session.capabilities) + result = await self._session.ocs("DELETE", f"{self._ep_base}/user_status/revert/{status_id}") + return result if result else None +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/users.html b/_modules/nc_py_api/users.html new file mode 100644 index 00000000..11749ad4 --- /dev/null +++ b/_modules/nc_py_api/users.html @@ -0,0 +1,557 @@ + + + + + + nc_py_api.users — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for nc_py_api.users

+"""Nextcloud API for working with users."""
+
+import dataclasses
+import datetime
+import typing
+
+from ._exceptions import check_error
+from ._misc import kwargs_to_params
+from ._session import AsyncNcSessionBasic, NcSessionBasic
+
+
+
+[docs] +@dataclasses.dataclass +class UserInfo: + """User information description.""" + + def __init__(self, raw_data: dict): + self._raw_data = raw_data + + @property + def enabled(self) -> bool: + """Flag indicating whether the user is enabled.""" + return self._raw_data.get("enabled", True) + + @property + def storage_location(self) -> str: + """User's home folder. Can be empty for LDAP or when the caller does not have administrative rights.""" + return self._raw_data.get("storageLocation", "") + + @property + def user_id(self) -> str: + """User ID.""" + return self._raw_data["id"] + + @property + def last_login(self) -> datetime.datetime: + """Last user login time.""" + return datetime.datetime.utcfromtimestamp(int(self._raw_data["lastLogin"] / 1000)).replace( + tzinfo=datetime.timezone.utc + ) + + @property + def backend(self) -> str: + """The type of backend in which user information is stored.""" + return self._raw_data["backend"] + + @property + def subadmin(self) -> list[str]: + """IDs of groups of which the user is a subadmin.""" + return self._raw_data.get("subadmin", []) + + @property + def quota(self) -> dict: + """Quota for the user, if set.""" + return self._raw_data["quota"] if isinstance(self._raw_data["quota"], dict) else {} + + @property + def manager(self) -> str: + """The user's manager UID.""" + return self._raw_data.get("manager", "") + + @property + def email(self) -> str: + """Email address of the user.""" + return self._raw_data["email"] if self._raw_data["email"] is not None else "" + + @property + def additional_mail(self) -> list[str]: + """List of additional emails.""" + return self._raw_data["additional_mail"] + + @property + def display_name(self) -> str: + """The display name of the new user.""" + return self._raw_data["displayname"] if "displayname" in self._raw_data else self._raw_data["display-name"] + + @property + def phone(self) -> str: + """Phone of the user.""" + return self._raw_data["phone"] + + @property + def address(self) -> str: + """Address of the user.""" + return self._raw_data["address"] + + @property + def website(self) -> str: + """Link to user website.""" + return self._raw_data["website"] + + @property + def twitter(self) -> str: + """Twitter handle.""" + return self._raw_data["twitter"] + + @property + def fediverse(self) -> str: + """Fediverse(e.g. Mastodon) in the user profile.""" + return self._raw_data["fediverse"] + + @property + def organisation(self) -> str: + """Organisation in the user profile.""" + return self._raw_data["organisation"] + + @property + def role(self) -> str: + """Role in the user profile.""" + return self._raw_data["role"] + + @property + def headline(self) -> str: + """Headline in the user profile.""" + return self._raw_data["headline"] + + @property + def biography(self) -> str: + """Biography in the user profile.""" + return self._raw_data["biography"] + + @property + def profile_enabled(self) -> bool: + """Flag indicating whether the user profile is enabled.""" + return str(self._raw_data["profile_enabled"]).lower() in ("1", "true") + + @property + def groups(self) -> list[str]: + """ID of the groups the user is a member of.""" + return self._raw_data["groups"] + + @property + def language(self) -> str: + """The language to use when sending something to a user.""" + return self._raw_data["language"] + + @property + def locale(self) -> str: + """The locale set for the user.""" + return self._raw_data.get("locale", "") + + @property + def notify_email(self) -> str: + """The user's preferred email address. + + .. note:: The primary mail address may be set be the user to specify a different + email address where mails by Nextcloud are sent to. It is not necessarily set. + """ + return self._raw_data["notify_email"] if self._raw_data["notify_email"] is not None else "" + + @property + def backend_capabilities(self) -> dict: + """By default, only the ``setDisplayName`` and ``setPassword`` keys are available.""" + return self._raw_data["backendCapabilities"] + + def __repr__(self): + return f"<{self.__class__.__name__} id={self.user_id}, backend={self.backend}, last_login={self.last_login}>"
+ + + +
+[docs] +class _UsersAPI: + """The class provides the user API on the Nextcloud server. + + .. note:: In NextcloudApp mode, only ``get_list``, ``editable_fields`` and ``get_user`` methods are available. + """ + + _ep_base: str = "/ocs/v1.php/cloud/users" + + def __init__(self, session: NcSessionBasic): + self._session = session + +
+[docs] + def get_list(self, mask: str | None = "", limit: int | None = None, offset: int | None = None) -> list[str]: + """Returns list of user IDs.""" + data = kwargs_to_params(["search", "limit", "offset"], search=mask, limit=limit, offset=offset) + response_data = self._session.ocs("GET", self._ep_base, params=data) + return response_data["users"] if response_data else {}
+ + +
+[docs] + def get_user(self, user_id: str = "") -> UserInfo: + """Returns detailed user information.""" + return UserInfo(self._session.ocs("GET", f"{self._ep_base}/{user_id}" if user_id else "/ocs/v1.php/cloud/user"))
+ + +
+[docs] + def create(self, user_id: str, display_name: str | None = None, **kwargs) -> None: + """Create a new user on the Nextcloud server. + + :param user_id: id of the user to create. + :param display_name: display name for a created user. + :param kwargs: See below. + + Additionally supported arguments: + + * ``password`` - password that should be set for user. + * ``email`` - email of the new user. If ``password`` is not provided, then this field should be filled. + * ``groups`` - list of groups IDs to which user belongs. + * ``subadmin`` - boolean indicating is user should be the subadmin. + * ``quota`` - quota for the user, if needed. + * ``language`` - default language for the user. + """ + self._session.ocs("POST", self._ep_base, json=_create(user_id, display_name, **kwargs))
+ + +
+[docs] + def delete(self, user_id: str) -> None: + """Deletes user from the Nextcloud server.""" + self._session.ocs("DELETE", f"{self._ep_base}/{user_id}")
+ + +
+[docs] + def enable(self, user_id: str) -> None: + """Enables user on the Nextcloud server.""" + self._session.ocs("PUT", f"{self._ep_base}/{user_id}/enable")
+ + +
+[docs] + def disable(self, user_id: str) -> None: + """Disables user on the Nextcloud server.""" + self._session.ocs("PUT", f"{self._ep_base}/{user_id}/disable")
+ + +
+[docs] + def resend_welcome_email(self, user_id: str) -> None: + """Send welcome email for specified user again.""" + self._session.ocs("POST", f"{self._ep_base}/{user_id}/welcome")
+ + +
+[docs] + def editable_fields(self) -> list[str]: + """Returns user fields that avalaible for edit.""" + return self._session.ocs("GET", "/ocs/v1.php/cloud/user/fields")
+ + +
+[docs] + def edit(self, user_id: str, **kwargs) -> None: + """Edits user metadata. + + :param user_id: id of the user. + :param kwargs: dictionary where keys are values from ``editable_fields`` method, and values to set. + """ + for k, v in kwargs.items(): + self._session.ocs("PUT", f"{self._ep_base}/{user_id}", params={"key": k, "value": v})
+ + +
+[docs] + def add_to_group(self, user_id: str, group_id: str) -> None: + """Adds user to the group.""" + self._session.ocs("POST", f"{self._ep_base}/{user_id}/groups", params={"groupid": group_id})
+ + +
+[docs] + def remove_from_group(self, user_id: str, group_id: str) -> None: + """Removes user from the group.""" + self._session.ocs("DELETE", f"{self._ep_base}/{user_id}/groups", params={"groupid": group_id})
+ + +
+[docs] + def promote_to_subadmin(self, user_id: str, group_id: str) -> None: + """Makes user admin of the group.""" + self._session.ocs("POST", f"{self._ep_base}/{user_id}/subadmins", params={"groupid": group_id})
+ + +
+[docs] + def demote_from_subadmin(self, user_id: str, group_id: str) -> None: + """Removes user from the admin role of the group.""" + self._session.ocs("DELETE", f"{self._ep_base}/{user_id}/subadmins", params={"groupid": group_id})
+ + +
+[docs] + def get_avatar( + self, user_id: str = "", size: typing.Literal[64, 512] = 512, dark: bool = False, guest: bool = False + ) -> bytes: + """Returns user avatar binary data. + + :param user_id: The ID of the user whose avatar should be returned. + .. note:: To return the current user's avatar, leave the field blank. + :param size: Size of the avatar. Currently supported values: ``64`` and ``512``. + :param dark: Flag indicating whether a dark theme avatar should be returned or not. + :param guest: Flag indicating whether user ID is a guest name or not. + """ + if not user_id and not guest: + user_id = self._session.user + url_path = f"/index.php/avatar/{user_id}/{size}" if not guest else f"/index.php/avatar/guest/{user_id}/{size}" + if dark: + url_path += "/dark" + response = self._session.adapter.get(url_path) + check_error(response) + return response.content
+
+ + + +class _AsyncUsersAPI: + """The class provides the async user API on the Nextcloud server. + + .. note:: In NextcloudApp mode, only ``get_list``, ``editable_fields`` and ``get_user`` methods are available. + """ + + _ep_base: str = "/ocs/v1.php/cloud/users" + + def __init__(self, session: AsyncNcSessionBasic): + self._session = session + + async def get_list(self, mask: str | None = "", limit: int | None = None, offset: int | None = None) -> list[str]: + """Returns list of user IDs.""" + data = kwargs_to_params(["search", "limit", "offset"], search=mask, limit=limit, offset=offset) + response_data = await self._session.ocs("GET", self._ep_base, params=data) + return response_data["users"] if response_data else {} + + async def get_user(self, user_id: str = "") -> UserInfo: + """Returns detailed user information.""" + return UserInfo( + await self._session.ocs("GET", f"{self._ep_base}/{user_id}" if user_id else "/ocs/v1.php/cloud/user") + ) + + async def create(self, user_id: str, display_name: str | None = None, **kwargs) -> None: + """Create a new user on the Nextcloud server. + + :param user_id: id of the user to create. + :param display_name: display name for a created user. + :param kwargs: See below. + + Additionally supported arguments: + + * ``password`` - password that should be set for user. + * ``email`` - email of the new user. If ``password`` is not provided, then this field should be filled. + * ``groups`` - list of groups IDs to which user belongs. + * ``subadmin`` - boolean indicating is user should be the subadmin. + * ``quota`` - quota for the user, if needed. + * ``language`` - default language for the user. + """ + await self._session.ocs("POST", self._ep_base, json=_create(user_id, display_name, **kwargs)) + + async def delete(self, user_id: str) -> None: + """Deletes user from the Nextcloud server.""" + await self._session.ocs("DELETE", f"{self._ep_base}/{user_id}") + + async def enable(self, user_id: str) -> None: + """Enables user on the Nextcloud server.""" + await self._session.ocs("PUT", f"{self._ep_base}/{user_id}/enable") + + async def disable(self, user_id: str) -> None: + """Disables user on the Nextcloud server.""" + await self._session.ocs("PUT", f"{self._ep_base}/{user_id}/disable") + + async def resend_welcome_email(self, user_id: str) -> None: + """Send welcome email for specified user again.""" + await self._session.ocs("POST", f"{self._ep_base}/{user_id}/welcome") + + async def editable_fields(self) -> list[str]: + """Returns user fields that avalaible for edit.""" + return await self._session.ocs("GET", "/ocs/v1.php/cloud/user/fields") + + async def edit(self, user_id: str, **kwargs) -> None: + """Edits user metadata. + + :param user_id: id of the user. + :param kwargs: dictionary where keys are values from ``editable_fields`` method, and values to set. + """ + for k, v in kwargs.items(): + await self._session.ocs("PUT", f"{self._ep_base}/{user_id}", params={"key": k, "value": v}) + + async def add_to_group(self, user_id: str, group_id: str) -> None: + """Adds user to the group.""" + await self._session.ocs("POST", f"{self._ep_base}/{user_id}/groups", params={"groupid": group_id}) + + async def remove_from_group(self, user_id: str, group_id: str) -> None: + """Removes user from the group.""" + await self._session.ocs("DELETE", f"{self._ep_base}/{user_id}/groups", params={"groupid": group_id}) + + async def promote_to_subadmin(self, user_id: str, group_id: str) -> None: + """Makes user admin of the group.""" + await self._session.ocs("POST", f"{self._ep_base}/{user_id}/subadmins", params={"groupid": group_id}) + + async def demote_from_subadmin(self, user_id: str, group_id: str) -> None: + """Removes user from the admin role of the group.""" + await self._session.ocs("DELETE", f"{self._ep_base}/{user_id}/subadmins", params={"groupid": group_id}) + + async def get_avatar( + self, user_id: str = "", size: typing.Literal[64, 512] = 512, dark: bool = False, guest: bool = False + ) -> bytes: + """Returns user avatar binary data. + + :param user_id: The ID of the user whose avatar should be returned. + .. note:: To return the current user's avatar, leave the field blank. + :param size: Size of the avatar. Currently supported values: ``64`` and ``512``. + :param dark: Flag indicating whether a dark theme avatar should be returned or not. + :param guest: Flag indicating whether user ID is a guest name or not. + """ + if not user_id and not guest: + user_id = await self._session.user + url_path = f"/index.php/avatar/{user_id}/{size}" if not guest else f"/index.php/avatar/guest/{user_id}/{size}" + if dark: + url_path += "/dark" + response = await self._session.adapter.get(url_path) + check_error(response) + return response.content + + +def _create(user_id: str, display_name: str | None, **kwargs) -> dict[str, typing.Any]: + password = kwargs.get("password", None) + email = kwargs.get("email", None) + if not password and not email: + raise ValueError("Either password or email must be set") + data = {"userid": user_id} + for k in ("password", "email", "groups", "subadmin", "quota", "language"): + if k in kwargs: + data[k] = kwargs[k] + if display_name is not None: + data["displayName"] = display_name + return data +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/users_groups.html b/_modules/nc_py_api/users_groups.html new file mode 100644 index 00000000..77f62518 --- /dev/null +++ b/_modules/nc_py_api/users_groups.html @@ -0,0 +1,303 @@ + + + + + + nc_py_api.users_groups — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for nc_py_api.users_groups

+"""Nextcloud API for working with user groups."""
+
+import dataclasses
+
+from ._misc import kwargs_to_params
+from ._session import AsyncNcSessionBasic, NcSessionBasic
+
+
+
+[docs] +@dataclasses.dataclass +class GroupDetails: + """User Group information.""" + + def __init__(self, raw_data: dict): + self._raw_data = raw_data + + @property + def group_id(self) -> str: + """ID of the group.""" + return self._raw_data["id"] + + @property + def display_name(self) -> str: + """A display name of the group.""" + return self._raw_data["displayname"] + + @property + def user_count(self) -> int: + """Number of users in the group.""" + return self._raw_data["usercount"] + + @property + def disabled(self) -> bool: + """Flag indicating is group disabled.""" + return bool(self._raw_data["disabled"]) + + @property + def can_add(self) -> bool: + """Flag indicating the caller has enough rights to add users to this group.""" + return bool(self._raw_data["canAdd"]) + + @property + def can_remove(self) -> bool: + """Flag indicating the caller has enough rights to remove users from this group.""" + return bool(self._raw_data["canRemove"]) + + def __repr__(self): + return f"<{self.__class__.__name__} id={self.group_id}, user_count={self.user_count}, disabled={self.disabled}>"
+ + + +
+[docs] +class _UsersGroupsAPI: + """Class providing an API for managing user groups on the Nextcloud server. + + .. note:: In NextcloudApp mode, only ``get_list`` and ``get_details`` methods are available. + """ + + _ep_base: str = "/ocs/v1.php/cloud/groups" + + def __init__(self, session: NcSessionBasic): + self._session = session + +
+[docs] + def get_list(self, mask: str | None = None, limit: int | None = None, offset: int | None = None) -> list[str]: + """Returns a list of user groups IDs.""" + data = kwargs_to_params(["search", "limit", "offset"], search=mask, limit=limit, offset=offset) + response_data = self._session.ocs("GET", self._ep_base, params=data) + return response_data["groups"] if response_data else []
+ + +
+[docs] + def get_details( + self, mask: str | None = None, limit: int | None = None, offset: int | None = None + ) -> list[GroupDetails]: + """Returns a list of user groups with detailed information.""" + data = kwargs_to_params(["search", "limit", "offset"], search=mask, limit=limit, offset=offset) + response_data = self._session.ocs("GET", f"{self._ep_base}/details", params=data) + return [GroupDetails(i) for i in response_data["groups"]] if response_data else []
+ + +
+[docs] + def create(self, group_id: str, display_name: str | None = None) -> None: + """Creates the users group.""" + params = {"groupid": group_id} + if display_name is not None: + params["displayname"] = display_name + self._session.ocs("POST", f"{self._ep_base}", params=params)
+ + +
+[docs] + def edit(self, group_id: str, display_name: str) -> None: + """Edits users group information.""" + params = {"key": "displayname", "value": display_name} + self._session.ocs("PUT", f"{self._ep_base}/{group_id}", params=params)
+ + +
+[docs] + def delete(self, group_id: str) -> None: + """Removes the users group.""" + self._session.ocs("DELETE", f"{self._ep_base}/{group_id}")
+ + +
+[docs] + def get_members(self, group_id: str) -> list[str]: + """Returns a list of group users.""" + response_data = self._session.ocs("GET", f"{self._ep_base}/{group_id}") + return response_data["users"] if response_data else {}
+ + +
+[docs] + def get_subadmins(self, group_id: str) -> list[str]: + """Returns list of users who is subadmins of the group.""" + return self._session.ocs("GET", f"{self._ep_base}/{group_id}/subadmins")
+
+ + + +class _AsyncUsersGroupsAPI: + """Class provides an async API for managing user groups on the Nextcloud server. + + .. note:: In NextcloudApp mode, only ``get_list`` and ``get_details`` methods are available. + """ + + _ep_base: str = "/ocs/v1.php/cloud/groups" + + def __init__(self, session: AsyncNcSessionBasic): + self._session = session + + async def get_list(self, mask: str | None = None, limit: int | None = None, offset: int | None = None) -> list[str]: + """Returns a list of user groups IDs.""" + data = kwargs_to_params(["search", "limit", "offset"], search=mask, limit=limit, offset=offset) + response_data = await self._session.ocs("GET", self._ep_base, params=data) + return response_data["groups"] if response_data else [] + + async def get_details( + self, mask: str | None = None, limit: int | None = None, offset: int | None = None + ) -> list[GroupDetails]: + """Returns a list of user groups with detailed information.""" + data = kwargs_to_params(["search", "limit", "offset"], search=mask, limit=limit, offset=offset) + response_data = await self._session.ocs("GET", f"{self._ep_base}/details", params=data) + return [GroupDetails(i) for i in response_data["groups"]] if response_data else [] + + async def create(self, group_id: str, display_name: str | None = None) -> None: + """Creates the users group.""" + params = {"groupid": group_id} + if display_name is not None: + params["displayname"] = display_name + await self._session.ocs("POST", f"{self._ep_base}", params=params) + + async def edit(self, group_id: str, display_name: str) -> None: + """Edits users group information.""" + params = {"key": "displayname", "value": display_name} + await self._session.ocs("PUT", f"{self._ep_base}/{group_id}", params=params) + + async def delete(self, group_id: str) -> None: + """Removes the users group.""" + await self._session.ocs("DELETE", f"{self._ep_base}/{group_id}") + + async def get_members(self, group_id: str) -> list[str]: + """Returns a list of group users.""" + response_data = await self._session.ocs("GET", f"{self._ep_base}/{group_id}") + return response_data["users"] if response_data else {} + + async def get_subadmins(self, group_id: str) -> list[str]: + """Returns list of users who is subadmins of the group.""" + return await self._session.ocs("GET", f"{self._ep_base}/{group_id}/subadmins") +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/weather_status.html b/_modules/nc_py_api/weather_status.html new file mode 100644 index 00000000..306edca5 --- /dev/null +++ b/_modules/nc_py_api/weather_status.html @@ -0,0 +1,326 @@ + + + + + + nc_py_api.weather_status — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for nc_py_api.weather_status

+"""Nextcloud API for working with weather statuses."""
+
+import dataclasses
+import enum
+
+from ._misc import check_capabilities, require_capabilities
+from ._session import AsyncNcSessionBasic, NcSessionBasic
+
+
+
+[docs] +class WeatherLocationMode(enum.IntEnum): + """Source from where Nextcloud should determine user's location.""" + + UNKNOWN = 0 + """Source is not defined""" + MODE_BROWSER_LOCATION = 1 + """User location taken from the browser""" + MODE_MANUAL_LOCATION = 2 + """User has set their location manually"""
+ + + +
+[docs] +@dataclasses.dataclass +class WeatherLocation: + """Class representing information about the user's location.""" + + latitude: float + """Latitude in decimal degree format""" + longitude: float + """Longitude in decimal degree format""" + address: str + """Any approximate or exact address""" + mode: WeatherLocationMode + """Weather status mode""" + + def __init__(self, raw_location: dict): + lat = raw_location.get("lat", "") + lon = raw_location.get("lon", "") + self.latitude = float(lat if lat else "0") + self.longitude = float(lon if lon else "0") + self.address = raw_location.get("address", "") + self.mode = WeatherLocationMode(int(raw_location.get("mode", 0)))
+ + + +
+[docs] +class _WeatherStatusAPI: + """Class providing the weather status management API on the Nextcloud server.""" + + _ep_base: str = "/ocs/v1.php/apps/weather_status/api/v1" + + def __init__(self, session: NcSessionBasic): + self._session = session + + @property + def available(self) -> bool: + """Returns True if the Nextcloud instance supports this feature, False otherwise.""" + return not check_capabilities("weather_status.enabled", self._session.capabilities) + +
+[docs] + def get_location(self) -> WeatherLocation: + """Returns the current location set on the Nextcloud server for the user.""" + require_capabilities("weather_status.enabled", self._session.capabilities) + return WeatherLocation(self._session.ocs("GET", f"{self._ep_base}/location"))
+ + +
+[docs] + def set_location( + self, + latitude: float | None = None, + longitude: float | None = None, + address: str | None = None, + ) -> bool: + """Sets the user's location on the Nextcloud server. + + :param latitude: north-south position of a point on the surface of the Earth. + :param longitude: east-west position of a point on the surface of the Earth. + :param address: city, index(*optional*) and country, e.g. "Paris, 75007, France" + """ + require_capabilities("weather_status.enabled", self._session.capabilities) + params: dict[str, str | float] = {} + if latitude is not None and longitude is not None: + params.update({"lat": latitude, "lon": longitude}) + elif address: + params["address"] = address + else: + raise ValueError("latitude & longitude or address should be present") + result = self._session.ocs("PUT", f"{self._ep_base}/location", params=params) + return result.get("success", False)
+ + +
+[docs] + def get_forecast(self) -> list[dict]: + """Get forecast for the current location.""" + require_capabilities("weather_status.enabled", self._session.capabilities) + return self._session.ocs("GET", f"{self._ep_base}/forecast")
+ + +
+[docs] + def get_favorites(self) -> list[str]: + """Returns favorites addresses list.""" + require_capabilities("weather_status.enabled", self._session.capabilities) + return self._session.ocs("GET", f"{self._ep_base}/favorites")
+ + +
+[docs] + def set_favorites(self, favorites: list[str]) -> bool: + """Sets favorites addresses list.""" + require_capabilities("weather_status.enabled", self._session.capabilities) + result = self._session.ocs("PUT", f"{self._ep_base}/favorites", json={"favorites": favorites}) + return result.get("success", False)
+ + +
+[docs] + def set_mode(self, mode: WeatherLocationMode) -> bool: + """Change the weather status mode.""" + if int(mode) == WeatherLocationMode.UNKNOWN.value: + raise ValueError("This mode can not be set") + require_capabilities("weather_status.enabled", self._session.capabilities) + result = self._session.ocs("PUT", f"{self._ep_base}/mode", params={"mode": int(mode)}) + return result.get("success", False)
+
+ + + +class _AsyncWeatherStatusAPI: + """Class provides async weather status management API on the Nextcloud server.""" + + _ep_base: str = "/ocs/v1.php/apps/weather_status/api/v1" + + def __init__(self, session: AsyncNcSessionBasic): + self._session = session + + @property + async def available(self) -> bool: + """Returns True if the Nextcloud instance supports this feature, False otherwise.""" + return not check_capabilities("weather_status.enabled", await self._session.capabilities) + + async def get_location(self) -> WeatherLocation: + """Returns the current location set on the Nextcloud server for the user.""" + require_capabilities("weather_status.enabled", await self._session.capabilities) + return WeatherLocation(await self._session.ocs("GET", f"{self._ep_base}/location")) + + async def set_location( + self, + latitude: float | None = None, + longitude: float | None = None, + address: str | None = None, + ) -> bool: + """Sets the user's location on the Nextcloud server. + + :param latitude: north-south position of a point on the surface of the Earth. + :param longitude: east-west position of a point on the surface of the Earth. + :param address: city, index(*optional*) and country, e.g. "Paris, 75007, France" + """ + require_capabilities("weather_status.enabled", await self._session.capabilities) + params: dict[str, str | float] = {} + if latitude is not None and longitude is not None: + params.update({"lat": latitude, "lon": longitude}) + elif address: + params["address"] = address + else: + raise ValueError("latitude & longitude or address should be present") + result = await self._session.ocs("PUT", f"{self._ep_base}/location", params=params) + return result.get("success", False) + + async def get_forecast(self) -> list[dict]: + """Get forecast for the current location.""" + require_capabilities("weather_status.enabled", await self._session.capabilities) + return await self._session.ocs("GET", f"{self._ep_base}/forecast") + + async def get_favorites(self) -> list[str]: + """Returns favorites addresses list.""" + require_capabilities("weather_status.enabled", await self._session.capabilities) + return await self._session.ocs("GET", f"{self._ep_base}/favorites") + + async def set_favorites(self, favorites: list[str]) -> bool: + """Sets favorites addresses list.""" + require_capabilities("weather_status.enabled", await self._session.capabilities) + result = await self._session.ocs("PUT", f"{self._ep_base}/favorites", json={"favorites": favorites}) + return result.get("success", False) + + async def set_mode(self, mode: WeatherLocationMode) -> bool: + """Change the weather status mode.""" + if int(mode) == WeatherLocationMode.UNKNOWN.value: + raise ValueError("This mode can not be set") + require_capabilities("weather_status.enabled", await self._session.capabilities) + result = await self._session.ocs("PUT", f"{self._ep_base}/mode", params={"mode": int(mode)}) + return result.get("success", False) +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_modules/nc_py_api/webhooks.html b/_modules/nc_py_api/webhooks.html new file mode 100644 index 00000000..98d8044b --- /dev/null +++ b/_modules/nc_py_api/webhooks.html @@ -0,0 +1,343 @@ + + + + + + nc_py_api.webhooks — NcPyApi 0.17.0.dev0 documentation + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ +
+
+
+ +
+
+
+
+ +

Source code for nc_py_api.webhooks

+"""Nextcloud Webhooks API."""
+
+import dataclasses
+
+from ._misc import clear_from_params_empty  # , require_capabilities
+from ._session import AsyncNcSessionBasic, NcSessionBasic
+
+
+
+[docs] +@dataclasses.dataclass +class WebhookInfo: + """Information about the Webhook.""" + + def __init__(self, raw_data: dict): + self._raw_data = raw_data + + @property + def webhook_id(self) -> int: + """`ID` of the webhook.""" + return self._raw_data["id"] + + @property + def app_id(self) -> str: + """`ID` of the ExApp that registered webhook.""" + return self._raw_data["appId"] if self._raw_data["appId"] else "" + + @property + def user_id(self) -> str: + """`UserID` if webhook was registered in user context.""" + return self._raw_data["userId"] if self._raw_data["userId"] else "" + + @property + def http_method(self) -> str: + """HTTP method used to call webhook.""" + return self._raw_data["httpMethod"] + + @property + def uri(self) -> str: + """URL address that will be called for this webhook.""" + return self._raw_data["uri"] + + @property + def event(self) -> str: + """Nextcloud PHP event that triggers this webhook.""" + return self._raw_data["event"] + + @property + def event_filter(self): + """Mongo filter to apply to the serialized data to decide if firing.""" + return self._raw_data["eventFilter"] + + @property + def user_id_filter(self) -> str: + """Currently unknown.""" + return self._raw_data["userIdFilter"] + + @property + def headers(self) -> dict: + """Headers that should be added to request when calling webhook.""" + return self._raw_data["headers"] if self._raw_data["headers"] else {} + + @property + def auth_method(self) -> str: + """Currently unknown.""" + return self._raw_data["authMethod"] + + @property + def auth_data(self) -> dict: + """Currently unknown.""" + return self._raw_data["authData"] if self._raw_data["authData"] else {} + + def __repr__(self): + return f"<{self.__class__.__name__} id={self.webhook_id}, event={self.event}>"
+ + + +
+[docs] +class _WebhooksAPI: + """The class provides the application management API on the Nextcloud server.""" + + _ep_base: str = "/ocs/v1.php/apps/webhook_listeners/api/v1/webhooks" + + def __init__(self, session: NcSessionBasic): + self._session = session + + def get_list(self, uri_filter: str = "") -> list[WebhookInfo]: + params = {"uri": uri_filter} if uri_filter else {} + return [WebhookInfo(i) for i in self._session.ocs("GET", f"{self._ep_base}", params=params)] + + def get_entry(self, webhook_id: int) -> WebhookInfo: + return WebhookInfo(self._session.ocs("GET", f"{self._ep_base}/{webhook_id}")) + + def register( + self, + http_method: str, + uri: str, + event: str, + event_filter: dict | None = None, + user_id_filter: str = "", + headers: dict | None = None, + auth_method: str = "none", + auth_data: dict | None = None, + ): + params = { + "httpMethod": http_method, + "uri": uri, + "event": event, + "eventFilter": event_filter, + "userIdFilter": user_id_filter, + "headers": headers, + "authMethod": auth_method, + "authData": auth_data, + } + clear_from_params_empty(["eventFilter", "userIdFilter", "headers", "authMethod", "authData"], params) + return WebhookInfo(self._session.ocs("POST", f"{self._ep_base}", json=params)) + + def update( + self, + webhook_id: int, + http_method: str, + uri: str, + event: str, + event_filter: dict | None = None, + user_id_filter: str = "", + headers: dict | None = None, + auth_method: str = "none", + auth_data: dict | None = None, + ): + params = { + "id": webhook_id, + "httpMethod": http_method, + "uri": uri, + "event": event, + "eventFilter": event_filter, + "userIdFilter": user_id_filter, + "headers": headers, + "authMethod": auth_method, + "authData": auth_data, + } + clear_from_params_empty(["eventFilter", "userIdFilter", "headers", "authMethod", "authData"], params) + return WebhookInfo(self._session.ocs("POST", f"{self._ep_base}/{webhook_id}", json=params)) + + def unregister(self, webhook_id: int) -> bool: + return self._session.ocs("DELETE", f"{self._ep_base}/{webhook_id}")
+ + + +class _AsyncWebhooksAPI: + """The class provides the async application management API on the Nextcloud server.""" + + _ep_base: str = "/ocs/v1.php/webhooks" + + def __init__(self, session: AsyncNcSessionBasic): + self._session = session + + async def get_list(self, uri_filter: str = "") -> list[WebhookInfo]: + params = {"uri": uri_filter} if uri_filter else {} + return [WebhookInfo(i) for i in await self._session.ocs("GET", f"{self._ep_base}", params=params)] + + async def get_entry(self, webhook_id: int) -> WebhookInfo: + return WebhookInfo(await self._session.ocs("GET", f"{self._ep_base}/{webhook_id}")) + + async def register( + self, + http_method: str, + uri: str, + event: str, + event_filter: dict | None = None, + user_id_filter: str = "", + headers: dict | None = None, + auth_method: str = "none", + auth_data: dict | None = None, + ): + params = { + "httpMethod": http_method, + "uri": uri, + "event": event, + "eventFilter": event_filter, + "userIdFilter": user_id_filter, + "headers": headers, + "authMethod": auth_method, + "authData": auth_data, + } + clear_from_params_empty(["eventFilter", "userIdFilter", "headers", "authMethod", "authData"], params) + return WebhookInfo(await self._session.ocs("POST", f"{self._ep_base}", json=params)) + + async def update( + self, + webhook_id: int, + http_method: str, + uri: str, + event: str, + event_filter: dict | None = None, + user_id_filter: str = "", + headers: dict | None = None, + auth_method: str = "none", + auth_data: dict | None = None, + ): + params = { + "id": webhook_id, + "httpMethod": http_method, + "uri": uri, + "event": event, + "eventFilter": event_filter, + "userIdFilter": user_id_filter, + "headers": headers, + "authMethod": auth_method, + "authData": auth_data, + } + clear_from_params_empty(["eventFilter", "userIdFilter", "headers", "authMethod", "authData"], params) + return WebhookInfo(await self._session.ocs("POST", f"{self._ep_base}/{webhook_id}", json=params)) + + async def unregister(self, webhook_id: int) -> bool: + return await self._session.ocs("DELETE", f"{self._ep_base}/{webhook_id}") +
+ +
+
+ +
+
+
+
+ + + + \ No newline at end of file diff --git a/_sources/DevSetup.rst.txt b/_sources/DevSetup.rst.txt new file mode 100644 index 00000000..adc2b0ec --- /dev/null +++ b/_sources/DevSetup.rst.txt @@ -0,0 +1,63 @@ +Setting 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!** diff --git a/_sources/FirstSteps.rst.txt b/_sources/FirstSteps.rst.txt new file mode 100644 index 00000000..2818337d --- /dev/null +++ b/_sources/FirstSteps.rst.txt @@ -0,0 +1,137 @@ +.. _first-steps: + +First steps +=========== + +For this part, you will need an environment with **nc_py_api** installed and Nextcloud version 26 or higher. + +Full support is only available from version ``27.1`` of Nextcloud. + + +.. note:: In many cases, even if you want to develop an application, + it's a good idea to first debug and develop part of it as a client. + +Basics +^^^^^^ + +Creating Nextcloud client class +""""""""""""""""""""""""""""""" + +.. code-block:: python + + from nc_py_api import Nextcloud + + + nc = Nextcloud(nextcloud_url="http://nextcloud.local", nc_auth_user="admin", nc_auth_pass="admin") + +Where ``nc_auth_pass`` can be usual Nextcloud application password. + +To test if this works, let's print the capabilities of the Nextcloud instance: + +.. code-block:: python + + from json import dumps + + from nc_py_api import Nextcloud + + + nc = Nextcloud(nextcloud_url="http://nextcloud.local", nc_auth_user="admin", nc_auth_pass="admin") + pretty_capabilities = dumps(nc.capabilities, indent=4, sort_keys=True) + print(pretty_capabilities) + +Checking Nextcloud capabilities +""""""""""""""""""""""""""""""" + +In most cases, APIs perform capability checks before invoking them and raise a :class:`~nc_py_api._exceptions.NextcloudMissingCapabilities` +exception if the Nextcloud instance lacks the requisite capability. +However, there are situations where this approach might not be the most convenient, +and you may wish to earlier whether a certain capability is available and active. + +To address this need, the ``check_capabilities`` method is provided. +This method offers a straightforward way to proactively check for the existence and status of a particular capability. + +Using this method is quite simple: + +.. code-block:: python + + import nc_py_api + + + nc = Nextcloud(nextcloud_url="http://nextcloud.local", nc_auth_user="admin", nc_auth_pass="admin") + if nc.check_capabilities("files_sharing"): # check one capability + print("Sharing API is not present.") + + # check child values in the same call + if nc.check_capabilities("files_sharing.api_enabled"): + print("Sharing API is present, but is not enabled.") + + # check multiply capabilities at one + missing_cap = nc.check_capabilities(["files_sharing.api_enabled", "user_status.enabled"]) + if missing_cap: + print(f"Missing capabilities: {missing_cap}") + +Files +^^^^^ + +Getting list of files of User +""""""""""""""""""""""""""""" + +This is a hard way to get list of all files recursively: + +.. literalinclude:: ../examples/as_client/files/listing.py + +This code do the same in one DAV call, but prints **directories** in addition to files: + +.. code-block:: python + + from nc_py_api import Nextcloud + + + nc = Nextcloud(nextcloud_url="http://nextcloud.local", nc_auth_user="admin", nc_auth_pass="admin") + print("Files & folders on the instance for the selected user:") + all_files_folders = nc.files.listdir(depth=-1) + for obj in all_files_folders: + print(obj.user_path) + +To print only files, you can use list comprehension: + +.. code-block:: python + + print("Files on the instance for the selected user:") + all_files = [i for i in nc.files.listdir(depth=-1) if not i.is_dir] + for obj in all_files: + print(obj.user_path) + +Uploading a single file +""""""""""""""""""""""" + +It is always better to use ``upload_stream`` instead of ``upload`` as it works +with chunks and ``in future`` will support **multi threaded** upload. + +.. literalinclude:: ../examples/as_client/files/upload.py + +Downloading a single file +""""""""""""""""""""""""" + +A very simple example of downloading an image as one piece of data to memory and displaying it. + +.. note:: For big files, it is always better to use ``download2stream`` method, as it uses chunks. + +.. literalinclude:: ../examples/as_client/files/download.py + +Searching for a file +"""""""""""""""""""" + +Example of using ``file.find()`` to search for file objects. + +.. note:: We welcome the idea of how to make the definition of search queries more friendly. + +.. literalinclude:: ../examples/as_client/files/find.py + +Conclusion +^^^^^^^^^^ + +Once you have a good understanding of working with files, you can move on to more APIs. + +You don't have to learn them all at the same time, but it's good to at least have a general idea, so let's go with +:ref:`more-apis`! diff --git a/_sources/Installation.rst.txt b/_sources/Installation.rst.txt new file mode 100644 index 00000000..8a704764 --- /dev/null +++ b/_sources/Installation.rst.txt @@ -0,0 +1,32 @@ +Installation +============ + +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. diff --git a/_sources/MoreAPIs.rst.txt b/_sources/MoreAPIs.rst.txt new file mode 100644 index 00000000..33840aaa --- /dev/null +++ b/_sources/MoreAPIs.rst.txt @@ -0,0 +1,33 @@ +.. _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) diff --git a/_sources/NextcloudApp.rst.txt b/_sources/NextcloudApp.rst.txt new file mode 100644 index 00000000..c5a9e86f --- /dev/null +++ b/_sources/NextcloudApp.rst.txt @@ -0,0 +1,287 @@ +Writing 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. diff --git a/_sources/NextcloudApp3rdParty.rst.txt b/_sources/NextcloudApp3rdParty.rst.txt new file mode 100644 index 00000000..b599cc08 --- /dev/null +++ b/_sources/NextcloudApp3rdParty.rst.txt @@ -0,0 +1,82 @@ +Packaging 3rd party software as a Nextcloud Application +======================================================= + +This chapter explains how you can package any 3rd party software to be compatible with Nextcloud. +You should already be familiar with :doc:`NextcloudApp` before reading this part. + +You should also have a bit of knowledge about classic PHP Nextcloud apps and `how to develop them `_. + +Architecture +------------ + +The packaged ExApp will contain two pieces of software: + +#. The ExApp itself which is talking to Nextcloud directly and responsible for the whole lifecycle. +#. The 3rd party software you want to package. +#. Frontend code which will be loaded by Nextcloud to display your iframe. + +Due to current restrictions of ExApps they can only utilize a single port, which means all requests for the 3rd part software have to be proxied through the ExApp. +This will be improved in future released, by allowing multiple ports, so that no proxying is necessary anymore. + +Everything will be packaged into a single Docker image which will be used for deployments. +Therefore it is an advantage if the 3rd party software is already able to run inside a Docker container and has a public Docker image available. + +Steps +------------------ + +Creating the ExApp +^^^^^^^^^^^^^^^^^^ + +Please follow the instructions in :doc:`NextcloudApp` and then return here. + +Adding the frontend +^^^^^^^^^^^^^^^^^^^ + +To be able to access the 3rd party software via the browser it is necessary to embed an iframe into Nextcloud. +The frontend has to be added in the same way how you add it in a classic PHP app. +The iframe ``src`` needs to point to ``/apps/app_api/proxy/APP_ID``, but it is necessary to use the ``generateUrl`` method to ensure the path will also work with Nextcloud instances hosted at a sub-path. +If you require some features like clipboard read/write you need to allow them for the iframe using the ``allow`` attribute. + +To now show the frontend inside Nextcloud add the following to your enabled handler: + +.. code-block:: python + + def enabled_handler(enabled: bool, nc: NextcloudApp) -> str: + if enabled: + nc.ui.resources.set_script("top_menu", "APP_ID", "js/APP_ID-main") + nc.ui.top_menu.register("APP_ID", "App Name", "img/app.svg") + else: + nc.ui.resources.delete_script("top_menu", "APP_ID", "js/APP_ID-main") + nc.ui.top_menu.unregister("APP_ID") + return "" + +Proxying the requests +^^^^^^^^^^^^^^^^^^^^^ + +For proxying the requests to the 3rd party software you need to register a new route: + +.. code-block:: python + + @APP.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "PATCH", "TRACE"]) + async def proxy_requests(request: Request, path: str): + pass + +This route should have the lowest priority of all your routes, as it catches all requests that didn't match any previous route. + +In this request handler you need to send a new HTTP request to the 3rd party software and copy all incoming parameters like sub-path, query parameters, body and headers. +When returning the response including body, headers and status code, make sure to add or override the CSP and CORS headers if necessary. + +Adjusting the Dockerfile +^^^^^^^^^^^^^^^^^^^^^^^^ + +The Dockerfile should be based on the 3rd party software you want to package. +In case a Docker image is already available you should use that, otherwise you need to first create your own Docker image (it doesn't have to be a separate image, it can just be a stage in the Dockerfile for your ExApp). + +The 3rd party software needs to be adapted to be able to handle the proxied requests and generated correct URLs in the frontend. +Depending on how the software works this might only be a config option you need to set or you need to modify the source code within the Docker image (and potentially rebuild the software afterwards). +The root path of the software will be hosted at ``/index.php/apps/app_api/proxy/APP_ID`` which is the same location that was configured in the iframe ``src``. + +After these steps you can just continue with the normal ExApp Dockerfile steps of installing the dependencies and copying the source code. +Be aware that you will need to install Python manually in your image in case the Docker image you used so far doesn't include it. + +At the end you will have to add a custom entrypoint script that runs the ExApp and the 3rd party software side-by-side to allow them to live in the same container. diff --git a/_sources/NextcloudTalkBot.rst.txt b/_sources/NextcloudTalkBot.rst.txt new file mode 100644 index 00000000..c132af50 --- /dev/null +++ b/_sources/NextcloudTalkBot.rst.txt @@ -0,0 +1,72 @@ +Nextcloud Talk Bot API in Applications +====================================== + +The AppAPI is an excellent choice for developing and deploying bots for Nextcloud Talk. + +Bots for Nextcloud Talk, in essence, don't differ significantly from regular external applications. +The functionality of an external application can include just the bot or provide additional functionalities as well. + +Let's consider a simple example of how to transform the `skeleton` of an external application into a Nextcloud Talk bot. + +The first step is to add the **TALK_BOT** and **TALK** scopes to your `info.xml` file: + +.. code-block:: xml + + + TALK + TALK_BOT + + +The TALK_BOT scope enables your application to register the bot within the Nextcloud system, while the TALK scope permits access to Talk's endpoints. + +In the global **enabled_handler**, you should include a call to your bot's enabled_handler, as shown in the bot example: + +.. code-block:: python + + def enabled_handler(enabled: bool, nc: NextcloudApp) -> str: + try: + CURRENCY_BOT.enabled_handler(enabled, nc) # registering/unregistering the bot's stuff. + except Exception as e: + return str(e) + return "" + +Afterward, using FastAPI, you can define endpoints that will be invoked by Talk: + +.. code-block:: python + + @APP.post("/currency_talk_bot") + async def currency_talk_bot( + message: Annotated[talk_bot.TalkBotMessage, Depends(atalk_bot_msg)], + background_tasks: BackgroundTasks, + ): + return Response() + +.. note:: + You must include to each endpoint your bot provides the **Depends(nc_app)**. + + Depending on **nc_app** serves as an automatic authentication handler for messages from the cloud. + +**message: Annotated[talk_bot.TalkBotMessage, Depends(talk_bot_app)]** - returns the received message from Nextcloud upon successful authentication. + +Additionally, if your bot can provide quick and fixed execution times, you may not need to create background tasks. +However, in most cases, it's recommended to segregate functionality and perform operations in the background, while promptly returning an empty response to Nextcloud. + +An application can implement multiple bots concurrently, but each bot's endpoints must be unique. + +All authentication is facilitated by the Python SDK, ensuring you needn't concern yourself with anything other than writing useful functionality. + +Currently, bots have access only to three methods: + +* :py:meth:`~nc_py_api.talk_bot.TalkBot.send_message` +* :py:meth:`~nc_py_api.talk_bot.TalkBot.react_to_message` +* :py:meth:`~nc_py_api.talk_bot.TalkBot.delete_reaction` + +.. note:: **The usage of system application functionality for user impersonation in bot development is strongly discouraged**. + All bot messages should only be sent using the ``send_message`` method! + +All other rules and algorithms remain consistent with regular external applications. + +Full source of bot example can be found here: +`TalkBot `_ + +Wishing success with your Nextcloud bot integration! May the force be with you! diff --git a/_sources/NextcloudTalkBotTransformers.rst.txt b/_sources/NextcloudTalkBotTransformers.rst.txt new file mode 100644 index 00000000..ad785985 --- /dev/null +++ b/_sources/NextcloudTalkBotTransformers.rst.txt @@ -0,0 +1,97 @@ +Talk Bot App with Transformers +============================== + +`Transformers provides thousands of pretrained models to perform tasks on different modalities such as text, vision, and audio.` + +In this article, we'll demonstrate how straightforward it is to leverage the extensive capabilities +of the `Transformers `_ library in your Nextcloud application. + +Specifically, we'll cover: + +* Setting the models cache path for the Transformers library +* Downloading AI models during the application initialization step +* Receiving messages from Nextcloud Talk Chat and sending them to a language model +* Sending the language model's reply back to the Nextcloud Talk Chat + +Packaging the Application +""""""""""""""""""""""""" + +Firstly, let's touch upon the somewhat mundane topic of application packaging. + +For this example, we've chosen Debian as the base image because it simplifies the installation of required Python packages. + +.. code-block:: + + FROM python:3.11-bookworm + + +While Alpine might be a better choice in some situations, that's not the focus of this example. + +.. note:: The selection of a suitable base image for an application is a complex topic that merits its own in-depth discussion. + +Requirements +"""""""""""" + +.. literalinclude:: ../examples/as_app/talk_bot_ai/requirements.txt + +We opt for the latest version of the Transformers library. +Because the example was developed on a Mac, we ended up using Torchvision. + +`You're free to use TensorFlow instead of PyTorch.` + +Next, we integrate the latest version of `nc_py_api` to minimize code redundancy and focus on the application's logic. + +Prepare of Language Model +""""""""""""""""""""""""" + +.. code-block:: + + MODEL_NAME = "MBZUAI/LaMini-Flan-T5-77M" + +We specify the model name globally so that we can easily change the model name if necessary. + +**When Should We Download the Language Model?** + +To make process of initializing applications more robust, separate logic was introduced, with an ``/init`` endpoint. + +This library also provides an additional functionality over this endpoint for easy downloading of models from the `huggingface `_. + +.. code-block:: + + @asynccontextmanager + async def lifespan(_app: FastAPI): + set_handlers(APP, enabled_handler, models_to_fetch={MODEL_NAME:{}}) + yield + +This will automatically download models specified in ``models_to_fetch`` parameter to the application persistent storage. + +If you want write your own logic, you can always pass your own defined ``init_handler`` callback to ``set_handlers``. + +Working with Language Models +"""""""""""""""""""""""""""" + +Finally, we arrive at the core aspect of the application, where we interact with the **Language Model**: + +.. code-block:: + + def ai_talk_bot_process_request(message: talk_bot.TalkBotMessage): + # Process only messages started with "@ai" + r = re.search(r"@ai\s(.*)", message.object_content["message"], re.IGNORECASE) + if r is None: + return + model = pipeline( + "text2text-generation", + model=snapshot_download(MODEL_NAME, local_files_only=True, cache_dir=persistent_storage()), + ) + # Pass all text after "@ai" we to the Language model. + response_text = model(r.group(1), max_length=64, do_sample=True)[0]["generated_text"] + AI_BOT.send_message(response_text, message) + + +Simply put, AI logic is a few lines of code when using Transformers, which is incredibly efficient and cool. + +Messages from the AI model are then sent back to Talk Chat as you would expect from a typical chatbot. + +`Full source code is here `_ + +That's it for now! Stay tuned—this is merely the start of an exciting journey into the integration of AI and chat functionality in Nextcloud. diff --git a/_sources/NextcloudUiApp.rst.txt b/_sources/NextcloudUiApp.rst.txt new file mode 100644 index 00000000..56a6d87b --- /dev/null +++ b/_sources/NextcloudUiApp.rst.txt @@ -0,0 +1,97 @@ +Writing Nextcloud App with UI +============================= + +.. note:: It is advisable to have experience writing PHP applications for Nextcloud, + since the UI of applications not written in PHP is exactly the same. + +One of the most interesting features is the ability to register a page in the Nextcloud Top Menu. + +Full source of UI example can be found here: +`UiExample `_ + +Here we will simply describe in detail what happens in the example. + +.. code-block:: python + + if enabled: + nc.ui.resources.set_initial_state( + "top_menu", "first_menu", "ui_example_state", {"initial_value": "test init value"} + ) + nc.ui.resources.set_script("top_menu", "first_menu", "js/ui_example-main") + nc.ui.top_menu.register("first_menu", "UI example", "img/icon.svg") + if nc.srv_version["major"] >= 29: + nc.ui.settings.register_form(SETTINGS_EXAMPLE) + +**set_initial_state** is analogue of PHP ``OCP\AppFramework\Services\IInitialState::provideInitialState`` + +**set_script** is analogue of PHP ``Util::addScript`` + +There is also **set_style** (``Util::addStyle``) that can be used for CSS files and works the same way as **set_script**. + +Starting with Nextcloud **29** AppAPI supports declaring Settings UI, with very simple and robust API. + +Settings values you declare will be saved to ``preferences_ex`` or ``appconfig_ex`` tables and can be retrieved using +:py:class:`nc_py_api._preferences_ex.PreferencesExAPI` or :py:class:`nc_py_api._preferences_ex.AppConfigExAPI` APIs. + +Backend +------- + +.. code-block:: python + + class Button1Format(BaseModel): + initial_value: str + + + @APP.post("/verify_initial_value") + async def verify_initial_value( + input1: Button1Format, + ): + print("Old value: ", input1.initial_value) + return responses.JSONResponse(content={"initial_value": str(random.randint(0, 100))}, status_code=200) + + + class FileInfo(BaseModel): + getlastmodified: str + getetag: str + getcontenttype: str + fileid: int + permissions: str + size: int + getcontentlength: int + favorite: int + + + @APP.post("/nextcloud_file") + async def nextcloud_file( + args: dict, + ): + print(args["file_info"]) + return responses.Response() + +Here is defining two endpoints for test purposes. + +The first is to get the current initial state of the page when the button is clicked. + +Second one is receiving a default information about file in the Nextcloud. + +Frontend +-------- + +The frontend part is the same as the default Nextcloud apps, with slightly different URL generation since all requests are sent through the AppAPI. + +JS Frontend part is covered by AppAPI documentation: ``to_do`` + +Important notes +--------------- + +We do not call ``top_menu.unregister`` or ``resources.delete_script`` as during uninstalling of application **AppAPI** will automatically remove this. + +.. note:: Recommended way is to manually clean all stuff and probably if it was not an example, we would call all unregister and cleanup stuff during ``disabling``. + + +All resources of ExApp should be avalaible and mounted to webserver(**FastAPI** + **uvicorn** are used by default for this). + +.. note:: This is in case you have custom folders that Nextcloud instance should have access. + + +*P.S.: If you are missing some required stuff for the UI part, please inform us, and we will consider adding it.* diff --git a/_sources/Options.rst.txt b/_sources/Options.rst.txt new file mode 100644 index 00000000..4ec2a45d --- /dev/null +++ b/_sources/Options.rst.txt @@ -0,0 +1,43 @@ +.. _options: + +Options +------- + +.. automodule:: nc_py_api.options + :members: + +Usage examples +^^^^^^^^^^^^^^ + +Using kwargs +"""""""""""" + +.. note:: The names of the options if you wish to specify it in ``kwargs`` is **lowercase**. + +.. code-block:: python + + nc_client = Nextcloud(xdebug_session="PHPSTORM", npa_nc_cert=False) + +Will set `XDEBUG_SESSION` to ``"PHPSTORM"`` and `NPA_NC_CERT` to ``False``. + +With .env +""""""""" + +Place **.env** file in your project's directory, and it will be automatically loaded using `dotenv `_ + +`Loading occurs only once, when "nc_py_api" is imported into the Python interpreter.` + +Modifying at module level +""""""""""""""""""""""""" + +Import **nc_py_api** and modify options by setting values you need directly in **nc_py_api.options**, +and all newly created classes will respect that. + +.. code-block:: python + + import nc_py_api + + nc_py_api.options.NPA_TIMEOUT = None + nc_py_api.options.NPA_TIMEOUT_DAV = None + +.. note:: In case you debugging PHP code it is always a good idea to set **Timeouts** to ``None``. diff --git a/_sources/benchmarks/AppAPI.rst.txt b/_sources/benchmarks/AppAPI.rst.txt new file mode 100644 index 00000000..2f30d37f --- /dev/null +++ b/_sources/benchmarks/AppAPI.rst.txt @@ -0,0 +1,52 @@ +AppAPI 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 diff --git a/_sources/index.rst.txt b/_sources/index.rst.txt new file mode 100644 index 00000000..cfe7bacc --- /dev/null +++ b/_sources/index.rst.txt @@ -0,0 +1,47 @@ +NC_Py_API documentation +======================= + +| *A framework, library, or your best Python tool to work with Nextcloud.* +| It supports two modes: as a client library or as a framework for developing next-generation applications. + +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. + +Overview +======== + +The main goal of this project is to provide a fast and easy way to develop and deploy Python applications for Nextcloud. +The option to use it as a client library is a beneficial side effect. +The code is unified, and the codebase for both modes is the same. Tests are carried out for both modes. + +If you have any questions or corrections regarding the documentation, +we would be glad to address them in discussions, incorporate corrections through pull requests, +and handle complex problems through issues. + +Have a great time with Python and Nextcloud! + +.. toctree:: + :maxdepth: 1 + + Installation + FirstSteps + MoreAPIs + NextcloudApp + NextcloudApp3rdParty + NextcloudTalkBot + NextcloudTalkBotTransformers + NextcloudUiApp + Options + reference/index.rst + DevSetup + benchmarks/AppAPI.rst + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` diff --git a/_sources/reference/ActivityApp.rst.txt b/_sources/reference/ActivityApp.rst.txt new file mode 100644 index 00000000..cd3399bf --- /dev/null +++ b/_sources/reference/ActivityApp.rst.txt @@ -0,0 +1,11 @@ +Activity App +------------ + +.. autoclass:: nc_py_api.activity.ActivityFilter + :members: + +.. autoclass:: nc_py_api.activity.Activity + :members: + +.. autoclass:: nc_py_api.activity._ActivityAPI + :members: diff --git a/_sources/reference/Apps.rst.txt b/_sources/reference/Apps.rst.txt new file mode 100644 index 00000000..d8c440ae --- /dev/null +++ b/_sources/reference/Apps.rst.txt @@ -0,0 +1,32 @@ +Applications Management +----------------------- + +.. autoclass:: nc_py_api.apps._AppsAPI + :members: + +.. autoclass:: nc_py_api.apps.ExAppInfo + :members: + +Preferences +^^^^^^^^^^^ + +.. autoclass:: nc_py_api._preferences_ex.CfgRecord + :members: + :undoc-members: + +User specific +""""""""""""" + +.. autoclass:: nc_py_api._preferences.PreferencesAPI + :members: + +.. autoclass:: nc_py_api._preferences_ex.PreferencesExAPI + :members: + :inherited-members: + +Non-user specific +""""""""""""""""" + +.. autoclass:: nc_py_api._preferences_ex.AppConfigExAPI + :members: + :inherited-members: diff --git a/_sources/reference/Calendar.rst.txt b/_sources/reference/Calendar.rst.txt new file mode 100644 index 00000000..adb637ac --- /dev/null +++ b/_sources/reference/Calendar.rst.txt @@ -0,0 +1,22 @@ +.. py:currentmodule:: nc_py_api.calendar + +Calendar API +============ + +.. note:: To make this API work you should install **nc_py_api** with **calendar** extra dependency. + +.. code-block:: python + + principal = nc.cal.principal() + calendars = principal.calendars() # get list of calendars + +``nc.cal`` is usual ``caldav.DAVClient`` object with the same API. + +Documentation for ``caldav`` can be found here: `CalDAV <"https://caldav.readthedocs.io/en/latest">`_ + +.. class:: _CalendarAPI + + Class that encapsulates ``caldav.DAVClient``. Avalaible as **cal** in the Nextcloud class. + + .. note:: You should not call ``close`` or ``request`` methods of CalendarAPI, they will be removed somewhere + in the future when ``caldav.DAVClient`` will be rewritten(API compatability will remains). diff --git a/_sources/reference/ExApp.rst.txt b/_sources/reference/ExApp.rst.txt new file mode 100644 index 00000000..f3c08f2b --- /dev/null +++ b/_sources/reference/ExApp.rst.txt @@ -0,0 +1,100 @@ +.. py:currentmodule:: nc_py_api.ex_app + +External Application +==================== + +Constants +--------- + +.. autoclass:: LogLvl + :members: + +Special functions +----------------- + +.. autofunction:: persistent_storage + +.. autofunction:: verify_version + +User Interface(UI) +------------------ + +UI methods should be accessed with the help of :class:`~nc_py_api.nextcloud.NextcloudApp` + +.. code-block:: python + + # this is an example, in most cases you will get `NextcloudApp` class instance as input param. + nc = NextcloudApp() + nc.ex_app.ui.files_dropdown_menu.register(...) + +.. autoclass:: nc_py_api.ex_app.ui.ui.UiApi + :members: + +.. automodule:: nc_py_api.ex_app.ui.files_actions + :members: + +.. autoclass:: nc_py_api.ex_app.ui.files_actions._UiFilesActionsAPI + :members: + +.. automodule:: nc_py_api.ex_app.ui.top_menu + :members: + +.. autoclass:: nc_py_api.ex_app.ui.top_menu._UiTopMenuAPI + :members: + +.. autoclass:: nc_py_api.ex_app.ui.resources._UiResources + :members: + +.. autoclass:: nc_py_api.ex_app.ui.resources.UiInitState + :members: + +.. autoclass:: nc_py_api.ex_app.ui.resources.UiScript + :members: + +.. autoclass:: nc_py_api.ex_app.ui.resources.UiStyle + :members: + +.. autoclass:: nc_py_api.ex_app.ui.settings.SettingsField + :members: + +.. autoclass:: nc_py_api.ex_app.ui.settings.SettingsForm + :members: + +.. autoclass:: nc_py_api.ex_app.ui.settings.SettingsFieldType + :members: + +.. autoclass:: nc_py_api.ex_app.ui.settings._DeclarativeSettingsAPI + :members: + +.. autoclass:: nc_py_api.ex_app.providers.providers.ProvidersApi + :members: + +.. autoclass:: nc_py_api.ex_app.providers.task_processing.ShapeType + :members: + +.. autoclass:: nc_py_api.ex_app.providers.task_processing.ShapeEnumValue + :members: + +.. autoclass:: nc_py_api.ex_app.providers.task_processing.ShapeDescriptor + :members: + +.. autoclass:: nc_py_api.ex_app.providers.task_processing.TaskType + :members: + +.. autoclass:: nc_py_api.ex_app.providers.task_processing.TaskProcessingProvider + :members: + +.. autoclass:: nc_py_api.ex_app.providers.task_processing._TaskProcessingProviderAPI + :members: + +.. autoclass:: nc_py_api.ex_app.events_listener.EventsListener + :members: + +.. autoclass:: nc_py_api.ex_app.events_listener.EventsListenerAPI + :members: + +.. autoclass:: nc_py_api.ex_app.occ_commands.OccCommand + :members: + +.. autoclass:: nc_py_api.ex_app.occ_commands.OccCommandsAPI + :members: diff --git a/_sources/reference/Exceptions.rst.txt b/_sources/reference/Exceptions.rst.txt new file mode 100644 index 00000000..fc96f77d --- /dev/null +++ b/_sources/reference/Exceptions.rst.txt @@ -0,0 +1,18 @@ +.. py:currentmodule:: nc_py_api._exceptions + +Exceptions +========== + +Avalaible as `nc_py_api.{exception_name}` + +.. autoclass:: NextcloudException + :members: + +.. autoclass:: NextcloudExceptionNotModified + :members: + +.. autoclass:: NextcloudExceptionNotFound + :members: + +.. autoclass:: NextcloudMissingCapabilities + :members: diff --git a/_sources/reference/Files/Files.rst.txt b/_sources/reference/Files/Files.rst.txt new file mode 100644 index 00000000..1f859af4 --- /dev/null +++ b/_sources/reference/Files/Files.rst.txt @@ -0,0 +1,34 @@ +File System +=========== + +The Files API is universal for both modes and provides all the necessary methods for working with the Nextcloud file system. +Refer to the `Files examples `_ to see how to use them nicely. + +All File APIs are designed to work relative to the current user. + +.. autoclass:: nc_py_api.files.files.FilesAPI + :members: + +.. autoclass:: nc_py_api.files.FsNodeInfo + :members: + +.. autoclass:: nc_py_api.files.FsNode + :members: + +.. autoclass:: nc_py_api.files.FilePermissions + :members: + +.. autoclass:: nc_py_api.files.SystemTag + :members: + +.. autoclass:: nc_py_api.files.LockType + :members: + +.. autoclass:: nc_py_api.files.FsNodeLockInfo + :members: + +.. autoclass:: nc_py_api.files.ActionFileInfo + :members: fileId, name, directory, etag, mime, fileType, size, favorite, permissions, mtime, userId, instanceId, to_fs_node + +.. autoclass:: nc_py_api.files.ActionFileInfoEx + :members: files diff --git a/_sources/reference/Files/Shares.rst.txt b/_sources/reference/Files/Shares.rst.txt new file mode 100644 index 00000000..2b52c057 --- /dev/null +++ b/_sources/reference/Files/Shares.rst.txt @@ -0,0 +1,13 @@ +File Sharing +============ + +The Shares API is universal for both modes and provides all the necessary methods for working with the Nextcloud Shares system. + +.. autoclass:: nc_py_api.files.sharing._FilesSharingAPI + :members: + +.. autoclass:: nc_py_api.files.sharing.Share + :members: + +.. autoclass:: nc_py_api.files.sharing.ShareType + :members: diff --git a/_sources/reference/Files/index.rst.txt b/_sources/reference/Files/index.rst.txt new file mode 100644 index 00000000..aa8a42be --- /dev/null +++ b/_sources/reference/Files/index.rst.txt @@ -0,0 +1,8 @@ +Files API +========= + +.. toctree:: + :maxdepth: 2 + + Files + Shares diff --git a/_sources/reference/LoginFlowV2.rst.txt b/_sources/reference/LoginFlowV2.rst.txt new file mode 100644 index 00000000..1beeb35a --- /dev/null +++ b/_sources/reference/LoginFlowV2.rst.txt @@ -0,0 +1,18 @@ +.. py:currentmodule:: nc_py_api.loginflow_v2 + +LoginFlow V2 +============ + +Login flow v2 is an authorization process for the standard Nextcloud client that allows each client to have their own set of credentials. + +.. autoclass:: _LoginFlowV2API + :inherited-members: + :members: + +.. autoclass:: Credentials + :inherited-members: + :members: + +.. autoclass:: LoginFlow + :inherited-members: + :members: diff --git a/_sources/reference/Nextcloud.rst.txt b/_sources/reference/Nextcloud.rst.txt new file mode 100644 index 00000000..1e4478bd --- /dev/null +++ b/_sources/reference/Nextcloud.rst.txt @@ -0,0 +1,18 @@ +.. py:currentmodule:: nc_py_api.nextcloud + +Nextcloud +========= + +Two base classes for working with Nextcloud. The first for working as a client, the second as an application. + +All required functionality is incorporated in them, they contains all other classes required to work with the Nextcloud. + +.. autoclass:: Nextcloud + :inherited-members: + :members: + + .. automethod:: __init__ + +.. autoclass:: NextcloudApp + :inherited-members: + :members: diff --git a/_sources/reference/Notes.rst.txt b/_sources/reference/Notes.rst.txt new file mode 100644 index 00000000..514397f8 --- /dev/null +++ b/_sources/reference/Notes.rst.txt @@ -0,0 +1,13 @@ +.. py:currentmodule:: nc_py_api.notes + +Notes API +========= + +.. autoclass:: nc_py_api.notes.Note + :members: + +.. autoclass:: nc_py_api.notes.NotesSettings + :members: + +.. autoclass:: nc_py_api.notes._NotesAPI + :members: diff --git a/_sources/reference/Session.rst.txt b/_sources/reference/Session.rst.txt new file mode 100644 index 00000000..316a1005 --- /dev/null +++ b/_sources/reference/Session.rst.txt @@ -0,0 +1,28 @@ +.. py:currentmodule:: nc_py_api._session + +Session Structures +================== + +.. autoclass:: ServerVersion + :members: + +.. autoclass:: nc_py_api._theming.ThemingInfo + :members: + +.. autoclass:: AppConfig + :members: + +Internal +^^^^^^^^ + +.. note:: The Session API is currently private and subject to change without deprecation. + +.. autoclass:: NcSessionBasic + :members: + +.. autoclass:: NcSessionApp + :members: + :inherited-members: + +.. autoclass:: NcSession + :members: diff --git a/_sources/reference/Talk.rst.txt b/_sources/reference/Talk.rst.txt new file mode 100644 index 00000000..61d0f103 --- /dev/null +++ b/_sources/reference/Talk.rst.txt @@ -0,0 +1,79 @@ +Talk API +-------- + +.. autoclass:: nc_py_api.talk.Conversation + :members: + :inherited-members: + +.. autoclass:: nc_py_api.talk.Participant + :members: + :inherited-members: + +.. autoclass:: nc_py_api.talk.TalkMessage + :members: + :inherited-members: + +.. autoclass:: nc_py_api.talk.TalkFileMessage + :members: + :inherited-members: + +.. autoclass:: nc_py_api._talk_api._TalkAPI + :members: + +.. autoclass:: nc_py_api.talk.ConversationType + :members: + +.. autoclass:: nc_py_api.talk.ParticipantType + :members: + +.. autoclass:: nc_py_api.talk.AttendeePermissions + :members: + +.. autoclass:: nc_py_api.talk.InCallFlags + :members: + :undoc-members: + +.. autoclass:: nc_py_api.talk.ListableScope + :members: + :undoc-members: + +.. autoclass:: nc_py_api.talk.NotificationLevel + :members: + :undoc-members: + +.. autoclass:: nc_py_api.talk.WebinarLobbyStates + :members: + :undoc-members: + +.. autoclass:: nc_py_api.talk.SipEnabledStatus + :members: + :undoc-members: + +.. autoclass:: nc_py_api.talk.CallRecordingStatus + :members: + :undoc-members: + +.. autoclass:: nc_py_api.talk.BreakoutRoomMode + :members: + :undoc-members: + +.. autoclass:: nc_py_api.talk.BreakoutRoomStatus + :members: + :undoc-members: + +.. autoclass:: nc_py_api.talk.MessageReactions + :members: + :undoc-members: + +.. autoclass:: nc_py_api.talk.BotInfo + :members: + :inherited-members: + +.. autoclass:: nc_py_api.talk.BotInfoBasic + :members: + +.. autoclass:: nc_py_api.talk.Poll + :members: + +.. autoclass:: nc_py_api.talk.PollDetail + :members: diff --git a/_sources/reference/TalkBot.rst.txt b/_sources/reference/TalkBot.rst.txt new file mode 100644 index 00000000..e5a9d0e7 --- /dev/null +++ b/_sources/reference/TalkBot.rst.txt @@ -0,0 +1,17 @@ +.. py:currentmodule:: nc_py_api + +Talk Bot API +------------ + +.. autoclass:: nc_py_api.talk_bot.TalkBotMessage + :members: + :undoc-members: + +.. autoclass:: nc_py_api.talk_bot.TalkBot + :members: + +.. autoclass:: nc_py_api.talk_bot.ObjectContent + :members: + :undoc-members: + +.. autofunction:: nc_py_api.talk_bot.get_bot_secret diff --git a/_sources/reference/Users/Notifications.rst.txt b/_sources/reference/Users/Notifications.rst.txt new file mode 100644 index 00000000..dcb50dbf --- /dev/null +++ b/_sources/reference/Users/Notifications.rst.txt @@ -0,0 +1,8 @@ +Notifications +------------- + +.. autoclass:: nc_py_api.notifications._NotificationsAPI + :members: + +.. autoclass:: nc_py_api.notifications.Notification + :members: diff --git a/_sources/reference/Users/Users.rst.txt b/_sources/reference/Users/Users.rst.txt new file mode 100644 index 00000000..a12bc50a --- /dev/null +++ b/_sources/reference/Users/Users.rst.txt @@ -0,0 +1,8 @@ +User Management +--------------- + +.. autoclass:: nc_py_api.users.UserInfo + :members: + +.. autoclass:: nc_py_api.users._UsersAPI + :members: diff --git a/_sources/reference/Users/UsersGroups.rst.txt b/_sources/reference/Users/UsersGroups.rst.txt new file mode 100644 index 00000000..976270a7 --- /dev/null +++ b/_sources/reference/Users/UsersGroups.rst.txt @@ -0,0 +1,8 @@ +User Groups Management +---------------------- + +.. autoclass:: nc_py_api.users_groups._UsersGroupsAPI + :members: + +.. autoclass:: nc_py_api.users_groups.GroupDetails + :members: diff --git a/_sources/reference/Users/UsersStatus.rst.txt b/_sources/reference/Users/UsersStatus.rst.txt new file mode 100644 index 00000000..c6cd8ef2 --- /dev/null +++ b/_sources/reference/Users/UsersStatus.rst.txt @@ -0,0 +1,18 @@ +User Status +----------- + +.. autoclass:: nc_py_api.user_status._UserStatusAPI + :members: + +.. autoclass:: nc_py_api.user_status.CurrentUserStatus + :members: + +.. autoclass:: nc_py_api.user_status.UserStatus + :members: + :inherited-members: + +.. autoclass:: nc_py_api.user_status.PredefinedStatus + :members: + +.. autoclass:: nc_py_api.user_status.ClearAt + :members: diff --git a/_sources/reference/Users/WeatherStatus.rst.txt b/_sources/reference/Users/WeatherStatus.rst.txt new file mode 100644 index 00000000..155b9a40 --- /dev/null +++ b/_sources/reference/Users/WeatherStatus.rst.txt @@ -0,0 +1,11 @@ +Weather Status +-------------- + +.. autoclass:: nc_py_api.weather_status._WeatherStatusAPI + :members: + +.. autoclass:: nc_py_api.weather_status.WeatherLocation + :members: + +.. autoclass:: nc_py_api.weather_status.WeatherLocationMode + :members: diff --git a/_sources/reference/Users/index.rst.txt b/_sources/reference/Users/index.rst.txt new file mode 100644 index 00000000..3aec9a56 --- /dev/null +++ b/_sources/reference/Users/index.rst.txt @@ -0,0 +1,11 @@ +Users API +========= + +.. toctree:: + :maxdepth: 2 + + Users + UsersGroups + UsersStatus + Notifications + WeatherStatus diff --git a/_sources/reference/Webhooks.rst.txt b/_sources/reference/Webhooks.rst.txt new file mode 100644 index 00000000..ff7251f8 --- /dev/null +++ b/_sources/reference/Webhooks.rst.txt @@ -0,0 +1,10 @@ +.. py:currentmodule:: nc_py_api.webhooks + +Webhooks API +============ + +.. autoclass:: nc_py_api.webhooks.WebhookInfo + :members: + +.. autoclass:: nc_py_api.webhooks._WebhooksAPI + :members: diff --git a/_sources/reference/index.rst.txt b/_sources/reference/index.rst.txt new file mode 100644 index 00000000..c7275426 --- /dev/null +++ b/_sources/reference/index.rst.txt @@ -0,0 +1,20 @@ +Reference +========= + +.. toctree:: + :maxdepth: 2 + + Nextcloud + ExApp + Apps + Files/index.rst + Users/index.rst + Exceptions + Talk + TalkBot + Calendar + ActivityApp + Notes + Session + LoginFlowV2 + Webhooks diff --git a/_static/_sphinx_javascript_frameworks_compat.js b/_static/_sphinx_javascript_frameworks_compat.js new file mode 100644 index 00000000..81415803 --- /dev/null +++ b/_static/_sphinx_javascript_frameworks_compat.js @@ -0,0 +1,123 @@ +/* Compatability shim for jQuery and underscores.js. + * + * Copyright Sphinx contributors + * Released under the two clause BSD licence + */ + +/** + * small helper function to urldecode strings + * + * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/decodeURIComponent#Decoding_query_parameters_from_a_URL + */ +jQuery.urldecode = function(x) { + if (!x) { + return x + } + return decodeURIComponent(x.replace(/\+/g, ' ')); +}; + +/** + * small helper function to urlencode strings + */ +jQuery.urlencode = encodeURIComponent; + +/** + * This function returns the parsed url parameters of the + * current request. Multiple values per key are supported, + * it will always return arrays of strings for the value parts. + */ +jQuery.getQueryParameters = function(s) { + if (typeof s === 'undefined') + s = document.location.search; + var parts = s.substr(s.indexOf('?') + 1).split('&'); + var result = {}; + for (var i = 0; i < parts.length; i++) { + var tmp = parts[i].split('=', 2); + var key = jQuery.urldecode(tmp[0]); + var value = jQuery.urldecode(tmp[1]); + if (key in result) + result[key].push(value); + else + result[key] = [value]; + } + return result; +}; + +/** + * highlight a given string on a jquery object by wrapping it in + * span elements with the given class name. + */ +jQuery.fn.highlightText = function(text, className) { + function highlight(node, addItems) { + if (node.nodeType === 3) { + var val = node.nodeValue; + var pos = val.toLowerCase().indexOf(text); + if (pos >= 0 && + !jQuery(node.parentNode).hasClass(className) && + !jQuery(node.parentNode).hasClass("nohighlight")) { + var span; + var isInSVG = jQuery(node).closest("body, svg, foreignObject").is("svg"); + if (isInSVG) { + span = document.createElementNS("http://www.w3.org/2000/svg", "tspan"); + } else { + span = document.createElement("span"); + span.className = className; + } + span.appendChild(document.createTextNode(val.substr(pos, text.length))); + node.parentNode.insertBefore(span, node.parentNode.insertBefore( + document.createTextNode(val.substr(pos + text.length)), + node.nextSibling)); + node.nodeValue = val.substr(0, pos); + if (isInSVG) { + var rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + var bbox = node.parentElement.getBBox(); + rect.x.baseVal.value = bbox.x; + rect.y.baseVal.value = bbox.y; + rect.width.baseVal.value = bbox.width; + rect.height.baseVal.value = bbox.height; + rect.setAttribute('class', className); + addItems.push({ + "parent": node.parentNode, + "target": rect}); + } + } + } + else if (!jQuery(node).is("button, select, textarea")) { + jQuery.each(node.childNodes, function() { + highlight(this, addItems); + }); + } + } + var addItems = []; + var result = this.each(function() { + highlight(this, addItems); + }); + for (var i = 0; i < addItems.length; ++i) { + jQuery(addItems[i].parent).before(addItems[i].target); + } + return result; +}; + +/* + * backward compatibility for jQuery.browser + * This will be supported until firefox bug is fixed. + */ +if (!jQuery.browser) { + jQuery.uaMatch = function(ua) { + ua = ua.toLowerCase(); + + var match = /(chrome)[ \/]([\w.]+)/.exec(ua) || + /(webkit)[ \/]([\w.]+)/.exec(ua) || + /(opera)(?:.*version|)[ \/]([\w.]+)/.exec(ua) || + /(msie) ([\w.]+)/.exec(ua) || + ua.indexOf("compatible") < 0 && /(mozilla)(?:.*? rv:([\w.]+)|)/.exec(ua) || + []; + + return { + browser: match[ 1 ] || "", + version: match[ 2 ] || "0" + }; + }; + jQuery.browser = {}; + jQuery.browser[jQuery.uaMatch(navigator.userAgent).browser] = true; +} diff --git a/_static/autodoc_pydantic.css b/_static/autodoc_pydantic.css new file mode 100644 index 00000000..994a3e54 --- /dev/null +++ b/_static/autodoc_pydantic.css @@ -0,0 +1,11 @@ +.autodoc_pydantic_validator_arrow { + padding-left: 8px; + } + +.autodoc_pydantic_collapsable_json { + cursor: pointer; + } + +.autodoc_pydantic_collapsable_erd { + cursor: pointer; + } \ No newline at end of file diff --git a/_static/basic.css b/_static/basic.css new file mode 100644 index 00000000..f316efcb --- /dev/null +++ b/_static/basic.css @@ -0,0 +1,925 @@ +/* + * basic.css + * ~~~~~~~~~ + * + * Sphinx stylesheet -- basic theme. + * + * :copyright: Copyright 2007-2024 by the Sphinx team, see AUTHORS. + * :license: BSD, see LICENSE for details. + * + */ + +/* -- main layout ----------------------------------------------------------- */ + +div.clearer { + clear: both; +} + +div.section::after { + display: block; + content: ''; + clear: left; +} + +/* -- relbar ---------------------------------------------------------------- */ + +div.related { + width: 100%; + font-size: 90%; +} + +div.related h3 { + display: none; +} + +div.related ul { + margin: 0; + padding: 0 0 0 10px; + list-style: none; +} + +div.related li { + display: inline; +} + +div.related li.right { + float: right; + margin-right: 5px; +} + +/* -- sidebar --------------------------------------------------------------- */ + +div.sphinxsidebarwrapper { + padding: 10px 5px 0 10px; +} + +div.sphinxsidebar { + float: left; + width: 230px; + margin-left: -100%; + font-size: 90%; + word-wrap: break-word; + overflow-wrap : break-word; +} + +div.sphinxsidebar ul { + list-style: none; +} + +div.sphinxsidebar ul ul, +div.sphinxsidebar ul.want-points { + margin-left: 20px; + list-style: square; +} + +div.sphinxsidebar ul ul { + margin-top: 0; + margin-bottom: 0; +} + +div.sphinxsidebar form { + margin-top: 10px; +} + +div.sphinxsidebar input { + border: 1px solid #98dbcc; + font-family: sans-serif; + font-size: 1em; +} + +div.sphinxsidebar #searchbox form.search { + overflow: hidden; +} + +div.sphinxsidebar #searchbox input[type="text"] { + float: left; + width: 80%; + padding: 0.25em; + box-sizing: border-box; +} + +div.sphinxsidebar #searchbox input[type="submit"] { + float: left; + width: 20%; + border-left: none; + padding: 0.25em; + box-sizing: border-box; +} + + +img { + border: 0; + max-width: 100%; +} + +/* -- search page ----------------------------------------------------------- */ + +ul.search { + margin: 10px 0 0 20px; + padding: 0; +} + +ul.search li { + padding: 5px 0 5px 20px; + background-image: url(file.png); + background-repeat: no-repeat; + background-position: 0 7px; +} + +ul.search li a { + font-weight: bold; +} + +ul.search li p.context { + color: #888; + margin: 2px 0 0 30px; + text-align: left; +} + +ul.keywordmatches li.goodmatch a { + font-weight: bold; +} + +/* -- index page ------------------------------------------------------------ */ + +table.contentstable { + width: 90%; + margin-left: auto; + margin-right: auto; +} + +table.contentstable p.biglink { + line-height: 150%; +} + +a.biglink { + font-size: 1.3em; +} + +span.linkdescr { + font-style: italic; + padding-top: 5px; + font-size: 90%; +} + +/* -- general index --------------------------------------------------------- */ + +table.indextable { + width: 100%; +} + +table.indextable td { + text-align: left; + vertical-align: top; +} + +table.indextable ul { + margin-top: 0; + margin-bottom: 0; + list-style-type: none; +} + +table.indextable > tbody > tr > td > ul { + padding-left: 0em; +} + +table.indextable tr.pcap { + height: 10px; +} + +table.indextable tr.cap { + margin-top: 10px; + background-color: #f2f2f2; +} + +img.toggler { + margin-right: 3px; + margin-top: 3px; + cursor: pointer; +} + +div.modindex-jumpbox { + border-top: 1px solid #ddd; + border-bottom: 1px solid #ddd; + margin: 1em 0 1em 0; + padding: 0.4em; +} + +div.genindex-jumpbox { + border-top: 1px solid #ddd; + border-bottom: 1px solid #ddd; + margin: 1em 0 1em 0; + padding: 0.4em; +} + +/* -- domain module index --------------------------------------------------- */ + +table.modindextable td { + padding: 2px; + border-collapse: collapse; +} + +/* -- general body styles --------------------------------------------------- */ + +div.body { + min-width: 360px; + max-width: 800px; +} + +div.body p, div.body dd, div.body li, div.body blockquote { + -moz-hyphens: auto; + -ms-hyphens: auto; + -webkit-hyphens: auto; + hyphens: auto; +} + +a.headerlink { + visibility: hidden; +} + +a:visited { + color: #551A8B; +} + +h1:hover > a.headerlink, +h2:hover > a.headerlink, +h3:hover > a.headerlink, +h4:hover > a.headerlink, +h5:hover > a.headerlink, +h6:hover > a.headerlink, +dt:hover > a.headerlink, +caption:hover > a.headerlink, +p.caption:hover > a.headerlink, +div.code-block-caption:hover > a.headerlink { + visibility: visible; +} + +div.body p.caption { + text-align: inherit; +} + +div.body td { + text-align: left; +} + +.first { + margin-top: 0 !important; +} + +p.rubric { + margin-top: 30px; + font-weight: bold; +} + +img.align-left, figure.align-left, .figure.align-left, object.align-left { + clear: left; + float: left; + margin-right: 1em; +} + +img.align-right, figure.align-right, .figure.align-right, object.align-right { + clear: right; + float: right; + margin-left: 1em; +} + +img.align-center, figure.align-center, .figure.align-center, object.align-center { + display: block; + margin-left: auto; + margin-right: auto; +} + +img.align-default, figure.align-default, .figure.align-default { + display: block; + margin-left: auto; + margin-right: auto; +} + +.align-left { + text-align: left; +} + +.align-center { + text-align: center; +} + +.align-default { + text-align: center; +} + +.align-right { + text-align: right; +} + +/* -- sidebars -------------------------------------------------------------- */ + +div.sidebar, +aside.sidebar { + margin: 0 0 0.5em 1em; + border: 1px solid #ddb; + padding: 7px; + background-color: #ffe; + width: 40%; + float: right; + clear: right; + overflow-x: auto; +} + +p.sidebar-title { + font-weight: bold; +} + +nav.contents, +aside.topic, +div.admonition, div.topic, blockquote { + clear: left; +} + +/* -- topics ---------------------------------------------------------------- */ + +nav.contents, +aside.topic, +div.topic { + border: 1px solid #ccc; + padding: 7px; + margin: 10px 0 10px 0; +} + +p.topic-title { + font-size: 1.1em; + font-weight: bold; + margin-top: 10px; +} + +/* -- admonitions ----------------------------------------------------------- */ + +div.admonition { + margin-top: 10px; + margin-bottom: 10px; + padding: 7px; +} + +div.admonition dt { + font-weight: bold; +} + +p.admonition-title { + margin: 0px 10px 5px 0px; + font-weight: bold; +} + +div.body p.centered { + text-align: center; + margin-top: 25px; +} + +/* -- content of sidebars/topics/admonitions -------------------------------- */ + +div.sidebar > :last-child, +aside.sidebar > :last-child, +nav.contents > :last-child, +aside.topic > :last-child, +div.topic > :last-child, +div.admonition > :last-child { + margin-bottom: 0; +} + +div.sidebar::after, +aside.sidebar::after, +nav.contents::after, +aside.topic::after, +div.topic::after, +div.admonition::after, +blockquote::after { + display: block; + content: ''; + clear: both; +} + +/* -- tables ---------------------------------------------------------------- */ + +table.docutils { + margin-top: 10px; + margin-bottom: 10px; + border: 0; + border-collapse: collapse; +} + +table.align-center { + margin-left: auto; + margin-right: auto; +} + +table.align-default { + margin-left: auto; + margin-right: auto; +} + +table caption span.caption-number { + font-style: italic; +} + +table caption span.caption-text { +} + +table.docutils td, table.docutils th { + padding: 1px 8px 1px 5px; + border-top: 0; + border-left: 0; + border-right: 0; + border-bottom: 1px solid #aaa; +} + +th { + text-align: left; + padding-right: 5px; +} + +table.citation { + border-left: solid 1px gray; + margin-left: 1px; +} + +table.citation td { + border-bottom: none; +} + +th > :first-child, +td > :first-child { + margin-top: 0px; +} + +th > :last-child, +td > :last-child { + margin-bottom: 0px; +} + +/* -- figures --------------------------------------------------------------- */ + +div.figure, figure { + margin: 0.5em; + padding: 0.5em; +} + +div.figure p.caption, figcaption { + padding: 0.3em; +} + +div.figure p.caption span.caption-number, +figcaption span.caption-number { + font-style: italic; +} + +div.figure p.caption span.caption-text, +figcaption span.caption-text { +} + +/* -- field list styles ----------------------------------------------------- */ + +table.field-list td, table.field-list th { + border: 0 !important; +} + +.field-list ul { + margin: 0; + padding-left: 1em; +} + +.field-list p { + margin: 0; +} + +.field-name { + -moz-hyphens: manual; + -ms-hyphens: manual; + -webkit-hyphens: manual; + hyphens: manual; +} + +/* -- hlist styles ---------------------------------------------------------- */ + +table.hlist { + margin: 1em 0; +} + +table.hlist td { + vertical-align: top; +} + +/* -- object description styles --------------------------------------------- */ + +.sig { + font-family: 'Consolas', 'Menlo', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', monospace; +} + +.sig-name, code.descname { + background-color: transparent; + font-weight: bold; +} + +.sig-name { + font-size: 1.1em; +} + +code.descname { + font-size: 1.2em; +} + +.sig-prename, code.descclassname { + background-color: transparent; +} + +.optional { + font-size: 1.3em; +} + +.sig-paren { + font-size: larger; +} + +.sig-param.n { + font-style: italic; +} + +/* C++ specific styling */ + +.sig-inline.c-texpr, +.sig-inline.cpp-texpr { + font-family: unset; +} + +.sig.c .k, .sig.c .kt, +.sig.cpp .k, .sig.cpp .kt { + color: #0033B3; +} + +.sig.c .m, +.sig.cpp .m { + color: #1750EB; +} + +.sig.c .s, .sig.c .sc, +.sig.cpp .s, .sig.cpp .sc { + color: #067D17; +} + + +/* -- other body styles ----------------------------------------------------- */ + +ol.arabic { + list-style: decimal; +} + +ol.loweralpha { + list-style: lower-alpha; +} + +ol.upperalpha { + list-style: upper-alpha; +} + +ol.lowerroman { + list-style: lower-roman; +} + +ol.upperroman { + list-style: upper-roman; +} + +:not(li) > ol > li:first-child > :first-child, +:not(li) > ul > li:first-child > :first-child { + margin-top: 0px; +} + +:not(li) > ol > li:last-child > :last-child, +:not(li) > ul > li:last-child > :last-child { + margin-bottom: 0px; +} + +ol.simple ol p, +ol.simple ul p, +ul.simple ol p, +ul.simple ul p { + margin-top: 0; +} + +ol.simple > li:not(:first-child) > p, +ul.simple > li:not(:first-child) > p { + margin-top: 0; +} + +ol.simple p, +ul.simple p { + margin-bottom: 0; +} + +aside.footnote > span, +div.citation > span { + float: left; +} +aside.footnote > span:last-of-type, +div.citation > span:last-of-type { + padding-right: 0.5em; +} +aside.footnote > p { + margin-left: 2em; +} +div.citation > p { + margin-left: 4em; +} +aside.footnote > p:last-of-type, +div.citation > p:last-of-type { + margin-bottom: 0em; +} +aside.footnote > p:last-of-type:after, +div.citation > p:last-of-type:after { + content: ""; + clear: both; +} + +dl.field-list { + display: grid; + grid-template-columns: fit-content(30%) auto; +} + +dl.field-list > dt { + font-weight: bold; + word-break: break-word; + padding-left: 0.5em; + padding-right: 5px; +} + +dl.field-list > dd { + padding-left: 0.5em; + margin-top: 0em; + margin-left: 0em; + margin-bottom: 0em; +} + +dl { + margin-bottom: 15px; +} + +dd > :first-child { + margin-top: 0px; +} + +dd ul, dd table { + margin-bottom: 10px; +} + +dd { + margin-top: 3px; + margin-bottom: 10px; + margin-left: 30px; +} + +.sig dd { + margin-top: 0px; + margin-bottom: 0px; +} + +.sig dl { + margin-top: 0px; + margin-bottom: 0px; +} + +dl > dd:last-child, +dl > dd:last-child > :last-child { + margin-bottom: 0; +} + +dt:target, span.highlighted { + background-color: #fbe54e; +} + +rect.highlighted { + fill: #fbe54e; +} + +dl.glossary dt { + font-weight: bold; + font-size: 1.1em; +} + +.versionmodified { + font-style: italic; +} + +.system-message { + background-color: #fda; + padding: 5px; + border: 3px solid red; +} + +.footnote:target { + background-color: #ffa; +} + +.line-block { + display: block; + margin-top: 1em; + margin-bottom: 1em; +} + +.line-block .line-block { + margin-top: 0; + margin-bottom: 0; + margin-left: 1.5em; +} + +.guilabel, .menuselection { + font-family: sans-serif; +} + +.accelerator { + text-decoration: underline; +} + +.classifier { + font-style: oblique; +} + +.classifier:before { + font-style: normal; + margin: 0 0.5em; + content: ":"; + display: inline-block; +} + +abbr, acronym { + border-bottom: dotted 1px; + cursor: help; +} + +.translated { + background-color: rgba(207, 255, 207, 0.2) +} + +.untranslated { + background-color: rgba(255, 207, 207, 0.2) +} + +/* -- code displays --------------------------------------------------------- */ + +pre { + overflow: auto; + overflow-y: hidden; /* fixes display issues on Chrome browsers */ +} + +pre, div[class*="highlight-"] { + clear: both; +} + +span.pre { + -moz-hyphens: none; + -ms-hyphens: none; + -webkit-hyphens: none; + hyphens: none; + white-space: nowrap; +} + +div[class*="highlight-"] { + margin: 1em 0; +} + +td.linenos pre { + border: 0; + background-color: transparent; + color: #aaa; +} + +table.highlighttable { + display: block; +} + +table.highlighttable tbody { + display: block; +} + +table.highlighttable tr { + display: flex; +} + +table.highlighttable td { + margin: 0; + padding: 0; +} + +table.highlighttable td.linenos { + padding-right: 0.5em; +} + +table.highlighttable td.code { + flex: 1; + overflow: hidden; +} + +.highlight .hll { + display: block; +} + +div.highlight pre, +table.highlighttable pre { + margin: 0; +} + +div.code-block-caption + div { + margin-top: 0; +} + +div.code-block-caption { + margin-top: 1em; + padding: 2px 5px; + font-size: small; +} + +div.code-block-caption code { + background-color: transparent; +} + +table.highlighttable td.linenos, +span.linenos, +div.highlight span.gp { /* gp: Generic.Prompt */ + user-select: none; + -webkit-user-select: text; /* Safari fallback only */ + -webkit-user-select: none; /* Chrome/Safari */ + -moz-user-select: none; /* Firefox */ + -ms-user-select: none; /* IE10+ */ +} + +div.code-block-caption span.caption-number { + padding: 0.1em 0.3em; + font-style: italic; +} + +div.code-block-caption span.caption-text { +} + +div.literal-block-wrapper { + margin: 1em 0; +} + +code.xref, a code { + background-color: transparent; + font-weight: bold; +} + +h1 code, h2 code, h3 code, h4 code, h5 code, h6 code { + background-color: transparent; +} + +.viewcode-link { + float: right; +} + +.viewcode-back { + float: right; + font-family: sans-serif; +} + +div.viewcode-block:target { + margin: -1px -10px; + padding: 0 10px; +} + +/* -- math display ---------------------------------------------------------- */ + +img.math { + vertical-align: middle; +} + +div.body div.math p { + text-align: center; +} + +span.eqno { + float: right; +} + +span.eqno a.headerlink { + position: absolute; + z-index: 1; +} + +div.math:hover a.headerlink { + visibility: visible; +} + +/* -- printout stylesheet --------------------------------------------------- */ + +@media print { + div.document, + div.documentwrapper, + div.bodywrapper { + margin: 0 !important; + width: 100%; + } + + div.sphinxsidebar, + div.related, + div.footer, + #top-link { + display: none; + } +} \ No newline at end of file diff --git a/_static/check-solid.svg b/_static/check-solid.svg new file mode 100644 index 00000000..92fad4b5 --- /dev/null +++ b/_static/check-solid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/_static/clipboard.min.js b/_static/clipboard.min.js new file mode 100644 index 00000000..54b3c463 --- /dev/null +++ b/_static/clipboard.min.js @@ -0,0 +1,7 @@ +/*! + * clipboard.js v2.0.8 + * https://clipboardjs.com/ + * + * Licensed MIT © Zeno Rocha + */ +!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.ClipboardJS=e():t.ClipboardJS=e()}(this,function(){return n={686:function(t,e,n){"use strict";n.d(e,{default:function(){return o}});var e=n(279),i=n.n(e),e=n(370),u=n.n(e),e=n(817),c=n.n(e);function a(t){try{return document.execCommand(t)}catch(t){return}}var f=function(t){t=c()(t);return a("cut"),t};var l=function(t){var e,n,o,r=1 + + + + diff --git a/_static/copybutton.css b/_static/copybutton.css new file mode 100644 index 00000000..f1916ec7 --- /dev/null +++ b/_static/copybutton.css @@ -0,0 +1,94 @@ +/* Copy buttons */ +button.copybtn { + position: absolute; + display: flex; + top: .3em; + right: .3em; + width: 1.7em; + height: 1.7em; + opacity: 0; + transition: opacity 0.3s, border .3s, background-color .3s; + user-select: none; + padding: 0; + border: none; + outline: none; + border-radius: 0.4em; + /* The colors that GitHub uses */ + border: #1b1f2426 1px solid; + background-color: #f6f8fa; + color: #57606a; +} + +button.copybtn.success { + border-color: #22863a; + color: #22863a; +} + +button.copybtn svg { + stroke: currentColor; + width: 1.5em; + height: 1.5em; + padding: 0.1em; +} + +div.highlight { + position: relative; +} + +/* Show the copybutton */ +.highlight:hover button.copybtn, button.copybtn.success { + opacity: 1; +} + +.highlight button.copybtn:hover { + background-color: rgb(235, 235, 235); +} + +.highlight button.copybtn:active { + background-color: rgb(187, 187, 187); +} + +/** + * A minimal CSS-only tooltip copied from: + * https://codepen.io/mildrenben/pen/rVBrpK + * + * To use, write HTML like the following: + * + *

Short

+ */ + .o-tooltip--left { + position: relative; + } + + .o-tooltip--left:after { + opacity: 0; + visibility: hidden; + position: absolute; + content: attr(data-tooltip); + padding: .2em; + font-size: .8em; + left: -.2em; + background: grey; + color: white; + white-space: nowrap; + z-index: 2; + border-radius: 2px; + transform: translateX(-102%) translateY(0); + transition: opacity 0.2s cubic-bezier(0.64, 0.09, 0.08, 1), transform 0.2s cubic-bezier(0.64, 0.09, 0.08, 1); +} + +.o-tooltip--left:hover:after { + display: block; + opacity: 1; + visibility: visible; + transform: translateX(-100%) translateY(0); + transition: opacity 0.2s cubic-bezier(0.64, 0.09, 0.08, 1), transform 0.2s cubic-bezier(0.64, 0.09, 0.08, 1); + transition-delay: .5s; +} + +/* By default the copy button shouldn't show up when printing a page */ +@media print { + button.copybtn { + display: none; + } +} diff --git a/_static/copybutton.js b/_static/copybutton.js new file mode 100644 index 00000000..2ea7ff3e --- /dev/null +++ b/_static/copybutton.js @@ -0,0 +1,248 @@ +// Localization support +const messages = { + 'en': { + 'copy': 'Copy', + 'copy_to_clipboard': 'Copy to clipboard', + 'copy_success': 'Copied!', + 'copy_failure': 'Failed to copy', + }, + 'es' : { + 'copy': 'Copiar', + 'copy_to_clipboard': 'Copiar al portapapeles', + 'copy_success': '¡Copiado!', + 'copy_failure': 'Error al copiar', + }, + 'de' : { + 'copy': 'Kopieren', + 'copy_to_clipboard': 'In die Zwischenablage kopieren', + 'copy_success': 'Kopiert!', + 'copy_failure': 'Fehler beim Kopieren', + }, + 'fr' : { + 'copy': 'Copier', + 'copy_to_clipboard': 'Copier dans le presse-papier', + 'copy_success': 'Copié !', + 'copy_failure': 'Échec de la copie', + }, + 'ru': { + 'copy': 'Скопировать', + 'copy_to_clipboard': 'Скопировать в буфер', + 'copy_success': 'Скопировано!', + 'copy_failure': 'Не удалось скопировать', + }, + 'zh-CN': { + 'copy': '复制', + 'copy_to_clipboard': '复制到剪贴板', + 'copy_success': '复制成功!', + 'copy_failure': '复制失败', + }, + 'it' : { + 'copy': 'Copiare', + 'copy_to_clipboard': 'Copiato negli appunti', + 'copy_success': 'Copiato!', + 'copy_failure': 'Errore durante la copia', + } +} + +let locale = 'en' +if( document.documentElement.lang !== undefined + && messages[document.documentElement.lang] !== undefined ) { + locale = document.documentElement.lang +} + +let doc_url_root = DOCUMENTATION_OPTIONS.URL_ROOT; +if (doc_url_root == '#') { + doc_url_root = ''; +} + +/** + * SVG files for our copy buttons + */ +let iconCheck = ` + ${messages[locale]['copy_success']} + + +` + +// If the user specified their own SVG use that, otherwise use the default +let iconCopy = ``; +if (!iconCopy) { + iconCopy = ` + ${messages[locale]['copy_to_clipboard']} + + + +` +} + +/** + * Set up copy/paste for code blocks + */ + +const runWhenDOMLoaded = cb => { + if (document.readyState != 'loading') { + cb() + } else if (document.addEventListener) { + document.addEventListener('DOMContentLoaded', cb) + } else { + document.attachEvent('onreadystatechange', function() { + if (document.readyState == 'complete') cb() + }) + } +} + +const codeCellId = index => `codecell${index}` + +// Clears selected text since ClipboardJS will select the text when copying +const clearSelection = () => { + if (window.getSelection) { + window.getSelection().removeAllRanges() + } else if (document.selection) { + document.selection.empty() + } +} + +// Changes tooltip text for a moment, then changes it back +// We want the timeout of our `success` class to be a bit shorter than the +// tooltip and icon change, so that we can hide the icon before changing back. +var timeoutIcon = 2000; +var timeoutSuccessClass = 1500; + +const temporarilyChangeTooltip = (el, oldText, newText) => { + el.setAttribute('data-tooltip', newText) + el.classList.add('success') + // Remove success a little bit sooner than we change the tooltip + // So that we can use CSS to hide the copybutton first + setTimeout(() => el.classList.remove('success'), timeoutSuccessClass) + setTimeout(() => el.setAttribute('data-tooltip', oldText), timeoutIcon) +} + +// Changes the copy button icon for two seconds, then changes it back +const temporarilyChangeIcon = (el) => { + el.innerHTML = iconCheck; + setTimeout(() => {el.innerHTML = iconCopy}, timeoutIcon) +} + +const addCopyButtonToCodeCells = () => { + // If ClipboardJS hasn't loaded, wait a bit and try again. This + // happens because we load ClipboardJS asynchronously. + if (window.ClipboardJS === undefined) { + setTimeout(addCopyButtonToCodeCells, 250) + return + } + + // Add copybuttons to all of our code cells + const COPYBUTTON_SELECTOR = 'div.highlight pre'; + const codeCells = document.querySelectorAll(COPYBUTTON_SELECTOR) + codeCells.forEach((codeCell, index) => { + const id = codeCellId(index) + codeCell.setAttribute('id', id) + + const clipboardButton = id => + `` + codeCell.insertAdjacentHTML('afterend', clipboardButton(id)) + }) + +function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +} + +/** + * Removes excluded text from a Node. + * + * @param {Node} target Node to filter. + * @param {string} exclude CSS selector of nodes to exclude. + * @returns {DOMString} Text from `target` with text removed. + */ +function filterText(target, exclude) { + const clone = target.cloneNode(true); // clone as to not modify the live DOM + if (exclude) { + // remove excluded nodes + clone.querySelectorAll(exclude).forEach(node => node.remove()); + } + return clone.innerText; +} + +// Callback when a copy button is clicked. Will be passed the node that was clicked +// should then grab the text and replace pieces of text that shouldn't be used in output +function formatCopyText(textContent, copybuttonPromptText, isRegexp = false, onlyCopyPromptLines = true, removePrompts = true, copyEmptyLines = true, lineContinuationChar = "", hereDocDelim = "") { + var regexp; + var match; + + // Do we check for line continuation characters and "HERE-documents"? + var useLineCont = !!lineContinuationChar + var useHereDoc = !!hereDocDelim + + // create regexp to capture prompt and remaining line + if (isRegexp) { + regexp = new RegExp('^(' + copybuttonPromptText + ')(.*)') + } else { + regexp = new RegExp('^(' + escapeRegExp(copybuttonPromptText) + ')(.*)') + } + + const outputLines = []; + var promptFound = false; + var gotLineCont = false; + var gotHereDoc = false; + const lineGotPrompt = []; + for (const line of textContent.split('\n')) { + match = line.match(regexp) + if (match || gotLineCont || gotHereDoc) { + promptFound = regexp.test(line) + lineGotPrompt.push(promptFound) + if (removePrompts && promptFound) { + outputLines.push(match[2]) + } else { + outputLines.push(line) + } + gotLineCont = line.endsWith(lineContinuationChar) & useLineCont + if (line.includes(hereDocDelim) & useHereDoc) + gotHereDoc = !gotHereDoc + } else if (!onlyCopyPromptLines) { + outputLines.push(line) + } else if (copyEmptyLines && line.trim() === '') { + outputLines.push(line) + } + } + + // If no lines with the prompt were found then just use original lines + if (lineGotPrompt.some(v => v === true)) { + textContent = outputLines.join('\n'); + } + + // Remove a trailing newline to avoid auto-running when pasting + if (textContent.endsWith("\n")) { + textContent = textContent.slice(0, -1) + } + return textContent +} + + +var copyTargetText = (trigger) => { + var target = document.querySelector(trigger.attributes['data-clipboard-target'].value); + + // get filtered text + let exclude = '.linenos'; + + let text = filterText(target, exclude); + return formatCopyText(text, '', false, true, true, true, '', '') +} + + // Initialize with a callback so we can modify the text before copy + const clipboard = new ClipboardJS('.copybtn', {text: copyTargetText}) + + // Update UI with error/success messages + clipboard.on('success', event => { + clearSelection() + temporarilyChangeTooltip(event.trigger, messages[locale]['copy'], messages[locale]['copy_success']) + temporarilyChangeIcon(event.trigger) + }) + + clipboard.on('error', event => { + temporarilyChangeTooltip(event.trigger, messages[locale]['copy'], messages[locale]['copy_failure']) + }) +} + +runWhenDOMLoaded(addCopyButtonToCodeCells) \ No newline at end of file diff --git a/_static/copybutton_funcs.js b/_static/copybutton_funcs.js new file mode 100644 index 00000000..dbe1aaad --- /dev/null +++ b/_static/copybutton_funcs.js @@ -0,0 +1,73 @@ +function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +} + +/** + * Removes excluded text from a Node. + * + * @param {Node} target Node to filter. + * @param {string} exclude CSS selector of nodes to exclude. + * @returns {DOMString} Text from `target` with text removed. + */ +export function filterText(target, exclude) { + const clone = target.cloneNode(true); // clone as to not modify the live DOM + if (exclude) { + // remove excluded nodes + clone.querySelectorAll(exclude).forEach(node => node.remove()); + } + return clone.innerText; +} + +// Callback when a copy button is clicked. Will be passed the node that was clicked +// should then grab the text and replace pieces of text that shouldn't be used in output +export function formatCopyText(textContent, copybuttonPromptText, isRegexp = false, onlyCopyPromptLines = true, removePrompts = true, copyEmptyLines = true, lineContinuationChar = "", hereDocDelim = "") { + var regexp; + var match; + + // Do we check for line continuation characters and "HERE-documents"? + var useLineCont = !!lineContinuationChar + var useHereDoc = !!hereDocDelim + + // create regexp to capture prompt and remaining line + if (isRegexp) { + regexp = new RegExp('^(' + copybuttonPromptText + ')(.*)') + } else { + regexp = new RegExp('^(' + escapeRegExp(copybuttonPromptText) + ')(.*)') + } + + const outputLines = []; + var promptFound = false; + var gotLineCont = false; + var gotHereDoc = false; + const lineGotPrompt = []; + for (const line of textContent.split('\n')) { + match = line.match(regexp) + if (match || gotLineCont || gotHereDoc) { + promptFound = regexp.test(line) + lineGotPrompt.push(promptFound) + if (removePrompts && promptFound) { + outputLines.push(match[2]) + } else { + outputLines.push(line) + } + gotLineCont = line.endsWith(lineContinuationChar) & useLineCont + if (line.includes(hereDocDelim) & useHereDoc) + gotHereDoc = !gotHereDoc + } else if (!onlyCopyPromptLines) { + outputLines.push(line) + } else if (copyEmptyLines && line.trim() === '') { + outputLines.push(line) + } + } + + // If no lines with the prompt were found then just use original lines + if (lineGotPrompt.some(v => v === true)) { + textContent = outputLines.join('\n'); + } + + // Remove a trailing newline to avoid auto-running when pasting + if (textContent.endsWith("\n")) { + textContent = textContent.slice(0, -1) + } + return textContent +} diff --git a/_static/css/badge_only.css b/_static/css/badge_only.css new file mode 100644 index 00000000..c718cee4 --- /dev/null +++ b/_static/css/badge_only.css @@ -0,0 +1 @@ +.clearfix{*zoom:1}.clearfix:after,.clearfix:before{display:table;content:""}.clearfix:after{clear:both}@font-face{font-family:FontAwesome;font-style:normal;font-weight:400;src:url(fonts/fontawesome-webfont.eot?674f50d287a8c48dc19ba404d20fe713?#iefix) format("embedded-opentype"),url(fonts/fontawesome-webfont.woff2?af7ae505a9eed503f8b8e6982036873e) format("woff2"),url(fonts/fontawesome-webfont.woff?fee66e712a8a08eef5805a46892932ad) format("woff"),url(fonts/fontawesome-webfont.ttf?b06871f281fee6b241d60582ae9369b9) format("truetype"),url(fonts/fontawesome-webfont.svg?912ec66d7572ff821749319396470bde#FontAwesome) format("svg")}.fa:before{font-family:FontAwesome;font-style:normal;font-weight:400;line-height:1}.fa:before,a .fa{text-decoration:inherit}.fa:before,a .fa,li .fa{display:inline-block}li .fa-large:before{width:1.875em}ul.fas{list-style-type:none;margin-left:2em;text-indent:-.8em}ul.fas li .fa{width:.8em}ul.fas li .fa-large:before{vertical-align:baseline}.fa-book:before,.icon-book:before{content:"\f02d"}.fa-caret-down:before,.icon-caret-down:before{content:"\f0d7"}.fa-caret-up:before,.icon-caret-up:before{content:"\f0d8"}.fa-caret-left:before,.icon-caret-left:before{content:"\f0d9"}.fa-caret-right:before,.icon-caret-right:before{content:"\f0da"}.rst-versions{position:fixed;bottom:0;left:0;width:300px;color:#fcfcfc;background:#1f1d1d;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;z-index:400}.rst-versions a{color:#2980b9;text-decoration:none}.rst-versions .rst-badge-small{display:none}.rst-versions .rst-current-version{padding:12px;background-color:#272525;display:block;text-align:right;font-size:90%;cursor:pointer;color:#27ae60}.rst-versions .rst-current-version:after{clear:both;content:"";display:block}.rst-versions .rst-current-version .fa{color:#fcfcfc}.rst-versions .rst-current-version .fa-book,.rst-versions .rst-current-version .icon-book{float:left}.rst-versions .rst-current-version.rst-out-of-date{background-color:#e74c3c;color:#fff}.rst-versions .rst-current-version.rst-active-old-version{background-color:#f1c40f;color:#000}.rst-versions.shift-up{height:auto;max-height:100%;overflow-y:scroll}.rst-versions.shift-up .rst-other-versions{display:block}.rst-versions .rst-other-versions{font-size:90%;padding:12px;color:grey;display:none}.rst-versions .rst-other-versions hr{display:block;height:1px;border:0;margin:20px 0;padding:0;border-top:1px solid #413d3d}.rst-versions .rst-other-versions dd{display:inline-block;margin:0}.rst-versions .rst-other-versions dd a{display:inline-block;padding:6px;color:#fcfcfc}.rst-versions.rst-badge{width:auto;bottom:20px;right:20px;left:auto;border:none;max-width:300px;max-height:90%}.rst-versions.rst-badge .fa-book,.rst-versions.rst-badge .icon-book{float:none;line-height:30px}.rst-versions.rst-badge.shift-up .rst-current-version{text-align:right}.rst-versions.rst-badge.shift-up .rst-current-version .fa-book,.rst-versions.rst-badge.shift-up .rst-current-version .icon-book{float:left}.rst-versions.rst-badge>.rst-current-version{width:auto;height:30px;line-height:30px;padding:0 6px;display:block;text-align:center}@media screen and (max-width:768px){.rst-versions{width:85%;display:none}.rst-versions.shift{display:block}} \ No newline at end of file diff --git a/_static/css/dark.css b/_static/css/dark.css new file mode 100644 index 00000000..8866c07e --- /dev/null +++ b/_static/css/dark.css @@ -0,0 +1,1996 @@ +@media (prefers-color-scheme: dark) { + html { + background-color: #181a1b !important; + } + + html, body, input, textarea, select, button { + background-color: #181a1b; + } + + html, body, input, textarea, select, button { + border-color: #736b5e; + color: #e8e6e3; + } + + a { + color: #3391ff; + } + + table { + border-color: #545b5e; + } + + ::placeholder { + color: #b2aba1; + } + + input:-webkit-autofill, + textarea:-webkit-autofill, + select:-webkit-autofill { + background-color: #555b00 !important; + color: #e8e6e3 !important; + } + + ::selection { + background-color: #004daa !important; + color: #e8e6e3 !important; + } + + ::-moz-selection { + background-color: #004daa !important; + color: #e8e6e3 !important; + } + + /* Invert Style */ + .jfk-bubble.gtx-bubble, embed[type="application/pdf"] { + filter: invert(100%) hue-rotate(180deg) contrast(90%) !important; + } + + /* Override Style */ + .vimvixen-hint { + background-color: #7b5300 !important; + border-color: #d8b013 !important; + color: #f3e8c8 !important; + } + + ::placeholder { + opacity: 0.5 !important; + } + + /* Variables Style */ + :root { + --darkreader-neutral-background: #181a1b; + --darkreader-neutral-text: #e8e6e3; + --darkreader-selection-background: #004daa; + --darkreader-selection-text: #e8e6e3; + } + + /* Modified CSS */ + a:hover, + a:active { + outline-color: initial; + } + + abbr[title] { + border-bottom-color: initial; + } + + ins { + background-image: initial; + background-color: rgb(112, 112, 0); + color: rgb(232, 230, 227); + text-decoration-color: initial; + } + + mark { + background-image: initial; + background-color: rgb(204, 204, 0); + color: rgb(232, 230, 227); + } + + ul, + ol, + dl { + list-style-image: none; + } + + li { + list-style-image: initial; + } + + img { + border-color: initial; + } + + fieldset { + border-color: initial; + } + + legend { + border-color: initial; + } + + .chromeframe { + background-image: initial; + background-color: rgb(53, 57, 59); + color: rgb(232, 230, 227); + } + + .ir { + border-color: initial; + background-color: transparent; + } + + .visuallyhidden { + border-color: initial; + } + + .fa-border { + border-color: rgb(53, 57, 59); + } + + .fa-inverse { + color: rgb(232, 230, 227); + } + + .sr-only { + border-color: initial; + } + + .fa::before, + .wy-menu-vertical li span.toctree-expand::before, + .wy-menu-vertical li.on a span.toctree-expand::before, + .wy-menu-vertical li.current > a span.toctree-expand::before, + .rst-content .admonition-title::before, + .rst-content h1 .headerlink::before, + .rst-content h2 .headerlink::before, + .rst-content h3 .headerlink::before, + .rst-content h4 .headerlink::before, + .rst-content h5 .headerlink::before, + .rst-content h6 .headerlink::before, + .rst-content dl dt .headerlink::before, + .rst-content p.caption .headerlink::before, + .rst-content table > caption .headerlink::before, + .rst-content .code-block-caption .headerlink::before, + .rst-content tt.download span:first-child::before, + .rst-content code.download span:first-child::before, + .icon::before, + .wy-dropdown .caret::before, + .wy-inline-validate.wy-inline-validate-success .wy-input-context::before, + .wy-inline-validate.wy-inline-validate-danger .wy-input-context::before, + .wy-inline-validate.wy-inline-validate-warning .wy-input-context::before, + .wy-inline-validate.wy-inline-validate-info .wy-input-context::before { + text-decoration-color: inherit; + } + + a .fa, + a .wy-menu-vertical li span.toctree-expand, + .wy-menu-vertical li a span.toctree-expand, + .wy-menu-vertical li.on a span.toctree-expand, + .wy-menu-vertical li.current > a span.toctree-expand, + a .rst-content .admonition-title, + .rst-content a .admonition-title, + a .rst-content h1 .headerlink, + .rst-content h1 a .headerlink, + a .rst-content h2 .headerlink, + .rst-content h2 a .headerlink, + a .rst-content h3 .headerlink, + .rst-content h3 a .headerlink, + a .rst-content h4 .headerlink, + .rst-content h4 a .headerlink, + a .rst-content h5 .headerlink, + .rst-content h5 a .headerlink, + a .rst-content h6 .headerlink, + .rst-content h6 a .headerlink, + a .rst-content dl dt .headerlink, + .rst-content dl dt a .headerlink, + a .rst-content p.caption .headerlink, + .rst-content p.caption a .headerlink, + a .rst-content table > caption .headerlink, + .rst-content table > caption a .headerlink, + a .rst-content .code-block-caption .headerlink, + .rst-content .code-block-caption a .headerlink, + a .rst-content tt.download span:first-child, + .rst-content tt.download a span:first-child, + a .rst-content code.download span:first-child, + .rst-content code.download a span:first-child, + a .icon { + text-decoration-color: inherit; + } + + .wy-alert, + .rst-content .note, + .rst-content .attention, + .rst-content .caution, + .rst-content .danger, + .rst-content .error, + .rst-content .hint, + .rst-content .important, + .rst-content .tip, + .rst-content .warning, + .rst-content .seealso, + .rst-content .admonition-todo, + .rst-content .admonition { + background-image: initial; + background-color: rgb(32, 35, 36); + } + + .wy-alert-title, + .rst-content .admonition-title { + color: rgb(232, 230, 227); + background-image: initial; + background-color: rgb(29, 91, 131); + } + + .wy-alert.wy-alert-danger, + .rst-content .wy-alert-danger.note, + .rst-content .wy-alert-danger.attention, + .rst-content .wy-alert-danger.caution, + .rst-content .danger, + .rst-content .error, + .rst-content .wy-alert-danger.hint, + .rst-content .wy-alert-danger.important, + .rst-content .wy-alert-danger.tip, + .rst-content .wy-alert-danger.warning, + .rst-content .wy-alert-danger.seealso, + .rst-content .wy-alert-danger.admonition-todo, + .rst-content .wy-alert-danger.admonition { + background-image: initial; + background-color: rgb(52, 12, 8); + } + + .wy-alert.wy-alert-danger .wy-alert-title, + .rst-content .wy-alert-danger.note .wy-alert-title, + .rst-content .wy-alert-danger.attention .wy-alert-title, + .rst-content .wy-alert-danger.caution .wy-alert-title, + .rst-content .danger .wy-alert-title, + .rst-content .error .wy-alert-title, + .rst-content .wy-alert-danger.hint .wy-alert-title, + .rst-content .wy-alert-danger.important .wy-alert-title, + .rst-content .wy-alert-danger.tip .wy-alert-title, + .rst-content .wy-alert-danger.warning .wy-alert-title, + .rst-content .wy-alert-danger.seealso .wy-alert-title, + .rst-content .wy-alert-danger.admonition-todo .wy-alert-title, + .rst-content .wy-alert-danger.admonition .wy-alert-title, + .wy-alert.wy-alert-danger .rst-content .admonition-title, + .rst-content .wy-alert.wy-alert-danger .admonition-title, + .rst-content .wy-alert-danger.note .admonition-title, + .rst-content .wy-alert-danger.attention .admonition-title, + .rst-content .wy-alert-danger.caution .admonition-title, + .rst-content .danger .admonition-title, + .rst-content .error .admonition-title, + .rst-content .wy-alert-danger.hint .admonition-title, + .rst-content .wy-alert-danger.important .admonition-title, + .rst-content .wy-alert-danger.tip .admonition-title, + .rst-content .wy-alert-danger.warning .admonition-title, + .rst-content .wy-alert-danger.seealso .admonition-title, + .rst-content .wy-alert-danger.admonition-todo .admonition-title, + .rst-content .wy-alert-danger.admonition .admonition-title { + background-image: initial; + background-color: rgb(108, 22, 13); + } + + .wy-alert.wy-alert-warning, + .rst-content .wy-alert-warning.note, + .rst-content .attention, + .rst-content .caution, + .rst-content .wy-alert-warning.danger, + .rst-content .wy-alert-warning.error, + .rst-content .wy-alert-warning.hint, + .rst-content .wy-alert-warning.important, + .rst-content .wy-alert-warning.tip, + .rst-content .warning, + .rst-content .wy-alert-warning.seealso, + .rst-content .admonition-todo, + .rst-content .wy-alert-warning.admonition { + background-image: initial; + background-color: rgb(82, 53, 0); + } + + .wy-alert.wy-alert-warning .wy-alert-title, + .rst-content .wy-alert-warning.note .wy-alert-title, + .rst-content .attention .wy-alert-title, + .rst-content .caution .wy-alert-title, + .rst-content .wy-alert-warning.danger .wy-alert-title, + .rst-content .wy-alert-warning.error .wy-alert-title, + .rst-content .wy-alert-warning.hint .wy-alert-title, + .rst-content .wy-alert-warning.important .wy-alert-title, + .rst-content .wy-alert-warning.tip .wy-alert-title, + .rst-content .warning .wy-alert-title, + .rst-content .wy-alert-warning.seealso .wy-alert-title, + .rst-content .admonition-todo .wy-alert-title, + .rst-content .wy-alert-warning.admonition .wy-alert-title, + .wy-alert.wy-alert-warning .rst-content .admonition-title, + .rst-content .wy-alert.wy-alert-warning .admonition-title, + .rst-content .wy-alert-warning.note .admonition-title, + .rst-content .attention .admonition-title, + .rst-content .caution .admonition-title, + .rst-content .wy-alert-warning.danger .admonition-title, + .rst-content .wy-alert-warning.error .admonition-title, + .rst-content .wy-alert-warning.hint .admonition-title, + .rst-content .wy-alert-warning.important .admonition-title, + .rst-content .wy-alert-warning.tip .admonition-title, + .rst-content .warning .admonition-title, + .rst-content .wy-alert-warning.seealso .admonition-title, + .rst-content .admonition-todo .admonition-title, + .rst-content .wy-alert-warning.admonition .admonition-title { + background-image: initial; + background-color: rgb(123, 65, 14); + } + + .wy-alert.wy-alert-info, + .rst-content .note, + .rst-content .wy-alert-info.attention, + .rst-content .wy-alert-info.caution, + .rst-content .wy-alert-info.danger, + .rst-content .wy-alert-info.error, + .rst-content .wy-alert-info.hint, + .rst-content .wy-alert-info.important, + .rst-content .wy-alert-info.tip, + .rst-content .wy-alert-info.warning, + .rst-content .seealso, + .rst-content .wy-alert-info.admonition-todo, + .rst-content .wy-alert-info.admonition { + background-image: initial; + background-color: rgb(32, 35, 36); + } + + .wy-alert.wy-alert-info .wy-alert-title, + .rst-content .note .wy-alert-title, + .rst-content .wy-alert-info.attention .wy-alert-title, + .rst-content .wy-alert-info.caution .wy-alert-title, + .rst-content .wy-alert-info.danger .wy-alert-title, + .rst-content .wy-alert-info.error .wy-alert-title, + .rst-content .wy-alert-info.hint .wy-alert-title, + .rst-content .wy-alert-info.important .wy-alert-title, + .rst-content .wy-alert-info.tip .wy-alert-title, + .rst-content .wy-alert-info.warning .wy-alert-title, + .rst-content .seealso .wy-alert-title, + .rst-content .wy-alert-info.admonition-todo .wy-alert-title, + .rst-content .wy-alert-info.admonition .wy-alert-title, + .wy-alert.wy-alert-info .rst-content .admonition-title, + .rst-content .wy-alert.wy-alert-info .admonition-title, + .rst-content .note .admonition-title, + .rst-content .wy-alert-info.attention .admonition-title, + .rst-content .wy-alert-info.caution .admonition-title, + .rst-content .wy-alert-info.danger .admonition-title, + .rst-content .wy-alert-info.error .admonition-title, + .rst-content .wy-alert-info.hint .admonition-title, + .rst-content .wy-alert-info.important .admonition-title, + .rst-content .wy-alert-info.tip .admonition-title, + .rst-content .wy-alert-info.warning .admonition-title, + .rst-content .seealso .admonition-title, + .rst-content .wy-alert-info.admonition-todo .admonition-title, + .rst-content .wy-alert-info.admonition .admonition-title { + background-image: initial; + background-color: rgb(29, 91, 131); + } + + .wy-alert.wy-alert-success, + .rst-content .wy-alert-success.note, + .rst-content .wy-alert-success.attention, + .rst-content .wy-alert-success.caution, + .rst-content .wy-alert-success.danger, + .rst-content .wy-alert-success.error, + .rst-content .hint, + .rst-content .important, + .rst-content .tip, + .rst-content .wy-alert-success.warning, + .rst-content .wy-alert-success.seealso, + .rst-content .wy-alert-success.admonition-todo, + .rst-content .wy-alert-success.admonition { + background-image: initial; + background-color: rgb(9, 66, 58); + } + + .wy-alert.wy-alert-success .wy-alert-title, + .rst-content .wy-alert-success.note .wy-alert-title, + .rst-content .wy-alert-success.attention .wy-alert-title, + .rst-content .wy-alert-success.caution .wy-alert-title, + .rst-content .wy-alert-success.danger .wy-alert-title, + .rst-content .wy-alert-success.error .wy-alert-title, + .rst-content .hint .wy-alert-title, + .rst-content .important .wy-alert-title, + .rst-content .tip .wy-alert-title, + .rst-content .wy-alert-success.warning .wy-alert-title, + .rst-content .wy-alert-success.seealso .wy-alert-title, + .rst-content .wy-alert-success.admonition-todo .wy-alert-title, + .rst-content .wy-alert-success.admonition .wy-alert-title, + .wy-alert.wy-alert-success .rst-content .admonition-title, + .rst-content .wy-alert.wy-alert-success .admonition-title, + .rst-content .wy-alert-success.note .admonition-title, + .rst-content .wy-alert-success.attention .admonition-title, + .rst-content .wy-alert-success.caution .admonition-title, + .rst-content .wy-alert-success.danger .admonition-title, + .rst-content .wy-alert-success.error .admonition-title, + .rst-content .hint .admonition-title, + .rst-content .important .admonition-title, + .rst-content .tip .admonition-title, + .rst-content .wy-alert-success.warning .admonition-title, + .rst-content .wy-alert-success.seealso .admonition-title, + .rst-content .wy-alert-success.admonition-todo .admonition-title, + .rst-content .wy-alert-success.admonition .admonition-title { + background-image: initial; + background-color: rgb(21, 150, 125); + } + + .wy-alert.wy-alert-neutral, + .rst-content .wy-alert-neutral.note, + .rst-content .wy-alert-neutral.attention, + .rst-content .wy-alert-neutral.caution, + .rst-content .wy-alert-neutral.danger, + .rst-content .wy-alert-neutral.error, + .rst-content .wy-alert-neutral.hint, + .rst-content .wy-alert-neutral.important, + .rst-content .wy-alert-neutral.tip, + .rst-content .wy-alert-neutral.warning, + .rst-content .wy-alert-neutral.seealso, + .rst-content .wy-alert-neutral.admonition-todo, + .rst-content .wy-alert-neutral.admonition { + background-image: initial; + background-color: rgb(27, 36, 36); + } + + .wy-alert.wy-alert-neutral .wy-alert-title, + .rst-content .wy-alert-neutral.note .wy-alert-title, + .rst-content .wy-alert-neutral.attention .wy-alert-title, + .rst-content .wy-alert-neutral.caution .wy-alert-title, + .rst-content .wy-alert-neutral.danger .wy-alert-title, + .rst-content .wy-alert-neutral.error .wy-alert-title, + .rst-content .wy-alert-neutral.hint .wy-alert-title, + .rst-content .wy-alert-neutral.important .wy-alert-title, + .rst-content .wy-alert-neutral.tip .wy-alert-title, + .rst-content .wy-alert-neutral.warning .wy-alert-title, + .rst-content .wy-alert-neutral.seealso .wy-alert-title, + .rst-content .wy-alert-neutral.admonition-todo .wy-alert-title, + .rst-content .wy-alert-neutral.admonition .wy-alert-title, + .wy-alert.wy-alert-neutral .rst-content .admonition-title, + .rst-content .wy-alert.wy-alert-neutral .admonition-title, + .rst-content .wy-alert-neutral.note .admonition-title, + .rst-content .wy-alert-neutral.attention .admonition-title, + .rst-content .wy-alert-neutral.caution .admonition-title, + .rst-content .wy-alert-neutral.danger .admonition-title, + .rst-content .wy-alert-neutral.error .admonition-title, + .rst-content .wy-alert-neutral.hint .admonition-title, + .rst-content .wy-alert-neutral.important .admonition-title, + .rst-content .wy-alert-neutral.tip .admonition-title, + .rst-content .wy-alert-neutral.warning .admonition-title, + .rst-content .wy-alert-neutral.seealso .admonition-title, + .rst-content .wy-alert-neutral.admonition-todo .admonition-title, + .rst-content .wy-alert-neutral.admonition .admonition-title { + color: rgb(192, 186, 178); + background-image: initial; + background-color: rgb(40, 43, 45); + } + + .wy-alert.wy-alert-neutral a, + .rst-content .wy-alert-neutral.note a, + .rst-content .wy-alert-neutral.attention a, + .rst-content .wy-alert-neutral.caution a, + .rst-content .wy-alert-neutral.danger a, + .rst-content .wy-alert-neutral.error a, + .rst-content .wy-alert-neutral.hint a, + .rst-content .wy-alert-neutral.important a, + .rst-content .wy-alert-neutral.tip a, + .rst-content .wy-alert-neutral.warning a, + .rst-content .wy-alert-neutral.seealso a, + .rst-content .wy-alert-neutral.admonition-todo a, + .rst-content .wy-alert-neutral.admonition a { + color: rgb(84, 164, 217); + } + + .wy-tray-container li { + background-image: initial; + background-color: transparent; + color: rgb(232, 230, 227); + box-shadow: rgba(0, 0, 0, 0.1) 0px 5px 5px 0px; + } + + .wy-tray-container li.wy-tray-item-success { + background-image: initial; + background-color: rgb(31, 139, 77); + } + + .wy-tray-container li.wy-tray-item-info { + background-image: initial; + background-color: rgb(33, 102, 148); + } + + .wy-tray-container li.wy-tray-item-warning { + background-image: initial; + background-color: rgb(178, 94, 20); + } + + .wy-tray-container li.wy-tray-item-danger { + background-image: initial; + background-color: rgb(162, 33, 20); + } + + .btn { + color: rgb(232, 230, 227); + border-color: rgba(140, 130, 115, 0.1); + background-color: rgb(31, 139, 77); + text-decoration-color: initial; + box-shadow: rgba(24, 26, 27, 0.5) 0px 1px 2px -1px inset, + rgba(0, 0, 0, 0.1) 0px -2px 0px 0px inset; + } + + .btn-hover { + background-image: initial; + background-color: rgb(37, 114, 165); + color: rgb(232, 230, 227); + } + + .btn:hover { + background-image: initial; + background-color: rgb(35, 156, 86); + color: rgb(232, 230, 227); + } + + .btn:focus { + background-image: initial; + background-color: rgb(35, 156, 86); + outline-color: initial; + } + + .btn:active { + box-shadow: rgba(0, 0, 0, 0.05) 0px -1px 0px 0px inset, + rgba(0, 0, 0, 0.1) 0px 2px 0px 0px inset; + } + + .btn:visited { + color: rgb(232, 230, 227); + } + + .btn:disabled { + background-image: none; + box-shadow: none; + } + + .btn-disabled { + background-image: none; + box-shadow: none; + } + + .btn-disabled:hover, + .btn-disabled:focus, + .btn-disabled:active { + background-image: none; + box-shadow: none; + } + + .btn-info { + background-color: rgb(33, 102, 148) !important; + } + + .btn-info:hover { + background-color: rgb(37, 114, 165) !important; + } + + .btn-neutral { + background-color: rgb(27, 36, 36) !important; + color: rgb(192, 186, 178) !important; + } + + .btn-neutral:hover { + color: rgb(192, 186, 178); + background-color: rgb(34, 44, 44) !important; + } + + .btn-neutral:visited { + color: rgb(192, 186, 178) !important; + } + + .btn-success { + background-color: rgb(31, 139, 77) !important; + } + + .btn-success:hover { + background-color: rgb(27, 122, 68) !important; + } + + .btn-danger { + background-color: rgb(162, 33, 20) !important; + } + + .btn-danger:hover { + background-color: rgb(149, 30, 18) !important; + } + + .btn-warning { + background-color: rgb(178, 94, 20) !important; + } + + .btn-warning:hover { + background-color: rgb(165, 87, 18) !important; + } + + .btn-invert { + background-color: rgb(26, 28, 29); + } + + .btn-invert:hover { + background-color: rgb(35, 38, 40) !important; + } + + .btn-link { + color: rgb(84, 164, 217); + box-shadow: none; + background-color: transparent !important; + border-color: transparent !important; + } + + .btn-link:hover { + box-shadow: none; + background-color: transparent !important; + color: rgb(79, 162, 216) !important; + } + + .btn-link:active { + box-shadow: none; + background-color: transparent !important; + color: rgb(79, 162, 216) !important; + } + + .btn-link:visited { + color: rgb(164, 103, 188); + } + + .wy-dropdown-menu { + background-image: initial; + background-color: rgb(26, 28, 29); + border-color: rgb(60, 65, 67); + box-shadow: rgba(0, 0, 0, 0.1) 0px 2px 2px 0px; + } + + .wy-dropdown-menu > dd > a { + color: rgb(192, 186, 178); + } + + .wy-dropdown-menu > dd > a:hover { + background-image: initial; + background-color: rgb(33, 102, 148); + color: rgb(232, 230, 227); + } + + .wy-dropdown-menu > dd.divider { + border-top-color: rgb(60, 65, 67); + } + + .wy-dropdown-menu > dd.call-to-action { + background-image: initial; + background-color: rgb(40, 43, 45); + } + + .wy-dropdown-menu > dd.call-to-action:hover { + background-image: initial; + background-color: rgb(40, 43, 45); + } + + .wy-dropdown-menu > dd.call-to-action .btn { + color: rgb(232, 230, 227); + } + + .wy-dropdown.wy-dropdown-bubble .wy-dropdown-menu { + background-image: initial; + background-color: rgb(26, 28, 29); + } + + .wy-dropdown.wy-dropdown-bubble .wy-dropdown-menu a:hover { + background-image: initial; + background-color: rgb(33, 102, 148); + color: rgb(232, 230, 227); + } + + .wy-dropdown-arrow::before { + border-bottom-color: rgb(51, 55, 57); + border-left-color: transparent; + border-right-color: transparent; + } + + fieldset { + border-color: initial; + } + + legend { + border-color: initial; + } + + label { + color: rgb(200, 195, 188); + } + + .wy-control-group.wy-control-group-required > label::after { + color: rgb(233, 88, 73); + } + + .wy-form-message-inline { + color: rgb(168, 160, 149); + } + + .wy-form-message { + color: rgb(168, 160, 149); + } + + input[type="text"], input[type="password"], input[type="email"], input[type="url"], input[type="date"], input[type="month"], input[type="time"], input[type="datetime"], input[type="datetime-local"], input[type="week"], input[type="number"], input[type="search"], input[type="tel"], input[type="color"] { + border-color: rgb(62, 68, 70); + box-shadow: rgb(43, 47, 49) 0px 1px 3px inset; + } + + input[type="text"]:focus, input[type="password"]:focus, input[type="email"]:focus, input[type="url"]:focus, input[type="date"]:focus, input[type="month"]:focus, input[type="time"]:focus, input[type="datetime"]:focus, input[type="datetime-local"]:focus, input[type="week"]:focus, input[type="number"]:focus, input[type="search"]:focus, input[type="tel"]:focus, input[type="color"]:focus { + outline-color: initial; + border-color: rgb(123, 114, 101); + } + + input.no-focus:focus { + border-color: rgb(62, 68, 70) !important; + } + + input[type="file"]:focus, input[type="radio"]:focus, input[type="checkbox"]:focus { + outline-color: rgb(13, 113, 167); + } + + input[type="text"][disabled], input[type="password"][disabled], input[type="email"][disabled], input[type="url"][disabled], input[type="date"][disabled], input[type="month"][disabled], input[type="time"][disabled], input[type="datetime"][disabled], input[type="datetime-local"][disabled], input[type="week"][disabled], input[type="number"][disabled], input[type="search"][disabled], input[type="tel"][disabled], input[type="color"][disabled] { + background-color: rgb(27, 29, 30); + } + + input:focus:invalid, + textarea:focus:invalid, + select:focus:invalid { + color: rgb(233, 88, 73); + border-color: rgb(149, 31, 18); + } + + input:focus:invalid:focus, + textarea:focus:invalid:focus, + select:focus:invalid:focus { + border-color: rgb(149, 31, 18); + } + + input[type="file"]:focus:invalid:focus, input[type="radio"]:focus:invalid:focus, input[type="checkbox"]:focus:invalid:focus { + outline-color: rgb(149, 31, 18); + } + + select, + textarea { + border-color: rgb(62, 68, 70); + box-shadow: rgb(43, 47, 49) 0px 1px 3px inset; + } + + select { + border-color: rgb(62, 68, 70); + background-color: rgb(24, 26, 27); + } + + select:focus, + textarea:focus { + outline-color: initial; + } + + select[disabled], + textarea[disabled], + input[readonly], + select[readonly], + textarea[readonly] { + background-color: rgb(27, 29, 30); + } + + .wy-checkbox, + .wy-radio { + color: rgb(192, 186, 178); + } + + .wy-input-prefix .wy-input-context, + .wy-input-suffix .wy-input-context { + background-color: rgb(27, 36, 36); + border-color: rgb(62, 68, 70); + color: rgb(168, 160, 149); + } + + .wy-input-suffix .wy-input-context { + border-left-color: initial; + } + + .wy-input-prefix .wy-input-context { + border-right-color: initial; + } + + .wy-switch::before { + background-image: initial; + background-color: rgb(53, 57, 59); + } + + .wy-switch::after { + background-image: initial; + background-color: rgb(82, 88, 92); + } + + .wy-switch span { + color: rgb(200, 195, 188); + } + + .wy-switch.active::before { + background-image: initial; + background-color: rgb(24, 106, 58); + } + + .wy-switch.active::after { + background-image: initial; + background-color: rgb(31, 139, 77); + } + + .wy-control-group.wy-control-group-error .wy-form-message, + .wy-control-group.wy-control-group-error > label { + color: rgb(233, 88, 73); + } + + .wy-control-group.wy-control-group-error input[type="text"], .wy-control-group.wy-control-group-error input[type="password"], .wy-control-group.wy-control-group-error input[type="email"], .wy-control-group.wy-control-group-error input[type="url"], .wy-control-group.wy-control-group-error input[type="date"], .wy-control-group.wy-control-group-error input[type="month"], .wy-control-group.wy-control-group-error input[type="time"], .wy-control-group.wy-control-group-error input[type="datetime"], .wy-control-group.wy-control-group-error input[type="datetime-local"], .wy-control-group.wy-control-group-error input[type="week"], .wy-control-group.wy-control-group-error input[type="number"], .wy-control-group.wy-control-group-error input[type="search"], .wy-control-group.wy-control-group-error input[type="tel"], .wy-control-group.wy-control-group-error input[type="color"] { + border-color: rgb(149, 31, 18); + } + + .wy-control-group.wy-control-group-error textarea { + border-color: rgb(149, 31, 18); + } + + .wy-inline-validate.wy-inline-validate-success .wy-input-context { + color: rgb(92, 218, 145); + } + + .wy-inline-validate.wy-inline-validate-danger .wy-input-context { + color: rgb(233, 88, 73); + } + + .wy-inline-validate.wy-inline-validate-warning .wy-input-context { + color: rgb(232, 138, 54); + } + + .wy-inline-validate.wy-inline-validate-info .wy-input-context { + color: rgb(84, 164, 217); + } + + .wy-table caption, + .rst-content table.docutils caption, + .rst-content table.field-list caption { + color: rgb(232, 230, 227); + } + + .wy-table thead, + .rst-content table.docutils thead, + .rst-content table.field-list thead { + color: rgb(232, 230, 227); + } + + .wy-table thead th, + .rst-content table.docutils thead th, + .rst-content table.field-list thead th { + border-bottom-color: rgb(56, 61, 63); + } + + .wy-table td, + .rst-content table.docutils td, + .rst-content table.field-list td { + background-color: transparent; + } + + .wy-table-secondary { + color: rgb(152, 143, 129); + } + + .wy-table-tertiary { + color: rgb(152, 143, 129); + } + + .wy-table-odd td, + .wy-table-striped tr:nth-child(2n-1) td, + .rst-content table.docutils:not(.field-list) tr:nth-child(2n-1) td { + background-color: rgb(27, 36, 36); + } + + .wy-table-backed { + background-color: rgb(27, 36, 36); + } + + .wy-table-bordered-all, + .rst-content table.docutils { + border-color: rgb(56, 61, 63); + } + + .wy-table-bordered-all td, + .rst-content table.docutils td { + border-bottom-color: rgb(56, 61, 63); + border-left-color: rgb(56, 61, 63); + } + + .wy-table-bordered { + border-color: rgb(56, 61, 63); + } + + .wy-table-bordered-rows td { + border-bottom-color: rgb(56, 61, 63); + } + + .wy-table-horizontal td, + .wy-table-horizontal th { + border-bottom-color: rgb(56, 61, 63); + } + + a { + color: rgb(84, 164, 217); + text-decoration-color: initial; + } + + a:hover { + color: rgb(68, 156, 214); + } + + a:visited { + color: rgb(164, 103, 188); + } + + body { + color: rgb(192, 186, 178); + background-image: initial; + background-color: rgb(33, 35, 37); + } + + .wy-text-strike { + text-decoration-color: initial; + } + + .wy-text-warning { + color: rgb(232, 138, 54) !important; + } + + a.wy-text-warning:hover { + color: rgb(236, 157, 87) !important; + } + + .wy-text-info { + color: rgb(84, 164, 217) !important; + } + + a.wy-text-info:hover { + color: rgb(79, 162, 216) !important; + } + + .wy-text-success { + color: rgb(92, 218, 145) !important; + } + + a.wy-text-success:hover { + color: rgb(73, 214, 133) !important; + } + + .wy-text-danger { + color: rgb(233, 88, 73) !important; + } + + a.wy-text-danger:hover { + color: rgb(237, 118, 104) !important; + } + + .wy-text-neutral { + color: rgb(192, 186, 178) !important; + } + + a.wy-text-neutral:hover { + color: rgb(176, 169, 159) !important; + } + + hr { + border-right-color: initial; + border-bottom-color: initial; + border-left-color: initial; + border-top-color: rgb(56, 61, 63); + } + + code, + .rst-content tt, + .rst-content code { + background-image: initial; + background-color: rgb(24, 26, 27); + border-color: rgb(56, 61, 63); + color: rgb(233, 88, 73); + } + + .wy-plain-list-disc, + .rst-content .section ul, + .rst-content .toctree-wrapper ul, + article ul { + list-style-image: initial; + } + + .wy-plain-list-disc li, + .rst-content .section ul li, + .rst-content .toctree-wrapper ul li, + article ul li { + list-style-image: initial; + } + + .wy-plain-list-disc li li, + .rst-content .section ul li li, + .rst-content .toctree-wrapper ul li li, + article ul li li { + list-style-image: initial; + } + + .wy-plain-list-disc li li li, + .rst-content .section ul li li li, + .rst-content .toctree-wrapper ul li li li, + article ul li li li { + list-style-image: initial; + } + + .wy-plain-list-disc li ol li, + .rst-content .section ul li ol li, + .rst-content .toctree-wrapper ul li ol li, + article ul li ol li { + list-style-image: initial; + } + + .wy-plain-list-decimal, + .rst-content .section ol, + .rst-content ol.arabic, + article ol { + list-style-image: initial; + } + + .wy-plain-list-decimal li, + .rst-content .section ol li, + .rst-content ol.arabic li, + article ol li { + list-style-image: initial; + } + + .wy-plain-list-decimal li ul li, + .rst-content .section ol li ul li, + .rst-content ol.arabic li ul li, + article ol li ul li { + list-style-image: initial; + } + + .wy-breadcrumbs li code, + .wy-breadcrumbs li .rst-content tt, + .rst-content .wy-breadcrumbs li tt { + border-color: initial; + background-image: none; + background-color: initial; + } + + .wy-breadcrumbs li code.literal, + .wy-breadcrumbs li .rst-content tt.literal, + .rst-content .wy-breadcrumbs li tt.literal { + color: rgb(192, 186, 178); + } + + .wy-breadcrumbs-extra { + color: rgb(184, 178, 169); + } + + .wy-menu a:hover { + text-decoration-color: initial; + } + + .wy-menu-horiz li:hover { + background-image: initial; + background-color: rgba(24, 26, 27, 0.1); + } + + .wy-menu-horiz li.divide-left { + border-left-color: rgb(119, 110, 98); + } + + .wy-menu-horiz li.divide-right { + border-right-color: rgb(119, 110, 98); + } + + .wy-menu-vertical header, + .wy-menu-vertical p.caption { + color: rgb(99, 161, 201); + } + + .wy-menu-vertical li.divide-top { + border-top-color: rgb(119, 110, 98); + } + + .wy-menu-vertical li.divide-bottom { + border-bottom-color: rgb(119, 110, 98); + } + + .wy-menu-vertical li.current { + background-image: initial; + background-color: rgb(40, 43, 45); + } + + .wy-menu-vertical li.current a { + color: rgb(152, 143, 129); + border-right-color: rgb(63, 69, 71); + } + + .wy-menu-vertical li.current a:hover { + background-image: initial; + background-color: rgb(47, 51, 53); + } + + .wy-menu-vertical li code, + .wy-menu-vertical li .rst-content tt, + .rst-content .wy-menu-vertical li tt { + border-color: initial; + background-image: inherit; + background-color: inherit; + color: inherit; + } + + .wy-menu-vertical li span.toctree-expand { + color: rgb(183, 177, 168); + } + + .wy-menu-vertical li.on a, + .wy-menu-vertical li.current > a { + color: rgb(192, 186, 178); + background-image: initial; + background-color: rgb(26, 28, 29); + border-color: initial; + } + + .wy-menu-vertical li.on a:hover, + .wy-menu-vertical li.current > a:hover { + background-image: initial; + background-color: rgb(26, 28, 29); + } + + .wy-menu-vertical li.on a:hover span.toctree-expand, + .wy-menu-vertical li.current > a:hover span.toctree-expand { + color: rgb(152, 143, 129); + } + + .wy-menu-vertical li.on a span.toctree-expand, + .wy-menu-vertical li.current > a span.toctree-expand { + color: rgb(200, 195, 188); + } + + .wy-menu-vertical li.toctree-l1.current > a { + border-bottom-color: rgb(63, 69, 71); + border-top-color: rgb(63, 69, 71); + } + + .wy-menu-vertical li.toctree-l2 a, + .wy-menu-vertical li.toctree-l3 a, + .wy-menu-vertical li.toctree-l4 a { + color: rgb(192, 186, 178); + } + + .wy-menu-vertical li.toctree-l2.current > a { + background-image: initial; + background-color: rgb(54, 59, 61); + } + + .wy-menu-vertical li.toctree-l2.current li.toctree-l3 > a { + background-image: initial; + background-color: rgb(54, 59, 61); + } + + .wy-menu-vertical li.toctree-l2 a:hover span.toctree-expand { + color: rgb(152, 143, 129); + } + + .wy-menu-vertical li.toctree-l2 span.toctree-expand { + color: rgb(174, 167, 156); + } + + .wy-menu-vertical li.toctree-l3.current > a { + background-image: initial; + background-color: rgb(61, 66, 69); + } + + .wy-menu-vertical li.toctree-l3.current li.toctree-l4 > a { + background-image: initial; + background-color: rgb(61, 66, 69); + } + + .wy-menu-vertical li.toctree-l3 a:hover span.toctree-expand { + color: rgb(152, 143, 129); + } + + .wy-menu-vertical li.toctree-l3 span.toctree-expand { + color: rgb(166, 158, 146); + } + + .wy-menu-vertical li.toctree-l2.current a, + .wy-menu-vertical li.toctree-l3.current a { + background-color: #363636; + } + + .wy-menu-vertical li ul li a { + color: rgb(208, 204, 198); + } + + .wy-menu-vertical a { + color: rgb(208, 204, 198); + } + + .wy-menu-vertical a:hover { + background-color: rgb(57, 62, 64); + } + + .wy-menu-vertical a:hover span.toctree-expand { + color: rgb(208, 204, 198); + } + + .wy-menu-vertical a:active { + background-color: rgb(33, 102, 148); + color: rgb(232, 230, 227); + } + + .wy-menu-vertical a:active span.toctree-expand { + color: rgb(232, 230, 227); + } + + .wy-side-nav-search { + background-color: rgb(33, 102, 148); + color: rgb(230, 228, 225); + } + + .wy-side-nav-search input[type="text"] { + border-color: rgb(35, 111, 160); + } + + .wy-side-nav-search img { + background-color: rgb(33, 102, 148); + } + + .wy-side-nav-search > a, + .wy-side-nav-search .wy-dropdown > a { + color: rgb(230, 228, 225); + } + + .wy-side-nav-search > a:hover, + .wy-side-nav-search .wy-dropdown > a:hover { + background-image: initial; + background-color: rgba(24, 26, 27, 0.1); + } + + .wy-side-nav-search > a img.logo, + .wy-side-nav-search .wy-dropdown > a img.logo { + background-image: initial; + background-color: transparent; + } + + .wy-side-nav-search > div.version { + color: rgba(232, 230, 227, 0.3); + } + + .wy-nav .wy-menu-vertical header { + color: rgb(84, 164, 217); + } + + .wy-nav .wy-menu-vertical a { + color: rgb(184, 178, 169); + } + + .wy-nav .wy-menu-vertical a:hover { + background-color: rgb(33, 102, 148); + color: rgb(232, 230, 227); + } + + .wy-body-for-nav { + background-image: initial; + background-color: rgb(24, 26, 27); + } + + .wy-nav-side { + color: rgb(169, 161, 150); + background-image: initial; + background-color: rgb(38, 41, 43); + } + + .wy-nav-top { + background-image: initial; + background-color: rgb(33, 102, 148); + color: rgb(232, 230, 227); + } + + .wy-nav-top a { + color: rgb(232, 230, 227); + } + + .wy-nav-top img { + background-color: rgb(33, 102, 148); + } + + .wy-nav-content-wrap { + background-image: initial; + background-color: rgb(26, 28, 29); + } + + .wy-body-mask { + background-image: initial; + background-color: rgba(0, 0, 0, 0.2); + } + + footer { + color: rgb(152, 143, 129); + } + + footer span.commit code, + footer span.commit .rst-content tt, + .rst-content footer span.commit tt { + background-image: none; + background-color: initial; + border-color: initial; + color: rgb(152, 143, 129); + } + + #search-results .search li { + border-bottom-color: rgb(56, 61, 63); + } + + #search-results .search li:first-child { + border-top-color: rgb(56, 61, 63); + } + + #search-results .context { + color: rgb(152, 143, 129); + } + + @media screen and (min-width: 1100px) { + .wy-nav-content-wrap { + background-image: initial; + background-color: rgba(0, 0, 0, 0.05); + } + + .wy-nav-content { + background-image: initial; + background-color: rgb(26, 28, 29); + } + } + .rst-versions { + color: rgb(230, 228, 225); + background-image: initial; + background-color: rgb(23, 24, 25); + } + + .rst-versions a { + color: rgb(84, 164, 217); + text-decoration-color: initial; + } + + .rst-versions .rst-current-version { + background-color: rgb(29, 31, 32); + color: rgb(92, 218, 145); + } + + .rst-versions .rst-current-version .fa, + .rst-versions .rst-current-version .wy-menu-vertical li span.toctree-expand, + .wy-menu-vertical li .rst-versions .rst-current-version span.toctree-expand, + .rst-versions .rst-current-version .rst-content .admonition-title, + .rst-content .rst-versions .rst-current-version .admonition-title, + .rst-versions .rst-current-version .rst-content h1 .headerlink, + .rst-content h1 .rst-versions .rst-current-version .headerlink, + .rst-versions .rst-current-version .rst-content h2 .headerlink, + .rst-content h2 .rst-versions .rst-current-version .headerlink, + .rst-versions .rst-current-version .rst-content h3 .headerlink, + .rst-content h3 .rst-versions .rst-current-version .headerlink, + .rst-versions .rst-current-version .rst-content h4 .headerlink, + .rst-content h4 .rst-versions .rst-current-version .headerlink, + .rst-versions .rst-current-version .rst-content h5 .headerlink, + .rst-content h5 .rst-versions .rst-current-version .headerlink, + .rst-versions .rst-current-version .rst-content h6 .headerlink, + .rst-content h6 .rst-versions .rst-current-version .headerlink, + .rst-versions .rst-current-version .rst-content dl dt .headerlink, + .rst-content dl dt .rst-versions .rst-current-version .headerlink, + .rst-versions .rst-current-version .rst-content p.caption .headerlink, + .rst-content p.caption .rst-versions .rst-current-version .headerlink, + .rst-versions .rst-current-version .rst-content table > caption .headerlink, + .rst-content table > caption .rst-versions .rst-current-version .headerlink, + .rst-versions .rst-current-version .rst-content .code-block-caption .headerlink, + .rst-content .code-block-caption .rst-versions .rst-current-version .headerlink, + .rst-versions .rst-current-version .rst-content tt.download span:first-child, + .rst-content tt.download .rst-versions .rst-current-version span:first-child, + .rst-versions .rst-current-version .rst-content code.download span:first-child, + .rst-content code.download .rst-versions .rst-current-version span:first-child, + .rst-versions .rst-current-version .icon { + color: rgb(230, 228, 225); + } + + .rst-versions .rst-current-version.rst-out-of-date { + background-color: rgb(162, 33, 20); + color: rgb(232, 230, 227); + } + + .rst-versions .rst-current-version.rst-active-old-version { + background-color: rgb(192, 156, 11); + color: rgb(232, 230, 227); + } + + .rst-versions .rst-other-versions { + color: rgb(152, 143, 129); + } + + .rst-versions .rst-other-versions hr { + border-right-color: initial; + border-bottom-color: initial; + border-left-color: initial; + border-top-color: rgb(119, 111, 98); + } + + .rst-versions .rst-other-versions dd a { + color: rgb(230, 228, 225); + } + + .rst-versions.rst-badge { + border-color: initial; + } + + .rst-content abbr[title] { + text-decoration-color: initial; + } + + .rst-content.style-external-links a.reference.external::after { + color: rgb(184, 178, 169); + } + + .rst-content pre.literal-block, .rst-content div[class^="highlight"] { + border-color: rgb(56, 61, 63); + } + + .rst-content pre.literal-block div[class^="highlight"], .rst-content div[class^="highlight"] div[class^="highlight"] { + border-color: initial; + } + + .rst-content .linenodiv pre { + border-right-color: rgb(54, 59, 61); + } + + .rst-content .admonition table { + border-color: rgba(140, 130, 115, 0.1); + } + + .rst-content .admonition table td, + .rst-content .admonition table th { + background-image: initial !important; + background-color: transparent !important; + border-color: rgba(140, 130, 115, 0.1) !important; + } + + .rst-content .section ol.loweralpha, + .rst-content .section ol.loweralpha li { + list-style-image: initial; + } + + .rst-content .section ol.upperalpha, + .rst-content .section ol.upperalpha li { + list-style-image: initial; + } + + .rst-content .toc-backref { + color: rgb(192, 186, 178); + } + + .rst-content .sidebar { + background-image: initial; + background-color: rgb(27, 36, 36); + border-color: rgb(56, 61, 63); + } + + .rst-content .sidebar .sidebar-title { + background-image: initial; + background-color: rgb(40, 43, 45); + } + + .rst-content .highlighted { + background-image: initial; + background-color: rgb(192, 156, 11); + } + + .rst-content table.docutils.citation, + .rst-content table.docutils.footnote { + background-image: none; + background-color: initial; + border-color: initial; + color: rgb(152, 143, 129); + } + + .rst-content table.docutils.citation td, + .rst-content table.docutils.citation tr, + .rst-content table.docutils.footnote td, + .rst-content table.docutils.footnote tr { + border-color: initial; + background-color: transparent !important; + } + + .rst-content table.docutils.citation tt, + .rst-content table.docutils.citation code, + .rst-content table.docutils.footnote tt, + .rst-content table.docutils.footnote code { + color: rgb(178, 172, 162); + } + + .rst-content table.docutils th { + border-color: rgb(56, 61, 63); + } + + .rst-content table.field-list { + border-color: initial; + } + + .rst-content table.field-list td { + border-color: initial; + } + + .rst-content tt, + .rst-content tt, + .rst-content code { + color: rgb(232, 230, 227); + } + + .rst-content tt.literal, + .rst-content tt.literal, + .rst-content code.literal { + color: rgb(233, 88, 73); + } + + .rst-content tt.xref, + a .rst-content tt, + .rst-content tt.xref, + .rst-content code.xref, + a .rst-content tt, + a .rst-content code { + color: rgb(192, 186, 178); + } + + .rst-content a tt, + .rst-content a tt, + .rst-content a code { + color: rgb(84, 164, 217); + } + + .rst-content dl:not(.docutils) dt { + background-image: initial; + background-color: rgb(32, 35, 36); + color: rgb(84, 164, 217); + border-top-color: rgb(28, 89, 128); + } + + .rst-content dl:not(.docutils) dt::before { + color: rgb(109, 178, 223); + } + + .rst-content dl:not(.docutils) dt .headerlink { + color: rgb(192, 186, 178); + } + + .rst-content dl:not(.docutils) dl dt { + border-top-color: initial; + border-right-color: initial; + border-bottom-color: initial; + border-left-color: rgb(62, 68, 70); + background-image: initial; + background-color: rgb(32, 35, 37); + color: rgb(178, 172, 162); + } + + .rst-content dl:not(.docutils) dl dt .headerlink { + color: rgb(192, 186, 178); + } + + .rst-content dl:not(.docutils) tt.descname, + .rst-content dl:not(.docutils) tt.descclassname, + .rst-content dl:not(.docutils) tt.descname, + .rst-content dl:not(.docutils) code.descname, + .rst-content dl:not(.docutils) tt.descclassname, + .rst-content dl:not(.docutils) code.descclassname { + background-color: transparent; + border-color: initial; + } + + .rst-content dl:not(.docutils) .optional { + color: rgb(232, 230, 227); + } + + .rst-content .viewcode-link, + .rst-content .viewcode-back { + color: rgb(92, 218, 145); + } + + .rst-content tt.download, + .rst-content code.download { + background-image: inherit; + background-color: inherit; + color: inherit; + border-color: inherit; + } + + .rst-content .guilabel { + border-color: rgb(27, 84, 122); + background-image: initial; + background-color: rgb(32, 35, 36); + } + + span[id*="MathJax-Span"] { + color: rgb(192, 186, 178); + } + + .highlight .hll { + background-color: rgb(82, 82, 0); + } + + .highlight { + background-image: initial; + background-color: rgb(61, 82, 0); + } + + .highlight .c { + color: rgb(119, 179, 195); + } + + .highlight .err { + border-color: rgb(179, 0, 0); + } + + .highlight .k { + color: rgb(126, 255, 163); + } + + .highlight .o { + color: rgb(168, 160, 149); + } + + .highlight .ch { + color: rgb(119, 179, 195); + } + + .highlight .cm { + color: rgb(119, 179, 195); + } + + .highlight .cp { + color: rgb(126, 255, 163); + } + + .highlight .cpf { + color: rgb(119, 179, 195); + } + + .highlight .c1 { + color: rgb(119, 179, 195); + } + + .highlight .cs { + color: rgb(119, 179, 195); + background-color: rgb(60, 0, 0); + } + + .highlight .gd { + color: rgb(255, 92, 92); + } + + .highlight .gr { + color: rgb(255, 26, 26); + } + + .highlight .gh { + color: rgb(127, 174, 255); + } + + .highlight .gi { + color: rgb(92, 255, 92); + } + + .highlight .go { + color: rgb(200, 195, 188); + } + + .highlight .gp { + color: rgb(246, 147, 68); + } + + .highlight .gu { + color: rgb(255, 114, 255); + } + + .highlight .gt { + color: rgb(71, 160, 255); + } + + .highlight .kc { + color: rgb(126, 255, 163); + } + + .highlight .kd { + color: rgb(126, 255, 163); + } + + .highlight .kn { + color: rgb(126, 255, 163); + } + + .highlight .kp { + color: rgb(126, 255, 163); + } + + .highlight .kr { + color: rgb(126, 255, 163); + } + + .highlight .kt { + color: rgb(255, 137, 103); + } + + .highlight .m { + color: rgb(125, 222, 174); + } + + .highlight .s { + color: rgb(123, 166, 202); + } + + .highlight .na { + color: rgb(123, 166, 202); + } + + .highlight .nb { + color: rgb(126, 255, 163); + } + + .highlight .nc { + color: rgb(81, 194, 242); + } + + .highlight .no { + color: rgb(103, 177, 215); + } + + .highlight .nd { + color: rgb(178, 172, 162); + } + + .highlight .ni { + color: rgb(217, 100, 73); + } + + .highlight .ne { + color: rgb(126, 255, 163); + } + + .highlight .nf { + color: rgb(131, 186, 249); + } + + .highlight .nl { + color: rgb(137, 193, 255); + } + + .highlight .nn { + color: rgb(81, 194, 242); + } + + .highlight .nt { + color: rgb(138, 191, 249); + } + + .highlight .nv { + color: rgb(190, 103, 215); + } + + .highlight .ow { + color: rgb(126, 255, 163); + } + + .highlight .w { + color: rgb(189, 183, 175); + } + + .highlight .mb { + color: rgb(125, 222, 174); + } + + .highlight .mf { + color: rgb(125, 222, 174); + } + + .highlight .mh { + color: rgb(125, 222, 174); + } + + .highlight .mi { + color: rgb(125, 222, 174); + } + + .highlight .mo { + color: rgb(125, 222, 174); + } + + .highlight .sa { + color: rgb(123, 166, 202); + } + + .highlight .sb { + color: rgb(123, 166, 202); + } + + .highlight .sc { + color: rgb(123, 166, 202); + } + + .highlight .dl { + color: rgb(123, 166, 202); + } + + .highlight .sd { + color: rgb(123, 166, 202); + } + + .highlight .s2 { + color: rgb(123, 166, 202); + } + + .highlight .se { + color: rgb(123, 166, 202); + } + + .highlight .sh { + color: rgb(123, 166, 202); + } + + .highlight .si { + color: rgb(117, 168, 209); + } + + .highlight .sx { + color: rgb(246, 147, 68); + } + + .highlight .sr { + color: rgb(133, 182, 224); + } + + .highlight .s1 { + color: rgb(123, 166, 202); + } + + .highlight .ss { + color: rgb(188, 230, 128); + } + + .highlight .bp { + color: rgb(126, 255, 163); + } + + .highlight .fm { + color: rgb(131, 186, 249); + } + + .highlight .vc { + color: rgb(190, 103, 215); + } + + .highlight .vg { + color: rgb(190, 103, 215); + } + + .highlight .vi { + color: rgb(190, 103, 215); + } + + .highlight .vm { + color: rgb(190, 103, 215); + } + + .highlight .il { + color: rgb(125, 222, 174); + } + + .rst-other-versions a { + border-color: initial; + } + + .ethical-sidebar .ethical-image-link, + .ethical-footer .ethical-image-link { + border-color: initial; + } + + .ethical-sidebar, + .ethical-footer { + background-color: rgb(34, 36, 38); + border-color: rgb(62, 68, 70); + color: rgb(226, 223, 219); + } + + .ethical-sidebar ul { + list-style-image: initial; + } + + .ethical-sidebar ul li { + background-color: rgb(5, 77, 121); + color: rgb(232, 230, 227); + } + + .ethical-sidebar a, + .ethical-sidebar a:visited, + .ethical-sidebar a:hover, + .ethical-sidebar a:active, + .ethical-footer a, + .ethical-footer a:visited, + .ethical-footer a:hover, + .ethical-footer a:active { + color: rgb(226, 223, 219); + text-decoration-color: initial !important; + border-bottom-color: initial !important; + } + + .ethical-callout a { + color: rgb(161, 153, 141) !important; + text-decoration-color: initial !important; + } + + .ethical-fixedfooter { + background-color: rgb(34, 36, 38); + border-top-color: rgb(66, 72, 74); + color: rgb(192, 186, 178); + } + + .ethical-fixedfooter .ethical-text::before { + background-color: rgb(61, 140, 64); + color: rgb(232, 230, 227); + } + + .ethical-fixedfooter .ethical-callout { + color: rgb(168, 160, 149); + } + + .ethical-fixedfooter a, + .ethical-fixedfooter a:hover, + .ethical-fixedfooter a:active, + .ethical-fixedfooter a:visited { + color: rgb(192, 186, 178); + text-decoration-color: initial; + } + + .ethical-rtd .ethical-sidebar { + color: rgb(184, 178, 169); + } + + .ethical-alabaster a.ethical-image-link { + border-color: initial !important; + } + + .ethical-dark-theme .ethical-sidebar { + background-color: rgb(58, 62, 65); + border-color: rgb(75, 81, 84); + color: rgb(193, 188, 180) !important; + } + + .ethical-dark-theme a, + .ethical-dark-theme a:visited { + color: rgb(216, 213, 208) !important; + border-bottom-color: initial !important; + } + + .ethical-dark-theme .ethical-callout a { + color: rgb(184, 178, 169) !important; + } + + .keep-us-sustainable { + border-color: rgb(87, 133, 38); + } + + .keep-us-sustainable a, + .keep-us-sustainable a:hover, + .keep-us-sustainable a:visited { + text-decoration-color: initial; + } + + .wy-body-for-nav .keep-us-sustainable { + color: rgb(184, 178, 169); + } + + .wy-body-for-nav .keep-us-sustainable a { + color: rgb(222, 219, 215); + } + + /* For black-on-white/transparent images at handbook/text-anchors.html */ + #text-anchors img { + filter: invert(1) brightness(0.85) hue-rotate(-60deg); + } +} diff --git a/_static/css/fonts/Roboto-Slab-Bold.woff b/_static/css/fonts/Roboto-Slab-Bold.woff new file mode 100644 index 00000000..6cb60000 Binary files /dev/null and b/_static/css/fonts/Roboto-Slab-Bold.woff differ diff --git a/_static/css/fonts/Roboto-Slab-Bold.woff2 b/_static/css/fonts/Roboto-Slab-Bold.woff2 new file mode 100644 index 00000000..7059e231 Binary files /dev/null and b/_static/css/fonts/Roboto-Slab-Bold.woff2 differ diff --git a/_static/css/fonts/Roboto-Slab-Regular.woff b/_static/css/fonts/Roboto-Slab-Regular.woff new file mode 100644 index 00000000..f815f63f Binary files /dev/null and b/_static/css/fonts/Roboto-Slab-Regular.woff differ diff --git a/_static/css/fonts/Roboto-Slab-Regular.woff2 b/_static/css/fonts/Roboto-Slab-Regular.woff2 new file mode 100644 index 00000000..f2c76e5b Binary files /dev/null and b/_static/css/fonts/Roboto-Slab-Regular.woff2 differ diff --git a/_static/css/fonts/fontawesome-webfont.eot b/_static/css/fonts/fontawesome-webfont.eot new file mode 100644 index 00000000..e9f60ca9 Binary files /dev/null and b/_static/css/fonts/fontawesome-webfont.eot differ diff --git a/_static/css/fonts/fontawesome-webfont.svg b/_static/css/fonts/fontawesome-webfont.svg new file mode 100644 index 00000000..855c845e --- /dev/null +++ b/_static/css/fonts/fontawesome-webfont.svg @@ -0,0 +1,2671 @@ + + + + +Created by FontForge 20120731 at Mon Oct 24 17:37:40 2016 + By ,,, +Copyright Dave Gandy 2016. All rights reserved. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/_static/css/fonts/fontawesome-webfont.ttf b/_static/css/fonts/fontawesome-webfont.ttf new file mode 100644 index 00000000..35acda2f Binary files /dev/null and b/_static/css/fonts/fontawesome-webfont.ttf differ diff --git a/_static/css/fonts/fontawesome-webfont.woff b/_static/css/fonts/fontawesome-webfont.woff new file mode 100644 index 00000000..400014a4 Binary files /dev/null and b/_static/css/fonts/fontawesome-webfont.woff differ diff --git a/_static/css/fonts/fontawesome-webfont.woff2 b/_static/css/fonts/fontawesome-webfont.woff2 new file mode 100644 index 00000000..4d13fc60 Binary files /dev/null and b/_static/css/fonts/fontawesome-webfont.woff2 differ diff --git a/_static/css/fonts/lato-bold-italic.woff b/_static/css/fonts/lato-bold-italic.woff new file mode 100644 index 00000000..88ad05b9 Binary files /dev/null and b/_static/css/fonts/lato-bold-italic.woff differ diff --git a/_static/css/fonts/lato-bold-italic.woff2 b/_static/css/fonts/lato-bold-italic.woff2 new file mode 100644 index 00000000..c4e3d804 Binary files /dev/null and b/_static/css/fonts/lato-bold-italic.woff2 differ diff --git a/_static/css/fonts/lato-bold.woff b/_static/css/fonts/lato-bold.woff new file mode 100644 index 00000000..c6dff51f Binary files /dev/null and b/_static/css/fonts/lato-bold.woff differ diff --git a/_static/css/fonts/lato-bold.woff2 b/_static/css/fonts/lato-bold.woff2 new file mode 100644 index 00000000..bb195043 Binary files /dev/null and b/_static/css/fonts/lato-bold.woff2 differ diff --git a/_static/css/fonts/lato-normal-italic.woff b/_static/css/fonts/lato-normal-italic.woff new file mode 100644 index 00000000..76114bc0 Binary files /dev/null and b/_static/css/fonts/lato-normal-italic.woff differ diff --git a/_static/css/fonts/lato-normal-italic.woff2 b/_static/css/fonts/lato-normal-italic.woff2 new file mode 100644 index 00000000..3404f37e Binary files /dev/null and b/_static/css/fonts/lato-normal-italic.woff2 differ diff --git a/_static/css/fonts/lato-normal.woff b/_static/css/fonts/lato-normal.woff new file mode 100644 index 00000000..ae1307ff Binary files /dev/null and b/_static/css/fonts/lato-normal.woff differ diff --git a/_static/css/fonts/lato-normal.woff2 b/_static/css/fonts/lato-normal.woff2 new file mode 100644 index 00000000..3bf98433 Binary files /dev/null and b/_static/css/fonts/lato-normal.woff2 differ diff --git a/_static/css/light.css b/_static/css/light.css new file mode 100644 index 00000000..04edd7b1 --- /dev/null +++ b/_static/css/light.css @@ -0,0 +1,8 @@ +@media (prefers-color-scheme: light) { + + .wy-menu-vertical li.toctree-l2.current a, + .wy-menu-vertical li.toctree-l3.current a { + background-color: #c9c9c9; + } + +} diff --git a/_static/css/styles.css b/_static/css/styles.css new file mode 100644 index 00000000..62f995e6 --- /dev/null +++ b/_static/css/styles.css @@ -0,0 +1,12 @@ +th p { + margin-bottom: 0; +} + +.rst-content tr .line-block { + font-size: 1rem; + margin-bottom: 0; +} + +.wy-nav-content { + max-width: 80% !important; +} diff --git a/_static/css/theme.css b/_static/css/theme.css new file mode 100644 index 00000000..19a446a0 --- /dev/null +++ b/_static/css/theme.css @@ -0,0 +1,4 @@ +html{box-sizing:border-box}*,:after,:before{box-sizing:inherit}article,aside,details,figcaption,figure,footer,header,hgroup,nav,section{display:block}audio,canvas,video{display:inline-block;*display:inline;*zoom:1}[hidden],audio:not([controls]){display:none}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:100%;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%}body{margin:0}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}blockquote{margin:0}dfn{font-style:italic}ins{background:#ff9;text-decoration:none}ins,mark{color:#000}mark{background:#ff0;font-style:italic;font-weight:700}.rst-content code,.rst-content tt,code,kbd,pre,samp{font-family:monospace,serif;_font-family:courier new,monospace;font-size:1em}pre{white-space:pre}q{quotes:none}q:after,q:before{content:"";content:none}small{font-size:85%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}dl,ol,ul{margin:0;padding:0;list-style:none;list-style-image:none}li{list-style:none}dd{margin:0}img{border:0;-ms-interpolation-mode:bicubic;vertical-align:middle;max-width:100%}svg:not(:root){overflow:hidden}figure,form{margin:0}label{cursor:pointer}button,input,select,textarea{font-size:100%;margin:0;vertical-align:baseline;*vertical-align:middle}button,input{line-height:normal}button,input[type=button],input[type=reset],input[type=submit]{cursor:pointer;-webkit-appearance:button;*overflow:visible}button[disabled],input[disabled]{cursor:default}input[type=search]{-webkit-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box}textarea{resize:vertical}table{border-collapse:collapse;border-spacing:0}td{vertical-align:top}.chromeframe{margin:.2em 0;background:#ccc;color:#000;padding:.2em 0}.ir{display:block;border:0;text-indent:-999em;overflow:hidden;background-color:transparent;background-repeat:no-repeat;text-align:left;direction:ltr;*line-height:0}.ir br{display:none}.hidden{display:none!important;visibility:hidden}.visuallyhidden{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.visuallyhidden.focusable:active,.visuallyhidden.focusable:focus{clip:auto;height:auto;margin:0;overflow:visible;position:static;width:auto}.invisible{visibility:hidden}.relative{position:relative}big,small{font-size:100%}@media print{body,html,section{background:none!important}*{box-shadow:none!important;text-shadow:none!important;filter:none!important;-ms-filter:none!important}a,a:visited{text-decoration:underline}.ir a:after,a[href^="#"]:after,a[href^="javascript:"]:after{content:""}blockquote,pre{page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}img{max-width:100%!important}@page{margin:.5cm}.rst-content .toctree-wrapper>p.caption,h2,h3,p{orphans:3;widows:3}.rst-content .toctree-wrapper>p.caption,h2,h3{page-break-after:avoid}}.btn,.fa:before,.icon:before,.rst-content .admonition,.rst-content .admonition-title:before,.rst-content .admonition-todo,.rst-content .attention,.rst-content .caution,.rst-content .code-block-caption .headerlink:before,.rst-content .danger,.rst-content .eqno .headerlink:before,.rst-content .error,.rst-content .hint,.rst-content .important,.rst-content .note,.rst-content .seealso,.rst-content .tip,.rst-content .warning,.rst-content code.download span:first-child:before,.rst-content dl dt .headerlink:before,.rst-content h1 .headerlink:before,.rst-content h2 .headerlink:before,.rst-content h3 .headerlink:before,.rst-content h4 .headerlink:before,.rst-content h5 .headerlink:before,.rst-content h6 .headerlink:before,.rst-content p.caption .headerlink:before,.rst-content p .headerlink:before,.rst-content table>caption .headerlink:before,.rst-content tt.download span:first-child:before,.wy-alert,.wy-dropdown .caret:before,.wy-inline-validate.wy-inline-validate-danger .wy-input-context:before,.wy-inline-validate.wy-inline-validate-info .wy-input-context:before,.wy-inline-validate.wy-inline-validate-success .wy-input-context:before,.wy-inline-validate.wy-inline-validate-warning .wy-input-context:before,.wy-menu-vertical li.current>a button.toctree-expand:before,.wy-menu-vertical li.on a button.toctree-expand:before,.wy-menu-vertical li button.toctree-expand:before,input[type=color],input[type=date],input[type=datetime-local],input[type=datetime],input[type=email],input[type=month],input[type=number],input[type=password],input[type=search],input[type=tel],input[type=text],input[type=time],input[type=url],input[type=week],select,textarea{-webkit-font-smoothing:antialiased}.clearfix{*zoom:1}.clearfix:after,.clearfix:before{display:table;content:""}.clearfix:after{clear:both}/*! + * Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome + * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) + */@font-face{font-family:FontAwesome;src:url(fonts/fontawesome-webfont.eot?674f50d287a8c48dc19ba404d20fe713);src:url(fonts/fontawesome-webfont.eot?674f50d287a8c48dc19ba404d20fe713?#iefix&v=4.7.0) format("embedded-opentype"),url(fonts/fontawesome-webfont.woff2?af7ae505a9eed503f8b8e6982036873e) format("woff2"),url(fonts/fontawesome-webfont.woff?fee66e712a8a08eef5805a46892932ad) format("woff"),url(fonts/fontawesome-webfont.ttf?b06871f281fee6b241d60582ae9369b9) format("truetype"),url(fonts/fontawesome-webfont.svg?912ec66d7572ff821749319396470bde#fontawesomeregular) format("svg");font-weight:400;font-style:normal}.fa,.icon,.rst-content .admonition-title,.rst-content .code-block-caption .headerlink,.rst-content .eqno .headerlink,.rst-content code.download span:first-child,.rst-content dl dt .headerlink,.rst-content h1 .headerlink,.rst-content h2 .headerlink,.rst-content h3 .headerlink,.rst-content h4 .headerlink,.rst-content h5 .headerlink,.rst-content h6 .headerlink,.rst-content p.caption .headerlink,.rst-content p .headerlink,.rst-content table>caption .headerlink,.rst-content tt.download span:first-child,.wy-menu-vertical li.current>a button.toctree-expand,.wy-menu-vertical li.on a button.toctree-expand,.wy-menu-vertical li button.toctree-expand{display:inline-block;font:normal normal normal 14px/1 FontAwesome;font-size:inherit;text-rendering:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.fa-lg{font-size:1.33333em;line-height:.75em;vertical-align:-15%}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-fw{width:1.28571em;text-align:center}.fa-ul{padding-left:0;margin-left:2.14286em;list-style-type:none}.fa-ul>li{position:relative}.fa-li{position:absolute;left:-2.14286em;width:2.14286em;top:.14286em;text-align:center}.fa-li.fa-lg{left:-1.85714em}.fa-border{padding:.2em .25em .15em;border:.08em solid #eee;border-radius:.1em}.fa-pull-left{float:left}.fa-pull-right{float:right}.fa-pull-left.icon,.fa.fa-pull-left,.rst-content .code-block-caption .fa-pull-left.headerlink,.rst-content .eqno .fa-pull-left.headerlink,.rst-content .fa-pull-left.admonition-title,.rst-content code.download span.fa-pull-left:first-child,.rst-content dl dt .fa-pull-left.headerlink,.rst-content h1 .fa-pull-left.headerlink,.rst-content h2 .fa-pull-left.headerlink,.rst-content h3 .fa-pull-left.headerlink,.rst-content h4 .fa-pull-left.headerlink,.rst-content h5 .fa-pull-left.headerlink,.rst-content h6 .fa-pull-left.headerlink,.rst-content p .fa-pull-left.headerlink,.rst-content table>caption .fa-pull-left.headerlink,.rst-content tt.download span.fa-pull-left:first-child,.wy-menu-vertical li.current>a button.fa-pull-left.toctree-expand,.wy-menu-vertical li.on a button.fa-pull-left.toctree-expand,.wy-menu-vertical li button.fa-pull-left.toctree-expand{margin-right:.3em}.fa-pull-right.icon,.fa.fa-pull-right,.rst-content .code-block-caption .fa-pull-right.headerlink,.rst-content .eqno .fa-pull-right.headerlink,.rst-content .fa-pull-right.admonition-title,.rst-content code.download span.fa-pull-right:first-child,.rst-content dl dt .fa-pull-right.headerlink,.rst-content h1 .fa-pull-right.headerlink,.rst-content h2 .fa-pull-right.headerlink,.rst-content h3 .fa-pull-right.headerlink,.rst-content h4 .fa-pull-right.headerlink,.rst-content h5 .fa-pull-right.headerlink,.rst-content h6 .fa-pull-right.headerlink,.rst-content p .fa-pull-right.headerlink,.rst-content table>caption .fa-pull-right.headerlink,.rst-content tt.download span.fa-pull-right:first-child,.wy-menu-vertical li.current>a button.fa-pull-right.toctree-expand,.wy-menu-vertical li.on a button.fa-pull-right.toctree-expand,.wy-menu-vertical li button.fa-pull-right.toctree-expand{margin-left:.3em}.pull-right{float:right}.pull-left{float:left}.fa.pull-left,.pull-left.icon,.rst-content .code-block-caption .pull-left.headerlink,.rst-content .eqno .pull-left.headerlink,.rst-content .pull-left.admonition-title,.rst-content code.download span.pull-left:first-child,.rst-content dl dt .pull-left.headerlink,.rst-content h1 .pull-left.headerlink,.rst-content h2 .pull-left.headerlink,.rst-content h3 .pull-left.headerlink,.rst-content h4 .pull-left.headerlink,.rst-content h5 .pull-left.headerlink,.rst-content h6 .pull-left.headerlink,.rst-content p .pull-left.headerlink,.rst-content table>caption .pull-left.headerlink,.rst-content tt.download span.pull-left:first-child,.wy-menu-vertical li.current>a button.pull-left.toctree-expand,.wy-menu-vertical li.on a button.pull-left.toctree-expand,.wy-menu-vertical li button.pull-left.toctree-expand{margin-right:.3em}.fa.pull-right,.pull-right.icon,.rst-content .code-block-caption .pull-right.headerlink,.rst-content .eqno .pull-right.headerlink,.rst-content .pull-right.admonition-title,.rst-content code.download span.pull-right:first-child,.rst-content dl dt .pull-right.headerlink,.rst-content h1 .pull-right.headerlink,.rst-content h2 .pull-right.headerlink,.rst-content h3 .pull-right.headerlink,.rst-content h4 .pull-right.headerlink,.rst-content h5 .pull-right.headerlink,.rst-content h6 .pull-right.headerlink,.rst-content p .pull-right.headerlink,.rst-content table>caption .pull-right.headerlink,.rst-content tt.download span.pull-right:first-child,.wy-menu-vertical li.current>a button.pull-right.toctree-expand,.wy-menu-vertical li.on a button.pull-right.toctree-expand,.wy-menu-vertical li button.pull-right.toctree-expand{margin-left:.3em}.fa-spin{-webkit-animation:fa-spin 2s linear infinite;animation:fa-spin 2s linear infinite}.fa-pulse{-webkit-animation:fa-spin 1s steps(8) infinite;animation:fa-spin 1s steps(8) infinite}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(359deg);transform:rotate(359deg)}}.fa-rotate-90{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=1)";-webkit-transform:rotate(90deg);-ms-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2)";-webkit-transform:rotate(180deg);-ms-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=3)";-webkit-transform:rotate(270deg);-ms-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1)";-webkit-transform:scaleX(-1);-ms-transform:scaleX(-1);transform:scaleX(-1)}.fa-flip-vertical{-ms-filter:"progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1)";-webkit-transform:scaleY(-1);-ms-transform:scaleY(-1);transform:scaleY(-1)}:root .fa-flip-horizontal,:root .fa-flip-vertical,:root .fa-rotate-90,:root .fa-rotate-180,:root .fa-rotate-270{filter:none}.fa-stack{position:relative;display:inline-block;width:2em;height:2em;line-height:2em;vertical-align:middle}.fa-stack-1x,.fa-stack-2x{position:absolute;left:0;width:100%;text-align:center}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:#fff}.fa-glass:before{content:""}.fa-music:before{content:""}.fa-search:before,.icon-search:before{content:""}.fa-envelope-o:before{content:""}.fa-heart:before{content:""}.fa-star:before{content:""}.fa-star-o:before{content:""}.fa-user:before{content:""}.fa-film:before{content:""}.fa-th-large:before{content:""}.fa-th:before{content:""}.fa-th-list:before{content:""}.fa-check:before{content:""}.fa-close:before,.fa-remove:before,.fa-times:before{content:""}.fa-search-plus:before{content:""}.fa-search-minus:before{content:""}.fa-power-off:before{content:""}.fa-signal:before{content:""}.fa-cog:before,.fa-gear:before{content:""}.fa-trash-o:before{content:""}.fa-home:before,.icon-home:before{content:""}.fa-file-o:before{content:""}.fa-clock-o:before{content:""}.fa-road:before{content:""}.fa-download:before,.rst-content code.download span:first-child:before,.rst-content tt.download span:first-child:before{content:""}.fa-arrow-circle-o-down:before{content:""}.fa-arrow-circle-o-up:before{content:""}.fa-inbox:before{content:""}.fa-play-circle-o:before{content:""}.fa-repeat:before,.fa-rotate-right:before{content:""}.fa-refresh:before{content:""}.fa-list-alt:before{content:""}.fa-lock:before{content:""}.fa-flag:before{content:""}.fa-headphones:before{content:""}.fa-volume-off:before{content:""}.fa-volume-down:before{content:""}.fa-volume-up:before{content:""}.fa-qrcode:before{content:""}.fa-barcode:before{content:""}.fa-tag:before{content:""}.fa-tags:before{content:""}.fa-book:before,.icon-book:before{content:""}.fa-bookmark:before{content:""}.fa-print:before{content:""}.fa-camera:before{content:""}.fa-font:before{content:""}.fa-bold:before{content:""}.fa-italic:before{content:""}.fa-text-height:before{content:""}.fa-text-width:before{content:""}.fa-align-left:before{content:""}.fa-align-center:before{content:""}.fa-align-right:before{content:""}.fa-align-justify:before{content:""}.fa-list:before{content:""}.fa-dedent:before,.fa-outdent:before{content:""}.fa-indent:before{content:""}.fa-video-camera:before{content:""}.fa-image:before,.fa-photo:before,.fa-picture-o:before{content:""}.fa-pencil:before{content:""}.fa-map-marker:before{content:""}.fa-adjust:before{content:""}.fa-tint:before{content:""}.fa-edit:before,.fa-pencil-square-o:before{content:""}.fa-share-square-o:before{content:""}.fa-check-square-o:before{content:""}.fa-arrows:before{content:""}.fa-step-backward:before{content:""}.fa-fast-backward:before{content:""}.fa-backward:before{content:""}.fa-play:before{content:""}.fa-pause:before{content:""}.fa-stop:before{content:""}.fa-forward:before{content:""}.fa-fast-forward:before{content:""}.fa-step-forward:before{content:""}.fa-eject:before{content:""}.fa-chevron-left:before{content:""}.fa-chevron-right:before{content:""}.fa-plus-circle:before{content:""}.fa-minus-circle:before{content:""}.fa-times-circle:before,.wy-inline-validate.wy-inline-validate-danger .wy-input-context:before{content:""}.fa-check-circle:before,.wy-inline-validate.wy-inline-validate-success .wy-input-context:before{content:""}.fa-question-circle:before{content:""}.fa-info-circle:before{content:""}.fa-crosshairs:before{content:""}.fa-times-circle-o:before{content:""}.fa-check-circle-o:before{content:""}.fa-ban:before{content:""}.fa-arrow-left:before{content:""}.fa-arrow-right:before{content:""}.fa-arrow-up:before{content:""}.fa-arrow-down:before{content:""}.fa-mail-forward:before,.fa-share:before{content:""}.fa-expand:before{content:""}.fa-compress:before{content:""}.fa-plus:before{content:""}.fa-minus:before{content:""}.fa-asterisk:before{content:""}.fa-exclamation-circle:before,.rst-content .admonition-title:before,.wy-inline-validate.wy-inline-validate-info .wy-input-context:before,.wy-inline-validate.wy-inline-validate-warning .wy-input-context:before{content:""}.fa-gift:before{content:""}.fa-leaf:before{content:""}.fa-fire:before,.icon-fire:before{content:""}.fa-eye:before{content:""}.fa-eye-slash:before{content:""}.fa-exclamation-triangle:before,.fa-warning:before{content:""}.fa-plane:before{content:""}.fa-calendar:before{content:""}.fa-random:before{content:""}.fa-comment:before{content:""}.fa-magnet:before{content:""}.fa-chevron-up:before{content:""}.fa-chevron-down:before{content:""}.fa-retweet:before{content:""}.fa-shopping-cart:before{content:""}.fa-folder:before{content:""}.fa-folder-open:before{content:""}.fa-arrows-v:before{content:""}.fa-arrows-h:before{content:""}.fa-bar-chart-o:before,.fa-bar-chart:before{content:""}.fa-twitter-square:before{content:""}.fa-facebook-square:before{content:""}.fa-camera-retro:before{content:""}.fa-key:before{content:""}.fa-cogs:before,.fa-gears:before{content:""}.fa-comments:before{content:""}.fa-thumbs-o-up:before{content:""}.fa-thumbs-o-down:before{content:""}.fa-star-half:before{content:""}.fa-heart-o:before{content:""}.fa-sign-out:before{content:""}.fa-linkedin-square:before{content:""}.fa-thumb-tack:before{content:""}.fa-external-link:before{content:""}.fa-sign-in:before{content:""}.fa-trophy:before{content:""}.fa-github-square:before{content:""}.fa-upload:before{content:""}.fa-lemon-o:before{content:""}.fa-phone:before{content:""}.fa-square-o:before{content:""}.fa-bookmark-o:before{content:""}.fa-phone-square:before{content:""}.fa-twitter:before{content:""}.fa-facebook-f:before,.fa-facebook:before{content:""}.fa-github:before,.icon-github:before{content:""}.fa-unlock:before{content:""}.fa-credit-card:before{content:""}.fa-feed:before,.fa-rss:before{content:""}.fa-hdd-o:before{content:""}.fa-bullhorn:before{content:""}.fa-bell:before{content:""}.fa-certificate:before{content:""}.fa-hand-o-right:before{content:""}.fa-hand-o-left:before{content:""}.fa-hand-o-up:before{content:""}.fa-hand-o-down:before{content:""}.fa-arrow-circle-left:before,.icon-circle-arrow-left:before{content:""}.fa-arrow-circle-right:before,.icon-circle-arrow-right:before{content:""}.fa-arrow-circle-up:before{content:""}.fa-arrow-circle-down:before{content:""}.fa-globe:before{content:""}.fa-wrench:before{content:""}.fa-tasks:before{content:""}.fa-filter:before{content:""}.fa-briefcase:before{content:""}.fa-arrows-alt:before{content:""}.fa-group:before,.fa-users:before{content:""}.fa-chain:before,.fa-link:before,.icon-link:before{content:""}.fa-cloud:before{content:""}.fa-flask:before{content:""}.fa-cut:before,.fa-scissors:before{content:""}.fa-copy:before,.fa-files-o:before{content:""}.fa-paperclip:before{content:""}.fa-floppy-o:before,.fa-save:before{content:""}.fa-square:before{content:""}.fa-bars:before,.fa-navicon:before,.fa-reorder:before{content:""}.fa-list-ul:before{content:""}.fa-list-ol:before{content:""}.fa-strikethrough:before{content:""}.fa-underline:before{content:""}.fa-table:before{content:""}.fa-magic:before{content:""}.fa-truck:before{content:""}.fa-pinterest:before{content:""}.fa-pinterest-square:before{content:""}.fa-google-plus-square:before{content:""}.fa-google-plus:before{content:""}.fa-money:before{content:""}.fa-caret-down:before,.icon-caret-down:before,.wy-dropdown .caret:before{content:""}.fa-caret-up:before{content:""}.fa-caret-left:before{content:""}.fa-caret-right:before{content:""}.fa-columns:before{content:""}.fa-sort:before,.fa-unsorted:before{content:""}.fa-sort-desc:before,.fa-sort-down:before{content:""}.fa-sort-asc:before,.fa-sort-up:before{content:""}.fa-envelope:before{content:""}.fa-linkedin:before{content:""}.fa-rotate-left:before,.fa-undo:before{content:""}.fa-gavel:before,.fa-legal:before{content:""}.fa-dashboard:before,.fa-tachometer:before{content:""}.fa-comment-o:before{content:""}.fa-comments-o:before{content:""}.fa-bolt:before,.fa-flash:before{content:""}.fa-sitemap:before{content:""}.fa-umbrella:before{content:""}.fa-clipboard:before,.fa-paste:before{content:""}.fa-lightbulb-o:before{content:""}.fa-exchange:before{content:""}.fa-cloud-download:before{content:""}.fa-cloud-upload:before{content:""}.fa-user-md:before{content:""}.fa-stethoscope:before{content:""}.fa-suitcase:before{content:""}.fa-bell-o:before{content:""}.fa-coffee:before{content:""}.fa-cutlery:before{content:""}.fa-file-text-o:before{content:""}.fa-building-o:before{content:""}.fa-hospital-o:before{content:""}.fa-ambulance:before{content:""}.fa-medkit:before{content:""}.fa-fighter-jet:before{content:""}.fa-beer:before{content:""}.fa-h-square:before{content:""}.fa-plus-square:before{content:""}.fa-angle-double-left:before{content:""}.fa-angle-double-right:before{content:""}.fa-angle-double-up:before{content:""}.fa-angle-double-down:before{content:""}.fa-angle-left:before{content:""}.fa-angle-right:before{content:""}.fa-angle-up:before{content:""}.fa-angle-down:before{content:""}.fa-desktop:before{content:""}.fa-laptop:before{content:""}.fa-tablet:before{content:""}.fa-mobile-phone:before,.fa-mobile:before{content:""}.fa-circle-o:before{content:""}.fa-quote-left:before{content:""}.fa-quote-right:before{content:""}.fa-spinner:before{content:""}.fa-circle:before{content:""}.fa-mail-reply:before,.fa-reply:before{content:""}.fa-github-alt:before{content:""}.fa-folder-o:before{content:""}.fa-folder-open-o:before{content:""}.fa-smile-o:before{content:""}.fa-frown-o:before{content:""}.fa-meh-o:before{content:""}.fa-gamepad:before{content:""}.fa-keyboard-o:before{content:""}.fa-flag-o:before{content:""}.fa-flag-checkered:before{content:""}.fa-terminal:before{content:""}.fa-code:before{content:""}.fa-mail-reply-all:before,.fa-reply-all:before{content:""}.fa-star-half-empty:before,.fa-star-half-full:before,.fa-star-half-o:before{content:""}.fa-location-arrow:before{content:""}.fa-crop:before{content:""}.fa-code-fork:before{content:""}.fa-chain-broken:before,.fa-unlink:before{content:""}.fa-question:before{content:""}.fa-info:before{content:""}.fa-exclamation:before{content:""}.fa-superscript:before{content:""}.fa-subscript:before{content:""}.fa-eraser:before{content:""}.fa-puzzle-piece:before{content:""}.fa-microphone:before{content:""}.fa-microphone-slash:before{content:""}.fa-shield:before{content:""}.fa-calendar-o:before{content:""}.fa-fire-extinguisher:before{content:""}.fa-rocket:before{content:""}.fa-maxcdn:before{content:""}.fa-chevron-circle-left:before{content:""}.fa-chevron-circle-right:before{content:""}.fa-chevron-circle-up:before{content:""}.fa-chevron-circle-down:before{content:""}.fa-html5:before{content:""}.fa-css3:before{content:""}.fa-anchor:before{content:""}.fa-unlock-alt:before{content:""}.fa-bullseye:before{content:""}.fa-ellipsis-h:before{content:""}.fa-ellipsis-v:before{content:""}.fa-rss-square:before{content:""}.fa-play-circle:before{content:""}.fa-ticket:before{content:""}.fa-minus-square:before{content:""}.fa-minus-square-o:before,.wy-menu-vertical li.current>a button.toctree-expand:before,.wy-menu-vertical li.on a button.toctree-expand:before{content:""}.fa-level-up:before{content:""}.fa-level-down:before{content:""}.fa-check-square:before{content:""}.fa-pencil-square:before{content:""}.fa-external-link-square:before{content:""}.fa-share-square:before{content:""}.fa-compass:before{content:""}.fa-caret-square-o-down:before,.fa-toggle-down:before{content:""}.fa-caret-square-o-up:before,.fa-toggle-up:before{content:""}.fa-caret-square-o-right:before,.fa-toggle-right:before{content:""}.fa-eur:before,.fa-euro:before{content:""}.fa-gbp:before{content:""}.fa-dollar:before,.fa-usd:before{content:""}.fa-inr:before,.fa-rupee:before{content:""}.fa-cny:before,.fa-jpy:before,.fa-rmb:before,.fa-yen:before{content:""}.fa-rouble:before,.fa-rub:before,.fa-ruble:before{content:""}.fa-krw:before,.fa-won:before{content:""}.fa-bitcoin:before,.fa-btc:before{content:""}.fa-file:before{content:""}.fa-file-text:before{content:""}.fa-sort-alpha-asc:before{content:""}.fa-sort-alpha-desc:before{content:""}.fa-sort-amount-asc:before{content:""}.fa-sort-amount-desc:before{content:""}.fa-sort-numeric-asc:before{content:""}.fa-sort-numeric-desc:before{content:""}.fa-thumbs-up:before{content:""}.fa-thumbs-down:before{content:""}.fa-youtube-square:before{content:""}.fa-youtube:before{content:""}.fa-xing:before{content:""}.fa-xing-square:before{content:""}.fa-youtube-play:before{content:""}.fa-dropbox:before{content:""}.fa-stack-overflow:before{content:""}.fa-instagram:before{content:""}.fa-flickr:before{content:""}.fa-adn:before{content:""}.fa-bitbucket:before,.icon-bitbucket:before{content:""}.fa-bitbucket-square:before{content:""}.fa-tumblr:before{content:""}.fa-tumblr-square:before{content:""}.fa-long-arrow-down:before{content:""}.fa-long-arrow-up:before{content:""}.fa-long-arrow-left:before{content:""}.fa-long-arrow-right:before{content:""}.fa-apple:before{content:""}.fa-windows:before{content:""}.fa-android:before{content:""}.fa-linux:before{content:""}.fa-dribbble:before{content:""}.fa-skype:before{content:""}.fa-foursquare:before{content:""}.fa-trello:before{content:""}.fa-female:before{content:""}.fa-male:before{content:""}.fa-gittip:before,.fa-gratipay:before{content:""}.fa-sun-o:before{content:""}.fa-moon-o:before{content:""}.fa-archive:before{content:""}.fa-bug:before{content:""}.fa-vk:before{content:""}.fa-weibo:before{content:""}.fa-renren:before{content:""}.fa-pagelines:before{content:""}.fa-stack-exchange:before{content:""}.fa-arrow-circle-o-right:before{content:""}.fa-arrow-circle-o-left:before{content:""}.fa-caret-square-o-left:before,.fa-toggle-left:before{content:""}.fa-dot-circle-o:before{content:""}.fa-wheelchair:before{content:""}.fa-vimeo-square:before{content:""}.fa-try:before,.fa-turkish-lira:before{content:""}.fa-plus-square-o:before,.wy-menu-vertical li button.toctree-expand:before{content:""}.fa-space-shuttle:before{content:""}.fa-slack:before{content:""}.fa-envelope-square:before{content:""}.fa-wordpress:before{content:""}.fa-openid:before{content:""}.fa-bank:before,.fa-institution:before,.fa-university:before{content:""}.fa-graduation-cap:before,.fa-mortar-board:before{content:""}.fa-yahoo:before{content:""}.fa-google:before{content:""}.fa-reddit:before{content:""}.fa-reddit-square:before{content:""}.fa-stumbleupon-circle:before{content:""}.fa-stumbleupon:before{content:""}.fa-delicious:before{content:""}.fa-digg:before{content:""}.fa-pied-piper-pp:before{content:""}.fa-pied-piper-alt:before{content:""}.fa-drupal:before{content:""}.fa-joomla:before{content:""}.fa-language:before{content:""}.fa-fax:before{content:""}.fa-building:before{content:""}.fa-child:before{content:""}.fa-paw:before{content:""}.fa-spoon:before{content:""}.fa-cube:before{content:""}.fa-cubes:before{content:""}.fa-behance:before{content:""}.fa-behance-square:before{content:""}.fa-steam:before{content:""}.fa-steam-square:before{content:""}.fa-recycle:before{content:""}.fa-automobile:before,.fa-car:before{content:""}.fa-cab:before,.fa-taxi:before{content:""}.fa-tree:before{content:""}.fa-spotify:before{content:""}.fa-deviantart:before{content:""}.fa-soundcloud:before{content:""}.fa-database:before{content:""}.fa-file-pdf-o:before{content:""}.fa-file-word-o:before{content:""}.fa-file-excel-o:before{content:""}.fa-file-powerpoint-o:before{content:""}.fa-file-image-o:before,.fa-file-photo-o:before,.fa-file-picture-o:before{content:""}.fa-file-archive-o:before,.fa-file-zip-o:before{content:""}.fa-file-audio-o:before,.fa-file-sound-o:before{content:""}.fa-file-movie-o:before,.fa-file-video-o:before{content:""}.fa-file-code-o:before{content:""}.fa-vine:before{content:""}.fa-codepen:before{content:""}.fa-jsfiddle:before{content:""}.fa-life-bouy:before,.fa-life-buoy:before,.fa-life-ring:before,.fa-life-saver:before,.fa-support:before{content:""}.fa-circle-o-notch:before{content:""}.fa-ra:before,.fa-rebel:before,.fa-resistance:before{content:""}.fa-empire:before,.fa-ge:before{content:""}.fa-git-square:before{content:""}.fa-git:before{content:""}.fa-hacker-news:before,.fa-y-combinator-square:before,.fa-yc-square:before{content:""}.fa-tencent-weibo:before{content:""}.fa-qq:before{content:""}.fa-wechat:before,.fa-weixin:before{content:""}.fa-paper-plane:before,.fa-send:before{content:""}.fa-paper-plane-o:before,.fa-send-o:before{content:""}.fa-history:before{content:""}.fa-circle-thin:before{content:""}.fa-header:before{content:""}.fa-paragraph:before{content:""}.fa-sliders:before{content:""}.fa-share-alt:before{content:""}.fa-share-alt-square:before{content:""}.fa-bomb:before{content:""}.fa-futbol-o:before,.fa-soccer-ball-o:before{content:""}.fa-tty:before{content:""}.fa-binoculars:before{content:""}.fa-plug:before{content:""}.fa-slideshare:before{content:""}.fa-twitch:before{content:""}.fa-yelp:before{content:""}.fa-newspaper-o:before{content:""}.fa-wifi:before{content:""}.fa-calculator:before{content:""}.fa-paypal:before{content:""}.fa-google-wallet:before{content:""}.fa-cc-visa:before{content:""}.fa-cc-mastercard:before{content:""}.fa-cc-discover:before{content:""}.fa-cc-amex:before{content:""}.fa-cc-paypal:before{content:""}.fa-cc-stripe:before{content:""}.fa-bell-slash:before{content:""}.fa-bell-slash-o:before{content:""}.fa-trash:before{content:""}.fa-copyright:before{content:""}.fa-at:before{content:""}.fa-eyedropper:before{content:""}.fa-paint-brush:before{content:""}.fa-birthday-cake:before{content:""}.fa-area-chart:before{content:""}.fa-pie-chart:before{content:""}.fa-line-chart:before{content:""}.fa-lastfm:before{content:""}.fa-lastfm-square:before{content:""}.fa-toggle-off:before{content:""}.fa-toggle-on:before{content:""}.fa-bicycle:before{content:""}.fa-bus:before{content:""}.fa-ioxhost:before{content:""}.fa-angellist:before{content:""}.fa-cc:before{content:""}.fa-ils:before,.fa-shekel:before,.fa-sheqel:before{content:""}.fa-meanpath:before{content:""}.fa-buysellads:before{content:""}.fa-connectdevelop:before{content:""}.fa-dashcube:before{content:""}.fa-forumbee:before{content:""}.fa-leanpub:before{content:""}.fa-sellsy:before{content:""}.fa-shirtsinbulk:before{content:""}.fa-simplybuilt:before{content:""}.fa-skyatlas:before{content:""}.fa-cart-plus:before{content:""}.fa-cart-arrow-down:before{content:""}.fa-diamond:before{content:""}.fa-ship:before{content:""}.fa-user-secret:before{content:""}.fa-motorcycle:before{content:""}.fa-street-view:before{content:""}.fa-heartbeat:before{content:""}.fa-venus:before{content:""}.fa-mars:before{content:""}.fa-mercury:before{content:""}.fa-intersex:before,.fa-transgender:before{content:""}.fa-transgender-alt:before{content:""}.fa-venus-double:before{content:""}.fa-mars-double:before{content:""}.fa-venus-mars:before{content:""}.fa-mars-stroke:before{content:""}.fa-mars-stroke-v:before{content:""}.fa-mars-stroke-h:before{content:""}.fa-neuter:before{content:""}.fa-genderless:before{content:""}.fa-facebook-official:before{content:""}.fa-pinterest-p:before{content:""}.fa-whatsapp:before{content:""}.fa-server:before{content:""}.fa-user-plus:before{content:""}.fa-user-times:before{content:""}.fa-bed:before,.fa-hotel:before{content:""}.fa-viacoin:before{content:""}.fa-train:before{content:""}.fa-subway:before{content:""}.fa-medium:before{content:""}.fa-y-combinator:before,.fa-yc:before{content:""}.fa-optin-monster:before{content:""}.fa-opencart:before{content:""}.fa-expeditedssl:before{content:""}.fa-battery-4:before,.fa-battery-full:before,.fa-battery:before{content:""}.fa-battery-3:before,.fa-battery-three-quarters:before{content:""}.fa-battery-2:before,.fa-battery-half:before{content:""}.fa-battery-1:before,.fa-battery-quarter:before{content:""}.fa-battery-0:before,.fa-battery-empty:before{content:""}.fa-mouse-pointer:before{content:""}.fa-i-cursor:before{content:""}.fa-object-group:before{content:""}.fa-object-ungroup:before{content:""}.fa-sticky-note:before{content:""}.fa-sticky-note-o:before{content:""}.fa-cc-jcb:before{content:""}.fa-cc-diners-club:before{content:""}.fa-clone:before{content:""}.fa-balance-scale:before{content:""}.fa-hourglass-o:before{content:""}.fa-hourglass-1:before,.fa-hourglass-start:before{content:""}.fa-hourglass-2:before,.fa-hourglass-half:before{content:""}.fa-hourglass-3:before,.fa-hourglass-end:before{content:""}.fa-hourglass:before{content:""}.fa-hand-grab-o:before,.fa-hand-rock-o:before{content:""}.fa-hand-paper-o:before,.fa-hand-stop-o:before{content:""}.fa-hand-scissors-o:before{content:""}.fa-hand-lizard-o:before{content:""}.fa-hand-spock-o:before{content:""}.fa-hand-pointer-o:before{content:""}.fa-hand-peace-o:before{content:""}.fa-trademark:before{content:""}.fa-registered:before{content:""}.fa-creative-commons:before{content:""}.fa-gg:before{content:""}.fa-gg-circle:before{content:""}.fa-tripadvisor:before{content:""}.fa-odnoklassniki:before{content:""}.fa-odnoklassniki-square:before{content:""}.fa-get-pocket:before{content:""}.fa-wikipedia-w:before{content:""}.fa-safari:before{content:""}.fa-chrome:before{content:""}.fa-firefox:before{content:""}.fa-opera:before{content:""}.fa-internet-explorer:before{content:""}.fa-television:before,.fa-tv:before{content:""}.fa-contao:before{content:""}.fa-500px:before{content:""}.fa-amazon:before{content:""}.fa-calendar-plus-o:before{content:""}.fa-calendar-minus-o:before{content:""}.fa-calendar-times-o:before{content:""}.fa-calendar-check-o:before{content:""}.fa-industry:before{content:""}.fa-map-pin:before{content:""}.fa-map-signs:before{content:""}.fa-map-o:before{content:""}.fa-map:before{content:""}.fa-commenting:before{content:""}.fa-commenting-o:before{content:""}.fa-houzz:before{content:""}.fa-vimeo:before{content:""}.fa-black-tie:before{content:""}.fa-fonticons:before{content:""}.fa-reddit-alien:before{content:""}.fa-edge:before{content:""}.fa-credit-card-alt:before{content:""}.fa-codiepie:before{content:""}.fa-modx:before{content:""}.fa-fort-awesome:before{content:""}.fa-usb:before{content:""}.fa-product-hunt:before{content:""}.fa-mixcloud:before{content:""}.fa-scribd:before{content:""}.fa-pause-circle:before{content:""}.fa-pause-circle-o:before{content:""}.fa-stop-circle:before{content:""}.fa-stop-circle-o:before{content:""}.fa-shopping-bag:before{content:""}.fa-shopping-basket:before{content:""}.fa-hashtag:before{content:""}.fa-bluetooth:before{content:""}.fa-bluetooth-b:before{content:""}.fa-percent:before{content:""}.fa-gitlab:before,.icon-gitlab:before{content:""}.fa-wpbeginner:before{content:""}.fa-wpforms:before{content:""}.fa-envira:before{content:""}.fa-universal-access:before{content:""}.fa-wheelchair-alt:before{content:""}.fa-question-circle-o:before{content:""}.fa-blind:before{content:""}.fa-audio-description:before{content:""}.fa-volume-control-phone:before{content:""}.fa-braille:before{content:""}.fa-assistive-listening-systems:before{content:""}.fa-american-sign-language-interpreting:before,.fa-asl-interpreting:before{content:""}.fa-deaf:before,.fa-deafness:before,.fa-hard-of-hearing:before{content:""}.fa-glide:before{content:""}.fa-glide-g:before{content:""}.fa-sign-language:before,.fa-signing:before{content:""}.fa-low-vision:before{content:""}.fa-viadeo:before{content:""}.fa-viadeo-square:before{content:""}.fa-snapchat:before{content:""}.fa-snapchat-ghost:before{content:""}.fa-snapchat-square:before{content:""}.fa-pied-piper:before{content:""}.fa-first-order:before{content:""}.fa-yoast:before{content:""}.fa-themeisle:before{content:""}.fa-google-plus-circle:before,.fa-google-plus-official:before{content:""}.fa-fa:before,.fa-font-awesome:before{content:""}.fa-handshake-o:before{content:""}.fa-envelope-open:before{content:""}.fa-envelope-open-o:before{content:""}.fa-linode:before{content:""}.fa-address-book:before{content:""}.fa-address-book-o:before{content:""}.fa-address-card:before,.fa-vcard:before{content:""}.fa-address-card-o:before,.fa-vcard-o:before{content:""}.fa-user-circle:before{content:""}.fa-user-circle-o:before{content:""}.fa-user-o:before{content:""}.fa-id-badge:before{content:""}.fa-drivers-license:before,.fa-id-card:before{content:""}.fa-drivers-license-o:before,.fa-id-card-o:before{content:""}.fa-quora:before{content:""}.fa-free-code-camp:before{content:""}.fa-telegram:before{content:""}.fa-thermometer-4:before,.fa-thermometer-full:before,.fa-thermometer:before{content:""}.fa-thermometer-3:before,.fa-thermometer-three-quarters:before{content:""}.fa-thermometer-2:before,.fa-thermometer-half:before{content:""}.fa-thermometer-1:before,.fa-thermometer-quarter:before{content:""}.fa-thermometer-0:before,.fa-thermometer-empty:before{content:""}.fa-shower:before{content:""}.fa-bath:before,.fa-bathtub:before,.fa-s15:before{content:""}.fa-podcast:before{content:""}.fa-window-maximize:before{content:""}.fa-window-minimize:before{content:""}.fa-window-restore:before{content:""}.fa-times-rectangle:before,.fa-window-close:before{content:""}.fa-times-rectangle-o:before,.fa-window-close-o:before{content:""}.fa-bandcamp:before{content:""}.fa-grav:before{content:""}.fa-etsy:before{content:""}.fa-imdb:before{content:""}.fa-ravelry:before{content:""}.fa-eercast:before{content:""}.fa-microchip:before{content:""}.fa-snowflake-o:before{content:""}.fa-superpowers:before{content:""}.fa-wpexplorer:before{content:""}.fa-meetup:before{content:""}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}.fa,.icon,.rst-content .admonition-title,.rst-content .code-block-caption .headerlink,.rst-content .eqno .headerlink,.rst-content code.download span:first-child,.rst-content dl dt .headerlink,.rst-content h1 .headerlink,.rst-content h2 .headerlink,.rst-content h3 .headerlink,.rst-content h4 .headerlink,.rst-content h5 .headerlink,.rst-content h6 .headerlink,.rst-content p.caption .headerlink,.rst-content p .headerlink,.rst-content table>caption .headerlink,.rst-content tt.download span:first-child,.wy-dropdown .caret,.wy-inline-validate.wy-inline-validate-danger .wy-input-context,.wy-inline-validate.wy-inline-validate-info .wy-input-context,.wy-inline-validate.wy-inline-validate-success .wy-input-context,.wy-inline-validate.wy-inline-validate-warning .wy-input-context,.wy-menu-vertical li.current>a button.toctree-expand,.wy-menu-vertical li.on a button.toctree-expand,.wy-menu-vertical li button.toctree-expand{font-family:inherit}.fa:before,.icon:before,.rst-content .admonition-title:before,.rst-content .code-block-caption .headerlink:before,.rst-content .eqno .headerlink:before,.rst-content code.download span:first-child:before,.rst-content dl dt .headerlink:before,.rst-content h1 .headerlink:before,.rst-content h2 .headerlink:before,.rst-content h3 .headerlink:before,.rst-content h4 .headerlink:before,.rst-content h5 .headerlink:before,.rst-content h6 .headerlink:before,.rst-content p.caption .headerlink:before,.rst-content p .headerlink:before,.rst-content table>caption .headerlink:before,.rst-content tt.download span:first-child:before,.wy-dropdown .caret:before,.wy-inline-validate.wy-inline-validate-danger .wy-input-context:before,.wy-inline-validate.wy-inline-validate-info .wy-input-context:before,.wy-inline-validate.wy-inline-validate-success .wy-input-context:before,.wy-inline-validate.wy-inline-validate-warning .wy-input-context:before,.wy-menu-vertical li.current>a button.toctree-expand:before,.wy-menu-vertical li.on a button.toctree-expand:before,.wy-menu-vertical li button.toctree-expand:before{font-family:FontAwesome;display:inline-block;font-style:normal;font-weight:400;line-height:1;text-decoration:inherit}.rst-content .code-block-caption a .headerlink,.rst-content .eqno a .headerlink,.rst-content a .admonition-title,.rst-content code.download a span:first-child,.rst-content dl dt a .headerlink,.rst-content h1 a .headerlink,.rst-content h2 a .headerlink,.rst-content h3 a .headerlink,.rst-content h4 a .headerlink,.rst-content h5 a .headerlink,.rst-content h6 a .headerlink,.rst-content p.caption a .headerlink,.rst-content p a .headerlink,.rst-content table>caption a .headerlink,.rst-content tt.download a span:first-child,.wy-menu-vertical li.current>a button.toctree-expand,.wy-menu-vertical li.on a button.toctree-expand,.wy-menu-vertical li a button.toctree-expand,a .fa,a .icon,a .rst-content .admonition-title,a .rst-content .code-block-caption .headerlink,a .rst-content .eqno .headerlink,a .rst-content code.download span:first-child,a .rst-content dl dt .headerlink,a .rst-content h1 .headerlink,a .rst-content h2 .headerlink,a .rst-content h3 .headerlink,a .rst-content h4 .headerlink,a .rst-content h5 .headerlink,a .rst-content h6 .headerlink,a .rst-content p.caption .headerlink,a .rst-content p .headerlink,a .rst-content table>caption .headerlink,a .rst-content tt.download span:first-child,a .wy-menu-vertical li button.toctree-expand{display:inline-block;text-decoration:inherit}.btn .fa,.btn .icon,.btn .rst-content .admonition-title,.btn .rst-content .code-block-caption .headerlink,.btn .rst-content .eqno .headerlink,.btn .rst-content code.download span:first-child,.btn .rst-content dl dt .headerlink,.btn .rst-content h1 .headerlink,.btn .rst-content h2 .headerlink,.btn .rst-content h3 .headerlink,.btn .rst-content h4 .headerlink,.btn .rst-content h5 .headerlink,.btn .rst-content h6 .headerlink,.btn .rst-content p .headerlink,.btn .rst-content table>caption .headerlink,.btn .rst-content tt.download span:first-child,.btn .wy-menu-vertical li.current>a button.toctree-expand,.btn .wy-menu-vertical li.on a button.toctree-expand,.btn .wy-menu-vertical li button.toctree-expand,.nav .fa,.nav .icon,.nav .rst-content .admonition-title,.nav .rst-content .code-block-caption .headerlink,.nav .rst-content .eqno .headerlink,.nav .rst-content code.download span:first-child,.nav .rst-content dl dt .headerlink,.nav .rst-content h1 .headerlink,.nav .rst-content h2 .headerlink,.nav .rst-content h3 .headerlink,.nav .rst-content h4 .headerlink,.nav .rst-content h5 .headerlink,.nav .rst-content h6 .headerlink,.nav .rst-content p .headerlink,.nav .rst-content table>caption .headerlink,.nav .rst-content tt.download span:first-child,.nav .wy-menu-vertical li.current>a button.toctree-expand,.nav .wy-menu-vertical li.on a button.toctree-expand,.nav .wy-menu-vertical li button.toctree-expand,.rst-content .btn .admonition-title,.rst-content .code-block-caption .btn .headerlink,.rst-content .code-block-caption .nav .headerlink,.rst-content .eqno .btn .headerlink,.rst-content .eqno .nav .headerlink,.rst-content .nav .admonition-title,.rst-content code.download .btn span:first-child,.rst-content code.download .nav span:first-child,.rst-content dl dt .btn .headerlink,.rst-content dl dt .nav .headerlink,.rst-content h1 .btn .headerlink,.rst-content h1 .nav .headerlink,.rst-content h2 .btn .headerlink,.rst-content h2 .nav .headerlink,.rst-content h3 .btn .headerlink,.rst-content h3 .nav .headerlink,.rst-content h4 .btn .headerlink,.rst-content h4 .nav .headerlink,.rst-content h5 .btn .headerlink,.rst-content h5 .nav .headerlink,.rst-content h6 .btn .headerlink,.rst-content h6 .nav .headerlink,.rst-content p .btn .headerlink,.rst-content p .nav .headerlink,.rst-content table>caption .btn .headerlink,.rst-content table>caption .nav .headerlink,.rst-content tt.download .btn span:first-child,.rst-content tt.download .nav span:first-child,.wy-menu-vertical li .btn button.toctree-expand,.wy-menu-vertical li.current>a .btn button.toctree-expand,.wy-menu-vertical li.current>a .nav button.toctree-expand,.wy-menu-vertical li .nav button.toctree-expand,.wy-menu-vertical li.on a .btn button.toctree-expand,.wy-menu-vertical li.on a .nav button.toctree-expand{display:inline}.btn .fa-large.icon,.btn .fa.fa-large,.btn .rst-content .code-block-caption .fa-large.headerlink,.btn .rst-content .eqno .fa-large.headerlink,.btn .rst-content .fa-large.admonition-title,.btn .rst-content code.download span.fa-large:first-child,.btn .rst-content dl dt .fa-large.headerlink,.btn .rst-content h1 .fa-large.headerlink,.btn .rst-content h2 .fa-large.headerlink,.btn .rst-content h3 .fa-large.headerlink,.btn .rst-content h4 .fa-large.headerlink,.btn .rst-content h5 .fa-large.headerlink,.btn .rst-content h6 .fa-large.headerlink,.btn .rst-content p .fa-large.headerlink,.btn .rst-content table>caption .fa-large.headerlink,.btn .rst-content tt.download span.fa-large:first-child,.btn .wy-menu-vertical li button.fa-large.toctree-expand,.nav .fa-large.icon,.nav .fa.fa-large,.nav .rst-content .code-block-caption .fa-large.headerlink,.nav .rst-content .eqno .fa-large.headerlink,.nav .rst-content .fa-large.admonition-title,.nav .rst-content code.download span.fa-large:first-child,.nav .rst-content dl dt .fa-large.headerlink,.nav .rst-content h1 .fa-large.headerlink,.nav .rst-content h2 .fa-large.headerlink,.nav .rst-content h3 .fa-large.headerlink,.nav .rst-content h4 .fa-large.headerlink,.nav .rst-content h5 .fa-large.headerlink,.nav .rst-content h6 .fa-large.headerlink,.nav .rst-content p .fa-large.headerlink,.nav .rst-content table>caption .fa-large.headerlink,.nav .rst-content tt.download span.fa-large:first-child,.nav .wy-menu-vertical li button.fa-large.toctree-expand,.rst-content .btn .fa-large.admonition-title,.rst-content .code-block-caption .btn .fa-large.headerlink,.rst-content .code-block-caption .nav .fa-large.headerlink,.rst-content .eqno .btn .fa-large.headerlink,.rst-content .eqno .nav .fa-large.headerlink,.rst-content .nav .fa-large.admonition-title,.rst-content code.download .btn span.fa-large:first-child,.rst-content code.download .nav span.fa-large:first-child,.rst-content dl dt .btn .fa-large.headerlink,.rst-content dl dt .nav .fa-large.headerlink,.rst-content h1 .btn .fa-large.headerlink,.rst-content h1 .nav .fa-large.headerlink,.rst-content h2 .btn .fa-large.headerlink,.rst-content h2 .nav .fa-large.headerlink,.rst-content h3 .btn .fa-large.headerlink,.rst-content h3 .nav .fa-large.headerlink,.rst-content h4 .btn .fa-large.headerlink,.rst-content h4 .nav .fa-large.headerlink,.rst-content h5 .btn .fa-large.headerlink,.rst-content h5 .nav .fa-large.headerlink,.rst-content h6 .btn .fa-large.headerlink,.rst-content h6 .nav .fa-large.headerlink,.rst-content p .btn .fa-large.headerlink,.rst-content p .nav .fa-large.headerlink,.rst-content table>caption .btn .fa-large.headerlink,.rst-content table>caption .nav .fa-large.headerlink,.rst-content tt.download .btn span.fa-large:first-child,.rst-content tt.download .nav span.fa-large:first-child,.wy-menu-vertical li .btn button.fa-large.toctree-expand,.wy-menu-vertical li .nav button.fa-large.toctree-expand{line-height:.9em}.btn .fa-spin.icon,.btn .fa.fa-spin,.btn .rst-content .code-block-caption .fa-spin.headerlink,.btn .rst-content .eqno .fa-spin.headerlink,.btn .rst-content .fa-spin.admonition-title,.btn .rst-content code.download span.fa-spin:first-child,.btn .rst-content dl dt .fa-spin.headerlink,.btn .rst-content h1 .fa-spin.headerlink,.btn .rst-content h2 .fa-spin.headerlink,.btn .rst-content h3 .fa-spin.headerlink,.btn .rst-content h4 .fa-spin.headerlink,.btn .rst-content h5 .fa-spin.headerlink,.btn .rst-content h6 .fa-spin.headerlink,.btn .rst-content p .fa-spin.headerlink,.btn .rst-content table>caption .fa-spin.headerlink,.btn .rst-content tt.download span.fa-spin:first-child,.btn .wy-menu-vertical li button.fa-spin.toctree-expand,.nav .fa-spin.icon,.nav .fa.fa-spin,.nav .rst-content .code-block-caption .fa-spin.headerlink,.nav .rst-content .eqno .fa-spin.headerlink,.nav .rst-content .fa-spin.admonition-title,.nav .rst-content code.download span.fa-spin:first-child,.nav .rst-content dl dt .fa-spin.headerlink,.nav .rst-content h1 .fa-spin.headerlink,.nav .rst-content h2 .fa-spin.headerlink,.nav .rst-content h3 .fa-spin.headerlink,.nav .rst-content h4 .fa-spin.headerlink,.nav .rst-content h5 .fa-spin.headerlink,.nav .rst-content h6 .fa-spin.headerlink,.nav .rst-content p .fa-spin.headerlink,.nav .rst-content table>caption .fa-spin.headerlink,.nav .rst-content tt.download span.fa-spin:first-child,.nav .wy-menu-vertical li button.fa-spin.toctree-expand,.rst-content .btn .fa-spin.admonition-title,.rst-content .code-block-caption .btn .fa-spin.headerlink,.rst-content .code-block-caption .nav .fa-spin.headerlink,.rst-content .eqno .btn .fa-spin.headerlink,.rst-content .eqno .nav .fa-spin.headerlink,.rst-content .nav .fa-spin.admonition-title,.rst-content code.download .btn span.fa-spin:first-child,.rst-content code.download .nav span.fa-spin:first-child,.rst-content dl dt .btn .fa-spin.headerlink,.rst-content dl dt .nav .fa-spin.headerlink,.rst-content h1 .btn .fa-spin.headerlink,.rst-content h1 .nav .fa-spin.headerlink,.rst-content h2 .btn .fa-spin.headerlink,.rst-content h2 .nav .fa-spin.headerlink,.rst-content h3 .btn .fa-spin.headerlink,.rst-content h3 .nav .fa-spin.headerlink,.rst-content h4 .btn .fa-spin.headerlink,.rst-content h4 .nav .fa-spin.headerlink,.rst-content h5 .btn .fa-spin.headerlink,.rst-content h5 .nav .fa-spin.headerlink,.rst-content h6 .btn .fa-spin.headerlink,.rst-content h6 .nav .fa-spin.headerlink,.rst-content p .btn .fa-spin.headerlink,.rst-content p .nav .fa-spin.headerlink,.rst-content table>caption .btn .fa-spin.headerlink,.rst-content table>caption .nav .fa-spin.headerlink,.rst-content tt.download .btn span.fa-spin:first-child,.rst-content tt.download .nav span.fa-spin:first-child,.wy-menu-vertical li .btn button.fa-spin.toctree-expand,.wy-menu-vertical li .nav button.fa-spin.toctree-expand{display:inline-block}.btn.fa:before,.btn.icon:before,.rst-content .btn.admonition-title:before,.rst-content .code-block-caption .btn.headerlink:before,.rst-content .eqno .btn.headerlink:before,.rst-content code.download span.btn:first-child:before,.rst-content dl dt .btn.headerlink:before,.rst-content h1 .btn.headerlink:before,.rst-content h2 .btn.headerlink:before,.rst-content h3 .btn.headerlink:before,.rst-content h4 .btn.headerlink:before,.rst-content h5 .btn.headerlink:before,.rst-content h6 .btn.headerlink:before,.rst-content p .btn.headerlink:before,.rst-content table>caption .btn.headerlink:before,.rst-content tt.download span.btn:first-child:before,.wy-menu-vertical li button.btn.toctree-expand:before{opacity:.5;-webkit-transition:opacity .05s ease-in;-moz-transition:opacity .05s ease-in;transition:opacity .05s ease-in}.btn.fa:hover:before,.btn.icon:hover:before,.rst-content .btn.admonition-title:hover:before,.rst-content .code-block-caption .btn.headerlink:hover:before,.rst-content .eqno .btn.headerlink:hover:before,.rst-content code.download span.btn:first-child:hover:before,.rst-content dl dt .btn.headerlink:hover:before,.rst-content h1 .btn.headerlink:hover:before,.rst-content h2 .btn.headerlink:hover:before,.rst-content h3 .btn.headerlink:hover:before,.rst-content h4 .btn.headerlink:hover:before,.rst-content h5 .btn.headerlink:hover:before,.rst-content h6 .btn.headerlink:hover:before,.rst-content p .btn.headerlink:hover:before,.rst-content table>caption .btn.headerlink:hover:before,.rst-content tt.download span.btn:first-child:hover:before,.wy-menu-vertical li button.btn.toctree-expand:hover:before{opacity:1}.btn-mini .fa:before,.btn-mini .icon:before,.btn-mini .rst-content .admonition-title:before,.btn-mini .rst-content .code-block-caption .headerlink:before,.btn-mini .rst-content .eqno .headerlink:before,.btn-mini .rst-content code.download span:first-child:before,.btn-mini .rst-content dl dt .headerlink:before,.btn-mini .rst-content h1 .headerlink:before,.btn-mini .rst-content h2 .headerlink:before,.btn-mini .rst-content h3 .headerlink:before,.btn-mini .rst-content h4 .headerlink:before,.btn-mini .rst-content h5 .headerlink:before,.btn-mini .rst-content h6 .headerlink:before,.btn-mini .rst-content p .headerlink:before,.btn-mini .rst-content table>caption .headerlink:before,.btn-mini .rst-content tt.download span:first-child:before,.btn-mini .wy-menu-vertical li button.toctree-expand:before,.rst-content .btn-mini .admonition-title:before,.rst-content .code-block-caption .btn-mini .headerlink:before,.rst-content .eqno .btn-mini .headerlink:before,.rst-content code.download .btn-mini span:first-child:before,.rst-content dl dt .btn-mini .headerlink:before,.rst-content h1 .btn-mini .headerlink:before,.rst-content h2 .btn-mini .headerlink:before,.rst-content h3 .btn-mini .headerlink:before,.rst-content h4 .btn-mini .headerlink:before,.rst-content h5 .btn-mini .headerlink:before,.rst-content h6 .btn-mini .headerlink:before,.rst-content p .btn-mini .headerlink:before,.rst-content table>caption .btn-mini .headerlink:before,.rst-content tt.download .btn-mini span:first-child:before,.wy-menu-vertical li .btn-mini button.toctree-expand:before{font-size:14px;vertical-align:-15%}.rst-content .admonition,.rst-content .admonition-todo,.rst-content .attention,.rst-content .caution,.rst-content .danger,.rst-content .error,.rst-content .hint,.rst-content .important,.rst-content .note,.rst-content .seealso,.rst-content .tip,.rst-content .warning,.wy-alert{padding:12px;line-height:24px;margin-bottom:24px;background:#e7f2fa}.rst-content .admonition-title,.wy-alert-title{font-weight:700;display:block;color:#fff;background:#6ab0de;padding:6px 12px;margin:-12px -12px 12px}.rst-content .danger,.rst-content .error,.rst-content .wy-alert-danger.admonition,.rst-content .wy-alert-danger.admonition-todo,.rst-content .wy-alert-danger.attention,.rst-content .wy-alert-danger.caution,.rst-content .wy-alert-danger.hint,.rst-content .wy-alert-danger.important,.rst-content .wy-alert-danger.note,.rst-content .wy-alert-danger.seealso,.rst-content .wy-alert-danger.tip,.rst-content .wy-alert-danger.warning,.wy-alert.wy-alert-danger{background:#fdf3f2}.rst-content .danger .admonition-title,.rst-content .danger .wy-alert-title,.rst-content .error .admonition-title,.rst-content .error .wy-alert-title,.rst-content .wy-alert-danger.admonition-todo .admonition-title,.rst-content .wy-alert-danger.admonition-todo .wy-alert-title,.rst-content .wy-alert-danger.admonition .admonition-title,.rst-content .wy-alert-danger.admonition .wy-alert-title,.rst-content .wy-alert-danger.attention .admonition-title,.rst-content .wy-alert-danger.attention .wy-alert-title,.rst-content .wy-alert-danger.caution .admonition-title,.rst-content .wy-alert-danger.caution .wy-alert-title,.rst-content .wy-alert-danger.hint .admonition-title,.rst-content .wy-alert-danger.hint .wy-alert-title,.rst-content .wy-alert-danger.important .admonition-title,.rst-content .wy-alert-danger.important .wy-alert-title,.rst-content .wy-alert-danger.note .admonition-title,.rst-content .wy-alert-danger.note .wy-alert-title,.rst-content .wy-alert-danger.seealso .admonition-title,.rst-content .wy-alert-danger.seealso .wy-alert-title,.rst-content .wy-alert-danger.tip .admonition-title,.rst-content .wy-alert-danger.tip .wy-alert-title,.rst-content .wy-alert-danger.warning .admonition-title,.rst-content .wy-alert-danger.warning .wy-alert-title,.rst-content .wy-alert.wy-alert-danger .admonition-title,.wy-alert.wy-alert-danger .rst-content .admonition-title,.wy-alert.wy-alert-danger .wy-alert-title{background:#f29f97}.rst-content .admonition-todo,.rst-content .attention,.rst-content .caution,.rst-content .warning,.rst-content .wy-alert-warning.admonition,.rst-content .wy-alert-warning.danger,.rst-content .wy-alert-warning.error,.rst-content .wy-alert-warning.hint,.rst-content .wy-alert-warning.important,.rst-content .wy-alert-warning.note,.rst-content .wy-alert-warning.seealso,.rst-content .wy-alert-warning.tip,.wy-alert.wy-alert-warning{background:#ffedcc}.rst-content .admonition-todo .admonition-title,.rst-content .admonition-todo .wy-alert-title,.rst-content .attention .admonition-title,.rst-content .attention .wy-alert-title,.rst-content .caution .admonition-title,.rst-content .caution .wy-alert-title,.rst-content .warning .admonition-title,.rst-content .warning .wy-alert-title,.rst-content .wy-alert-warning.admonition .admonition-title,.rst-content .wy-alert-warning.admonition .wy-alert-title,.rst-content .wy-alert-warning.danger .admonition-title,.rst-content .wy-alert-warning.danger .wy-alert-title,.rst-content .wy-alert-warning.error .admonition-title,.rst-content .wy-alert-warning.error .wy-alert-title,.rst-content .wy-alert-warning.hint .admonition-title,.rst-content .wy-alert-warning.hint .wy-alert-title,.rst-content .wy-alert-warning.important .admonition-title,.rst-content .wy-alert-warning.important .wy-alert-title,.rst-content .wy-alert-warning.note .admonition-title,.rst-content .wy-alert-warning.note .wy-alert-title,.rst-content .wy-alert-warning.seealso .admonition-title,.rst-content .wy-alert-warning.seealso .wy-alert-title,.rst-content .wy-alert-warning.tip .admonition-title,.rst-content .wy-alert-warning.tip .wy-alert-title,.rst-content .wy-alert.wy-alert-warning .admonition-title,.wy-alert.wy-alert-warning .rst-content .admonition-title,.wy-alert.wy-alert-warning .wy-alert-title{background:#f0b37e}.rst-content .note,.rst-content .seealso,.rst-content .wy-alert-info.admonition,.rst-content .wy-alert-info.admonition-todo,.rst-content .wy-alert-info.attention,.rst-content .wy-alert-info.caution,.rst-content .wy-alert-info.danger,.rst-content .wy-alert-info.error,.rst-content .wy-alert-info.hint,.rst-content .wy-alert-info.important,.rst-content .wy-alert-info.tip,.rst-content .wy-alert-info.warning,.wy-alert.wy-alert-info{background:#e7f2fa}.rst-content .note .admonition-title,.rst-content .note .wy-alert-title,.rst-content .seealso .admonition-title,.rst-content .seealso .wy-alert-title,.rst-content .wy-alert-info.admonition-todo .admonition-title,.rst-content .wy-alert-info.admonition-todo .wy-alert-title,.rst-content .wy-alert-info.admonition .admonition-title,.rst-content .wy-alert-info.admonition .wy-alert-title,.rst-content .wy-alert-info.attention .admonition-title,.rst-content .wy-alert-info.attention .wy-alert-title,.rst-content .wy-alert-info.caution .admonition-title,.rst-content .wy-alert-info.caution .wy-alert-title,.rst-content .wy-alert-info.danger .admonition-title,.rst-content .wy-alert-info.danger .wy-alert-title,.rst-content .wy-alert-info.error .admonition-title,.rst-content .wy-alert-info.error .wy-alert-title,.rst-content .wy-alert-info.hint .admonition-title,.rst-content .wy-alert-info.hint .wy-alert-title,.rst-content .wy-alert-info.important .admonition-title,.rst-content .wy-alert-info.important .wy-alert-title,.rst-content .wy-alert-info.tip .admonition-title,.rst-content .wy-alert-info.tip .wy-alert-title,.rst-content .wy-alert-info.warning .admonition-title,.rst-content .wy-alert-info.warning .wy-alert-title,.rst-content .wy-alert.wy-alert-info .admonition-title,.wy-alert.wy-alert-info .rst-content .admonition-title,.wy-alert.wy-alert-info .wy-alert-title{background:#6ab0de}.rst-content .hint,.rst-content .important,.rst-content .tip,.rst-content .wy-alert-success.admonition,.rst-content .wy-alert-success.admonition-todo,.rst-content .wy-alert-success.attention,.rst-content .wy-alert-success.caution,.rst-content .wy-alert-success.danger,.rst-content .wy-alert-success.error,.rst-content .wy-alert-success.note,.rst-content .wy-alert-success.seealso,.rst-content .wy-alert-success.warning,.wy-alert.wy-alert-success{background:#dbfaf4}.rst-content .hint .admonition-title,.rst-content .hint .wy-alert-title,.rst-content .important .admonition-title,.rst-content .important .wy-alert-title,.rst-content .tip .admonition-title,.rst-content .tip .wy-alert-title,.rst-content .wy-alert-success.admonition-todo .admonition-title,.rst-content .wy-alert-success.admonition-todo .wy-alert-title,.rst-content .wy-alert-success.admonition .admonition-title,.rst-content .wy-alert-success.admonition .wy-alert-title,.rst-content .wy-alert-success.attention .admonition-title,.rst-content .wy-alert-success.attention .wy-alert-title,.rst-content .wy-alert-success.caution .admonition-title,.rst-content .wy-alert-success.caution .wy-alert-title,.rst-content .wy-alert-success.danger .admonition-title,.rst-content .wy-alert-success.danger .wy-alert-title,.rst-content .wy-alert-success.error .admonition-title,.rst-content .wy-alert-success.error .wy-alert-title,.rst-content .wy-alert-success.note .admonition-title,.rst-content .wy-alert-success.note .wy-alert-title,.rst-content .wy-alert-success.seealso .admonition-title,.rst-content .wy-alert-success.seealso .wy-alert-title,.rst-content .wy-alert-success.warning .admonition-title,.rst-content .wy-alert-success.warning .wy-alert-title,.rst-content .wy-alert.wy-alert-success .admonition-title,.wy-alert.wy-alert-success .rst-content .admonition-title,.wy-alert.wy-alert-success .wy-alert-title{background:#1abc9c}.rst-content .wy-alert-neutral.admonition,.rst-content .wy-alert-neutral.admonition-todo,.rst-content .wy-alert-neutral.attention,.rst-content .wy-alert-neutral.caution,.rst-content .wy-alert-neutral.danger,.rst-content .wy-alert-neutral.error,.rst-content .wy-alert-neutral.hint,.rst-content .wy-alert-neutral.important,.rst-content .wy-alert-neutral.note,.rst-content .wy-alert-neutral.seealso,.rst-content .wy-alert-neutral.tip,.rst-content .wy-alert-neutral.warning,.wy-alert.wy-alert-neutral{background:#f3f6f6}.rst-content .wy-alert-neutral.admonition-todo .admonition-title,.rst-content .wy-alert-neutral.admonition-todo .wy-alert-title,.rst-content .wy-alert-neutral.admonition .admonition-title,.rst-content .wy-alert-neutral.admonition .wy-alert-title,.rst-content .wy-alert-neutral.attention .admonition-title,.rst-content .wy-alert-neutral.attention .wy-alert-title,.rst-content .wy-alert-neutral.caution .admonition-title,.rst-content .wy-alert-neutral.caution .wy-alert-title,.rst-content .wy-alert-neutral.danger .admonition-title,.rst-content .wy-alert-neutral.danger .wy-alert-title,.rst-content .wy-alert-neutral.error .admonition-title,.rst-content .wy-alert-neutral.error .wy-alert-title,.rst-content .wy-alert-neutral.hint .admonition-title,.rst-content .wy-alert-neutral.hint .wy-alert-title,.rst-content .wy-alert-neutral.important .admonition-title,.rst-content .wy-alert-neutral.important .wy-alert-title,.rst-content .wy-alert-neutral.note .admonition-title,.rst-content .wy-alert-neutral.note .wy-alert-title,.rst-content .wy-alert-neutral.seealso .admonition-title,.rst-content .wy-alert-neutral.seealso .wy-alert-title,.rst-content .wy-alert-neutral.tip .admonition-title,.rst-content .wy-alert-neutral.tip .wy-alert-title,.rst-content .wy-alert-neutral.warning .admonition-title,.rst-content .wy-alert-neutral.warning .wy-alert-title,.rst-content .wy-alert.wy-alert-neutral .admonition-title,.wy-alert.wy-alert-neutral .rst-content .admonition-title,.wy-alert.wy-alert-neutral .wy-alert-title{color:#404040;background:#e1e4e5}.rst-content .wy-alert-neutral.admonition-todo a,.rst-content .wy-alert-neutral.admonition a,.rst-content .wy-alert-neutral.attention a,.rst-content .wy-alert-neutral.caution a,.rst-content .wy-alert-neutral.danger a,.rst-content .wy-alert-neutral.error a,.rst-content .wy-alert-neutral.hint a,.rst-content .wy-alert-neutral.important a,.rst-content .wy-alert-neutral.note a,.rst-content .wy-alert-neutral.seealso a,.rst-content .wy-alert-neutral.tip a,.rst-content .wy-alert-neutral.warning a,.wy-alert.wy-alert-neutral a{color:#2980b9}.rst-content .admonition-todo p:last-child,.rst-content .admonition p:last-child,.rst-content .attention p:last-child,.rst-content .caution p:last-child,.rst-content .danger p:last-child,.rst-content .error p:last-child,.rst-content .hint p:last-child,.rst-content .important p:last-child,.rst-content .note p:last-child,.rst-content .seealso p:last-child,.rst-content .tip p:last-child,.rst-content .warning p:last-child,.wy-alert p:last-child{margin-bottom:0}.wy-tray-container{position:fixed;bottom:0;left:0;z-index:600}.wy-tray-container li{display:block;width:300px;background:transparent;color:#fff;text-align:center;box-shadow:0 5px 5px 0 rgba(0,0,0,.1);padding:0 24px;min-width:20%;opacity:0;height:0;line-height:56px;overflow:hidden;-webkit-transition:all .3s ease-in;-moz-transition:all .3s ease-in;transition:all .3s ease-in}.wy-tray-container li.wy-tray-item-success{background:#27ae60}.wy-tray-container li.wy-tray-item-info{background:#2980b9}.wy-tray-container li.wy-tray-item-warning{background:#e67e22}.wy-tray-container li.wy-tray-item-danger{background:#e74c3c}.wy-tray-container li.on{opacity:1;height:56px}@media screen and (max-width:768px){.wy-tray-container{bottom:auto;top:0;width:100%}.wy-tray-container li{width:100%}}button{font-size:100%;margin:0;vertical-align:baseline;*vertical-align:middle;cursor:pointer;line-height:normal;-webkit-appearance:button;*overflow:visible}button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}button[disabled]{cursor:default}.btn{display:inline-block;border-radius:2px;line-height:normal;white-space:nowrap;text-align:center;cursor:pointer;font-size:100%;padding:6px 12px 8px;color:#fff;border:1px solid rgba(0,0,0,.1);background-color:#27ae60;text-decoration:none;font-weight:400;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;box-shadow:inset 0 1px 2px -1px hsla(0,0%,100%,.5),inset 0 -2px 0 0 rgba(0,0,0,.1);outline-none:false;vertical-align:middle;*display:inline;zoom:1;-webkit-user-drag:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-transition:all .1s linear;-moz-transition:all .1s linear;transition:all .1s linear}.btn-hover{background:#2e8ece;color:#fff}.btn:hover{background:#2cc36b;color:#fff}.btn:focus{background:#2cc36b;outline:0}.btn:active{box-shadow:inset 0 -1px 0 0 rgba(0,0,0,.05),inset 0 2px 0 0 rgba(0,0,0,.1);padding:8px 12px 6px}.btn:visited{color:#fff}.btn-disabled,.btn-disabled:active,.btn-disabled:focus,.btn-disabled:hover,.btn:disabled{background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled = false);filter:alpha(opacity=40);opacity:.4;cursor:not-allowed;box-shadow:none}.btn::-moz-focus-inner{padding:0;border:0}.btn-small{font-size:80%}.btn-info{background-color:#2980b9!important}.btn-info:hover{background-color:#2e8ece!important}.btn-neutral{background-color:#f3f6f6!important;color:#404040!important}.btn-neutral:hover{background-color:#e5ebeb!important;color:#404040}.btn-neutral:visited{color:#404040!important}.btn-success{background-color:#27ae60!important}.btn-success:hover{background-color:#295!important}.btn-danger{background-color:#e74c3c!important}.btn-danger:hover{background-color:#ea6153!important}.btn-warning{background-color:#e67e22!important}.btn-warning:hover{background-color:#e98b39!important}.btn-invert{background-color:#222}.btn-invert:hover{background-color:#2f2f2f!important}.btn-link{background-color:transparent!important;color:#2980b9;box-shadow:none;border-color:transparent!important}.btn-link:active,.btn-link:hover{background-color:transparent!important;color:#409ad5!important;box-shadow:none}.btn-link:visited{color:#9b59b6}.wy-btn-group .btn,.wy-control .btn{vertical-align:middle}.wy-btn-group{margin-bottom:24px;*zoom:1}.wy-btn-group:after,.wy-btn-group:before{display:table;content:""}.wy-btn-group:after{clear:both}.wy-dropdown{position:relative;display:inline-block}.wy-dropdown-active .wy-dropdown-menu{display:block}.wy-dropdown-menu{position:absolute;left:0;display:none;float:left;top:100%;min-width:100%;background:#fcfcfc;z-index:100;border:1px solid #cfd7dd;box-shadow:0 2px 2px 0 rgba(0,0,0,.1);padding:12px}.wy-dropdown-menu>dd>a{display:block;clear:both;color:#404040;white-space:nowrap;font-size:90%;padding:0 12px;cursor:pointer}.wy-dropdown-menu>dd>a:hover{background:#2980b9;color:#fff}.wy-dropdown-menu>dd.divider{border-top:1px solid #cfd7dd;margin:6px 0}.wy-dropdown-menu>dd.search{padding-bottom:12px}.wy-dropdown-menu>dd.search input[type=search]{width:100%}.wy-dropdown-menu>dd.call-to-action{background:#e3e3e3;text-transform:uppercase;font-weight:500;font-size:80%}.wy-dropdown-menu>dd.call-to-action:hover{background:#e3e3e3}.wy-dropdown-menu>dd.call-to-action .btn{color:#fff}.wy-dropdown.wy-dropdown-up .wy-dropdown-menu{bottom:100%;top:auto;left:auto;right:0}.wy-dropdown.wy-dropdown-bubble .wy-dropdown-menu{background:#fcfcfc;margin-top:2px}.wy-dropdown.wy-dropdown-bubble .wy-dropdown-menu a{padding:6px 12px}.wy-dropdown.wy-dropdown-bubble .wy-dropdown-menu a:hover{background:#2980b9;color:#fff}.wy-dropdown.wy-dropdown-left .wy-dropdown-menu{right:0;left:auto;text-align:right}.wy-dropdown-arrow:before{content:" ";border-bottom:5px solid #f5f5f5;border-left:5px solid transparent;border-right:5px solid transparent;position:absolute;display:block;top:-4px;left:50%;margin-left:-3px}.wy-dropdown-arrow.wy-dropdown-arrow-left:before{left:11px}.wy-form-stacked select{display:block}.wy-form-aligned .wy-help-inline,.wy-form-aligned input,.wy-form-aligned label,.wy-form-aligned select,.wy-form-aligned textarea{display:inline-block;*display:inline;*zoom:1;vertical-align:middle}.wy-form-aligned .wy-control-group>label{display:inline-block;vertical-align:middle;width:10em;margin:6px 12px 0 0;float:left}.wy-form-aligned .wy-control{float:left}.wy-form-aligned .wy-control label{display:block}.wy-form-aligned .wy-control select{margin-top:6px}fieldset{margin:0}fieldset,legend{border:0;padding:0}legend{width:100%;white-space:normal;margin-bottom:24px;font-size:150%;*margin-left:-7px}label,legend{display:block}label{margin:0 0 .3125em;color:#333;font-size:90%}input,select,textarea{font-size:100%;margin:0;vertical-align:baseline;*vertical-align:middle}.wy-control-group{margin-bottom:24px;max-width:1200px;margin-left:auto;margin-right:auto;*zoom:1}.wy-control-group:after,.wy-control-group:before{display:table;content:""}.wy-control-group:after{clear:both}.wy-control-group.wy-control-group-required>label:after{content:" *";color:#e74c3c}.wy-control-group .wy-form-full,.wy-control-group .wy-form-halves,.wy-control-group .wy-form-thirds{padding-bottom:12px}.wy-control-group .wy-form-full input[type=color],.wy-control-group .wy-form-full input[type=date],.wy-control-group .wy-form-full input[type=datetime-local],.wy-control-group .wy-form-full input[type=datetime],.wy-control-group .wy-form-full input[type=email],.wy-control-group .wy-form-full input[type=month],.wy-control-group .wy-form-full input[type=number],.wy-control-group .wy-form-full input[type=password],.wy-control-group .wy-form-full input[type=search],.wy-control-group .wy-form-full input[type=tel],.wy-control-group .wy-form-full input[type=text],.wy-control-group .wy-form-full input[type=time],.wy-control-group .wy-form-full input[type=url],.wy-control-group .wy-form-full input[type=week],.wy-control-group .wy-form-full select,.wy-control-group .wy-form-halves input[type=color],.wy-control-group .wy-form-halves input[type=date],.wy-control-group .wy-form-halves input[type=datetime-local],.wy-control-group .wy-form-halves input[type=datetime],.wy-control-group .wy-form-halves input[type=email],.wy-control-group .wy-form-halves input[type=month],.wy-control-group .wy-form-halves input[type=number],.wy-control-group .wy-form-halves input[type=password],.wy-control-group .wy-form-halves input[type=search],.wy-control-group .wy-form-halves input[type=tel],.wy-control-group .wy-form-halves input[type=text],.wy-control-group .wy-form-halves input[type=time],.wy-control-group .wy-form-halves input[type=url],.wy-control-group .wy-form-halves input[type=week],.wy-control-group .wy-form-halves select,.wy-control-group .wy-form-thirds input[type=color],.wy-control-group .wy-form-thirds input[type=date],.wy-control-group .wy-form-thirds input[type=datetime-local],.wy-control-group .wy-form-thirds input[type=datetime],.wy-control-group .wy-form-thirds input[type=email],.wy-control-group .wy-form-thirds input[type=month],.wy-control-group .wy-form-thirds input[type=number],.wy-control-group .wy-form-thirds input[type=password],.wy-control-group .wy-form-thirds input[type=search],.wy-control-group .wy-form-thirds input[type=tel],.wy-control-group .wy-form-thirds input[type=text],.wy-control-group .wy-form-thirds input[type=time],.wy-control-group .wy-form-thirds input[type=url],.wy-control-group .wy-form-thirds input[type=week],.wy-control-group .wy-form-thirds select{width:100%}.wy-control-group .wy-form-full{float:left;display:block;width:100%;margin-right:0}.wy-control-group .wy-form-full:last-child{margin-right:0}.wy-control-group .wy-form-halves{float:left;display:block;margin-right:2.35765%;width:48.82117%}.wy-control-group .wy-form-halves:last-child,.wy-control-group .wy-form-halves:nth-of-type(2n){margin-right:0}.wy-control-group .wy-form-halves:nth-of-type(odd){clear:left}.wy-control-group .wy-form-thirds{float:left;display:block;margin-right:2.35765%;width:31.76157%}.wy-control-group .wy-form-thirds:last-child,.wy-control-group .wy-form-thirds:nth-of-type(3n){margin-right:0}.wy-control-group .wy-form-thirds:nth-of-type(3n+1){clear:left}.wy-control-group.wy-control-group-no-input .wy-control,.wy-control-no-input{margin:6px 0 0;font-size:90%}.wy-control-no-input{display:inline-block}.wy-control-group.fluid-input input[type=color],.wy-control-group.fluid-input input[type=date],.wy-control-group.fluid-input input[type=datetime-local],.wy-control-group.fluid-input input[type=datetime],.wy-control-group.fluid-input input[type=email],.wy-control-group.fluid-input input[type=month],.wy-control-group.fluid-input input[type=number],.wy-control-group.fluid-input input[type=password],.wy-control-group.fluid-input input[type=search],.wy-control-group.fluid-input input[type=tel],.wy-control-group.fluid-input input[type=text],.wy-control-group.fluid-input input[type=time],.wy-control-group.fluid-input input[type=url],.wy-control-group.fluid-input input[type=week]{width:100%}.wy-form-message-inline{padding-left:.3em;color:#666;font-size:90%}.wy-form-message{display:block;color:#999;font-size:70%;margin-top:.3125em;font-style:italic}.wy-form-message p{font-size:inherit;font-style:italic;margin-bottom:6px}.wy-form-message p:last-child{margin-bottom:0}input{line-height:normal}input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;*overflow:visible}input[type=color],input[type=date],input[type=datetime-local],input[type=datetime],input[type=email],input[type=month],input[type=number],input[type=password],input[type=search],input[type=tel],input[type=text],input[type=time],input[type=url],input[type=week]{-webkit-appearance:none;padding:6px;display:inline-block;border:1px solid #ccc;font-size:80%;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;box-shadow:inset 0 1px 3px #ddd;border-radius:0;-webkit-transition:border .3s linear;-moz-transition:border .3s linear;transition:border .3s linear}input[type=datetime-local]{padding:.34375em .625em}input[disabled]{cursor:default}input[type=checkbox],input[type=radio]{padding:0;margin-right:.3125em;*height:13px;*width:13px}input[type=checkbox],input[type=radio],input[type=search]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}input[type=color]:focus,input[type=date]:focus,input[type=datetime-local]:focus,input[type=datetime]:focus,input[type=email]:focus,input[type=month]:focus,input[type=number]:focus,input[type=password]:focus,input[type=search]:focus,input[type=tel]:focus,input[type=text]:focus,input[type=time]:focus,input[type=url]:focus,input[type=week]:focus{outline:0;outline:thin dotted\9;border-color:#333}input.no-focus:focus{border-color:#ccc!important}input[type=checkbox]:focus,input[type=file]:focus,input[type=radio]:focus{outline:thin dotted #333;outline:1px auto #129fea}input[type=color][disabled],input[type=date][disabled],input[type=datetime-local][disabled],input[type=datetime][disabled],input[type=email][disabled],input[type=month][disabled],input[type=number][disabled],input[type=password][disabled],input[type=search][disabled],input[type=tel][disabled],input[type=text][disabled],input[type=time][disabled],input[type=url][disabled],input[type=week][disabled]{cursor:not-allowed;background-color:#fafafa}input:focus:invalid,select:focus:invalid,textarea:focus:invalid{color:#e74c3c;border:1px solid #e74c3c}input:focus:invalid:focus,select:focus:invalid:focus,textarea:focus:invalid:focus{border-color:#e74c3c}input[type=checkbox]:focus:invalid:focus,input[type=file]:focus:invalid:focus,input[type=radio]:focus:invalid:focus{outline-color:#e74c3c}input.wy-input-large{padding:12px;font-size:100%}textarea{overflow:auto;vertical-align:top;width:100%;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif}select,textarea{padding:.5em .625em;display:inline-block;border:1px solid #ccc;font-size:80%;box-shadow:inset 0 1px 3px #ddd;-webkit-transition:border .3s linear;-moz-transition:border .3s linear;transition:border .3s linear}select{border:1px solid #ccc;background-color:#fff}select[multiple]{height:auto}select:focus,textarea:focus{outline:0}input[readonly],select[disabled],select[readonly],textarea[disabled],textarea[readonly]{cursor:not-allowed;background-color:#fafafa}input[type=checkbox][disabled],input[type=radio][disabled]{cursor:not-allowed}.wy-checkbox,.wy-radio{margin:6px 0;color:#404040;display:block}.wy-checkbox input,.wy-radio input{vertical-align:baseline}.wy-form-message-inline{display:inline-block;*display:inline;*zoom:1;vertical-align:middle}.wy-input-prefix,.wy-input-suffix{white-space:nowrap;padding:6px}.wy-input-prefix .wy-input-context,.wy-input-suffix .wy-input-context{line-height:27px;padding:0 8px;display:inline-block;font-size:80%;background-color:#f3f6f6;border:1px solid #ccc;color:#999}.wy-input-suffix .wy-input-context{border-left:0}.wy-input-prefix .wy-input-context{border-right:0}.wy-switch{position:relative;display:block;height:24px;margin-top:12px;cursor:pointer}.wy-switch:before{left:0;top:0;width:36px;height:12px;background:#ccc}.wy-switch:after,.wy-switch:before{position:absolute;content:"";display:block;border-radius:4px;-webkit-transition:all .2s ease-in-out;-moz-transition:all .2s ease-in-out;transition:all .2s ease-in-out}.wy-switch:after{width:18px;height:18px;background:#999;left:-3px;top:-3px}.wy-switch span{position:absolute;left:48px;display:block;font-size:12px;color:#ccc;line-height:1}.wy-switch.active:before{background:#1e8449}.wy-switch.active:after{left:24px;background:#27ae60}.wy-switch.disabled{cursor:not-allowed;opacity:.8}.wy-control-group.wy-control-group-error .wy-form-message,.wy-control-group.wy-control-group-error>label{color:#e74c3c}.wy-control-group.wy-control-group-error input[type=color],.wy-control-group.wy-control-group-error input[type=date],.wy-control-group.wy-control-group-error input[type=datetime-local],.wy-control-group.wy-control-group-error input[type=datetime],.wy-control-group.wy-control-group-error input[type=email],.wy-control-group.wy-control-group-error input[type=month],.wy-control-group.wy-control-group-error input[type=number],.wy-control-group.wy-control-group-error input[type=password],.wy-control-group.wy-control-group-error input[type=search],.wy-control-group.wy-control-group-error input[type=tel],.wy-control-group.wy-control-group-error input[type=text],.wy-control-group.wy-control-group-error input[type=time],.wy-control-group.wy-control-group-error input[type=url],.wy-control-group.wy-control-group-error input[type=week],.wy-control-group.wy-control-group-error textarea{border:1px solid #e74c3c}.wy-inline-validate{white-space:nowrap}.wy-inline-validate .wy-input-context{padding:.5em .625em;display:inline-block;font-size:80%}.wy-inline-validate.wy-inline-validate-success .wy-input-context{color:#27ae60}.wy-inline-validate.wy-inline-validate-danger .wy-input-context{color:#e74c3c}.wy-inline-validate.wy-inline-validate-warning .wy-input-context{color:#e67e22}.wy-inline-validate.wy-inline-validate-info .wy-input-context{color:#2980b9}.rotate-90{-webkit-transform:rotate(90deg);-moz-transform:rotate(90deg);-ms-transform:rotate(90deg);-o-transform:rotate(90deg);transform:rotate(90deg)}.rotate-180{-webkit-transform:rotate(180deg);-moz-transform:rotate(180deg);-ms-transform:rotate(180deg);-o-transform:rotate(180deg);transform:rotate(180deg)}.rotate-270{-webkit-transform:rotate(270deg);-moz-transform:rotate(270deg);-ms-transform:rotate(270deg);-o-transform:rotate(270deg);transform:rotate(270deg)}.mirror{-webkit-transform:scaleX(-1);-moz-transform:scaleX(-1);-ms-transform:scaleX(-1);-o-transform:scaleX(-1);transform:scaleX(-1)}.mirror.rotate-90{-webkit-transform:scaleX(-1) rotate(90deg);-moz-transform:scaleX(-1) rotate(90deg);-ms-transform:scaleX(-1) rotate(90deg);-o-transform:scaleX(-1) rotate(90deg);transform:scaleX(-1) rotate(90deg)}.mirror.rotate-180{-webkit-transform:scaleX(-1) rotate(180deg);-moz-transform:scaleX(-1) rotate(180deg);-ms-transform:scaleX(-1) rotate(180deg);-o-transform:scaleX(-1) rotate(180deg);transform:scaleX(-1) rotate(180deg)}.mirror.rotate-270{-webkit-transform:scaleX(-1) rotate(270deg);-moz-transform:scaleX(-1) rotate(270deg);-ms-transform:scaleX(-1) rotate(270deg);-o-transform:scaleX(-1) rotate(270deg);transform:scaleX(-1) rotate(270deg)}@media only screen and (max-width:480px){.wy-form button[type=submit]{margin:.7em 0 0}.wy-form input[type=color],.wy-form input[type=date],.wy-form input[type=datetime-local],.wy-form input[type=datetime],.wy-form input[type=email],.wy-form input[type=month],.wy-form input[type=number],.wy-form input[type=password],.wy-form input[type=search],.wy-form input[type=tel],.wy-form input[type=text],.wy-form input[type=time],.wy-form input[type=url],.wy-form input[type=week],.wy-form label{margin-bottom:.3em;display:block}.wy-form input[type=color],.wy-form input[type=date],.wy-form input[type=datetime-local],.wy-form input[type=datetime],.wy-form input[type=email],.wy-form input[type=month],.wy-form input[type=number],.wy-form input[type=password],.wy-form input[type=search],.wy-form input[type=tel],.wy-form input[type=time],.wy-form input[type=url],.wy-form input[type=week]{margin-bottom:0}.wy-form-aligned .wy-control-group label{margin-bottom:.3em;text-align:left;display:block;width:100%}.wy-form-aligned .wy-control{margin:1.5em 0 0}.wy-form-message,.wy-form-message-inline,.wy-form .wy-help-inline{display:block;font-size:80%;padding:6px 0}}@media screen and (max-width:768px){.tablet-hide{display:none}}@media screen and (max-width:480px){.mobile-hide{display:none}}.float-left{float:left}.float-right{float:right}.full-width{width:100%}.rst-content table.docutils,.rst-content table.field-list,.wy-table{border-collapse:collapse;border-spacing:0;empty-cells:show;margin-bottom:24px}.rst-content table.docutils caption,.rst-content table.field-list caption,.wy-table caption{color:#000;font:italic 85%/1 arial,sans-serif;padding:1em 0;text-align:center}.rst-content table.docutils td,.rst-content table.docutils th,.rst-content table.field-list td,.rst-content table.field-list th,.wy-table td,.wy-table th{font-size:90%;margin:0;overflow:visible;padding:8px 16px}.rst-content table.docutils td:first-child,.rst-content table.docutils th:first-child,.rst-content table.field-list td:first-child,.rst-content table.field-list th:first-child,.wy-table td:first-child,.wy-table th:first-child{border-left-width:0}.rst-content table.docutils thead,.rst-content table.field-list thead,.wy-table thead{color:#000;text-align:left;vertical-align:bottom;white-space:nowrap}.rst-content table.docutils thead th,.rst-content table.field-list thead th,.wy-table thead th{font-weight:700;border-bottom:2px solid #e1e4e5}.rst-content table.docutils td,.rst-content table.field-list td,.wy-table td{background-color:transparent;vertical-align:middle}.rst-content table.docutils td p,.rst-content table.field-list td p,.wy-table td p{line-height:18px}.rst-content table.docutils td p:last-child,.rst-content table.field-list td p:last-child,.wy-table td p:last-child{margin-bottom:0}.rst-content table.docutils .wy-table-cell-min,.rst-content table.field-list .wy-table-cell-min,.wy-table .wy-table-cell-min{width:1%;padding-right:0}.rst-content table.docutils .wy-table-cell-min input[type=checkbox],.rst-content table.field-list .wy-table-cell-min input[type=checkbox],.wy-table .wy-table-cell-min input[type=checkbox]{margin:0}.wy-table-secondary{color:grey;font-size:90%}.wy-table-tertiary{color:grey;font-size:80%}.rst-content table.docutils:not(.field-list) tr:nth-child(2n-1) td,.wy-table-backed,.wy-table-odd td,.wy-table-striped tr:nth-child(2n-1) td{background-color:#f3f6f6}.rst-content table.docutils,.wy-table-bordered-all{border:1px solid #e1e4e5}.rst-content table.docutils td,.wy-table-bordered-all td{border-bottom:1px solid #e1e4e5;border-left:1px solid #e1e4e5}.rst-content table.docutils tbody>tr:last-child td,.wy-table-bordered-all tbody>tr:last-child td{border-bottom-width:0}.wy-table-bordered{border:1px solid #e1e4e5}.wy-table-bordered-rows td{border-bottom:1px solid #e1e4e5}.wy-table-bordered-rows tbody>tr:last-child td{border-bottom-width:0}.wy-table-horizontal td,.wy-table-horizontal th{border-width:0 0 1px;border-bottom:1px solid #e1e4e5}.wy-table-horizontal tbody>tr:last-child td{border-bottom-width:0}.wy-table-responsive{margin-bottom:24px;max-width:100%;overflow:auto}.wy-table-responsive table{margin-bottom:0!important}.wy-table-responsive table td,.wy-table-responsive table th{white-space:nowrap}a{color:#2980b9;text-decoration:none;cursor:pointer}a:hover{color:#3091d1}a:visited{color:#9b59b6}html{height:100%}body,html{overflow-x:hidden}body{font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;font-weight:400;color:#404040;min-height:100%;background:#edf0f2}.wy-text-left{text-align:left}.wy-text-center{text-align:center}.wy-text-right{text-align:right}.wy-text-large{font-size:120%}.wy-text-normal{font-size:100%}.wy-text-small,small{font-size:80%}.wy-text-strike{text-decoration:line-through}.wy-text-warning{color:#e67e22!important}a.wy-text-warning:hover{color:#eb9950!important}.wy-text-info{color:#2980b9!important}a.wy-text-info:hover{color:#409ad5!important}.wy-text-success{color:#27ae60!important}a.wy-text-success:hover{color:#36d278!important}.wy-text-danger{color:#e74c3c!important}a.wy-text-danger:hover{color:#ed7669!important}.wy-text-neutral{color:#404040!important}a.wy-text-neutral:hover{color:#595959!important}.rst-content .toctree-wrapper>p.caption,h1,h2,h3,h4,h5,h6,legend{margin-top:0;font-weight:700;font-family:Roboto Slab,ff-tisa-web-pro,Georgia,Arial,sans-serif}p{line-height:24px;font-size:16px;margin:0 0 24px}h1{font-size:175%}.rst-content .toctree-wrapper>p.caption,h2{font-size:150%}h3{font-size:125%}h4{font-size:115%}h5{font-size:110%}h6{font-size:100%}hr{display:block;height:1px;border:0;border-top:1px solid #e1e4e5;margin:24px 0;padding:0}.rst-content code,.rst-content tt,code{white-space:nowrap;max-width:100%;background:#fff;border:1px solid #e1e4e5;font-size:75%;padding:0 5px;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;color:#e74c3c;overflow-x:auto}.rst-content tt.code-large,code.code-large{font-size:90%}.rst-content .section ul,.rst-content .toctree-wrapper ul,.rst-content section ul,.wy-plain-list-disc,article ul{list-style:disc;line-height:24px;margin-bottom:24px}.rst-content .section ul li,.rst-content .toctree-wrapper ul li,.rst-content section ul li,.wy-plain-list-disc li,article ul li{list-style:disc;margin-left:24px}.rst-content .section ul li p:last-child,.rst-content .section ul li ul,.rst-content .toctree-wrapper ul li p:last-child,.rst-content .toctree-wrapper ul li ul,.rst-content section ul li p:last-child,.rst-content section ul li ul,.wy-plain-list-disc li p:last-child,.wy-plain-list-disc li ul,article ul li p:last-child,article ul li ul{margin-bottom:0}.rst-content .section ul li li,.rst-content .toctree-wrapper ul li li,.rst-content section ul li li,.wy-plain-list-disc li li,article ul li li{list-style:circle}.rst-content .section ul li li li,.rst-content .toctree-wrapper ul li li li,.rst-content section ul li li li,.wy-plain-list-disc li li li,article ul li li li{list-style:square}.rst-content .section ul li ol li,.rst-content .toctree-wrapper ul li ol li,.rst-content section ul li ol li,.wy-plain-list-disc li ol li,article ul li ol li{list-style:decimal}.rst-content .section ol,.rst-content .section ol.arabic,.rst-content .toctree-wrapper ol,.rst-content .toctree-wrapper ol.arabic,.rst-content section ol,.rst-content section ol.arabic,.wy-plain-list-decimal,article ol{list-style:decimal;line-height:24px;margin-bottom:24px}.rst-content .section ol.arabic li,.rst-content .section ol li,.rst-content .toctree-wrapper ol.arabic li,.rst-content .toctree-wrapper ol li,.rst-content section ol.arabic li,.rst-content section ol li,.wy-plain-list-decimal li,article ol li{list-style:decimal;margin-left:24px}.rst-content .section ol.arabic li ul,.rst-content .section ol li p:last-child,.rst-content .section ol li ul,.rst-content .toctree-wrapper ol.arabic li ul,.rst-content .toctree-wrapper ol li p:last-child,.rst-content .toctree-wrapper ol li ul,.rst-content section ol.arabic li ul,.rst-content section ol li p:last-child,.rst-content section ol li ul,.wy-plain-list-decimal li p:last-child,.wy-plain-list-decimal li ul,article ol li p:last-child,article ol li ul{margin-bottom:0}.rst-content .section ol.arabic li ul li,.rst-content .section ol li ul li,.rst-content .toctree-wrapper ol.arabic li ul li,.rst-content .toctree-wrapper ol li ul li,.rst-content section ol.arabic li ul li,.rst-content section ol li ul li,.wy-plain-list-decimal li ul li,article ol li ul li{list-style:disc}.wy-breadcrumbs{*zoom:1}.wy-breadcrumbs:after,.wy-breadcrumbs:before{display:table;content:""}.wy-breadcrumbs:after{clear:both}.wy-breadcrumbs>li{display:inline-block;padding-top:5px}.wy-breadcrumbs>li.wy-breadcrumbs-aside{float:right}.rst-content .wy-breadcrumbs>li code,.rst-content .wy-breadcrumbs>li tt,.wy-breadcrumbs>li .rst-content tt,.wy-breadcrumbs>li code{all:inherit;color:inherit}.breadcrumb-item:before{content:"/";color:#bbb;font-size:13px;padding:0 6px 0 3px}.wy-breadcrumbs-extra{margin-bottom:0;color:#b3b3b3;font-size:80%;display:inline-block}@media screen and (max-width:480px){.wy-breadcrumbs-extra,.wy-breadcrumbs li.wy-breadcrumbs-aside{display:none}}@media print{.wy-breadcrumbs li.wy-breadcrumbs-aside{display:none}}html{font-size:16px}.wy-affix{position:fixed;top:1.618em}.wy-menu a:hover{text-decoration:none}.wy-menu-horiz{*zoom:1}.wy-menu-horiz:after,.wy-menu-horiz:before{display:table;content:""}.wy-menu-horiz:after{clear:both}.wy-menu-horiz li,.wy-menu-horiz ul{display:inline-block}.wy-menu-horiz li:hover{background:hsla(0,0%,100%,.1)}.wy-menu-horiz li.divide-left{border-left:1px solid #404040}.wy-menu-horiz li.divide-right{border-right:1px solid #404040}.wy-menu-horiz a{height:32px;display:inline-block;line-height:32px;padding:0 16px}.wy-menu-vertical{width:300px}.wy-menu-vertical header,.wy-menu-vertical p.caption{color:#55a5d9;height:32px;line-height:32px;padding:0 1.618em;margin:12px 0 0;display:block;font-weight:700;text-transform:uppercase;font-size:85%;white-space:nowrap}.wy-menu-vertical ul{margin-bottom:0}.wy-menu-vertical li.divide-top{border-top:1px solid #404040}.wy-menu-vertical li.divide-bottom{border-bottom:1px solid #404040}.wy-menu-vertical li.current{background:#e3e3e3}.wy-menu-vertical li.current a{color:grey;border-right:1px solid #c9c9c9;padding:.4045em 2.427em}.wy-menu-vertical li.current a:hover{background:#d6d6d6}.rst-content .wy-menu-vertical li tt,.wy-menu-vertical li .rst-content tt,.wy-menu-vertical li code{border:none;background:inherit;color:inherit;padding-left:0;padding-right:0}.wy-menu-vertical li button.toctree-expand{display:block;float:left;margin-left:-1.2em;line-height:18px;color:#4d4d4d;border:none;background:none;padding:0}.wy-menu-vertical li.current>a,.wy-menu-vertical li.on a{color:#404040;font-weight:700;position:relative;background:#fcfcfc;border:none;padding:.4045em 1.618em}.wy-menu-vertical li.current>a:hover,.wy-menu-vertical li.on a:hover{background:#fcfcfc}.wy-menu-vertical li.current>a:hover button.toctree-expand,.wy-menu-vertical li.on a:hover button.toctree-expand{color:grey}.wy-menu-vertical li.current>a button.toctree-expand,.wy-menu-vertical li.on a button.toctree-expand{display:block;line-height:18px;color:#333}.wy-menu-vertical li.toctree-l1.current>a{border-bottom:1px solid #c9c9c9;border-top:1px solid #c9c9c9}.wy-menu-vertical .toctree-l1.current .toctree-l2>ul,.wy-menu-vertical .toctree-l2.current .toctree-l3>ul,.wy-menu-vertical .toctree-l3.current .toctree-l4>ul,.wy-menu-vertical .toctree-l4.current .toctree-l5>ul,.wy-menu-vertical .toctree-l5.current .toctree-l6>ul,.wy-menu-vertical .toctree-l6.current .toctree-l7>ul,.wy-menu-vertical .toctree-l7.current .toctree-l8>ul,.wy-menu-vertical .toctree-l8.current .toctree-l9>ul,.wy-menu-vertical .toctree-l9.current .toctree-l10>ul,.wy-menu-vertical .toctree-l10.current .toctree-l11>ul{display:none}.wy-menu-vertical .toctree-l1.current .current.toctree-l2>ul,.wy-menu-vertical .toctree-l2.current .current.toctree-l3>ul,.wy-menu-vertical .toctree-l3.current .current.toctree-l4>ul,.wy-menu-vertical .toctree-l4.current .current.toctree-l5>ul,.wy-menu-vertical .toctree-l5.current .current.toctree-l6>ul,.wy-menu-vertical .toctree-l6.current .current.toctree-l7>ul,.wy-menu-vertical .toctree-l7.current .current.toctree-l8>ul,.wy-menu-vertical .toctree-l8.current .current.toctree-l9>ul,.wy-menu-vertical .toctree-l9.current .current.toctree-l10>ul,.wy-menu-vertical .toctree-l10.current .current.toctree-l11>ul{display:block}.wy-menu-vertical li.toctree-l3,.wy-menu-vertical li.toctree-l4{font-size:.9em}.wy-menu-vertical li.toctree-l2 a,.wy-menu-vertical li.toctree-l3 a,.wy-menu-vertical li.toctree-l4 a,.wy-menu-vertical li.toctree-l5 a,.wy-menu-vertical li.toctree-l6 a,.wy-menu-vertical li.toctree-l7 a,.wy-menu-vertical li.toctree-l8 a,.wy-menu-vertical li.toctree-l9 a,.wy-menu-vertical li.toctree-l10 a{color:#404040}.wy-menu-vertical li.toctree-l2 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l3 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l4 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l5 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l6 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l7 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l8 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l9 a:hover button.toctree-expand,.wy-menu-vertical li.toctree-l10 a:hover button.toctree-expand{color:grey}.wy-menu-vertical li.toctree-l2.current li.toctree-l3>a,.wy-menu-vertical li.toctree-l3.current li.toctree-l4>a,.wy-menu-vertical li.toctree-l4.current li.toctree-l5>a,.wy-menu-vertical li.toctree-l5.current li.toctree-l6>a,.wy-menu-vertical li.toctree-l6.current li.toctree-l7>a,.wy-menu-vertical li.toctree-l7.current li.toctree-l8>a,.wy-menu-vertical li.toctree-l8.current li.toctree-l9>a,.wy-menu-vertical li.toctree-l9.current li.toctree-l10>a,.wy-menu-vertical li.toctree-l10.current li.toctree-l11>a{display:block}.wy-menu-vertical li.toctree-l2.current>a{padding:.4045em 2.427em}.wy-menu-vertical li.toctree-l2.current li.toctree-l3>a{padding:.4045em 1.618em .4045em 4.045em}.wy-menu-vertical li.toctree-l3.current>a{padding:.4045em 4.045em}.wy-menu-vertical li.toctree-l3.current li.toctree-l4>a{padding:.4045em 1.618em .4045em 5.663em}.wy-menu-vertical li.toctree-l4.current>a{padding:.4045em 5.663em}.wy-menu-vertical li.toctree-l4.current li.toctree-l5>a{padding:.4045em 1.618em .4045em 7.281em}.wy-menu-vertical li.toctree-l5.current>a{padding:.4045em 7.281em}.wy-menu-vertical li.toctree-l5.current li.toctree-l6>a{padding:.4045em 1.618em .4045em 8.899em}.wy-menu-vertical li.toctree-l6.current>a{padding:.4045em 8.899em}.wy-menu-vertical li.toctree-l6.current li.toctree-l7>a{padding:.4045em 1.618em .4045em 10.517em}.wy-menu-vertical li.toctree-l7.current>a{padding:.4045em 10.517em}.wy-menu-vertical li.toctree-l7.current li.toctree-l8>a{padding:.4045em 1.618em .4045em 12.135em}.wy-menu-vertical li.toctree-l8.current>a{padding:.4045em 12.135em}.wy-menu-vertical li.toctree-l8.current li.toctree-l9>a{padding:.4045em 1.618em .4045em 13.753em}.wy-menu-vertical li.toctree-l9.current>a{padding:.4045em 13.753em}.wy-menu-vertical li.toctree-l9.current li.toctree-l10>a{padding:.4045em 1.618em .4045em 15.371em}.wy-menu-vertical li.toctree-l10.current>a{padding:.4045em 15.371em}.wy-menu-vertical li.toctree-l10.current li.toctree-l11>a{padding:.4045em 1.618em .4045em 16.989em}.wy-menu-vertical li.toctree-l2.current>a,.wy-menu-vertical li.toctree-l2.current li.toctree-l3>a{background:#c9c9c9}.wy-menu-vertical li.toctree-l2 button.toctree-expand{color:#a3a3a3}.wy-menu-vertical li.toctree-l3.current>a,.wy-menu-vertical li.toctree-l3.current li.toctree-l4>a{background:#bdbdbd}.wy-menu-vertical li.toctree-l3 button.toctree-expand{color:#969696}.wy-menu-vertical li.current ul{display:block}.wy-menu-vertical li ul{margin-bottom:0;display:none}.wy-menu-vertical li ul li a{margin-bottom:0;color:#d9d9d9;font-weight:400}.wy-menu-vertical a{line-height:18px;padding:.4045em 1.618em;display:block;position:relative;font-size:90%;color:#d9d9d9}.wy-menu-vertical a:hover{background-color:#4e4a4a;cursor:pointer}.wy-menu-vertical a:hover button.toctree-expand{color:#d9d9d9}.wy-menu-vertical a:active{background-color:#2980b9;cursor:pointer;color:#fff}.wy-menu-vertical a:active button.toctree-expand{color:#fff}.wy-side-nav-search{display:block;width:300px;padding:.809em;margin-bottom:.809em;z-index:200;background-color:#2980b9;text-align:center;color:#fcfcfc}.wy-side-nav-search input[type=text]{width:100%;border-radius:50px;padding:6px 12px;border-color:#2472a4}.wy-side-nav-search img{display:block;margin:auto auto .809em;height:45px;width:45px;background-color:#2980b9;padding:5px;border-radius:100%}.wy-side-nav-search .wy-dropdown>a,.wy-side-nav-search>a{color:#fcfcfc;font-size:100%;font-weight:700;display:inline-block;padding:4px 6px;margin-bottom:.809em;max-width:100%}.wy-side-nav-search .wy-dropdown>a:hover,.wy-side-nav-search>a:hover{background:hsla(0,0%,100%,.1)}.wy-side-nav-search .wy-dropdown>a img.logo,.wy-side-nav-search>a img.logo{display:block;margin:0 auto;height:auto;width:auto;border-radius:0;max-width:100%;background:transparent}.wy-side-nav-search .wy-dropdown>a.icon img.logo,.wy-side-nav-search>a.icon img.logo{margin-top:.85em}.wy-side-nav-search>div.version{margin-top:-.4045em;margin-bottom:.809em;font-weight:400;color:hsla(0,0%,100%,.3)}.wy-nav .wy-menu-vertical header{color:#2980b9}.wy-nav .wy-menu-vertical a{color:#b3b3b3}.wy-nav .wy-menu-vertical a:hover{background-color:#2980b9;color:#fff}[data-menu-wrap]{-webkit-transition:all .2s ease-in;-moz-transition:all .2s ease-in;transition:all .2s ease-in;position:absolute;opacity:1;width:100%;opacity:0}[data-menu-wrap].move-center{left:0;right:auto;opacity:1}[data-menu-wrap].move-left{right:auto;left:-100%;opacity:0}[data-menu-wrap].move-right{right:-100%;left:auto;opacity:0}.wy-body-for-nav{background:#fcfcfc}.wy-grid-for-nav{position:absolute;width:100%;height:100%}.wy-nav-side{position:fixed;top:0;bottom:0;left:0;padding-bottom:2em;width:300px;overflow-x:hidden;overflow-y:hidden;min-height:100%;color:#9b9b9b;background:#343131;z-index:200}.wy-side-scroll{width:320px;position:relative;overflow-x:hidden;overflow-y:scroll;height:100%}.wy-nav-top{display:none;background:#2980b9;color:#fff;padding:.4045em .809em;position:relative;line-height:50px;text-align:center;font-size:100%;*zoom:1}.wy-nav-top:after,.wy-nav-top:before{display:table;content:""}.wy-nav-top:after{clear:both}.wy-nav-top a{color:#fff;font-weight:700}.wy-nav-top img{margin-right:12px;height:45px;width:45px;background-color:#2980b9;padding:5px;border-radius:100%}.wy-nav-top i{font-size:30px;float:left;cursor:pointer;padding-top:inherit}.wy-nav-content-wrap{margin-left:300px;background:#fcfcfc;min-height:100%}.wy-nav-content{padding:1.618em 3.236em;height:100%;max-width:800px;margin:auto}.wy-body-mask{position:fixed;width:100%;height:100%;background:rgba(0,0,0,.2);display:none;z-index:499}.wy-body-mask.on{display:block}footer{color:grey}footer p{margin-bottom:12px}.rst-content footer span.commit tt,footer span.commit .rst-content tt,footer span.commit code{padding:0;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;font-size:1em;background:none;border:none;color:grey}.rst-footer-buttons{*zoom:1}.rst-footer-buttons:after,.rst-footer-buttons:before{width:100%;display:table;content:""}.rst-footer-buttons:after{clear:both}.rst-breadcrumbs-buttons{margin-top:12px;*zoom:1}.rst-breadcrumbs-buttons:after,.rst-breadcrumbs-buttons:before{display:table;content:""}.rst-breadcrumbs-buttons:after{clear:both}#search-results .search li{margin-bottom:24px;border-bottom:1px solid #e1e4e5;padding-bottom:24px}#search-results .search li:first-child{border-top:1px solid #e1e4e5;padding-top:24px}#search-results .search li a{font-size:120%;margin-bottom:12px;display:inline-block}#search-results .context{color:grey;font-size:90%}.genindextable li>ul{margin-left:24px}@media screen and (max-width:768px){.wy-body-for-nav{background:#fcfcfc}.wy-nav-top{display:block}.wy-nav-side{left:-300px}.wy-nav-side.shift{width:85%;left:0}.wy-menu.wy-menu-vertical,.wy-side-nav-search,.wy-side-scroll{width:auto}.wy-nav-content-wrap{margin-left:0}.wy-nav-content-wrap .wy-nav-content{padding:1.618em}.wy-nav-content-wrap.shift{position:fixed;min-width:100%;left:85%;top:0;height:100%;overflow:hidden}}@media screen and (min-width:1100px){.wy-nav-content-wrap{background:rgba(0,0,0,.05)}.wy-nav-content{margin:0;background:#fcfcfc}}@media print{.rst-versions,.wy-nav-side,footer{display:none}.wy-nav-content-wrap{margin-left:0}}.rst-versions{position:fixed;bottom:0;left:0;width:300px;color:#fcfcfc;background:#1f1d1d;font-family:Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;z-index:400}.rst-versions a{color:#2980b9;text-decoration:none}.rst-versions .rst-badge-small{display:none}.rst-versions .rst-current-version{padding:12px;background-color:#272525;display:block;text-align:right;font-size:90%;cursor:pointer;color:#27ae60;*zoom:1}.rst-versions .rst-current-version:after,.rst-versions .rst-current-version:before{display:table;content:""}.rst-versions .rst-current-version:after{clear:both}.rst-content .code-block-caption .rst-versions .rst-current-version .headerlink,.rst-content .eqno .rst-versions .rst-current-version .headerlink,.rst-content .rst-versions .rst-current-version .admonition-title,.rst-content code.download .rst-versions .rst-current-version span:first-child,.rst-content dl dt .rst-versions .rst-current-version .headerlink,.rst-content h1 .rst-versions .rst-current-version .headerlink,.rst-content h2 .rst-versions .rst-current-version .headerlink,.rst-content h3 .rst-versions .rst-current-version .headerlink,.rst-content h4 .rst-versions .rst-current-version .headerlink,.rst-content h5 .rst-versions .rst-current-version .headerlink,.rst-content h6 .rst-versions .rst-current-version .headerlink,.rst-content p .rst-versions .rst-current-version .headerlink,.rst-content table>caption .rst-versions .rst-current-version .headerlink,.rst-content tt.download .rst-versions .rst-current-version span:first-child,.rst-versions .rst-current-version .fa,.rst-versions .rst-current-version .icon,.rst-versions .rst-current-version .rst-content .admonition-title,.rst-versions .rst-current-version .rst-content .code-block-caption .headerlink,.rst-versions .rst-current-version .rst-content .eqno .headerlink,.rst-versions .rst-current-version .rst-content code.download span:first-child,.rst-versions .rst-current-version .rst-content dl dt .headerlink,.rst-versions .rst-current-version .rst-content h1 .headerlink,.rst-versions .rst-current-version .rst-content h2 .headerlink,.rst-versions .rst-current-version .rst-content h3 .headerlink,.rst-versions .rst-current-version .rst-content h4 .headerlink,.rst-versions .rst-current-version .rst-content h5 .headerlink,.rst-versions .rst-current-version .rst-content h6 .headerlink,.rst-versions .rst-current-version .rst-content p .headerlink,.rst-versions .rst-current-version .rst-content table>caption .headerlink,.rst-versions .rst-current-version .rst-content tt.download span:first-child,.rst-versions .rst-current-version .wy-menu-vertical li button.toctree-expand,.wy-menu-vertical li .rst-versions .rst-current-version button.toctree-expand{color:#fcfcfc}.rst-versions .rst-current-version .fa-book,.rst-versions .rst-current-version .icon-book{float:left}.rst-versions .rst-current-version.rst-out-of-date{background-color:#e74c3c;color:#fff}.rst-versions .rst-current-version.rst-active-old-version{background-color:#f1c40f;color:#000}.rst-versions.shift-up{height:auto;max-height:100%;overflow-y:scroll}.rst-versions.shift-up .rst-other-versions{display:block}.rst-versions .rst-other-versions{font-size:90%;padding:12px;color:grey;display:none}.rst-versions .rst-other-versions hr{display:block;height:1px;border:0;margin:20px 0;padding:0;border-top:1px solid #413d3d}.rst-versions .rst-other-versions dd{display:inline-block;margin:0}.rst-versions .rst-other-versions dd a{display:inline-block;padding:6px;color:#fcfcfc}.rst-versions.rst-badge{width:auto;bottom:20px;right:20px;left:auto;border:none;max-width:300px;max-height:90%}.rst-versions.rst-badge .fa-book,.rst-versions.rst-badge .icon-book{float:none;line-height:30px}.rst-versions.rst-badge.shift-up .rst-current-version{text-align:right}.rst-versions.rst-badge.shift-up .rst-current-version .fa-book,.rst-versions.rst-badge.shift-up .rst-current-version .icon-book{float:left}.rst-versions.rst-badge>.rst-current-version{width:auto;height:30px;line-height:30px;padding:0 6px;display:block;text-align:center}@media screen and (max-width:768px){.rst-versions{width:85%;display:none}.rst-versions.shift{display:block}}.rst-content .toctree-wrapper>p.caption,.rst-content h1,.rst-content h2,.rst-content h3,.rst-content h4,.rst-content h5,.rst-content h6{margin-bottom:24px}.rst-content img{max-width:100%;height:auto}.rst-content div.figure,.rst-content figure{margin-bottom:24px}.rst-content div.figure .caption-text,.rst-content figure .caption-text{font-style:italic}.rst-content div.figure p:last-child.caption,.rst-content figure p:last-child.caption{margin-bottom:0}.rst-content div.figure.align-center,.rst-content figure.align-center{text-align:center}.rst-content .section>a>img,.rst-content .section>img,.rst-content section>a>img,.rst-content section>img{margin-bottom:24px}.rst-content abbr[title]{text-decoration:none}.rst-content.style-external-links a.reference.external:after{font-family:FontAwesome;content:"\f08e";color:#b3b3b3;vertical-align:super;font-size:60%;margin:0 .2em}.rst-content blockquote{margin-left:24px;line-height:24px;margin-bottom:24px}.rst-content pre.literal-block{white-space:pre;margin:0;padding:12px;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;display:block;overflow:auto}.rst-content div[class^=highlight],.rst-content pre.literal-block{border:1px solid #e1e4e5;overflow-x:auto;margin:1px 0 24px}.rst-content div[class^=highlight] div[class^=highlight],.rst-content pre.literal-block div[class^=highlight]{padding:0;border:none;margin:0}.rst-content div[class^=highlight] td.code{width:100%}.rst-content .linenodiv pre{border-right:1px solid #e6e9ea;margin:0;padding:12px;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;user-select:none;pointer-events:none}.rst-content div[class^=highlight] pre{white-space:pre;margin:0;padding:12px;display:block;overflow:auto}.rst-content div[class^=highlight] pre .hll{display:block;margin:0 -12px;padding:0 12px}.rst-content .linenodiv pre,.rst-content div[class^=highlight] pre,.rst-content pre.literal-block{font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;font-size:12px;line-height:1.4}.rst-content div.highlight .gp,.rst-content div.highlight span.linenos{user-select:none;pointer-events:none}.rst-content div.highlight span.linenos{display:inline-block;padding-left:0;padding-right:12px;margin-right:12px;border-right:1px solid #e6e9ea}.rst-content .code-block-caption{font-style:italic;font-size:85%;line-height:1;padding:1em 0;text-align:center}@media print{.rst-content .codeblock,.rst-content div[class^=highlight],.rst-content div[class^=highlight] pre{white-space:pre-wrap}}.rst-content .admonition,.rst-content .admonition-todo,.rst-content .attention,.rst-content .caution,.rst-content .danger,.rst-content .error,.rst-content .hint,.rst-content .important,.rst-content .note,.rst-content .seealso,.rst-content .tip,.rst-content .warning{clear:both}.rst-content .admonition-todo .last,.rst-content .admonition-todo>:last-child,.rst-content .admonition .last,.rst-content .admonition>:last-child,.rst-content .attention .last,.rst-content .attention>:last-child,.rst-content .caution .last,.rst-content .caution>:last-child,.rst-content .danger .last,.rst-content .danger>:last-child,.rst-content .error .last,.rst-content .error>:last-child,.rst-content .hint .last,.rst-content .hint>:last-child,.rst-content .important .last,.rst-content .important>:last-child,.rst-content .note .last,.rst-content .note>:last-child,.rst-content .seealso .last,.rst-content .seealso>:last-child,.rst-content .tip .last,.rst-content .tip>:last-child,.rst-content .warning .last,.rst-content .warning>:last-child{margin-bottom:0}.rst-content .admonition-title:before{margin-right:4px}.rst-content .admonition table{border-color:rgba(0,0,0,.1)}.rst-content .admonition table td,.rst-content .admonition table th{background:transparent!important;border-color:rgba(0,0,0,.1)!important}.rst-content .section ol.loweralpha,.rst-content .section ol.loweralpha>li,.rst-content .toctree-wrapper ol.loweralpha,.rst-content .toctree-wrapper ol.loweralpha>li,.rst-content section ol.loweralpha,.rst-content section ol.loweralpha>li{list-style:lower-alpha}.rst-content .section ol.upperalpha,.rst-content .section ol.upperalpha>li,.rst-content .toctree-wrapper ol.upperalpha,.rst-content .toctree-wrapper ol.upperalpha>li,.rst-content section ol.upperalpha,.rst-content section ol.upperalpha>li{list-style:upper-alpha}.rst-content .section ol li>*,.rst-content .section ul li>*,.rst-content .toctree-wrapper ol li>*,.rst-content .toctree-wrapper ul li>*,.rst-content section ol li>*,.rst-content section ul li>*{margin-top:12px;margin-bottom:12px}.rst-content .section ol li>:first-child,.rst-content .section ul li>:first-child,.rst-content .toctree-wrapper ol li>:first-child,.rst-content .toctree-wrapper ul li>:first-child,.rst-content section ol li>:first-child,.rst-content section ul li>:first-child{margin-top:0}.rst-content .section ol li>p,.rst-content .section ol li>p:last-child,.rst-content .section ul li>p,.rst-content .section ul li>p:last-child,.rst-content .toctree-wrapper ol li>p,.rst-content .toctree-wrapper ol li>p:last-child,.rst-content .toctree-wrapper ul li>p,.rst-content .toctree-wrapper ul li>p:last-child,.rst-content section ol li>p,.rst-content section ol li>p:last-child,.rst-content section ul li>p,.rst-content section ul li>p:last-child{margin-bottom:12px}.rst-content .section ol li>p:only-child,.rst-content .section ol li>p:only-child:last-child,.rst-content .section ul li>p:only-child,.rst-content .section ul li>p:only-child:last-child,.rst-content .toctree-wrapper ol li>p:only-child,.rst-content .toctree-wrapper ol li>p:only-child:last-child,.rst-content .toctree-wrapper ul li>p:only-child,.rst-content .toctree-wrapper ul li>p:only-child:last-child,.rst-content section ol li>p:only-child,.rst-content section ol li>p:only-child:last-child,.rst-content section ul li>p:only-child,.rst-content section ul li>p:only-child:last-child{margin-bottom:0}.rst-content .section ol li>ol,.rst-content .section ol li>ul,.rst-content .section ul li>ol,.rst-content .section ul li>ul,.rst-content .toctree-wrapper ol li>ol,.rst-content .toctree-wrapper ol li>ul,.rst-content .toctree-wrapper ul li>ol,.rst-content .toctree-wrapper ul li>ul,.rst-content section ol li>ol,.rst-content section ol li>ul,.rst-content section ul li>ol,.rst-content section ul li>ul{margin-bottom:12px}.rst-content .section ol.simple li>*,.rst-content .section ol.simple li ol,.rst-content .section ol.simple li ul,.rst-content .section ul.simple li>*,.rst-content .section ul.simple li ol,.rst-content .section ul.simple li ul,.rst-content .toctree-wrapper ol.simple li>*,.rst-content .toctree-wrapper ol.simple li ol,.rst-content .toctree-wrapper ol.simple li ul,.rst-content .toctree-wrapper ul.simple li>*,.rst-content .toctree-wrapper ul.simple li ol,.rst-content .toctree-wrapper ul.simple li ul,.rst-content section ol.simple li>*,.rst-content section ol.simple li ol,.rst-content section ol.simple li ul,.rst-content section ul.simple li>*,.rst-content section ul.simple li ol,.rst-content section ul.simple li ul{margin-top:0;margin-bottom:0}.rst-content .line-block{margin-left:0;margin-bottom:24px;line-height:24px}.rst-content .line-block .line-block{margin-left:24px;margin-bottom:0}.rst-content .topic-title{font-weight:700;margin-bottom:12px}.rst-content .toc-backref{color:#404040}.rst-content .align-right{float:right;margin:0 0 24px 24px}.rst-content .align-left{float:left;margin:0 24px 24px 0}.rst-content .align-center{margin:auto}.rst-content .align-center:not(table){display:block}.rst-content .code-block-caption .headerlink,.rst-content .eqno .headerlink,.rst-content .toctree-wrapper>p.caption .headerlink,.rst-content dl dt .headerlink,.rst-content h1 .headerlink,.rst-content h2 .headerlink,.rst-content h3 .headerlink,.rst-content h4 .headerlink,.rst-content h5 .headerlink,.rst-content h6 .headerlink,.rst-content p.caption .headerlink,.rst-content p .headerlink,.rst-content table>caption .headerlink{opacity:0;font-size:14px;font-family:FontAwesome;margin-left:.5em}.rst-content .code-block-caption .headerlink:focus,.rst-content .code-block-caption:hover .headerlink,.rst-content .eqno .headerlink:focus,.rst-content .eqno:hover .headerlink,.rst-content .toctree-wrapper>p.caption .headerlink:focus,.rst-content .toctree-wrapper>p.caption:hover .headerlink,.rst-content dl dt .headerlink:focus,.rst-content dl dt:hover .headerlink,.rst-content h1 .headerlink:focus,.rst-content h1:hover .headerlink,.rst-content h2 .headerlink:focus,.rst-content h2:hover .headerlink,.rst-content h3 .headerlink:focus,.rst-content h3:hover .headerlink,.rst-content h4 .headerlink:focus,.rst-content h4:hover .headerlink,.rst-content h5 .headerlink:focus,.rst-content h5:hover .headerlink,.rst-content h6 .headerlink:focus,.rst-content h6:hover .headerlink,.rst-content p.caption .headerlink:focus,.rst-content p.caption:hover .headerlink,.rst-content p .headerlink:focus,.rst-content p:hover .headerlink,.rst-content table>caption .headerlink:focus,.rst-content table>caption:hover .headerlink{opacity:1}.rst-content p a{overflow-wrap:anywhere}.rst-content .wy-table td p,.rst-content .wy-table td ul,.rst-content .wy-table th p,.rst-content .wy-table th ul,.rst-content table.docutils td p,.rst-content table.docutils td ul,.rst-content table.docutils th p,.rst-content table.docutils th ul,.rst-content table.field-list td p,.rst-content table.field-list td ul,.rst-content table.field-list th p,.rst-content table.field-list th ul{font-size:inherit}.rst-content .btn:focus{outline:2px solid}.rst-content table>caption .headerlink:after{font-size:12px}.rst-content .centered{text-align:center}.rst-content .sidebar{float:right;width:40%;display:block;margin:0 0 24px 24px;padding:24px;background:#f3f6f6;border:1px solid #e1e4e5}.rst-content .sidebar dl,.rst-content .sidebar p,.rst-content .sidebar ul{font-size:90%}.rst-content .sidebar .last,.rst-content .sidebar>:last-child{margin-bottom:0}.rst-content .sidebar .sidebar-title{display:block;font-family:Roboto Slab,ff-tisa-web-pro,Georgia,Arial,sans-serif;font-weight:700;background:#e1e4e5;padding:6px 12px;margin:-24px -24px 24px;font-size:100%}.rst-content .highlighted{background:#f1c40f;box-shadow:0 0 0 2px #f1c40f;display:inline;font-weight:700}.rst-content .citation-reference,.rst-content .footnote-reference{vertical-align:baseline;position:relative;top:-.4em;line-height:0;font-size:90%}.rst-content .citation-reference>span.fn-bracket,.rst-content .footnote-reference>span.fn-bracket{display:none}.rst-content .hlist{width:100%}.rst-content dl dt span.classifier:before{content:" : "}.rst-content dl dt span.classifier-delimiter{display:none!important}html.writer-html4 .rst-content table.docutils.citation,html.writer-html4 .rst-content table.docutils.footnote{background:none;border:none}html.writer-html4 .rst-content table.docutils.citation td,html.writer-html4 .rst-content table.docutils.citation tr,html.writer-html4 .rst-content table.docutils.footnote td,html.writer-html4 .rst-content table.docutils.footnote tr{border:none;background-color:transparent!important;white-space:normal}html.writer-html4 .rst-content table.docutils.citation td.label,html.writer-html4 .rst-content table.docutils.footnote td.label{padding-left:0;padding-right:0;vertical-align:top}html.writer-html5 .rst-content dl.citation,html.writer-html5 .rst-content dl.field-list,html.writer-html5 .rst-content dl.footnote{display:grid;grid-template-columns:auto minmax(80%,95%)}html.writer-html5 .rst-content dl.citation>dt,html.writer-html5 .rst-content dl.field-list>dt,html.writer-html5 .rst-content dl.footnote>dt{display:inline-grid;grid-template-columns:max-content auto}html.writer-html5 .rst-content aside.citation,html.writer-html5 .rst-content aside.footnote,html.writer-html5 .rst-content div.citation{display:grid;grid-template-columns:auto auto minmax(.65rem,auto) minmax(40%,95%)}html.writer-html5 .rst-content aside.citation>span.label,html.writer-html5 .rst-content aside.footnote>span.label,html.writer-html5 .rst-content div.citation>span.label{grid-column-start:1;grid-column-end:2}html.writer-html5 .rst-content aside.citation>span.backrefs,html.writer-html5 .rst-content aside.footnote>span.backrefs,html.writer-html5 .rst-content div.citation>span.backrefs{grid-column-start:2;grid-column-end:3;grid-row-start:1;grid-row-end:3}html.writer-html5 .rst-content aside.citation>p,html.writer-html5 .rst-content aside.footnote>p,html.writer-html5 .rst-content div.citation>p{grid-column-start:4;grid-column-end:5}html.writer-html5 .rst-content dl.citation,html.writer-html5 .rst-content dl.field-list,html.writer-html5 .rst-content dl.footnote{margin-bottom:24px}html.writer-html5 .rst-content dl.citation>dt,html.writer-html5 .rst-content dl.field-list>dt,html.writer-html5 .rst-content dl.footnote>dt{padding-left:1rem}html.writer-html5 .rst-content dl.citation>dd,html.writer-html5 .rst-content dl.citation>dt,html.writer-html5 .rst-content dl.field-list>dd,html.writer-html5 .rst-content dl.field-list>dt,html.writer-html5 .rst-content dl.footnote>dd,html.writer-html5 .rst-content dl.footnote>dt{margin-bottom:0}html.writer-html5 .rst-content dl.citation,html.writer-html5 .rst-content dl.footnote{font-size:.9rem}html.writer-html5 .rst-content dl.citation>dt,html.writer-html5 .rst-content dl.footnote>dt{margin:0 .5rem .5rem 0;line-height:1.2rem;word-break:break-all;font-weight:400}html.writer-html5 .rst-content dl.citation>dt>span.brackets:before,html.writer-html5 .rst-content dl.footnote>dt>span.brackets:before{content:"["}html.writer-html5 .rst-content dl.citation>dt>span.brackets:after,html.writer-html5 .rst-content dl.footnote>dt>span.brackets:after{content:"]"}html.writer-html5 .rst-content dl.citation>dt>span.fn-backref,html.writer-html5 .rst-content dl.footnote>dt>span.fn-backref{text-align:left;font-style:italic;margin-left:.65rem;word-break:break-word;word-spacing:-.1rem;max-width:5rem}html.writer-html5 .rst-content dl.citation>dt>span.fn-backref>a,html.writer-html5 .rst-content dl.footnote>dt>span.fn-backref>a{word-break:keep-all}html.writer-html5 .rst-content dl.citation>dt>span.fn-backref>a:not(:first-child):before,html.writer-html5 .rst-content dl.footnote>dt>span.fn-backref>a:not(:first-child):before{content:" "}html.writer-html5 .rst-content dl.citation>dd,html.writer-html5 .rst-content dl.footnote>dd{margin:0 0 .5rem;line-height:1.2rem}html.writer-html5 .rst-content dl.citation>dd p,html.writer-html5 .rst-content dl.footnote>dd p{font-size:.9rem}html.writer-html5 .rst-content aside.citation,html.writer-html5 .rst-content aside.footnote,html.writer-html5 .rst-content div.citation{padding-left:1rem;padding-right:1rem;font-size:.9rem;line-height:1.2rem}html.writer-html5 .rst-content aside.citation p,html.writer-html5 .rst-content aside.footnote p,html.writer-html5 .rst-content div.citation p{font-size:.9rem;line-height:1.2rem;margin-bottom:12px}html.writer-html5 .rst-content aside.citation span.backrefs,html.writer-html5 .rst-content aside.footnote span.backrefs,html.writer-html5 .rst-content div.citation span.backrefs{text-align:left;font-style:italic;margin-left:.65rem;word-break:break-word;word-spacing:-.1rem;max-width:5rem}html.writer-html5 .rst-content aside.citation span.backrefs>a,html.writer-html5 .rst-content aside.footnote span.backrefs>a,html.writer-html5 .rst-content div.citation span.backrefs>a{word-break:keep-all}html.writer-html5 .rst-content aside.citation span.backrefs>a:not(:first-child):before,html.writer-html5 .rst-content aside.footnote span.backrefs>a:not(:first-child):before,html.writer-html5 .rst-content div.citation span.backrefs>a:not(:first-child):before{content:" "}html.writer-html5 .rst-content aside.citation span.label,html.writer-html5 .rst-content aside.footnote span.label,html.writer-html5 .rst-content div.citation span.label{line-height:1.2rem}html.writer-html5 .rst-content aside.citation-list,html.writer-html5 .rst-content aside.footnote-list,html.writer-html5 .rst-content div.citation-list{margin-bottom:24px}html.writer-html5 .rst-content dl.option-list kbd{font-size:.9rem}.rst-content table.docutils.footnote,html.writer-html4 .rst-content table.docutils.citation,html.writer-html5 .rst-content aside.footnote,html.writer-html5 .rst-content aside.footnote-list aside.footnote,html.writer-html5 .rst-content div.citation-list>div.citation,html.writer-html5 .rst-content dl.citation,html.writer-html5 .rst-content dl.footnote{color:grey}.rst-content table.docutils.footnote code,.rst-content table.docutils.footnote tt,html.writer-html4 .rst-content table.docutils.citation code,html.writer-html4 .rst-content table.docutils.citation tt,html.writer-html5 .rst-content aside.footnote-list aside.footnote code,html.writer-html5 .rst-content aside.footnote-list aside.footnote tt,html.writer-html5 .rst-content aside.footnote code,html.writer-html5 .rst-content aside.footnote tt,html.writer-html5 .rst-content div.citation-list>div.citation code,html.writer-html5 .rst-content div.citation-list>div.citation tt,html.writer-html5 .rst-content dl.citation code,html.writer-html5 .rst-content dl.citation tt,html.writer-html5 .rst-content dl.footnote code,html.writer-html5 .rst-content dl.footnote tt{color:#555}.rst-content .wy-table-responsive.citation,.rst-content .wy-table-responsive.footnote{margin-bottom:0}.rst-content .wy-table-responsive.citation+:not(.citation),.rst-content .wy-table-responsive.footnote+:not(.footnote){margin-top:24px}.rst-content .wy-table-responsive.citation:last-child,.rst-content .wy-table-responsive.footnote:last-child{margin-bottom:24px}.rst-content table.docutils th{border-color:#e1e4e5}html.writer-html5 .rst-content table.docutils th{border:1px solid #e1e4e5}html.writer-html5 .rst-content table.docutils td>p,html.writer-html5 .rst-content table.docutils th>p{line-height:1rem;margin-bottom:0;font-size:.9rem}.rst-content table.docutils td .last,.rst-content table.docutils td .last>:last-child{margin-bottom:0}.rst-content table.field-list,.rst-content table.field-list td{border:none}.rst-content table.field-list td p{line-height:inherit}.rst-content table.field-list td>strong{display:inline-block}.rst-content table.field-list .field-name{padding-right:10px;text-align:left;white-space:nowrap}.rst-content table.field-list .field-body{text-align:left}.rst-content code,.rst-content tt{color:#000;font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;padding:2px 5px}.rst-content code big,.rst-content code em,.rst-content tt big,.rst-content tt em{font-size:100%!important;line-height:normal}.rst-content code.literal,.rst-content tt.literal{color:#e74c3c;white-space:normal}.rst-content code.xref,.rst-content tt.xref,a .rst-content code,a .rst-content tt{font-weight:700;color:#404040;overflow-wrap:normal}.rst-content kbd,.rst-content pre,.rst-content samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace}.rst-content a code,.rst-content a tt{color:#2980b9}.rst-content dl{margin-bottom:24px}.rst-content dl dt{font-weight:700;margin-bottom:12px}.rst-content dl ol,.rst-content dl p,.rst-content dl table,.rst-content dl ul{margin-bottom:12px}.rst-content dl dd{margin:0 0 12px 24px;line-height:24px}.rst-content dl dd>ol:last-child,.rst-content dl dd>p:last-child,.rst-content dl dd>table:last-child,.rst-content dl dd>ul:last-child{margin-bottom:0}html.writer-html4 .rst-content dl:not(.docutils),html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple){margin-bottom:24px}html.writer-html4 .rst-content dl:not(.docutils)>dt,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt{display:table;margin:6px 0;font-size:90%;line-height:normal;background:#e7f2fa;color:#2980b9;border-top:3px solid #6ab0de;padding:6px;position:relative}html.writer-html4 .rst-content dl:not(.docutils)>dt:before,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt:before{color:#6ab0de}html.writer-html4 .rst-content dl:not(.docutils)>dt .headerlink,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt .headerlink{color:#404040;font-size:100%!important}html.writer-html4 .rst-content dl:not(.docutils) dl:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) dl:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt{margin-bottom:6px;border:none;border-left:3px solid #ccc;background:#f0f0f0;color:#555}html.writer-html4 .rst-content dl:not(.docutils) dl:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt .headerlink,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) dl:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt .headerlink{color:#404040;font-size:100%!important}html.writer-html4 .rst-content dl:not(.docutils)>dt:first-child,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple)>dt:first-child{margin-top:0}html.writer-html4 .rst-content dl:not(.docutils) code.descclassname,html.writer-html4 .rst-content dl:not(.docutils) code.descname,html.writer-html4 .rst-content dl:not(.docutils) tt.descclassname,html.writer-html4 .rst-content dl:not(.docutils) tt.descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) code.descclassname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) code.descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) tt.descclassname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) tt.descname{background-color:transparent;border:none;padding:0;font-size:100%!important}html.writer-html4 .rst-content dl:not(.docutils) code.descname,html.writer-html4 .rst-content dl:not(.docutils) tt.descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) code.descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) tt.descname{font-weight:700}html.writer-html4 .rst-content dl:not(.docutils) .optional,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .optional{display:inline-block;padding:0 4px;color:#000;font-weight:700}html.writer-html4 .rst-content dl:not(.docutils) .property,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .property{display:inline-block;padding-right:8px;max-width:100%}html.writer-html4 .rst-content dl:not(.docutils) .k,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .k{font-style:italic}html.writer-html4 .rst-content dl:not(.docutils) .descclassname,html.writer-html4 .rst-content dl:not(.docutils) .descname,html.writer-html4 .rst-content dl:not(.docutils) .sig-name,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .descclassname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .descname,html.writer-html5 .rst-content dl[class]:not(.option-list):not(.field-list):not(.footnote):not(.citation):not(.glossary):not(.simple) .sig-name{font-family:SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,Courier,monospace;color:#000}.rst-content .viewcode-back,.rst-content .viewcode-link{display:inline-block;color:#27ae60;font-size:80%;padding-left:24px}.rst-content .viewcode-back{display:block;float:right}.rst-content p.rubric{margin-bottom:12px;font-weight:700}.rst-content code.download,.rst-content tt.download{background:inherit;padding:inherit;font-weight:400;font-family:inherit;font-size:inherit;color:inherit;border:inherit;white-space:inherit}.rst-content code.download span:first-child,.rst-content tt.download span:first-child{-webkit-font-smoothing:subpixel-antialiased}.rst-content code.download span:first-child:before,.rst-content tt.download span:first-child:before{margin-right:4px}.rst-content .guilabel,.rst-content .menuselection{font-size:80%;font-weight:700;border-radius:4px;padding:2.4px 6px;margin:auto 2px}.rst-content .guilabel,.rst-content .menuselection{border:1px solid #7fbbe3;background:#e7f2fa}.rst-content :not(dl.option-list)>:not(dt):not(kbd):not(.kbd)>.kbd,.rst-content :not(dl.option-list)>:not(dt):not(kbd):not(.kbd)>kbd{color:inherit;font-size:80%;background-color:#fff;border:1px solid #a6a6a6;border-radius:4px;box-shadow:0 2px grey;padding:2.4px 6px;margin:auto 0}.rst-content .versionmodified{font-style:italic}@media screen and (max-width:480px){.rst-content .sidebar{width:100%}}span[id*=MathJax-Span]{color:#404040}.math{text-align:center}@font-face{font-family:Lato;src:url(fonts/lato-normal.woff2?bd03a2cc277bbbc338d464e679fe9942) format("woff2"),url(fonts/lato-normal.woff?27bd77b9162d388cb8d4c4217c7c5e2a) format("woff");font-weight:400;font-style:normal;font-display:block}@font-face{font-family:Lato;src:url(fonts/lato-bold.woff2?cccb897485813c7c256901dbca54ecf2) format("woff2"),url(fonts/lato-bold.woff?d878b6c29b10beca227e9eef4246111b) format("woff");font-weight:700;font-style:normal;font-display:block}@font-face{font-family:Lato;src:url(fonts/lato-bold-italic.woff2?0b6bb6725576b072c5d0b02ecdd1900d) format("woff2"),url(fonts/lato-bold-italic.woff?9c7e4e9eb485b4a121c760e61bc3707c) format("woff");font-weight:700;font-style:italic;font-display:block}@font-face{font-family:Lato;src:url(fonts/lato-normal-italic.woff2?4eb103b4d12be57cb1d040ed5e162e9d) format("woff2"),url(fonts/lato-normal-italic.woff?f28f2d6482446544ef1ea1ccc6dd5892) format("woff");font-weight:400;font-style:italic;font-display:block}@font-face{font-family:Roboto Slab;font-style:normal;font-weight:400;src:url(fonts/Roboto-Slab-Regular.woff2?7abf5b8d04d26a2cafea937019bca958) format("woff2"),url(fonts/Roboto-Slab-Regular.woff?c1be9284088d487c5e3ff0a10a92e58c) format("woff");font-display:block}@font-face{font-family:Roboto Slab;font-style:normal;font-weight:700;src:url(fonts/Roboto-Slab-Bold.woff2?9984f4a9bda09be08e83f2506954adbe) format("woff2"),url(fonts/Roboto-Slab-Bold.woff?bed5564a116b05148e3b3bea6fb1162a) format("woff");font-display:block} \ No newline at end of file diff --git a/_static/doctools.js b/_static/doctools.js new file mode 100644 index 00000000..4d67807d --- /dev/null +++ b/_static/doctools.js @@ -0,0 +1,156 @@ +/* + * doctools.js + * ~~~~~~~~~~~ + * + * Base JavaScript utilities for all Sphinx HTML documentation. + * + * :copyright: Copyright 2007-2024 by the Sphinx team, see AUTHORS. + * :license: BSD, see LICENSE for details. + * + */ +"use strict"; + +const BLACKLISTED_KEY_CONTROL_ELEMENTS = new Set([ + "TEXTAREA", + "INPUT", + "SELECT", + "BUTTON", +]); + +const _ready = (callback) => { + if (document.readyState !== "loading") { + callback(); + } else { + document.addEventListener("DOMContentLoaded", callback); + } +}; + +/** + * Small JavaScript module for the documentation. + */ +const Documentation = { + init: () => { + Documentation.initDomainIndexTable(); + Documentation.initOnKeyListeners(); + }, + + /** + * i18n support + */ + TRANSLATIONS: {}, + PLURAL_EXPR: (n) => (n === 1 ? 0 : 1), + LOCALE: "unknown", + + // gettext and ngettext don't access this so that the functions + // can safely bound to a different name (_ = Documentation.gettext) + gettext: (string) => { + const translated = Documentation.TRANSLATIONS[string]; + switch (typeof translated) { + case "undefined": + return string; // no translation + case "string": + return translated; // translation exists + default: + return translated[0]; // (singular, plural) translation tuple exists + } + }, + + ngettext: (singular, plural, n) => { + const translated = Documentation.TRANSLATIONS[singular]; + if (typeof translated !== "undefined") + return translated[Documentation.PLURAL_EXPR(n)]; + return n === 1 ? singular : plural; + }, + + addTranslations: (catalog) => { + Object.assign(Documentation.TRANSLATIONS, catalog.messages); + Documentation.PLURAL_EXPR = new Function( + "n", + `return (${catalog.plural_expr})` + ); + Documentation.LOCALE = catalog.locale; + }, + + /** + * helper function to focus on search bar + */ + focusSearchBar: () => { + document.querySelectorAll("input[name=q]")[0]?.focus(); + }, + + /** + * Initialise the domain index toggle buttons + */ + initDomainIndexTable: () => { + const toggler = (el) => { + const idNumber = el.id.substr(7); + const toggledRows = document.querySelectorAll(`tr.cg-${idNumber}`); + if (el.src.substr(-9) === "minus.png") { + el.src = `${el.src.substr(0, el.src.length - 9)}plus.png`; + toggledRows.forEach((el) => (el.style.display = "none")); + } else { + el.src = `${el.src.substr(0, el.src.length - 8)}minus.png`; + toggledRows.forEach((el) => (el.style.display = "")); + } + }; + + const togglerElements = document.querySelectorAll("img.toggler"); + togglerElements.forEach((el) => + el.addEventListener("click", (event) => toggler(event.currentTarget)) + ); + togglerElements.forEach((el) => (el.style.display = "")); + if (DOCUMENTATION_OPTIONS.COLLAPSE_INDEX) togglerElements.forEach(toggler); + }, + + initOnKeyListeners: () => { + // only install a listener if it is really needed + if ( + !DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS && + !DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS + ) + return; + + document.addEventListener("keydown", (event) => { + // bail for input elements + if (BLACKLISTED_KEY_CONTROL_ELEMENTS.has(document.activeElement.tagName)) return; + // bail with special keys + if (event.altKey || event.ctrlKey || event.metaKey) return; + + if (!event.shiftKey) { + switch (event.key) { + case "ArrowLeft": + if (!DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS) break; + + const prevLink = document.querySelector('link[rel="prev"]'); + if (prevLink && prevLink.href) { + window.location.href = prevLink.href; + event.preventDefault(); + } + break; + case "ArrowRight": + if (!DOCUMENTATION_OPTIONS.NAVIGATION_WITH_KEYS) break; + + const nextLink = document.querySelector('link[rel="next"]'); + if (nextLink && nextLink.href) { + window.location.href = nextLink.href; + event.preventDefault(); + } + break; + } + } + + // some keyboard layouts may need Shift to get / + switch (event.key) { + case "/": + if (!DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS) break; + Documentation.focusSearchBar(); + event.preventDefault(); + } + }); + }, +}; + +// quick alias for translations +const _ = Documentation.gettext; + +_ready(Documentation.init); diff --git a/_static/documentation_options.js b/_static/documentation_options.js new file mode 100644 index 00000000..0344540b --- /dev/null +++ b/_static/documentation_options.js @@ -0,0 +1,13 @@ +const DOCUMENTATION_OPTIONS = { + VERSION: '0.17.0.dev0', + LANGUAGE: 'en', + COLLAPSE_INDEX: false, + BUILDER: 'html', + FILE_SUFFIX: '.html', + LINK_SUFFIX: '.html', + HAS_SOURCE: true, + SOURCELINK_SUFFIX: '.txt', + NAVIGATION_WITH_KEYS: false, + SHOW_SEARCH_SUMMARY: true, + ENABLE_SEARCH_SHORTCUTS: true, +}; \ No newline at end of file diff --git a/_static/file.png b/_static/file.png new file mode 100644 index 00000000..a858a410 Binary files /dev/null and b/_static/file.png differ diff --git a/_static/jquery.js b/_static/jquery.js new file mode 100644 index 00000000..c4c6022f --- /dev/null +++ b/_static/jquery.js @@ -0,0 +1,2 @@ +/*! jQuery v3.6.0 | (c) OpenJS Foundation and other contributors | jquery.org/license */ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],r=Object.getPrototypeOf,s=t.slice,g=t.flat?function(e){return t.flat.call(e)}:function(e){return t.concat.apply([],e)},u=t.push,i=t.indexOf,n={},o=n.toString,v=n.hasOwnProperty,a=v.toString,l=a.call(Object),y={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},x=function(e){return null!=e&&e===e.window},E=C.document,c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.6.0",S=function(e,t){return new S.fn.init(e,t)};function p(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&y(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!N[t+" "]&&(!v||!v.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ye(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=S)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+xe(l[o]);c=l.join(",")}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){N(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return g(t.replace($,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[S]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ve(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ye(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e&&e.namespaceURI,n=e&&(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],v=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&v.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||v.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+S+"-]").length||v.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||v.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||v.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||v.push(".#.+[+~]"),e.querySelectorAll("\\\f"),v.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&v.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&v.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&v.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),v.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",F)}),v=v.length&&new RegExp(v.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),y=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},j=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&y(p,e)?-1:t==C||t.ownerDocument==p&&y(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&E&&!N[t+" "]&&(!s||!s.test(t))&&(!v||!v.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){N(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,n,r){return m(n)?S.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?S.grep(e,function(e){return e===n!==r}):"string"!=typeof n?S.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(S.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||D,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof S?t[0]:t,S.merge(this,S.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),N.test(r[1])&&S.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(S):S.makeArray(e,this)}).prototype=S.fn,D=S(E);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e}S.fn.extend({has:function(e){var t=S(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=E.createDocumentFragment().appendChild(E.createElement("div")),(fe=E.createElement("input")).setAttribute("type","radio"),fe.setAttribute("checked","checked"),fe.setAttribute("name","t"),ce.appendChild(fe),y.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="",y.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="",y.option=!!ce.lastChild;var ge={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ve(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?S.merge([e],n):n}function ye(e,t){for(var n=0,r=e.length;n",""]);var me=/<|&#?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function je(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function De(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function qe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Le(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var _t,zt=[],Ut=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=zt.pop()||S.expando+"_"+wt.guid++;return this[e]=!0,e}}),S.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Ut.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Ut.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Ut,"$1"+r):!1!==e.jsonp&&(e.url+=(Tt.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||S.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,zt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),y.createHTMLDocument=((_t=E.implementation.createHTMLDocument("").body).innerHTML="
",2===_t.childNodes.length),S.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(y.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(S.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem}).length},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,"position"),c=S(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=S.css(e,"top"),u=S.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===S.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===S.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,"borderTopWidth",!0),i.left+=S.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-S.css(r,"marginTop",!0),left:t.left-i.left-S.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===S.css(e,"position"))e=e.offsetParent;return e||re})}}),S.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;S.fn[t]=function(e){return $(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),S.each(["top","left"],function(e,n){S.cssHooks[n]=Fe(y.pixelPosition,function(e,t){if(t)return t=We(e,n),Pe.test(t)?S(e).position()[n]+"px":t})}),S.each({Height:"height",Width:"width"},function(a,s){S.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return $(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i)},s,n?e:void 0,n)}})}),S.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){S.fn[t]=function(e){return this.on(t,e)}}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),S.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){S.fn[n]=function(e,t){return 0",d.insertBefore(c.lastChild,d.firstChild)}function d(){var a=y.elements;return"string"==typeof a?a.split(" "):a}function e(a,b){var c=y.elements;"string"!=typeof c&&(c=c.join(" ")),"string"!=typeof a&&(a=a.join(" ")),y.elements=c+" "+a,j(b)}function f(a){var b=x[a[v]];return b||(b={},w++,a[v]=w,x[w]=b),b}function g(a,c,d){if(c||(c=b),q)return c.createElement(a);d||(d=f(c));var e;return e=d.cache[a]?d.cache[a].cloneNode():u.test(a)?(d.cache[a]=d.createElem(a)).cloneNode():d.createElem(a),!e.canHaveChildren||t.test(a)||e.tagUrn?e:d.frag.appendChild(e)}function h(a,c){if(a||(a=b),q)return a.createDocumentFragment();c=c||f(a);for(var e=c.frag.cloneNode(),g=0,h=d(),i=h.length;i>g;g++)e.createElement(h[g]);return e}function i(a,b){b.cache||(b.cache={},b.createElem=a.createElement,b.createFrag=a.createDocumentFragment,b.frag=b.createFrag()),a.createElement=function(c){return y.shivMethods?g(c,a,b):b.createElem(c)},a.createDocumentFragment=Function("h,f","return function(){var n=f.cloneNode(),c=n.createElement;h.shivMethods&&("+d().join().replace(/[\w\-:]+/g,function(a){return b.createElem(a),b.frag.createElement(a),'c("'+a+'")'})+");return n}")(y,b.frag)}function j(a){a||(a=b);var d=f(a);return!y.shivCSS||p||d.hasCSS||(d.hasCSS=!!c(a,"article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}mark{background:#FF0;color:#000}template{display:none}")),q||i(a,d),a}function k(a){for(var b,c=a.getElementsByTagName("*"),e=c.length,f=RegExp("^(?:"+d().join("|")+")$","i"),g=[];e--;)b=c[e],f.test(b.nodeName)&&g.push(b.applyElement(l(b)));return g}function l(a){for(var b,c=a.attributes,d=c.length,e=a.ownerDocument.createElement(A+":"+a.nodeName);d--;)b=c[d],b.specified&&e.setAttribute(b.nodeName,b.nodeValue);return e.style.cssText=a.style.cssText,e}function m(a){for(var b,c=a.split("{"),e=c.length,f=RegExp("(^|[\\s,>+~])("+d().join("|")+")(?=[[\\s,>+~#.:]|$)","gi"),g="$1"+A+"\\:$2";e--;)b=c[e]=c[e].split("}"),b[b.length-1]=b[b.length-1].replace(f,g),c[e]=b.join("}");return c.join("{")}function n(a){for(var b=a.length;b--;)a[b].removeNode()}function o(a){function b(){clearTimeout(g._removeSheetTimer),d&&d.removeNode(!0),d=null}var d,e,g=f(a),h=a.namespaces,i=a.parentWindow;return!B||a.printShived?a:("undefined"==typeof h[A]&&h.add(A),i.attachEvent("onbeforeprint",function(){b();for(var f,g,h,i=a.styleSheets,j=[],l=i.length,n=Array(l);l--;)n[l]=i[l];for(;h=n.pop();)if(!h.disabled&&z.test(h.media)){try{f=h.imports,g=f.length}catch(o){g=0}for(l=0;g>l;l++)n.push(f[l]);try{j.push(h.cssText)}catch(o){}}j=m(j.reverse().join("")),e=k(a),d=c(a,j)}),i.attachEvent("onafterprint",function(){n(e),clearTimeout(g._removeSheetTimer),g._removeSheetTimer=setTimeout(b,500)}),a.printShived=!0,a)}var p,q,r="3.7.3",s=a.html5||{},t=/^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i,u=/^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i,v="_html5shiv",w=0,x={};!function(){try{var a=b.createElement("a");a.innerHTML="",p="hidden"in a,q=1==a.childNodes.length||function(){b.createElement("a");var a=b.createDocumentFragment();return"undefined"==typeof a.cloneNode||"undefined"==typeof a.createDocumentFragment||"undefined"==typeof a.createElement}()}catch(c){p=!0,q=!0}}();var y={elements:s.elements||"abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output picture progress section summary template time video",version:r,shivCSS:s.shivCSS!==!1,supportsUnknownElements:q,shivMethods:s.shivMethods!==!1,type:"default",shivDocument:j,createElement:g,createDocumentFragment:h,addElements:e};a.html5=y,j(b);var z=/^$|\b(?:all|print)\b/,A="html5shiv",B=!q&&function(){var c=b.documentElement;return!("undefined"==typeof b.namespaces||"undefined"==typeof b.parentWindow||"undefined"==typeof c.applyElement||"undefined"==typeof c.removeNode||"undefined"==typeof a.attachEvent)}();y.type+=" print",y.shivPrint=o,o(b),"object"==typeof module&&module.exports&&(module.exports=y)}("undefined"!=typeof window?window:this,document); \ No newline at end of file diff --git a/_static/js/html5shiv.min.js b/_static/js/html5shiv.min.js new file mode 100644 index 00000000..cd1c674f --- /dev/null +++ b/_static/js/html5shiv.min.js @@ -0,0 +1,4 @@ +/** +* @preserve HTML5 Shiv 3.7.3 | @afarkas @jdalton @jon_neal @rem | MIT/GPL2 Licensed +*/ +!function(a,b){function c(a,b){var c=a.createElement("p"),d=a.getElementsByTagName("head")[0]||a.documentElement;return c.innerHTML="x",d.insertBefore(c.lastChild,d.firstChild)}function d(){var a=t.elements;return"string"==typeof a?a.split(" "):a}function e(a,b){var c=t.elements;"string"!=typeof c&&(c=c.join(" ")),"string"!=typeof a&&(a=a.join(" ")),t.elements=c+" "+a,j(b)}function f(a){var b=s[a[q]];return b||(b={},r++,a[q]=r,s[r]=b),b}function g(a,c,d){if(c||(c=b),l)return c.createElement(a);d||(d=f(c));var e;return e=d.cache[a]?d.cache[a].cloneNode():p.test(a)?(d.cache[a]=d.createElem(a)).cloneNode():d.createElem(a),!e.canHaveChildren||o.test(a)||e.tagUrn?e:d.frag.appendChild(e)}function h(a,c){if(a||(a=b),l)return a.createDocumentFragment();c=c||f(a);for(var e=c.frag.cloneNode(),g=0,h=d(),i=h.length;i>g;g++)e.createElement(h[g]);return e}function i(a,b){b.cache||(b.cache={},b.createElem=a.createElement,b.createFrag=a.createDocumentFragment,b.frag=b.createFrag()),a.createElement=function(c){return t.shivMethods?g(c,a,b):b.createElem(c)},a.createDocumentFragment=Function("h,f","return function(){var n=f.cloneNode(),c=n.createElement;h.shivMethods&&("+d().join().replace(/[\w\-:]+/g,function(a){return b.createElem(a),b.frag.createElement(a),'c("'+a+'")'})+");return n}")(t,b.frag)}function j(a){a||(a=b);var d=f(a);return!t.shivCSS||k||d.hasCSS||(d.hasCSS=!!c(a,"article,aside,dialog,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}mark{background:#FF0;color:#000}template{display:none}")),l||i(a,d),a}var k,l,m="3.7.3-pre",n=a.html5||{},o=/^<|^(?:button|map|select|textarea|object|iframe|option|optgroup)$/i,p=/^(?:a|b|code|div|fieldset|h1|h2|h3|h4|h5|h6|i|label|li|ol|p|q|span|strong|style|table|tbody|td|th|tr|ul)$/i,q="_html5shiv",r=0,s={};!function(){try{var a=b.createElement("a");a.innerHTML="",k="hidden"in a,l=1==a.childNodes.length||function(){b.createElement("a");var a=b.createDocumentFragment();return"undefined"==typeof a.cloneNode||"undefined"==typeof a.createDocumentFragment||"undefined"==typeof a.createElement}()}catch(c){k=!0,l=!0}}();var t={elements:n.elements||"abbr article aside audio bdi canvas data datalist details dialog figcaption figure footer header hgroup main mark meter nav output picture progress section summary template time video",version:m,shivCSS:n.shivCSS!==!1,supportsUnknownElements:l,shivMethods:n.shivMethods!==!1,type:"default",shivDocument:j,createElement:g,createDocumentFragment:h,addElements:e};a.html5=t,j(b),"object"==typeof module&&module.exports&&(module.exports=t)}("undefined"!=typeof window?window:this,document); \ No newline at end of file diff --git a/_static/js/script.js b/_static/js/script.js new file mode 100644 index 00000000..5cb6494e --- /dev/null +++ b/_static/js/script.js @@ -0,0 +1,58 @@ +jQuery(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 = $('