├── docs ├── .nojekyll ├── check_docstrings.bat ├── make_html.bat ├── source │ ├── _static │ │ ├── favicon.ico │ │ └── AYON_blackG_dot.svg │ ├── ayon_api.rst │ ├── index.rst │ └── conf.py ├── make_api.bat └── Makefile ├── tests ├── __init__.py ├── resources │ ├── addon │ │ ├── .gitignore │ │ ├── private │ │ │ └── ayon-symbol.png │ │ ├── package.py │ │ ├── server │ │ │ └── __init__.py │ │ └── create_package.py │ └── ayon-symbol.png ├── test_graphql_queries.py ├── test_get_events.py └── conftest.py ├── ayon_api ├── version.py ├── _api_helpers │ ├── __init__.py │ ├── secrets.py │ ├── base.py │ ├── attributes.py │ ├── installers.py │ ├── dependency_packages.py │ ├── activities.py │ ├── actions.py │ ├── thumbnails.py │ └── lists.py ├── events.py ├── exceptions.py ├── constants.py ├── typing.py └── __init__.py ├── .github └── workflows │ ├── pr_linting.yml │ ├── docs-publish.yml │ └── python-publish.yml ├── setup.py ├── pyproject.toml ├── ruff.toml ├── .gitignore ├── README.md ├── LICENSE └── automated_api.py /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/resources/addon/.gitignore: -------------------------------------------------------------------------------- 1 | /package/ 2 | /__pycache__/ -------------------------------------------------------------------------------- /docs/check_docstrings.bat: -------------------------------------------------------------------------------- 1 | poetry run pydocstyle --convention=google --add-ignore=D103,D104,D100 -------------------------------------------------------------------------------- /ayon_api/version.py: -------------------------------------------------------------------------------- 1 | """Package declaring Python API for AYON server.""" 2 | __version__ = "1.2.7-dev" 3 | -------------------------------------------------------------------------------- /docs/make_html.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | poetry run sphinx-build -M html .\source .\build 6 | -------------------------------------------------------------------------------- /docs/source/_static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ynput/ayon-python-api/HEAD/docs/source/_static/favicon.ico -------------------------------------------------------------------------------- /tests/resources/ayon-symbol.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ynput/ayon-python-api/HEAD/tests/resources/ayon-symbol.png -------------------------------------------------------------------------------- /docs/make_api.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | poetry run sphinx-apidoc -f -e -M -o .\source\ ..\ayon_api\ 6 | 7 | -------------------------------------------------------------------------------- /tests/resources/addon/private/ayon-symbol.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ynput/ayon-python-api/HEAD/tests/resources/addon/private/ayon-symbol.png -------------------------------------------------------------------------------- /tests/resources/addon/package.py: -------------------------------------------------------------------------------- 1 | name = "tests" 2 | title = "Tests" 3 | version = "1.0.0" 4 | 5 | client_dir = None 6 | # ayon_launcher_version = ">=1.0.2" 7 | 8 | ayon_required_addons = {} 9 | ayon_compatible_addons = {} 10 | -------------------------------------------------------------------------------- /docs/source/ayon_api.rst: -------------------------------------------------------------------------------- 1 | ayon\_api package 2 | ================= 3 | 4 | .. automodule:: ayon_api 5 | :members: 6 | :undoc-members: 7 | :show-inheritance: 8 | 9 | Submodules 10 | ---------- 11 | 12 | .. toctree:: 13 | :maxdepth: 4 14 | 15 | ayon_api.constants 16 | ayon_api.entity_hub 17 | ayon_api.events 18 | ayon_api.exceptions 19 | ayon_api.graphql 20 | ayon_api.graphql_queries 21 | ayon_api.operations 22 | ayon_api.server_api 23 | ayon_api.utils 24 | ayon_api.version 25 | -------------------------------------------------------------------------------- /.github/workflows/pr_linting.yml: -------------------------------------------------------------------------------- 1 | name: 📇 Code Linting 2 | 3 | on: 4 | push: 5 | branches: [ develop ] 6 | pull_request: 7 | branches: [ develop ] 8 | 9 | workflow_dispatch: 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.event.pull_request.number}} 13 | cancel-in-progress: true 14 | 15 | permissions: 16 | contents: read 17 | pull-requests: write 18 | 19 | jobs: 20 | linting: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: chartboost/ruff-action@v1 25 | -------------------------------------------------------------------------------- /tests/resources/addon/server/__init__.py: -------------------------------------------------------------------------------- 1 | from ayon_server.addons import BaseServerAddon 2 | from ayon_server.api.dependencies import CurrentUser 3 | 4 | 5 | class TestsAddon(BaseServerAddon): 6 | def initialize(self): 7 | self.add_endpoint( 8 | "test-get", 9 | self.get_test, 10 | method="GET", 11 | ) 12 | 13 | async def get_test( 14 | self, user: CurrentUser, 15 | ): 16 | """Return a random folder from the database""" 17 | return { 18 | "success": True, 19 | } 20 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /.github/workflows/docs-publish.yml: -------------------------------------------------------------------------------- 1 | name: Build sphinx documentation 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main", "develop"] 8 | 9 | permissions: 10 | contents: write 11 | 12 | jobs: 13 | docs: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.10.x" 20 | - uses: abatilo/actions-poetry@v2 21 | - name: Install dependencies 22 | run: | 23 | poetry install 24 | - name: Sphinx build HTML 25 | run: | 26 | poetry run sphinx-apidoc -f -e -M -o ./docs/source/ ./ayon_api/ 27 | - name: Sphinx build HTML 28 | run: | 29 | poetry run sphinx-build -M html ./docs/source ./docs/build 30 | 31 | - name: Deploy to GitHub Pages 32 | if: github.event_name == 'push' 33 | uses: peaceiris/actions-gh-pages@v4 34 | with: 35 | publish_branch: gh-pages 36 | github_token: ${{ secrets.GITHUB_TOKEN }} 37 | publish_dir: ./docs/build/html/ 38 | force_orphan: true -------------------------------------------------------------------------------- /ayon_api/_api_helpers/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import BaseServerAPI 2 | from .installers import InstallersAPI 3 | from .dependency_packages import DependencyPackagesAPI 4 | from .secrets import SecretsAPI 5 | from .bundles_addons import BundlesAddonsAPI 6 | from .events import EventsAPI 7 | from .attributes import AttributesAPI 8 | from .projects import ProjectsAPI 9 | from .folders import FoldersAPI 10 | from .tasks import TasksAPI 11 | from .products import ProductsAPI 12 | from .versions import VersionsAPI 13 | from .representations import RepresentationsAPI 14 | from .workfiles import WorkfilesAPI 15 | from .thumbnails import ThumbnailsAPI 16 | from .activities import ActivitiesAPI 17 | from .actions import ActionsAPI 18 | from .links import LinksAPI 19 | from .lists import ListsAPI 20 | 21 | 22 | __all__ = ( 23 | "BaseServerAPI", 24 | "InstallersAPI", 25 | "DependencyPackagesAPI", 26 | "SecretsAPI", 27 | "BundlesAddonsAPI", 28 | "EventsAPI", 29 | "AttributesAPI", 30 | "ProjectsAPI", 31 | "FoldersAPI", 32 | "TasksAPI", 33 | "ProductsAPI", 34 | "VersionsAPI", 35 | "RepresentationsAPI", 36 | "WorkfilesAPI", 37 | "ThumbnailsAPI", 38 | "ActivitiesAPI", 39 | "ActionsAPI", 40 | "LinksAPI", 41 | "ListsAPI", 42 | ) 43 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | from setuptools import setup 5 | 6 | REPO_ROOT = os.path.dirname(os.path.abspath(__file__)) 7 | README_PATH = os.path.join(REPO_ROOT, "README.md") 8 | VERSION_PATH = os.path.join(REPO_ROOT, "ayon_api", "version.py") 9 | _version_content = {} 10 | exec(open(VERSION_PATH).read(), _version_content) 11 | 12 | setup( 13 | name="ayon_python_api", 14 | version=_version_content["__version__"], 15 | py_modules=["ayon_api"], 16 | packages=["ayon_api", "ayon_api._api_helpers"], 17 | author="ynput.io", 18 | author_email="info@ynput.io", 19 | license="Apache License (2.0)", 20 | description="AYON Python API", 21 | long_description=open(README_PATH, encoding="utf-8").read(), 22 | long_description_content_type="text/markdown", 23 | url="https://github.com/ynput/ayon-python-api", 24 | include_package_data=True, 25 | # https://pypi.org/classifiers/ 26 | classifiers=[ 27 | "Development Status :: 5 - Production/Stable", 28 | "Programming Language :: Python", 29 | "Programming Language :: Python :: 3", 30 | ], 31 | install_requires=[ 32 | "requests >= 2.27.1", 33 | "Unidecode >= 1.3.0", 34 | "appdirs >=1, <2", 35 | ], 36 | keywords=["AYON", "ynput", "OpenPype", "vfx"], 37 | ) 38 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: ⬆️ Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | runs-on: ubuntu-latest 21 | environment: 22 | name: pypi 23 | url: https://pypi.org/p/ayon-python-api 24 | 25 | steps: 26 | - uses: actions/checkout@v3 27 | - name: Set up Python 28 | uses: actions/setup-python@v3 29 | with: 30 | python-version: '3.10.x' 31 | - name: Install dependencies 32 | run: | 33 | python -m pip install --upgrade pip 34 | pip install wheel 35 | - name: Build package 36 | run: python setup.py sdist bdist_wheel 37 | - name: Publish package 38 | uses: pypa/gh-action-pypi-publish@release/v1 39 | with: 40 | password: ${{ secrets.PYPI_API_TOKEN }} 41 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "ayon_python_api" 3 | version = "1.2.7-dev" 4 | description = "AYON Python API" 5 | license = {file = "LICENSE"} 6 | readme = {file = "README.md", content-type = "text/markdown"} 7 | authors = [ 8 | {name = "ynput.io", email = "info@ynput.io"} 9 | ] 10 | keywords = ["AYON", "ynput", "OpenPype", "vfx"] 11 | classifiers = [ 12 | "Development Status :: 5 - Production/Stable", 13 | "Programming Language :: Python", 14 | "Programming Language :: Python :: 3", 15 | ] 16 | dependencies = [ 17 | "requests >= 2.27.1", 18 | "Unidecode >= 1.3.0", 19 | ] 20 | 21 | [project.urls] 22 | Repository = "https://github.com/ynput/ayon-python-api" 23 | Changelog = "https://github.com/ynput/ayon-python-api/releases" 24 | 25 | [build-system] 26 | requires = ["poetry-core>=1.0.0"] 27 | build-backend = "poetry.core.masonry.api" 28 | 29 | [tool.poetry] 30 | name = "ayon_python_api" 31 | version = "1.2.2" 32 | description = "AYON Python API" 33 | authors = [ 34 | "ynput.io " 35 | ] 36 | packages = [ 37 | { include = "ayon_api" }, 38 | { include = "ayon_api/_api_helpers/*.py" }, 39 | ] 40 | 41 | [tool.poetry.dependencies] 42 | python = ">=3.6.5" 43 | requests = "^2.27" 44 | Unidecode = "^1.3" 45 | 46 | [tool.poetry.group.dev.dependencies] 47 | sphinx = "*" 48 | mock = "*" 49 | sphinx-autoapi = "*" 50 | revitron-sphinx-theme = { git = "https://github.com/revitron/revitron-sphinx-theme.git", branch = "master" } 51 | pytest = "^6.2.5" 52 | pydocstyle = "^6.3.0" 53 | -------------------------------------------------------------------------------- /ayon_api/events.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | 4 | class ServerEvent(object): 5 | def __init__( 6 | self, 7 | topic, 8 | sender=None, 9 | event_hash=None, 10 | project_name=None, 11 | username=None, 12 | dependencies=None, 13 | description=None, 14 | summary=None, 15 | payload=None, 16 | finished=True, 17 | store=True, 18 | ): 19 | if dependencies is None: 20 | dependencies = [] 21 | if payload is None: 22 | payload = {} 23 | if summary is None: 24 | summary = {} 25 | 26 | self.topic = topic 27 | self.sender = sender 28 | self.event_hash = event_hash 29 | self.project_name = project_name 30 | self.username = username 31 | self.dependencies = dependencies 32 | self.description = description 33 | self.summary = summary 34 | self.payload = payload 35 | self.finished = finished 36 | self.store = store 37 | 38 | def to_data(self): 39 | return { 40 | "topic": self.topic, 41 | "sender": self.sender, 42 | "hash": self.event_hash, 43 | "project": self.project_name, 44 | "user": self.username, 45 | "dependencies": copy.deepcopy(self.dependencies), 46 | "description": self.description, 47 | "summary": copy.deepcopy(self.summary), 48 | "payload": self.payload, 49 | "finished": self.finished, 50 | "store": self.store 51 | } 52 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to AYON Python API documentation! 2 | ========================================= 3 | 4 | .. container:: .image 5 | 6 | .. image:: ./_static/AYON_blackG_dot.svg 7 | 8 | .. container:: .large 9 | 10 | This is mainly auto-generated documentation for the AYON Python API. 11 | 12 | .. container:: .buttons 13 | 14 | `Python API Reference <./ayon_api.html>`_ 15 | `REST API `_ 16 | `All AYON Docs `_ 17 | 18 | 19 | Getting Started 20 | =============== 21 | 22 | .. code-block:: text 23 | :caption: Install latest version from PyPi 24 | 25 | pip install ayon-python-api 26 | 27 | .. code-block:: text 28 | :caption: Install from Github sources (Alternatively) 29 | 30 | git clone git@github.com:ynput/ayon-python-api.git 31 | cd ayon-python-api 32 | pip install . 33 | 34 | .. code-block:: text 35 | :caption: Ensure installed properly by printing ayon_api version 36 | 37 | python -c "import ayon_api ; print(ayon_api.__version__)" 38 | 39 | 40 | Python API 41 | ========== 42 | 43 | * `API Reference <./ayon_api.html>`_ 44 | ------------------------------------ 45 | 46 | * `Github Repository `_ 47 | ------------------------------------------------------------------ 48 | 49 | Miscellaneous 50 | ============= 51 | 52 | * :ref:`genindex` 53 | ----------------- 54 | 55 | * :ref:`modindex` 56 | ----------------- 57 | 58 | * :ref:`search` 59 | --------------- 60 | 61 | 62 | Summary 63 | ======= 64 | 65 | .. toctree:: 66 | :maxdepth: 4 67 | 68 | modules -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | # Exclude a variety of commonly ignored directories. 2 | exclude = [ 3 | ".bzr", 4 | ".direnv", 5 | ".eggs", 6 | ".git", 7 | ".git-rewrite", 8 | ".hg", 9 | ".ipynb_checkpoints", 10 | ".mypy_cache", 11 | ".nox", 12 | ".pants.d", 13 | ".pyenv", 14 | ".pytest_cache", 15 | ".pytype", 16 | ".ruff_cache", 17 | ".svn", 18 | ".tox", 19 | ".venv", 20 | ".vscode", 21 | "__pypackages__", 22 | "_build", 23 | "buck-out", 24 | "build", 25 | "dist", 26 | "node_modules", 27 | "site-packages", 28 | "venv", 29 | ] 30 | 31 | # Same as Black. 32 | line-length = 79 33 | indent-width = 4 34 | 35 | [lint] 36 | # Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. 37 | # Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or 38 | # McCabe complexity (`C901`) by default. 39 | select = ["E", "F", "W"] 40 | ignore = [] 41 | 42 | # Allow fix for all enabled rules (when `--fix`) is provided. 43 | fixable = ["ALL"] 44 | unfixable = [] 45 | 46 | # Allow unused variables when underscore-prefixed. 47 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 48 | 49 | [lint.extend-per-file-ignores] 50 | "tests/*" = ["F401", "F811"] 51 | 52 | [format] 53 | # Like Black, use double quotes for strings. 54 | quote-style = "double" 55 | 56 | # Like Black, indent with spaces, rather than tabs. 57 | indent-style = "space" 58 | 59 | # Like Black, respect magic trailing commas. 60 | skip-magic-trailing-comma = false 61 | 62 | # Like Black, automatically detect the appropriate line ending. 63 | line-ending = "auto" 64 | 65 | # Enable auto-formatting of code examples in docstrings. Markdown, 66 | # reStructuredText code/literal blocks and doctests are all supported. 67 | # 68 | # This is currently disabled by default, but it is planned for this 69 | # to be opt-out in the future. 70 | docstring-code-format = false 71 | 72 | # Set the line length limit used when formatting code snippets in 73 | # docstrings. 74 | # 75 | # This only has an effect when the `docstring-code-format` setting is 76 | # enabled. 77 | docstring-code-line-length = "dynamic" 78 | -------------------------------------------------------------------------------- /ayon_api/_api_helpers/secrets.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing 4 | 5 | from .base import BaseServerAPI 6 | if typing.TYPE_CHECKING: 7 | from ayon_api.typing import SecretDict 8 | 9 | 10 | class SecretsAPI(BaseServerAPI): 11 | def get_secrets(self) -> list[SecretDict]: 12 | """Get all secrets. 13 | 14 | Example output:: 15 | 16 | [ 17 | { 18 | "name": "secret_1", 19 | "value": "secret_value_1", 20 | }, 21 | { 22 | "name": "secret_2", 23 | "value": "secret_value_2", 24 | } 25 | ] 26 | 27 | Returns: 28 | list[SecretDict]: List of secret entities. 29 | 30 | """ 31 | response = self.get("secrets") 32 | response.raise_for_status() 33 | return response.data 34 | 35 | def get_secret(self, secret_name: str) -> SecretDict: 36 | """Get secret by name. 37 | 38 | Example output:: 39 | 40 | { 41 | "name": "secret_name", 42 | "value": "secret_value", 43 | } 44 | 45 | Args: 46 | secret_name (str): Name of secret. 47 | 48 | Returns: 49 | dict[str, str]: Secret entity data. 50 | 51 | """ 52 | response = self.get(f"secrets/{secret_name}") 53 | response.raise_for_status() 54 | return response.data 55 | 56 | def save_secret(self, secret_name: str, secret_value: str) -> None: 57 | """Save secret. 58 | 59 | This endpoint can create and update secret. 60 | 61 | Args: 62 | secret_name (str): Name of secret. 63 | secret_value (str): Value of secret. 64 | 65 | """ 66 | response = self.put( 67 | f"secrets/{secret_name}", 68 | name=secret_name, 69 | value=secret_value, 70 | ) 71 | response.raise_for_status() 72 | 73 | def delete_secret(self, secret_name: str) -> None: 74 | """Delete secret by name. 75 | 76 | Args: 77 | secret_name (str): Name of secret to delete. 78 | 79 | """ 80 | response = self.delete(f"secrets/{secret_name}") 81 | response.raise_for_status() 82 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | wheels/ 22 | pip-wheel-metadata/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | db.sqlite3-journal 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # IPython 80 | profile_default/ 81 | ipython_config.py 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # pipenv 87 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 88 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 89 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 90 | # install all needed dependencies. 91 | #Pipfile.lock 92 | 93 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 94 | __pypackages__/ 95 | 96 | # Celery stuff 97 | celerybeat-schedule 98 | celerybeat.pid 99 | 100 | # SageMath parsed files 101 | *.sage.py 102 | 103 | # Environments 104 | .env 105 | .venv 106 | env/ 107 | venv/ 108 | ENV/ 109 | env.bak/ 110 | venv.bak/ 111 | .poetry/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # JetBrains 132 | .idea/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AYON server API 2 | Python client for connection server. The client is using REST and GraphQl to communicate with server with `requests` module. 3 | 4 | AYON Python api should support connection to server with raw REST functions and prepared functionality for work with entities. Must not contain only functionality that can be used with core server functionality. 5 | 6 | Module support singleton connection which is using `AYON_SERVER_URL` and `AYON_API_KEY` environment variables as source for connection. The singleton connection is using `ServerAPI` object. There can be created multiple connection to different server at one time, for that purpose use `ServerAPIBase` object. 7 | 8 | ## Install 9 | AYON python api is available on PyPi: 10 | 11 | pip install ayon-python-api 12 | 13 | For development purposes you may follow [build](#build-wheel) guide to build and install custom wheels. 14 | 15 | 16 | ## Cloning the repository 17 | Repository does not have submodules or special cases. Clone is simple as: 18 | 19 | git clone git@github.com:ynput/ayon-python-api.git 20 | 21 | 22 | ## Build wheel 23 | For wheel build is required a `wheel` module from PyPi: 24 | 25 | pip install wheel 26 | 27 | Open terminal and change directory to ayon-python-api repository and build wheel: 28 | 29 | cd /ayon-python-api 30 | python setup.py sdist bdist_wheel 31 | 32 | 33 | Once finished a wheel should be created in `./dist/ayon_python_api--py3-none-any`. 34 | 35 | --- 36 | 37 | ### Wheel installation 38 | The wheel file can be used to install using pip: 39 | 40 | pip install /dist/ayon_python_api--py3-none-any 41 | 42 | If pip complain that `ayon-python-api` is already installed just uninstall existing one first: 43 | 44 | pip uninstall ayon-python-api 45 | 46 | 47 | ## TODOs 48 | - Find more suitable name of `ServerAPI` objects (right now is used `con` or `connection`) 49 | - Add all available CRUD operation on entities using REST 50 | - Add folder and task changes to operations 51 | - Enhance entity hub 52 | - Missing docstrings in EntityHub -> especially entity arguments are missing 53 | - Better order of arguments for entity classes 54 | - Move entity hub to first place 55 | - Skip those which are invalid for the entity and fake it for base or remove it from base 56 | - Entity hub should use operations session to do changes 57 | - Entity hub could also handle 'product', 'version' and 'representation' entities 58 | - Missing 'status' on folders 59 | - Missing assignees on tasks 60 | - Pass docstrings and arguments definitions from `ServerAPI` methods to global functions 61 | - Split `ServerAPI` into smaller chunks (somehow), the class has 4k+ lines of code 62 | - Add .pyi stub for ServerAPI 63 | - Missing websockets connection 64 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | sys.path.insert(0, os.path.abspath('../../')) 4 | 5 | # Configuration file for the Sphinx documentation builder. 6 | # 7 | # For the full list of built-in configuration values, see the documentation: 8 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 9 | 10 | # -- Project information ----------------------------------------------------- 11 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 12 | 13 | current_dir = os.path.dirname(os.path.abspath(__file__)) 14 | ayon_api_version_path = os.path.join( 15 | os.path.dirname(os.path.dirname(current_dir)), 16 | "ayon_api", 17 | "version.py" 18 | ) 19 | version_content = {} 20 | with open(ayon_api_version_path, "r") as stream: 21 | exec(stream.read(), version_content) 22 | project = 'ayon-python-api' 23 | copyright = '2024, ynput.io ' 24 | author = 'ynput.io ' 25 | release = version_content["__version__"] 26 | 27 | # -- General configuration --------------------------------------------------- 28 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 29 | 30 | extensions = [ 31 | 'sphinx.ext.autodoc', 32 | 'sphinx.ext.doctest', 33 | 'sphinx.ext.todo', 34 | 'sphinx.ext.coverage', 35 | 'sphinx.ext.mathjax', 36 | 'sphinx.ext.ifconfig', 37 | 'sphinx.ext.viewcode', 38 | 'sphinx.ext.githubpages', 39 | 'sphinx.ext.napoleon', 40 | 'revitron_sphinx_theme', 41 | ] 42 | 43 | # -- Napoleon settings ------------------------------------------------------- 44 | add_module_names = False 45 | 46 | napoleon_google_docstring = True 47 | napoleon_numpy_docstring = False 48 | napoleon_include_init_with_doc = False 49 | napoleon_include_private_with_doc = False 50 | napoleon_include_special_with_doc = False 51 | napoleon_use_admonition_for_examples = True 52 | napoleon_use_admonition_for_notes = True 53 | napoleon_use_admonition_for_references = True 54 | napoleon_use_ivar = True 55 | napoleon_use_param = True 56 | napoleon_use_rtype = True 57 | napoleon_preprocess_types = True 58 | napoleon_attr_annotations = True 59 | 60 | templates_path = ['_templates'] 61 | exclude_patterns = ['tests', 'venv', 'build', 'Thumbs.db', '.DS_Store'] 62 | 63 | 64 | 65 | # -- Options for HTML output ------------------------------------------------- 66 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 67 | 68 | html_theme = "revitron_sphinx_theme" 69 | html_static_path = ['_static'] 70 | html_logo = './_static/AYON_blackG_dot.svg' 71 | html_favicon = './_static/favicon.ico' 72 | 73 | html_context = { 74 | 'landing_page': { 75 | } 76 | } 77 | myst_footnote_transition = False 78 | html_sidebars = {} 79 | 80 | html_theme_options = { 81 | 'color_scheme': '', 82 | 'canonical_url': 'https://github.com/ynput/ayon-python-api', 83 | 'style_external_links': False, 84 | 'collapse_navigation': True, 85 | 'sticky_navigation': True, 86 | 'navigation_depth': 4, 87 | 'includehidden': False, 88 | 'titles_only': False, 89 | 'github_url': 'https://github.com/ynput/ayon-python-api', 90 | } 91 | -------------------------------------------------------------------------------- /docs/source/_static/AYON_blackG_dot.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /tests/test_graphql_queries.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from ayon_api.graphql import GraphQlQuery 4 | from ayon_api.graphql_queries import ( 5 | project_graphql_query, 6 | folders_graphql_query, 7 | ) 8 | 9 | from .conftest import project_name_fixture 10 | 11 | 12 | @pytest.fixture 13 | def empty_query(): 14 | return GraphQlQuery("ProjectQuery") 15 | 16 | 17 | @pytest.fixture 18 | def folder_query(): 19 | return folders_graphql_query(["name"]) 20 | 21 | 22 | def test_simple_duplicate_add_variable_exception( 23 | project_name_fixture, empty_query 24 | ): 25 | key, value_type, value = "projectName", "[String!]", project_name_fixture 26 | empty_query.add_variable(key, value_type, value) 27 | with pytest.raises(KeyError): 28 | empty_query.add_variable(key, value_type) 29 | 30 | 31 | def test_exception_empty_query(empty_query): 32 | with pytest.raises(ValueError, match="Missing fields to query"): 33 | _out = empty_query.calculate_query() 34 | 35 | 36 | def test_simple_project_query(): 37 | project_query = project_graphql_query(["name"]) 38 | result = project_query.calculate_query() 39 | expected = "\n".join([ 40 | "query ProjectQuery {", 41 | " project {", 42 | " name", 43 | " }", 44 | "}" 45 | ]) 46 | assert result == expected 47 | 48 | 49 | def make_project_query(keys, values, types): 50 | query = project_graphql_query(["name"]) 51 | 52 | # by default from project_graphql_query(["name"]) 53 | inserted = {"projectName"} 54 | 55 | for key, entity_type, value in zip(keys, types, values): 56 | try: 57 | query.add_variable(key, entity_type, value) 58 | except KeyError: 59 | if key not in inserted: 60 | return None 61 | else: 62 | query.set_variable_value(key, value) 63 | 64 | inserted.add(key) 65 | return query 66 | 67 | 68 | def make_expected_get_variables_values(keys, values): 69 | return dict(zip(keys, values)) 70 | 71 | 72 | @pytest.mark.parametrize( 73 | "keys, values, types", 74 | [ 75 | ( 76 | ["projectName", "projectId", "numOf"], 77 | ["my_name", "0x23", 3], 78 | ["[String!]", "[String!]", "Int"] 79 | ), ( 80 | ["projectName", "testStrInt"], 81 | ["my_name", 42], 82 | ["[String!]", "[String!]"] 83 | ), ( 84 | ["projectName", "testIntStr"], 85 | ["my_name", "test_123"], 86 | ["[String!]", "Int"] 87 | ), 88 | ] 89 | ) 90 | def test_get_variables_values(keys, values, types): 91 | query = make_project_query(keys, values, types) 92 | # None means: unexpected exception thrown while adding variables 93 | assert query is not None 94 | 95 | expected = make_expected_get_variables_values(keys, values) 96 | assert query.get_variables_values() == expected 97 | 98 | 99 | """ 100 | def test_filtering(empty_query): 101 | assert empty_query._children == [] 102 | project_name_var = empty_query.add_variable("projectName", "String!") 103 | project_field = empty_query.add_field("project") 104 | project_field.set_filter("name", project_name_var) 105 | 106 | for field in empty_query._children: 107 | print(field.get_filters()) 108 | 109 | print(empty_query.calculate_query()) 110 | 111 | 112 | def print_rec_filters(field): 113 | print(field.get_filters()) 114 | for k in field._children: 115 | print_rec_filters(k) 116 | 117 | 118 | def test_folders_graphql_query(folder_query): 119 | print(folder_query.calculate_query()) 120 | 121 | 122 | def test_filters(folder_query): 123 | print(folder_query._children[0]._children[0].get_filters()) 124 | folder_query._children[0]._children[0].remove_filter("ids") 125 | print(folder_query._children[0]._children[0].get_filters()) 126 | print(folder_query.calculate_query()) 127 | """ 128 | -------------------------------------------------------------------------------- /ayon_api/exceptions.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | try: 4 | # This should be used if 'requests' have it available 5 | from requests.exceptions import JSONDecodeError 6 | except ImportError: 7 | # Older versions of 'requests' don't have custom exception for json 8 | # decode error 9 | try: 10 | from simplejson import JSONDecodeError 11 | except ImportError: 12 | from json import JSONDecodeError 13 | 14 | RequestsJSONDecodeError = JSONDecodeError 15 | 16 | 17 | class UrlError(Exception): 18 | """Url cannot be parsed as url. 19 | 20 | Exception may contain hints of possible fixes of url that can be used in 21 | UI if needed. 22 | """ 23 | 24 | def __init__(self, message, title, hints=None): 25 | if hints is None: 26 | hints = [] 27 | 28 | self.title = title 29 | self.hints = hints 30 | super(UrlError, self).__init__(message) 31 | 32 | 33 | class ServerError(Exception): 34 | pass 35 | 36 | 37 | class UnauthorizedError(ServerError): 38 | pass 39 | 40 | 41 | class AuthenticationError(ServerError): 42 | pass 43 | 44 | 45 | class ServerNotReached(ServerError): 46 | pass 47 | 48 | 49 | class UnsupportedServerVersion(ServerError): 50 | """Server version does not support the requested operation. 51 | 52 | This is used for known incompatibilities between the python api and 53 | server. E.g. can be used when endpoint is not available anymore, or 54 | is not yet available on server. 55 | """ 56 | pass 57 | 58 | 59 | class RequestError(Exception): 60 | def __init__(self, message, response): 61 | self.response = response 62 | super(RequestError, self).__init__(message) 63 | 64 | 65 | class HTTPRequestError(RequestError): 66 | pass 67 | 68 | 69 | class GraphQlQueryFailed(Exception): 70 | def __init__(self, errors, query, variables): 71 | if variables is None: 72 | variables = {} 73 | 74 | error_messages = [] 75 | for error in errors: 76 | msg = error["message"] 77 | path = error.get("path") 78 | if path: 79 | msg += " on item '{}'".format("/".join( 80 | # Convert to string 81 | str(x) for x in path 82 | )) 83 | locations = error.get("locations") 84 | if locations: 85 | _locations = [ 86 | "Line {} Column {}".format( 87 | location["line"], location["column"] 88 | ) 89 | for location in locations 90 | ] 91 | 92 | msg += " ({})".format(" and ".join(_locations)) 93 | error_messages.append(msg) 94 | 95 | message = "GraphQl query Failed" 96 | if error_messages: 97 | message = "{}: {}".format(message, " | ".join(error_messages)) 98 | 99 | self.errors = errors 100 | self.query = query 101 | self.variables = copy.deepcopy(variables) 102 | super(GraphQlQueryFailed, self).__init__(message) 103 | 104 | 105 | class MissingEntityError(Exception): 106 | pass 107 | 108 | 109 | class ProjectNotFound(MissingEntityError): 110 | def __init__(self, project_name, message=None): 111 | if not message: 112 | message = "Project \"{}\" was not found".format(project_name) 113 | self.project_name = project_name 114 | super(ProjectNotFound, self).__init__(message) 115 | 116 | 117 | class FolderNotFound(MissingEntityError): 118 | def __init__(self, project_name, folder_id, message=None): 119 | self.project_name = project_name 120 | self.folder_id = folder_id 121 | if not message: 122 | message = ( 123 | "Folder with id \"{}\" was not found in project \"{}\"" 124 | ).format(folder_id, project_name) 125 | super(FolderNotFound, self).__init__(message) 126 | 127 | 128 | class FailedOperations(Exception): 129 | pass 130 | 131 | 132 | class FailedServiceInit(Exception): 133 | pass 134 | -------------------------------------------------------------------------------- /ayon_api/_api_helpers/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import typing 5 | from typing import Optional, Any, Iterable 6 | 7 | import requests 8 | 9 | from ayon_api.utils import TransferProgress, RequestType 10 | 11 | if typing.TYPE_CHECKING: 12 | from ayon_api.typing import ( 13 | AnyEntityDict, 14 | ServerVersion, 15 | ProjectDict, 16 | StreamType, 17 | AttributeScope, 18 | ) 19 | 20 | _PLACEHOLDER = object() 21 | 22 | 23 | class BaseServerAPI: 24 | @property 25 | def log(self) -> logging.Logger: 26 | raise NotImplementedError() 27 | 28 | def is_product_base_type_supported(self) -> bool: 29 | raise NotImplementedError() 30 | 31 | def get_server_version(self) -> str: 32 | raise NotImplementedError() 33 | 34 | def get_server_version_tuple(self) -> ServerVersion: 35 | raise NotImplementedError() 36 | 37 | def get_base_url(self) -> str: 38 | raise NotImplementedError() 39 | 40 | def get_rest_url(self) -> str: 41 | raise NotImplementedError() 42 | 43 | def get(self, entrypoint: str, **kwargs): 44 | raise NotImplementedError() 45 | 46 | def post(self, entrypoint: str, **kwargs): 47 | raise NotImplementedError() 48 | 49 | def put(self, entrypoint: str, **kwargs): 50 | raise NotImplementedError() 51 | 52 | def patch(self, entrypoint: str, **kwargs): 53 | raise NotImplementedError() 54 | 55 | def delete(self, entrypoint: str, **kwargs): 56 | raise NotImplementedError() 57 | 58 | def raw_get(self, entrypoint: str, **kwargs): 59 | raise NotImplementedError() 60 | 61 | def raw_post(self, entrypoint: str, **kwargs): 62 | raise NotImplementedError() 63 | 64 | def raw_put(self, entrypoint: str, **kwargs): 65 | raise NotImplementedError() 66 | 67 | def raw_patch(self, entrypoint: str, **kwargs): 68 | raise NotImplementedError() 69 | 70 | def raw_delete(self, entrypoint: str, **kwargs): 71 | raise NotImplementedError() 72 | 73 | def get_default_settings_variant(self) -> str: 74 | raise NotImplementedError() 75 | 76 | def get_site_id(self) -> Optional[str]: 77 | raise NotImplementedError() 78 | 79 | def get_default_fields_for_type(self, entity_type: str) -> set[str]: 80 | raise NotImplementedError() 81 | 82 | def upload_file( 83 | self, 84 | endpoint: str, 85 | filepath: str, 86 | progress: Optional[TransferProgress] = None, 87 | request_type: Optional[RequestType] = None, 88 | **kwargs 89 | ) -> requests.Response: 90 | raise NotImplementedError() 91 | 92 | def upload_file_from_stream( 93 | self, 94 | endpoint: str, 95 | stream: StreamType, 96 | progress: Optional[TransferProgress] = None, 97 | request_type: Optional[RequestType] = None, 98 | **kwargs 99 | ) -> requests.Response: 100 | raise NotImplementedError() 101 | 102 | def download_file( 103 | self, 104 | endpoint: str, 105 | filepath: str, 106 | chunk_size: Optional[int] = None, 107 | progress: Optional[TransferProgress] = None, 108 | ) -> TransferProgress: 109 | raise NotImplementedError() 110 | 111 | def get_rest_entity_by_id( 112 | self, 113 | project_name: str, 114 | entity_type: str, 115 | entity_id: str, 116 | ) -> Optional[AnyEntityDict]: 117 | raise NotImplementedError() 118 | 119 | def get_project( 120 | self, 121 | project_name: str, 122 | fields: Optional[Iterable[str]] = None, 123 | own_attributes: bool = False, 124 | ) -> Optional[ProjectDict]: 125 | raise NotImplementedError() 126 | 127 | def get_user( 128 | self, username: Optional[str] = None 129 | ) -> Optional[dict[str, Any]]: 130 | raise NotImplementedError() 131 | 132 | def get_attributes_fields_for_type( 133 | self, entity_type: AttributeScope 134 | ) -> set[str]: 135 | raise NotImplementedError() 136 | 137 | def _prepare_fields( 138 | self, 139 | entity_type: str, 140 | fields: set[str], 141 | own_attributes: bool = False, 142 | ): 143 | raise NotImplementedError() 144 | 145 | def _convert_entity_data(self, entity: AnyEntityDict): 146 | raise NotImplementedError() 147 | 148 | def _send_batch_operations( 149 | self, 150 | uri: str, 151 | operations: list[dict[str, Any]], 152 | can_fail: bool, 153 | raise_on_fail: bool 154 | ) -> list[dict[str, Any]]: 155 | raise NotImplementedError() 156 | -------------------------------------------------------------------------------- /ayon_api/_api_helpers/attributes.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing 4 | from typing import Optional 5 | import copy 6 | 7 | from .base import BaseServerAPI 8 | 9 | if typing.TYPE_CHECKING: 10 | from ayon_api.typing import ( 11 | AttributeSchemaDataDict, 12 | AttributeSchemaDict, 13 | AttributesSchemaDict, 14 | AttributeScope, 15 | ) 16 | 17 | 18 | class AttributesAPI(BaseServerAPI): 19 | _attributes_schema = None 20 | _entity_type_attributes_cache = {} 21 | 22 | def get_attributes_schema( 23 | self, use_cache: bool = True 24 | ) -> AttributesSchemaDict: 25 | if not use_cache: 26 | self.reset_attributes_schema() 27 | 28 | if self._attributes_schema is None: 29 | result = self.get("attributes") 30 | result.raise_for_status() 31 | self._attributes_schema = result.data 32 | return copy.deepcopy(self._attributes_schema) 33 | 34 | def reset_attributes_schema(self) -> None: 35 | self._attributes_schema = None 36 | self._entity_type_attributes_cache = {} 37 | 38 | def set_attribute_config( 39 | self, 40 | attribute_name: str, 41 | data: AttributeSchemaDataDict, 42 | scope: list[AttributeScope], 43 | position: Optional[int] = None, 44 | builtin: bool = False, 45 | ) -> None: 46 | if position is None: 47 | attributes = self.get("attributes").data["attributes"] 48 | origin_attr = next( 49 | ( 50 | attr for attr in attributes 51 | if attr["name"] == attribute_name 52 | ), 53 | None 54 | ) 55 | if origin_attr: 56 | position = origin_attr["position"] 57 | else: 58 | position = len(attributes) 59 | 60 | response = self.put( 61 | f"attributes/{attribute_name}", 62 | data=data, 63 | scope=scope, 64 | position=position, 65 | builtin=builtin 66 | ) 67 | if response.status_code != 204: 68 | # TODO raise different exception 69 | raise ValueError( 70 | f"Attribute \"{attribute_name}\" was not created/updated." 71 | f" {response.detail}" 72 | ) 73 | 74 | self.reset_attributes_schema() 75 | 76 | def remove_attribute_config(self, attribute_name: str) -> None: 77 | """Remove attribute from server. 78 | 79 | This can't be un-done, please use carefully. 80 | 81 | Args: 82 | attribute_name (str): Name of attribute to remove. 83 | 84 | """ 85 | response = self.delete(f"attributes/{attribute_name}") 86 | response.raise_for_status( 87 | f"Attribute \"{attribute_name}\" was not created/updated." 88 | f" {response.detail}" 89 | ) 90 | 91 | self.reset_attributes_schema() 92 | 93 | def get_attributes_for_type( 94 | self, entity_type: AttributeScope 95 | ) -> dict[str, AttributeSchemaDict]: 96 | """Get attribute schemas available for an entity type. 97 | 98 | Example:: 99 | 100 | ``` 101 | # Example attribute schema 102 | { 103 | # Common 104 | "type": "integer", 105 | "title": "Clip Out", 106 | "description": null, 107 | "example": 1, 108 | "default": 1, 109 | # These can be filled based on value of 'type' 110 | "gt": null, 111 | "ge": null, 112 | "lt": null, 113 | "le": null, 114 | "minLength": null, 115 | "maxLength": null, 116 | "minItems": null, 117 | "maxItems": null, 118 | "regex": null, 119 | "enum": null 120 | } 121 | ``` 122 | 123 | Args: 124 | entity_type (str): Entity type for which should be attributes 125 | received. 126 | 127 | Returns: 128 | dict[str, dict[str, Any]]: Attribute schemas that are available 129 | for entered entity type. 130 | 131 | """ 132 | attributes = self._entity_type_attributes_cache.get(entity_type) 133 | if attributes is None: 134 | attributes_schema = self.get_attributes_schema() 135 | attributes = {} 136 | for attr in attributes_schema["attributes"]: 137 | if entity_type not in attr["scope"]: 138 | continue 139 | attr_name = attr["name"] 140 | attributes[attr_name] = attr["data"] 141 | 142 | self._entity_type_attributes_cache[entity_type] = attributes 143 | 144 | return copy.deepcopy(attributes) 145 | 146 | def get_attributes_fields_for_type( 147 | self, entity_type: AttributeScope 148 | ) -> set[str]: 149 | """Prepare attribute fields for entity type. 150 | 151 | Returns: 152 | set[str]: Attributes fields for entity type. 153 | 154 | """ 155 | attributes = self.get_attributes_for_type(entity_type) 156 | return { 157 | f"attrib.{attr}" 158 | for attr in attributes 159 | } 160 | -------------------------------------------------------------------------------- /ayon_api/constants.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | # Environments where server url and api key are stored for global connection 4 | SERVER_URL_ENV_KEY = "AYON_SERVER_URL" 5 | SERVER_API_ENV_KEY = "AYON_API_KEY" 6 | SERVER_TIMEOUT_ENV_KEY = "AYON_SERVER_TIMEOUT" 7 | SERVER_RETRIES_ENV_KEY = "AYON_SERVER_RETRIES" 8 | # Default variant used for settings 9 | DEFAULT_VARIANT_ENV_KEY = "AYON_DEFAULT_SETTINGS_VARIANT" 10 | # Default site id used for connection 11 | SITE_ID_ENV_KEY = "AYON_SITE_ID" 12 | 13 | # Backwards compatibility 14 | SERVER_TOKEN_ENV_KEY = SERVER_API_ENV_KEY 15 | 16 | # This should be collected from server schema 17 | PROJECT_NAME_ALLOWED_SYMBOLS = "a-zA-Z0-9_" 18 | PROJECT_NAME_REGEX = re.compile( 19 | f"^[{PROJECT_NAME_ALLOWED_SYMBOLS}]+$" 20 | ) 21 | 22 | # --- User --- 23 | DEFAULT_USER_FIELDS = { 24 | "accessGroups", 25 | "defaultAccessGroups", 26 | "name", 27 | "isService", 28 | "isManager", 29 | "isGuest", 30 | "isAdmin", 31 | "createdAt", 32 | "active", 33 | "hasPassword", 34 | "updatedAt", 35 | "apiKeyPreview", 36 | "attrib.avatarUrl", 37 | "attrib.email", 38 | "attrib.fullName", 39 | } 40 | 41 | # --- Project folder types --- 42 | DEFAULT_FOLDER_TYPE_FIELDS = { 43 | "name", 44 | "icon", 45 | } 46 | 47 | # --- Project task types --- 48 | DEFAULT_TASK_TYPE_FIELDS = { 49 | "name", 50 | } 51 | 52 | # --- Project tags --- 53 | DEFAULT_PROJECT_TAGS_FIELDS = { 54 | "name", 55 | "color", 56 | } 57 | 58 | # --- Project statuses --- 59 | DEFAULT_PROJECT_STATUSES_FIELDS = { 60 | "color", 61 | "icon", 62 | "name", 63 | "scope", 64 | "shortName", 65 | "state", 66 | } 67 | 68 | # --- Project link types --- 69 | DEFAULT_PROJECT_LINK_TYPES_FIELDS = { 70 | "color", 71 | "inputType", 72 | "linkType", 73 | "name", 74 | "outputType", 75 | "style", 76 | } 77 | 78 | # --- Product types --- 79 | DEFAULT_PRODUCT_TYPE_FIELDS = { 80 | "name", 81 | "icon", 82 | "color", 83 | } 84 | 85 | # --- Product base type --- 86 | DEFAULT_PRODUCT_BASE_TYPE_FIELDS = { 87 | # Ignore 'icon' and 'color' 88 | # - current server implementation always returns 'null' 89 | "name", 90 | } 91 | 92 | # --- Project --- 93 | DEFAULT_PROJECT_FIELDS = { 94 | "active", 95 | "library", 96 | "name", 97 | "code", 98 | "config", 99 | "createdAt", 100 | "updatedAt", 101 | "data", 102 | "folderTypes", 103 | "taskTypes", 104 | "linkTypes", 105 | "statuses", 106 | "tags", 107 | "attrib", 108 | } 109 | 110 | # --- Folders --- 111 | DEFAULT_FOLDER_FIELDS = { 112 | "id", 113 | "name", 114 | "label", 115 | "folderType", 116 | "path", 117 | "parentId", 118 | "active", 119 | "thumbnailId", 120 | "data", 121 | "status", 122 | "tags", 123 | } 124 | 125 | # --- Tasks --- 126 | DEFAULT_TASK_FIELDS = { 127 | "id", 128 | "name", 129 | "label", 130 | "taskType", 131 | "folderId", 132 | "active", 133 | "thumbnailId", 134 | "assignees", 135 | "data", 136 | "status", 137 | "tags", 138 | } 139 | 140 | # --- Products --- 141 | DEFAULT_PRODUCT_FIELDS = { 142 | "id", 143 | "name", 144 | "folderId", 145 | "active", 146 | "productType", 147 | "data", 148 | "status", 149 | "tags", 150 | } 151 | 152 | # --- Versions --- 153 | DEFAULT_VERSION_FIELDS = { 154 | "id", 155 | "name", 156 | "version", 157 | "productId", 158 | "taskId", 159 | "active", 160 | "author", 161 | "thumbnailId", 162 | "createdAt", 163 | "updatedAt", 164 | "data", 165 | "status", 166 | "tags", 167 | } 168 | 169 | # --- Representations --- 170 | DEFAULT_REPRESENTATION_FIELDS = { 171 | "id", 172 | "name", 173 | "context", 174 | "createdAt", 175 | "active", 176 | "versionId", 177 | "data", 178 | "status", 179 | "tags", 180 | "traits", 181 | } 182 | 183 | REPRESENTATION_FILES_FIELDS = { 184 | "files.name", 185 | "files.hash", 186 | "files.id", 187 | "files.path", 188 | "files.size", 189 | } 190 | 191 | # --- Workfile info --- 192 | DEFAULT_WORKFILE_INFO_FIELDS = { 193 | "active", 194 | "createdAt", 195 | "createdBy", 196 | "id", 197 | "name", 198 | "path", 199 | "projectName", 200 | "taskId", 201 | "thumbnailId", 202 | "updatedAt", 203 | "updatedBy", 204 | "data", 205 | "status", 206 | "tags", 207 | } 208 | 209 | DEFAULT_EVENT_FIELDS = { 210 | "id", 211 | "hash", 212 | "createdAt", 213 | "dependsOn", 214 | "description", 215 | "project", 216 | "retries", 217 | "sender", 218 | "status", 219 | "topic", 220 | "updatedAt", 221 | "user", 222 | } 223 | 224 | DEFAULT_LINK_FIELDS = { 225 | "id", 226 | "linkType", 227 | "projectName", 228 | "entityType", 229 | "entityId", 230 | "name", 231 | "direction", 232 | "description", 233 | "author", 234 | } 235 | 236 | DEFAULT_ACTIVITY_FIELDS = { 237 | "activityId", 238 | "activityType", 239 | "activityData", 240 | "body", 241 | "entityId", 242 | "entityType", 243 | "author.name", 244 | } 245 | 246 | 247 | DEFAULT_ENTITY_LIST_FIELDS = { 248 | "id", 249 | "count", 250 | "attributes", 251 | "active", 252 | "createdBy", 253 | "createdAt", 254 | "entityListType", 255 | "data", 256 | "entityType", 257 | "label", 258 | "owner", 259 | "tags", 260 | "updatedAt", 261 | "updatedBy", 262 | "items.id", 263 | "items.entityId", 264 | "items.entityType", 265 | "items.position", 266 | } 267 | -------------------------------------------------------------------------------- /tests/test_get_events.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import pytest 3 | 4 | from ayon_api import ( 5 | get_events, 6 | get_default_fields_for_type, 7 | exceptions, 8 | set_timeout, 9 | get_timeout 10 | ) 11 | from .conftest import TestEventFilters 12 | 13 | 14 | @pytest.mark.parametrize("topics", TestEventFilters.topics[-3:]) 15 | @pytest.mark.parametrize( 16 | "event_ids", 17 | [None] + [pytest.param(None, marks=pytest.mark.usefixtures("event_ids"))] 18 | ) 19 | @pytest.mark.parametrize("project_names", TestEventFilters.project_names[-3:]) 20 | @pytest.mark.parametrize("states", TestEventFilters.states[-3:]) 21 | @pytest.mark.parametrize("users", TestEventFilters.users[-3:]) 22 | @pytest.mark.parametrize("include_logs", TestEventFilters.include_logs[-3:]) 23 | @pytest.mark.parametrize("has_children", TestEventFilters.has_children[-3:]) 24 | @pytest.mark.parametrize("newer_than", TestEventFilters.newer_than[-2:]) 25 | @pytest.mark.parametrize("older_than", TestEventFilters.older_than[-2:]) 26 | @pytest.mark.parametrize("fields", TestEventFilters.fields[0:1]) 27 | def test_get_events_all_filter_combinations( 28 | topics, 29 | event_ids, 30 | project_names, 31 | states, 32 | users, 33 | include_logs, 34 | has_children, 35 | newer_than, 36 | older_than, 37 | fields 38 | ): 39 | """Tests all combinations of possible filters for `get_events`. 40 | 41 | Verifies: 42 | - Calls `get_events` with the provided filter parameters. 43 | - Ensures each event in the result set matches the specified filters. 44 | - Checks that the number of returned events matches the expected count 45 | based on the filters applied. 46 | - Confirms that each event contains only the specified fields, with 47 | no extra keys. 48 | 49 | Note: 50 | - Adjusts the timeout setting if necessary to handle a large number 51 | of tests and avoid timeout errors. 52 | - Some combinations of filter parameters may lead to a server timeout 53 | error. When this occurs, the test will skip instead of failing. 54 | - Currently, a ServerError due to timeout may occur when `has_children` 55 | is set to False. 56 | 57 | """ 58 | if get_timeout() < 5: 59 | set_timeout(None) # default timeout 60 | 61 | try: 62 | res = list(get_events( 63 | topics=topics, 64 | event_ids=event_ids, 65 | project_names=project_names, 66 | statuses=states, 67 | users=users, 68 | include_logs=include_logs, 69 | has_children=has_children, 70 | newer_than=newer_than, 71 | older_than=older_than, 72 | fields=fields 73 | )) 74 | except exceptions.ServerError as exc: 75 | assert has_children is False, ( 76 | f"{exc} even if has_children is {has_children}." 77 | ) 78 | print("Warning: ServerError encountered, test skipped due to timeout.") 79 | pytest.skip("Skipping test due to server timeout.") 80 | 81 | for item in res: 82 | assert item.get("topic") in topics 83 | assert item.get("project") in project_names 84 | assert item.get("user") in users 85 | assert item.get("status") in states 86 | 87 | assert (newer_than is None) or ( 88 | datetime.fromisoformat(item.get("createdAt")) 89 | > datetime.fromisoformat(newer_than) 90 | ) 91 | assert (older_than is None) or ( 92 | datetime.fromisoformat(item.get("createdAt")) 93 | < datetime.fromisoformat(older_than) 94 | ) 95 | 96 | assert topics is None or len(res) == sum(len( 97 | list(get_events( 98 | topics=[topic], 99 | project_names=project_names, 100 | statuses=states, 101 | users=users, 102 | include_logs=include_logs, 103 | has_children=has_children, 104 | newer_than=newer_than, 105 | older_than=older_than, 106 | fields=fields 107 | )) or [] 108 | ) for topic in topics) 109 | 110 | assert project_names is None or len(res) == sum(len( 111 | list(get_events( 112 | topics=topics, 113 | project_names=[project_name], 114 | statuses=states, 115 | users=users, 116 | include_logs=include_logs, 117 | has_children=has_children, 118 | newer_than=newer_than, 119 | older_than=older_than, 120 | fields=fields 121 | )) or [] 122 | ) for project_name in project_names) 123 | 124 | assert states is None or len(res) == sum(len( 125 | list(get_events( 126 | topics=topics, 127 | project_names=project_names, 128 | statuses=[state], 129 | users=users, 130 | include_logs=include_logs, 131 | has_children=has_children, 132 | newer_than=newer_than, 133 | older_than=older_than, 134 | fields=fields 135 | )) or [] 136 | ) for state in states) 137 | 138 | assert users is None or len(res) == sum(len( 139 | list(get_events( 140 | topics=topics, 141 | project_names=project_names, 142 | statuses=states, 143 | users=[user], 144 | include_logs=include_logs, 145 | has_children=has_children, 146 | newer_than=newer_than, 147 | older_than=older_than, 148 | fields=fields 149 | )) or [] 150 | ) for user in users) 151 | 152 | if fields == []: 153 | fields = get_default_fields_for_type("event") 154 | 155 | assert fields is None \ 156 | or all( 157 | set(event.keys()) == set(fields) 158 | for event in res 159 | ) 160 | -------------------------------------------------------------------------------- /ayon_api/_api_helpers/installers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing 4 | from typing import Optional, Any 5 | 6 | import requests 7 | 8 | from ayon_api.utils import prepare_query_string, TransferProgress 9 | 10 | from .base import BaseServerAPI 11 | 12 | if typing.TYPE_CHECKING: 13 | from ayon_api.typing import InstallersInfoDict 14 | 15 | 16 | class InstallersAPI(BaseServerAPI): 17 | def get_installers( 18 | self, 19 | version: Optional[str] = None, 20 | platform_name: Optional[str] = None, 21 | ) -> InstallersInfoDict: 22 | """Information about desktop application installers on server. 23 | 24 | Desktop application installers are helpers to download/update AYON 25 | desktop application for artists. 26 | 27 | Args: 28 | version (Optional[str]): Filter installers by version. 29 | platform_name (Optional[str]): Filter installers by platform name. 30 | 31 | Returns: 32 | InstallersInfoDict: Information about installers known for server. 33 | 34 | """ 35 | query = prepare_query_string({ 36 | "version": version or None, 37 | "platform": platform_name or None, 38 | }) 39 | response = self.get(f"desktop/installers{query}") 40 | response.raise_for_status() 41 | return response.data 42 | 43 | def create_installer( 44 | self, 45 | filename: str, 46 | version: str, 47 | python_version: str, 48 | platform_name: str, 49 | python_modules: dict[str, str], 50 | runtime_python_modules: dict[str, str], 51 | checksum: str, 52 | checksum_algorithm: str, 53 | file_size: int, 54 | sources: Optional[list[dict[str, Any]]] = None, 55 | ) -> None: 56 | """Create new installer information on server. 57 | 58 | This step will create only metadata. Make sure to upload installer 59 | to the server using 'upload_installer' method. 60 | 61 | Runtime python modules are modules that are required to run AYON 62 | desktop application, but are not added to PYTHONPATH for any 63 | subprocess. 64 | 65 | Args: 66 | filename (str): Installer filename. 67 | version (str): Version of installer. 68 | python_version (str): Version of Python. 69 | platform_name (str): Name of platform. 70 | python_modules (dict[str, str]): Python modules that are available 71 | in installer. 72 | runtime_python_modules (dict[str, str]): Runtime python modules 73 | that are available in installer. 74 | checksum (str): Installer file checksum. 75 | checksum_algorithm (str): Type of checksum used to create checksum. 76 | file_size (int): File size. 77 | sources (Optional[list[dict[str, Any]]]): List of sources that 78 | can be used to download file. 79 | 80 | """ 81 | body = { 82 | "filename": filename, 83 | "version": version, 84 | "pythonVersion": python_version, 85 | "platform": platform_name, 86 | "pythonModules": python_modules, 87 | "runtimePythonModules": runtime_python_modules, 88 | "checksum": checksum, 89 | "checksumAlgorithm": checksum_algorithm, 90 | "size": file_size, 91 | } 92 | if sources: 93 | body["sources"] = sources 94 | 95 | response = self.post("desktop/installers", **body) 96 | response.raise_for_status() 97 | 98 | def update_installer( 99 | self, filename: str, sources: list[dict[str, Any]] 100 | ) -> None: 101 | """Update installer information on server. 102 | 103 | Args: 104 | filename (str): Installer filename. 105 | sources (list[dict[str, Any]]): List of sources that 106 | can be used to download file. Fully replaces existing sources. 107 | 108 | """ 109 | response = self.patch( 110 | f"desktop/installers/{filename}", 111 | sources=sources 112 | ) 113 | response.raise_for_status() 114 | 115 | def delete_installer(self, filename: str) -> None: 116 | """Delete installer from server. 117 | 118 | Args: 119 | filename (str): Installer filename. 120 | 121 | """ 122 | response = self.delete(f"desktop/installers/{filename}") 123 | response.raise_for_status() 124 | 125 | def download_installer( 126 | self, 127 | filename: str, 128 | dst_filepath: str, 129 | chunk_size: Optional[int] = None, 130 | progress: Optional[TransferProgress] = None 131 | ) -> TransferProgress: 132 | """Download installer file from server. 133 | 134 | Args: 135 | filename (str): Installer filename. 136 | dst_filepath (str): Destination filepath. 137 | chunk_size (Optional[int]): Download chunk size. 138 | progress (Optional[TransferProgress]): Object that gives ability 139 | to track download progress. 140 | 141 | Returns: 142 | TransferProgress: Progress object. 143 | 144 | """ 145 | return self.download_file( 146 | f"desktop/installers/{filename}", 147 | dst_filepath, 148 | chunk_size=chunk_size, 149 | progress=progress 150 | ) 151 | 152 | def upload_installer( 153 | self, 154 | src_filepath: str, 155 | dst_filename: str, 156 | progress: Optional[TransferProgress] = None, 157 | ) -> requests.Response: 158 | """Upload installer file to server. 159 | 160 | Args: 161 | src_filepath (str): Source filepath. 162 | dst_filename (str): Destination filename. 163 | progress (Optional[TransferProgress]): Object that gives ability 164 | to track download progress. 165 | 166 | Returns: 167 | requests.Response: Response object. 168 | 169 | """ 170 | return self.upload_file( 171 | f"desktop/installers/{dst_filename}", 172 | src_filepath, 173 | progress=progress 174 | ) 175 | -------------------------------------------------------------------------------- /ayon_api/_api_helpers/dependency_packages.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import warnings 5 | import platform 6 | import typing 7 | from typing import Optional, Any 8 | 9 | from ayon_api.utils import TransferProgress 10 | 11 | from .base import BaseServerAPI 12 | 13 | if typing.TYPE_CHECKING: 14 | from ayon_api.typing import DependencyPackagesDict 15 | 16 | 17 | class DependencyPackagesAPI(BaseServerAPI): 18 | def get_dependency_packages(self) -> DependencyPackagesDict: 19 | """Information about dependency packages on server. 20 | 21 | To download dependency package, use 'download_dependency_package' 22 | method and pass in 'filename'. 23 | 24 | Example data structure:: 25 | 26 | { 27 | "packages": [ 28 | { 29 | "filename": str, 30 | "platform": str, 31 | "checksum": str, 32 | "checksumAlgorithm": str, 33 | "size": int, 34 | "sources": list[dict[str, Any]], 35 | "supportedAddons": dict[str, str], 36 | "pythonModules": dict[str, str] 37 | } 38 | ] 39 | } 40 | 41 | Returns: 42 | DependencyPackagesDict: Information about dependency packages 43 | known for server. 44 | 45 | """ 46 | endpoint = self._get_dependency_package_route() 47 | result = self.get(endpoint) 48 | result.raise_for_status() 49 | return result.data 50 | 51 | def create_dependency_package( 52 | self, 53 | filename: str, 54 | python_modules: dict[str, str], 55 | source_addons: dict[str, str], 56 | installer_version: str, 57 | checksum: str, 58 | checksum_algorithm: str, 59 | file_size: int, 60 | sources: Optional[list[dict[str, Any]]] = None, 61 | platform_name: Optional[str] = None, 62 | ) -> None: 63 | """Create dependency package on server. 64 | 65 | The package will be created on a server, it is also required to upload 66 | the package archive file (using :meth:`upload_dependency_package`). 67 | 68 | Args: 69 | filename (str): Filename of dependency package. 70 | python_modules (dict[str, str]): Python modules in dependency 71 | package:: 72 | 73 | {"": "", ...} 74 | 75 | source_addons (dict[str, str]): Name of addons for which is 76 | dependency package created:: 77 | 78 | {"": "", ...} 79 | 80 | installer_version (str): Version of installer for which was 81 | package created. 82 | checksum (str): Checksum of archive file where dependencies are. 83 | checksum_algorithm (str): Algorithm used to calculate checksum. 84 | file_size (Optional[int]): Size of file. 85 | sources (Optional[list[dict[str, Any]]]): Information about 86 | sources from where it is possible to get file. 87 | platform_name (Optional[str]): Name of platform for which is 88 | dependency package targeted. Default value is 89 | current platform. 90 | 91 | """ 92 | post_body = { 93 | "filename": filename, 94 | "pythonModules": python_modules, 95 | "sourceAddons": source_addons, 96 | "installerVersion": installer_version, 97 | "checksum": checksum, 98 | "checksumAlgorithm": checksum_algorithm, 99 | "size": file_size, 100 | "platform": platform_name or platform.system().lower(), 101 | } 102 | if sources: 103 | post_body["sources"] = sources 104 | 105 | route = self._get_dependency_package_route() 106 | response = self.post(route, **post_body) 107 | response.raise_for_status() 108 | 109 | def update_dependency_package( 110 | self, filename: str, sources: list[dict[str, Any]] 111 | ) -> None: 112 | """Update dependency package metadata on server. 113 | 114 | Args: 115 | filename (str): Filename of dependency package. 116 | sources (list[dict[str, Any]]): Information about 117 | sources from where it is possible to get file. Fully replaces 118 | existing sources. 119 | 120 | """ 121 | response = self.patch( 122 | self._get_dependency_package_route(filename), 123 | sources=sources 124 | ) 125 | response.raise_for_status() 126 | 127 | def delete_dependency_package( 128 | self, filename: str, platform_name: Optional[str] = None 129 | ) -> None: 130 | """Remove dependency package for specific platform. 131 | 132 | Args: 133 | filename (str): Filename of dependency package. 134 | platform_name (Optional[str]): Deprecated. 135 | 136 | """ 137 | if platform_name is not None: 138 | warnings.warn( 139 | ( 140 | "Argument 'platform_name' is deprecated in" 141 | " 'delete_dependency_package'. The argument will be" 142 | " removed, please modify your code accordingly." 143 | ), 144 | DeprecationWarning 145 | ) 146 | 147 | route = self._get_dependency_package_route(filename) 148 | response = self.delete(route) 149 | response.raise_for_status("Failed to delete dependency file") 150 | 151 | def download_dependency_package( 152 | self, 153 | src_filename: str, 154 | dst_directory: str, 155 | dst_filename: str, 156 | platform_name: Optional[str] = None, 157 | chunk_size: Optional[int] = None, 158 | progress: Optional[TransferProgress] = None, 159 | ) -> str: 160 | """Download dependency package from server. 161 | 162 | This method requires to have authorized token available. The package 163 | is only downloaded. 164 | 165 | Args: 166 | src_filename (str): Filename of dependency pacakge. 167 | For server version 0.2.0 and lower it is name of package 168 | to download. 169 | dst_directory (str): Where the file should be downloaded. 170 | dst_filename (str): Name of destination filename. 171 | platform_name (Optional[str]): Deprecated. 172 | chunk_size (Optional[int]): Download chunk size. 173 | progress (Optional[TransferProgress]): Object that gives ability 174 | to track download progress. 175 | 176 | Returns: 177 | str: Filepath to downloaded file. 178 | 179 | """ 180 | if platform_name is not None: 181 | warnings.warn( 182 | ( 183 | "Argument 'platform_name' is deprecated in" 184 | " 'download_dependency_package'. The argument will be" 185 | " removed, please modify your code accordingly." 186 | ), 187 | DeprecationWarning 188 | ) 189 | route = self._get_dependency_package_route(src_filename) 190 | package_filepath = os.path.join(dst_directory, dst_filename) 191 | self.download_file( 192 | route, 193 | package_filepath, 194 | chunk_size=chunk_size, 195 | progress=progress 196 | ) 197 | return package_filepath 198 | 199 | def upload_dependency_package( 200 | self, 201 | src_filepath: str, 202 | dst_filename: str, 203 | platform_name: Optional[str] = None, 204 | progress: Optional[TransferProgress] = None, 205 | ) -> None: 206 | """Upload dependency package to server. 207 | 208 | Args: 209 | src_filepath (str): Path to a package file. 210 | dst_filename (str): Dependency package filename or name of package 211 | for server version 0.2.0 or lower. Must be unique. 212 | platform_name (Optional[str]): Deprecated. 213 | progress (Optional[TransferProgress]): Object to keep track about 214 | upload state. 215 | 216 | """ 217 | if platform_name is not None: 218 | warnings.warn( 219 | ( 220 | "Argument 'platform_name' is deprecated in" 221 | " 'upload_dependency_package'. The argument will be" 222 | " removed, please modify your code accordingly." 223 | ), 224 | DeprecationWarning 225 | ) 226 | 227 | route = self._get_dependency_package_route(dst_filename) 228 | self.upload_file(route, src_filepath, progress=progress) 229 | 230 | def _get_dependency_package_route( 231 | self, filename: Optional[str] = None 232 | ) -> str: 233 | endpoint = "desktop/dependencyPackages" 234 | if filename: 235 | return f"{endpoint}/{filename}" 236 | return endpoint 237 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta, timezone 2 | import pytest 3 | 4 | from ayon_api import ( 5 | get_project, 6 | create_project, 7 | update_project, 8 | delete_project, 9 | get_events, 10 | get_folders, 11 | get_products, 12 | get_tasks 13 | ) 14 | from ayon_api.entity_hub import EntityHub 15 | 16 | 17 | class _Cache: 18 | # Cache project entity as scope 'session' of a fixture does not handle 19 | # parametrized fixtures. 20 | project_entity = None 21 | 22 | 23 | @pytest.fixture(scope="session") 24 | def project_name_fixture(): 25 | return "AYONApiTestProject" 26 | 27 | 28 | @pytest.fixture(scope="session") 29 | def project_entity_fixture(project_name_fixture): 30 | project_entity = _Cache.project_entity 31 | created = False 32 | if _Cache.project_entity is None: 33 | created = True 34 | project_entity = get_project(project_name_fixture) 35 | if project_entity: 36 | delete_project(project_name_fixture) 37 | create_project(project_name_fixture, "AYTP") 38 | update_project( 39 | project_name_fixture, 40 | folder_types=[ 41 | { 42 | "name": "Folder", 43 | "icon": "folder", 44 | "shortName": "" 45 | }, 46 | { 47 | "name": "Episode", 48 | "icon": "live_tv", 49 | "shortName": "" 50 | }, 51 | { 52 | "name": "Sequence", 53 | "icon": "theaters", 54 | "shortName": "" 55 | }, 56 | { 57 | "name": "Shot", 58 | "icon": "movie", 59 | "shortName": "" 60 | } 61 | ] 62 | ) 63 | project_entity = get_project(project_name_fixture) 64 | _Cache.project_entity = project_entity 65 | 66 | yield project_entity 67 | if created: 68 | delete_project(project_name_fixture) 69 | 70 | 71 | @pytest.fixture 72 | def clean_project(project_name_fixture): 73 | hub = EntityHub(project_name_fixture) 74 | hub.fetch_hierarchy_entities() 75 | 76 | folder_ids = { 77 | folder["id"] 78 | for folder in get_folders(project_name_fixture, fields={"id"}) 79 | } 80 | task_ids = { 81 | task["id"] 82 | for task in get_tasks( 83 | project_name_fixture, folder_ids=folder_ids, fields={"id"} 84 | ) 85 | } 86 | product_ids = { 87 | product["id"] 88 | for product in get_products( 89 | project_name_fixture, folder_ids=folder_ids, fields={"id"} 90 | ) 91 | } 92 | for product_id in product_ids: 93 | product = hub.get_product_by_id(product_id) 94 | if product is not None: 95 | hub.delete_entity(product) 96 | 97 | for task_id in task_ids: 98 | task = hub.get_task_by_id(task_id) 99 | if task is not None: 100 | hub.delete_entity(task) 101 | 102 | hub.commit_changes() 103 | 104 | for folder_id in folder_ids: 105 | folder = hub.get_folder_by_id(folder_id) 106 | if folder is not None: 107 | hub.delete_entity(folder) 108 | 109 | hub.commit_changes() 110 | 111 | 112 | @pytest.fixture(params=[3, 4, 5]) 113 | def event_ids(request): 114 | length = request.param 115 | if length == 0: 116 | return None 117 | 118 | recent_events = list(get_events( 119 | newer_than=(datetime.now(timezone.utc) - timedelta(days=5)).isoformat() 120 | )) 121 | 122 | return [recent_event["id"] for recent_event in recent_events[:length]] 123 | 124 | 125 | @pytest.fixture 126 | def event_id(): 127 | """Fixture that retrieves the ID of a recent event created within 128 | the last 5 days. 129 | 130 | Returns: 131 | - The event ID of the most recent event within the last 5 days 132 | if available. 133 | - `None` if no recent events are found within this time frame. 134 | 135 | """ 136 | recent_events = list(get_events( 137 | newer_than=(datetime.now(timezone.utc) - timedelta(days=5)).isoformat() 138 | )) 139 | return recent_events[0]["id"] if recent_events else None 140 | 141 | 142 | class TestEventFilters: 143 | project_names = [ 144 | ([]), 145 | (["demo_Big_Episodic"]), 146 | (["demo_Big_Feature"]), 147 | (["demo_Commercial"]), 148 | (["AY_Tests"]), 149 | ([ 150 | "demo_Big_Episodic", 151 | "demo_Big_Feature", 152 | "demo_Commercial", 153 | "AY_Tests" 154 | ]) 155 | ] 156 | 157 | topics = [ 158 | ([]), 159 | (["entity.folder.attrib_changed"]), 160 | (["entity.task.created", "entity.project.created"]), 161 | (["settings.changed", "entity.version.status_changed"]), 162 | (["entity.task.status_changed", "entity.folder.deleted"]), 163 | ([ 164 | "entity.project.changed", 165 | "entity.task.tags_changed", 166 | "entity.product.created" 167 | ]) 168 | ] 169 | 170 | users = [ 171 | (None), 172 | ([]), 173 | (["admin"]), 174 | (["mkolar", "tadeas.8964"]), 175 | (["roy", "luke.inderwick", "ynbot"]), 176 | ([ 177 | "entity.folder.attrib_changed", 178 | "entity.project.created", 179 | "entity.task.created", 180 | "settings.changed" 181 | ]), 182 | ] 183 | 184 | # states is incorrect name for statuses 185 | states = [ 186 | (None), 187 | ([]), 188 | ([ 189 | "pending", 190 | "in_progress", 191 | "finished", 192 | "failed", 193 | "aborted", 194 | "restarted" 195 | ]), 196 | (["failed", "aborted"]), 197 | (["pending", "in_progress"]), 198 | (["finished", "failed", "restarted"]), 199 | (["finished"]), 200 | ] 201 | 202 | include_logs = [ 203 | (None), 204 | (True), 205 | (False), 206 | ] 207 | 208 | has_children = [ 209 | (None), 210 | (True), 211 | (False), 212 | ] 213 | 214 | now = datetime.now(timezone.utc) 215 | 216 | newer_than = [ 217 | (None), 218 | ((now - timedelta(days=2)).isoformat()), 219 | ((now - timedelta(days=5)).isoformat()), 220 | ((now - timedelta(days=10)).isoformat()), 221 | ((now - timedelta(days=20)).isoformat()), 222 | ((now - timedelta(days=30)).isoformat()), 223 | ] 224 | 225 | older_than = [ 226 | (None), 227 | ((now - timedelta(days=0)).isoformat()), 228 | ((now - timedelta(days=5)).isoformat()), 229 | ((now - timedelta(days=10)).isoformat()), 230 | ((now - timedelta(days=20)).isoformat()), 231 | ((now - timedelta(days=30)).isoformat()), 232 | ] 233 | 234 | fields = [ 235 | (None), 236 | ([]), 237 | ] 238 | 239 | 240 | class TestInvalidEventFilters: 241 | topics = [ 242 | (None), 243 | (["invalid_topic_name_1", "invalid_topic_name_2"]), 244 | (["invalid_topic_name_1"]), 245 | ] 246 | 247 | project_names = [ 248 | (None), 249 | (["invalid_project"]), 250 | (["invalid_project", "demo_Big_Episodic", "demo_Big_Feature"]), 251 | (["invalid_name_2", "demo_Commercial"]), 252 | (["demo_Commercial"]), 253 | ] 254 | 255 | states = [ 256 | (None), 257 | (["pending_invalid"]), 258 | (["in_progress_invalid"]), 259 | (["finished_invalid", "failed_invalid"]), 260 | ] 261 | 262 | users = [ 263 | (None), 264 | (["ayon_invalid_user"]), 265 | (["ayon_invalid_user1", "ayon_invalid_user2"]), 266 | (["ayon_invalid_user1", "ayon_invalid_user2", "admin"]), 267 | ] 268 | 269 | newer_than = [ 270 | (None), 271 | ((datetime.now(timezone.utc) + timedelta(days=2)).isoformat()), 272 | ((datetime.now(timezone.utc) + timedelta(days=5)).isoformat()), 273 | ((datetime.now(timezone.utc) - timedelta(days=5)).isoformat()), 274 | ] 275 | 276 | 277 | class TestUpdateEventData: 278 | update_sender = [ 279 | ("test.server.api"), 280 | ] 281 | 282 | update_username = [ 283 | ("testing_user"), 284 | ] 285 | 286 | update_status = [ 287 | ("pending"), 288 | ("in_progress"), 289 | ("finished"), 290 | ("failed"), 291 | ("aborted"), 292 | ("restarted") 293 | ] 294 | 295 | update_description = [ 296 | ( 297 | "Lorem ipsum dolor sit amet, consectetur adipiscing elit." 298 | " Fusce vivera." 299 | ), 300 | ("Updated description test...") 301 | ] 302 | 303 | update_retries = [ 304 | (1), 305 | (0), 306 | (10), 307 | ] 308 | 309 | 310 | class TestProductData: 311 | names = [ 312 | ("test_name"), 313 | ("test_123"), 314 | ] 315 | 316 | product_types = [ 317 | ("animation"), 318 | ("camera"), 319 | ("render"), 320 | ("workfile"), 321 | ] 322 | -------------------------------------------------------------------------------- /ayon_api/_api_helpers/activities.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import typing 5 | from typing import Optional, Iterable, Generator, Any 6 | 7 | from ayon_api.utils import ( 8 | SortOrder, 9 | prepare_list_filters, 10 | ) 11 | from ayon_api.graphql_queries import activities_graphql_query 12 | 13 | from .base import BaseServerAPI 14 | 15 | if typing.TYPE_CHECKING: 16 | from ayon_api.typing import ( 17 | ActivityType, 18 | ActivityReferenceType, 19 | ) 20 | 21 | 22 | class ActivitiesAPI(BaseServerAPI): 23 | def get_activities( 24 | self, 25 | project_name: str, 26 | activity_ids: Optional[Iterable[str]] = None, 27 | activity_types: Optional[Iterable[ActivityType]] = None, 28 | entity_ids: Optional[Iterable[str]] = None, 29 | entity_names: Optional[Iterable[str]] = None, 30 | entity_type: Optional[str] = None, 31 | changed_after: Optional[str] = None, 32 | changed_before: Optional[str] = None, 33 | reference_types: Optional[Iterable[ActivityReferenceType]] = None, 34 | fields: Optional[Iterable[str]] = None, 35 | limit: Optional[int] = None, 36 | order: Optional[SortOrder] = None, 37 | ) -> Generator[dict[str, Any], None, None]: 38 | """Get activities from server with filtering options. 39 | 40 | Args: 41 | project_name (str): Project on which activities happened. 42 | activity_ids (Optional[Iterable[str]]): Activity ids. 43 | activity_types (Optional[Iterable[ActivityType]]): Activity types. 44 | entity_ids (Optional[Iterable[str]]): Entity ids. 45 | entity_names (Optional[Iterable[str]]): Entity names. 46 | entity_type (Optional[str]): Entity type. 47 | changed_after (Optional[str]): Return only activities changed 48 | after given iso datetime string. 49 | changed_before (Optional[str]): Return only activities changed 50 | before given iso datetime string. 51 | reference_types (Optional[Iterable[ActivityReferenceType]]): 52 | Reference types filter. Defaults to `['origin']`. 53 | fields (Optional[Iterable[str]]): Fields that should be received 54 | for each activity. 55 | limit (Optional[int]): Limit number of activities to be fetched. 56 | order (Optional[SortOrder]): Order activities in ascending 57 | or descending order. It is recommended to set 'limit' 58 | when used descending. 59 | 60 | Returns: 61 | Generator[dict[str, Any]]: Available activities matching filters. 62 | 63 | """ 64 | if not project_name: 65 | return 66 | filters = { 67 | "projectName": project_name, 68 | } 69 | if reference_types is None: 70 | reference_types = {"origin"} 71 | 72 | if not prepare_list_filters( 73 | filters, 74 | ("activityIds", activity_ids), 75 | ("activityTypes", activity_types), 76 | ("entityIds", entity_ids), 77 | ("entityNames", entity_names), 78 | ("referenceTypes", reference_types), 79 | ): 80 | return 81 | 82 | for filter_key, filter_value in ( 83 | ("entityType", entity_type), 84 | ("changedAfter", changed_after), 85 | ("changedBefore", changed_before), 86 | ): 87 | if filter_value is not None: 88 | filters[filter_key] = filter_value 89 | 90 | if not fields: 91 | fields = self.get_default_fields_for_type("activity") 92 | 93 | query = activities_graphql_query(set(fields), order) 94 | for attr, filter_value in filters.items(): 95 | query.set_variable_value(attr, filter_value) 96 | 97 | if limit: 98 | activities_field = query.get_field_by_path("activities") 99 | activities_field.set_limit(limit) 100 | 101 | for parsed_data in query.continuous_query(self): 102 | for activity in parsed_data["project"]["activities"]: 103 | activity_data = activity.get("activityData") 104 | if isinstance(activity_data, str): 105 | activity["activityData"] = json.loads(activity_data) 106 | yield activity 107 | 108 | def get_activity_by_id( 109 | self, 110 | project_name: str, 111 | activity_id: str, 112 | reference_types: Optional[Iterable[ActivityReferenceType]] = None, 113 | fields: Optional[Iterable[str]] = None, 114 | ) -> Optional[dict[str, Any]]: 115 | """Get activity by id. 116 | 117 | Args: 118 | project_name (str): Project on which activity happened. 119 | activity_id (str): Activity id. 120 | reference_types: Optional[Iterable[ActivityReferenceType]]: Filter 121 | by reference types. 122 | fields (Optional[Iterable[str]]): Fields that should be received 123 | for each activity. 124 | 125 | Returns: 126 | Optional[dict[str, Any]]: Activity data or None if activity is not 127 | found. 128 | 129 | """ 130 | for activity in self.get_activities( 131 | project_name=project_name, 132 | activity_ids={activity_id}, 133 | reference_types=reference_types, 134 | fields=fields, 135 | ): 136 | return activity 137 | return None 138 | 139 | def create_activity( 140 | self, 141 | project_name: str, 142 | entity_id: str, 143 | entity_type: str, 144 | activity_type: ActivityType, 145 | activity_id: Optional[str] = None, 146 | body: Optional[str] = None, 147 | file_ids: Optional[list[str]] = None, 148 | timestamp: Optional[str] = None, 149 | data: Optional[dict[str, Any]] = None, 150 | ) -> str: 151 | """Create activity on a project. 152 | 153 | Args: 154 | project_name (str): Project on which activity happened. 155 | entity_id (str): Entity id. 156 | entity_type (str): Entity type. 157 | activity_type (ActivityType): Activity type. 158 | activity_id (Optional[str]): Activity id. 159 | body (Optional[str]): Activity body. 160 | file_ids (Optional[list[str]]): List of file ids attached 161 | to activity. 162 | timestamp (Optional[str]): Activity timestamp. 163 | data (Optional[dict[str, Any]]): Additional data. 164 | 165 | Returns: 166 | str: Activity id. 167 | 168 | """ 169 | post_data = { 170 | "activityType": activity_type, 171 | } 172 | for key, value in ( 173 | ("id", activity_id), 174 | ("body", body), 175 | ("files", file_ids), 176 | ("timestamp", timestamp), 177 | ("data", data), 178 | ): 179 | if value is not None: 180 | post_data[key] = value 181 | 182 | response = self.post( 183 | f"projects/{project_name}/{entity_type}/{entity_id}/activities", 184 | **post_data 185 | ) 186 | response.raise_for_status() 187 | return response.data["id"] 188 | 189 | def update_activity( 190 | self, 191 | project_name: str, 192 | activity_id: str, 193 | body: Optional[str] = None, 194 | file_ids: Optional[list[str]] = None, 195 | append_file_ids: Optional[bool] = False, 196 | data: Optional[dict[str, Any]] = None, 197 | ) -> None: 198 | """Update activity by id. 199 | 200 | Args: 201 | project_name (str): Project on which activity happened. 202 | activity_id (str): Activity id. 203 | body (str): Activity body. 204 | file_ids (Optional[list[str]]): List of file ids attached 205 | to activity. 206 | append_file_ids (Optional[bool]): Append file ids to existing 207 | list of file ids. 208 | data (Optional[dict[str, Any]]): Update data in activity. 209 | 210 | """ 211 | update_data = {} 212 | major, minor, patch, _, _ = self.get_server_version_tuple() 213 | new_patch_model = (major, minor, patch) > (1, 5, 6) 214 | if body is None and not new_patch_model: 215 | raise ValueError( 216 | "Update without 'body' is supported" 217 | " after server version 1.5.6." 218 | ) 219 | 220 | if body is not None: 221 | update_data["body"] = body 222 | 223 | if file_ids is not None: 224 | update_data["files"] = file_ids 225 | if new_patch_model: 226 | update_data["appendFiles"] = append_file_ids 227 | elif append_file_ids: 228 | raise ValueError( 229 | "Append file ids is supported after server version 1.5.6." 230 | ) 231 | 232 | if data is not None: 233 | if not new_patch_model: 234 | raise ValueError( 235 | "Update of data is supported after server version 1.5.6." 236 | ) 237 | update_data["data"] = data 238 | 239 | response = self.patch( 240 | f"projects/{project_name}/activities/{activity_id}", 241 | **update_data 242 | ) 243 | response.raise_for_status() 244 | 245 | def delete_activity(self, project_name: str, activity_id: str) -> None: 246 | """Delete activity by id. 247 | 248 | Args: 249 | project_name (str): Project on which activity happened. 250 | activity_id (str): Activity id to remove. 251 | 252 | """ 253 | response = self.delete( 254 | f"projects/{project_name}/activities/{activity_id}" 255 | ) 256 | response.raise_for_status() 257 | 258 | def send_activities_batch_operations( 259 | self, 260 | project_name: str, 261 | operations: list[dict[str, Any]], 262 | can_fail: bool = False, 263 | raise_on_fail: bool = True 264 | ) -> list[dict[str, Any]]: 265 | """Post multiple CRUD activities operations to server. 266 | 267 | When multiple changes should be made on server side this is the best 268 | way to go. It is possible to pass multiple operations to process on a 269 | server side and do the changes in a transaction. 270 | 271 | Args: 272 | project_name (str): On which project should be operations 273 | processed. 274 | operations (list[dict[str, Any]]): Operations to be processed. 275 | can_fail (Optional[bool]): Server will try to process all 276 | operations even if one of them fails. 277 | raise_on_fail (Optional[bool]): Raise exception if an operation 278 | fails. You can handle failed operations on your own 279 | when set to 'False'. 280 | 281 | Raises: 282 | ValueError: Operations can't be converted to json string. 283 | FailedOperations: When output does not contain server operations 284 | or 'raise_on_fail' is enabled and any operation fails. 285 | 286 | Returns: 287 | list[dict[str, Any]]: Operations result with process details. 288 | 289 | """ 290 | return self._send_batch_operations( 291 | f"projects/{project_name}/operations/activities", 292 | operations, 293 | can_fail, 294 | raise_on_fail, 295 | ) 296 | -------------------------------------------------------------------------------- /ayon_api/_api_helpers/actions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing 4 | from typing import Optional, Any 5 | 6 | from ayon_api.utils import prepare_query_string 7 | 8 | from .base import BaseServerAPI 9 | 10 | if typing.TYPE_CHECKING: 11 | from ayon_api.typing import ( 12 | ActionEntityTypes, 13 | ActionManifestDict, 14 | ActionTriggerResponse, 15 | ActionTakeResponse, 16 | ActionConfigResponse, 17 | ActionModeType, 18 | ) 19 | 20 | 21 | class ActionsAPI(BaseServerAPI): 22 | """Implementation of actions API for ServerAPI.""" 23 | def get_actions( 24 | self, 25 | project_name: Optional[str] = None, 26 | entity_type: Optional[ActionEntityTypes] = None, 27 | entity_ids: Optional[list[str]] = None, 28 | entity_subtypes: Optional[list[str]] = None, 29 | form_data: Optional[dict[str, Any]] = None, 30 | *, 31 | variant: Optional[str] = None, 32 | mode: Optional[ActionModeType] = None, 33 | ) -> list[ActionManifestDict]: 34 | """Get actions for a context. 35 | 36 | Args: 37 | project_name (Optional[str]): Name of the project. None for global 38 | actions. 39 | entity_type (Optional[ActionEntityTypes]): Entity type where the 40 | action is triggered. None for global actions. 41 | entity_ids (Optional[list[str]]): list of entity ids where the 42 | action is triggered. None for global actions. 43 | entity_subtypes (Optional[list[str]]): list of entity subtypes 44 | folder types for folder ids, task types for tasks ids. 45 | form_data (Optional[dict[str, Any]]): Form data of the action. 46 | variant (Optional[str]): Settings variant. 47 | mode (Optional[ActionModeType]): Action modes. 48 | 49 | Returns: 50 | list[ActionManifestDict]: list of action manifests. 51 | 52 | """ 53 | if variant is None: 54 | variant = self.get_default_settings_variant() 55 | query_data = {"variant": variant} 56 | if mode: 57 | query_data["mode"] = mode 58 | query = prepare_query_string(query_data) 59 | kwargs = { 60 | key: value 61 | for key, value in ( 62 | ("projectName", project_name), 63 | ("entityType", entity_type), 64 | ("entityIds", entity_ids), 65 | ("entitySubtypes", entity_subtypes), 66 | ("formData", form_data), 67 | ) 68 | if value is not None 69 | } 70 | response = self.post(f"actions/list{query}", **kwargs) 71 | response.raise_for_status() 72 | return response.data["actions"] 73 | 74 | def trigger_action( 75 | self, 76 | identifier: str, 77 | addon_name: str, 78 | addon_version: str, 79 | project_name: Optional[str] = None, 80 | entity_type: Optional[ActionEntityTypes] = None, 81 | entity_ids: Optional[list[str]] = None, 82 | entity_subtypes: Optional[list[str]] = None, 83 | form_data: Optional[dict[str, Any]] = None, 84 | *, 85 | variant: Optional[str] = None, 86 | ) -> ActionTriggerResponse: 87 | """Trigger action. 88 | 89 | Args: 90 | identifier (str): Identifier of the action. 91 | addon_name (str): Name of the addon. 92 | addon_version (str): Version of the addon. 93 | project_name (Optional[str]): Name of the project. None for global 94 | actions. 95 | entity_type (Optional[ActionEntityTypes]): Entity type where the 96 | action is triggered. None for global actions. 97 | entity_ids (Optional[list[str]]): list of entity ids where the 98 | action is triggered. None for global actions. 99 | entity_subtypes (Optional[list[str]]): list of entity subtypes 100 | folder types for folder ids, task types for tasks ids. 101 | form_data (Optional[dict[str, Any]]): Form data of the action. 102 | variant (Optional[str]): Settings variant. 103 | 104 | """ 105 | if variant is None: 106 | variant = self.get_default_settings_variant() 107 | query_data = { 108 | "addonName": addon_name, 109 | "addonVersion": addon_version, 110 | "identifier": identifier, 111 | "variant": variant, 112 | } 113 | query = prepare_query_string(query_data) 114 | 115 | kwargs = { 116 | key: value 117 | for key, value in ( 118 | ("projectName", project_name), 119 | ("entityType", entity_type), 120 | ("entityIds", entity_ids), 121 | ("entitySubtypes", entity_subtypes), 122 | ("formData", form_data), 123 | ) 124 | if value is not None 125 | } 126 | 127 | response = self.post(f"actions/execute{query}", **kwargs) 128 | response.raise_for_status() 129 | return response.data 130 | 131 | def get_action_config( 132 | self, 133 | identifier: str, 134 | addon_name: str, 135 | addon_version: str, 136 | project_name: Optional[str] = None, 137 | entity_type: Optional[ActionEntityTypes] = None, 138 | entity_ids: Optional[list[str]] = None, 139 | entity_subtypes: Optional[list[str]] = None, 140 | form_data: Optional[dict[str, Any]] = None, 141 | *, 142 | variant: Optional[str] = None, 143 | ) -> ActionConfigResponse: 144 | """Get action configuration. 145 | 146 | Args: 147 | identifier (str): Identifier of the action. 148 | addon_name (str): Name of the addon. 149 | addon_version (str): Version of the addon. 150 | project_name (Optional[str]): Name of the project. None for global 151 | actions. 152 | entity_type (Optional[ActionEntityTypes]): Entity type where the 153 | action is triggered. None for global actions. 154 | entity_ids (Optional[list[str]]): list of entity ids where the 155 | action is triggered. None for global actions. 156 | entity_subtypes (Optional[list[str]]): list of entity subtypes 157 | folder types for folder ids, task types for tasks ids. 158 | form_data (Optional[dict[str, Any]]): Form data of the action. 159 | variant (Optional[str]): Settings variant. 160 | 161 | Returns: 162 | ActionConfigResponse: Action configuration data. 163 | 164 | """ 165 | return self._send_config_request( 166 | identifier, 167 | addon_name, 168 | addon_version, 169 | None, 170 | project_name, 171 | entity_type, 172 | entity_ids, 173 | entity_subtypes, 174 | form_data, 175 | variant, 176 | ) 177 | 178 | def set_action_config( 179 | self, 180 | identifier: str, 181 | addon_name: str, 182 | addon_version: str, 183 | value: dict[str, Any], 184 | project_name: Optional[str] = None, 185 | entity_type: Optional[ActionEntityTypes] = None, 186 | entity_ids: Optional[list[str]] = None, 187 | entity_subtypes: Optional[list[str]] = None, 188 | form_data: Optional[dict[str, Any]] = None, 189 | *, 190 | variant: Optional[str] = None, 191 | ) -> ActionConfigResponse: 192 | """Set action configuration. 193 | 194 | Args: 195 | identifier (str): Identifier of the action. 196 | addon_name (str): Name of the addon. 197 | addon_version (str): Version of the addon. 198 | value (Optional[dict[str, Any]]): Value of the action 199 | configuration. 200 | project_name (Optional[str]): Name of the project. None for global 201 | actions. 202 | entity_type (Optional[ActionEntityTypes]): Entity type where the 203 | action is triggered. None for global actions. 204 | entity_ids (Optional[list[str]]): list of entity ids where the 205 | action is triggered. None for global actions. 206 | entity_subtypes (Optional[list[str]]): list of entity subtypes 207 | folder types for folder ids, task types for tasks ids. 208 | form_data (Optional[dict[str, Any]]): Form data of the action. 209 | variant (Optional[str]): Settings variant. 210 | 211 | Returns: 212 | ActionConfigResponse: New action configuration data. 213 | 214 | """ 215 | return self._send_config_request( 216 | identifier, 217 | addon_name, 218 | addon_version, 219 | value, 220 | project_name, 221 | entity_type, 222 | entity_ids, 223 | entity_subtypes, 224 | form_data, 225 | variant, 226 | ) 227 | 228 | def take_action(self, action_token: str) -> ActionTakeResponse: 229 | """Take action metadata using an action token. 230 | 231 | Args: 232 | action_token (str): AYON launcher action token. 233 | 234 | Returns: 235 | ActionTakeResponse: Action metadata describing how to launch 236 | action. 237 | 238 | """ 239 | response = self.get(f"actions/abort/{action_token}") 240 | response.raise_for_status() 241 | return response.data 242 | 243 | def abort_action( 244 | self, 245 | action_token: str, 246 | message: Optional[str] = None, 247 | ) -> None: 248 | """Abort action using an action token. 249 | 250 | Args: 251 | action_token (str): AYON launcher action token. 252 | message (Optional[str]): Message to display in the UI. 253 | 254 | """ 255 | if message is None: 256 | message = "Action aborted" 257 | response = self.post( 258 | f"actions/abort/{action_token}", 259 | message=message, 260 | ) 261 | response.raise_for_status() 262 | 263 | def _send_config_request( 264 | self, 265 | identifier: str, 266 | addon_name: str, 267 | addon_version: str, 268 | value: Optional[dict[str, Any]], 269 | project_name: Optional[str], 270 | entity_type: Optional[ActionEntityTypes], 271 | entity_ids: Optional[list[str]], 272 | entity_subtypes: Optional[list[str]], 273 | form_data: Optional[dict[str, Any]], 274 | variant: Optional[str], 275 | ) -> ActionConfigResponse: 276 | """Set and get action configuration.""" 277 | if variant is None: 278 | variant = self.get_default_settings_variant() 279 | query_data = { 280 | "addonName": addon_name, 281 | "addonVersion": addon_version, 282 | "identifier": identifier, 283 | "variant": variant, 284 | } 285 | query = prepare_query_string(query_data) 286 | 287 | kwargs = { 288 | query_key: query_value 289 | for query_key, query_value in ( 290 | ("projectName", project_name), 291 | ("entityType", entity_type), 292 | ("entityIds", entity_ids), 293 | ("entitySubtypes", entity_subtypes), 294 | ("formData", form_data), 295 | ) 296 | if query_value is not None 297 | } 298 | if value is not None: 299 | kwargs["value"] = value 300 | 301 | response = self.post(f"actions/config{query}", **kwargs) 302 | response.raise_for_status() 303 | return response.data 304 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /ayon_api/_api_helpers/thumbnails.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import warnings 5 | import typing 6 | from typing import Optional 7 | 8 | from ayon_api.utils import ( 9 | get_media_mime_type_for_stream, 10 | get_media_mime_type, 11 | ThumbnailContent, 12 | RequestTypes, 13 | RestApiResponse, 14 | ) 15 | 16 | from .base import BaseServerAPI 17 | 18 | if typing.TYPE_CHECKING: 19 | from .typing import StreamType 20 | 21 | 22 | class ThumbnailsAPI(BaseServerAPI): 23 | def get_thumbnail_by_id( 24 | self, project_name: str, thumbnail_id: str 25 | ) -> ThumbnailContent: 26 | """Get thumbnail from server by id. 27 | 28 | Warnings: 29 | Please keep in mind that used endpoint is allowed only for admins 30 | and managers. Use 'get_thumbnail' with entity type and id 31 | to allow access for artists. 32 | 33 | Notes: 34 | It is recommended to use one of prepared entity type specific 35 | methods 'get_folder_thumbnail', 'get_version_thumbnail' or 36 | 'get_workfile_thumbnail'. 37 | We do recommend pass thumbnail id if you have access to it. Each 38 | entity that allows thumbnails has 'thumbnailId' field, so it 39 | can be queried. 40 | 41 | Args: 42 | project_name (str): Project under which the entity is located. 43 | thumbnail_id (Optional[str]): DEPRECATED Use 44 | 'get_thumbnail_by_id'. 45 | 46 | Returns: 47 | ThumbnailContent: Thumbnail content wrapper. Does not have to be 48 | valid. 49 | 50 | """ 51 | response = self.raw_get( 52 | f"projects/{project_name}/thumbnails/{thumbnail_id}" 53 | ) 54 | return self._prepare_thumbnail_content(project_name, response) 55 | 56 | def get_thumbnail( 57 | self, 58 | project_name: str, 59 | entity_type: str, 60 | entity_id: str, 61 | thumbnail_id: Optional[str] = None, 62 | ) -> ThumbnailContent: 63 | """Get thumbnail from server. 64 | 65 | Permissions of thumbnails are related to entities so thumbnails must 66 | be queried per entity. So an entity type and entity id is required 67 | to be passed. 68 | 69 | Notes: 70 | It is recommended to use one of prepared entity type specific 71 | methods 'get_folder_thumbnail', 'get_version_thumbnail' or 72 | 'get_workfile_thumbnail'. 73 | We do recommend pass thumbnail id if you have access to it. Each 74 | entity that allows thumbnails has 'thumbnailId' field, so it 75 | can be queried. 76 | 77 | Args: 78 | project_name (str): Project under which the entity is located. 79 | entity_type (str): Entity type which passed entity id represents. 80 | entity_id (str): Entity id for which thumbnail should be returned. 81 | thumbnail_id (Optional[str]): DEPRECATED Use 82 | 'get_thumbnail_by_id'. 83 | 84 | Returns: 85 | ThumbnailContent: Thumbnail content wrapper. Does not have to be 86 | valid. 87 | 88 | """ 89 | if thumbnail_id: 90 | warnings.warn( 91 | ( 92 | "Function 'get_thumbnail' got 'thumbnail_id' which" 93 | " is deprecated and will be removed in future version." 94 | ), 95 | DeprecationWarning 96 | ) 97 | 98 | if entity_type in ( 99 | "folder", 100 | "task", 101 | "version", 102 | "workfile", 103 | ): 104 | entity_type += "s" 105 | 106 | response = self.raw_get( 107 | f"projects/{project_name}/{entity_type}/{entity_id}/thumbnail" 108 | ) 109 | return self._prepare_thumbnail_content(project_name, response) 110 | 111 | def get_folder_thumbnail( 112 | self, 113 | project_name: str, 114 | folder_id: str, 115 | thumbnail_id: Optional[str] = None, 116 | ) -> ThumbnailContent: 117 | """Prepared method to receive thumbnail for folder entity. 118 | 119 | Args: 120 | project_name (str): Project under which the entity is located. 121 | folder_id (str): Folder id for which thumbnail should be returned. 122 | thumbnail_id (Optional[str]): Prepared thumbnail id from entity. 123 | Used only to check if thumbnail was already cached. 124 | 125 | Returns: 126 | ThumbnailContent: Thumbnail content wrapper. Does not have to be 127 | valid. 128 | 129 | """ 130 | if thumbnail_id: 131 | warnings.warn( 132 | ( 133 | "Function 'get_folder_thumbnail' got 'thumbnail_id' which" 134 | " is deprecated and will be removed in future version." 135 | ), 136 | DeprecationWarning 137 | ) 138 | return self.get_thumbnail( 139 | project_name, "folder", folder_id 140 | ) 141 | 142 | def get_task_thumbnail( 143 | self, 144 | project_name: str, 145 | task_id: str, 146 | ) -> ThumbnailContent: 147 | """Prepared method to receive thumbnail for task entity. 148 | 149 | Args: 150 | project_name (str): Project under which the entity is located. 151 | task_id (str): Folder id for which thumbnail should be returned. 152 | 153 | Returns: 154 | ThumbnailContent: Thumbnail content wrapper. Does not have to be 155 | valid. 156 | 157 | """ 158 | return self.get_thumbnail(project_name, "task", task_id) 159 | 160 | def get_version_thumbnail( 161 | self, 162 | project_name: str, 163 | version_id: str, 164 | thumbnail_id: Optional[str] = None, 165 | ) -> ThumbnailContent: 166 | """Prepared method to receive thumbnail for version entity. 167 | 168 | Args: 169 | project_name (str): Project under which the entity is located. 170 | version_id (str): Version id for which thumbnail should be 171 | returned. 172 | thumbnail_id (Optional[str]): Prepared thumbnail id from entity. 173 | Used only to check if thumbnail was already cached. 174 | 175 | Returns: 176 | ThumbnailContent: Thumbnail content wrapper. Does not have to be 177 | valid. 178 | 179 | """ 180 | if thumbnail_id: 181 | warnings.warn( 182 | ( 183 | "Function 'get_version_thumbnail' got 'thumbnail_id' which" 184 | " is deprecated and will be removed in future version." 185 | ), 186 | DeprecationWarning 187 | ) 188 | return self.get_thumbnail( 189 | project_name, "version", version_id 190 | ) 191 | 192 | def get_workfile_thumbnail( 193 | self, 194 | project_name: str, 195 | workfile_id: str, 196 | thumbnail_id: Optional[str] = None, 197 | ) -> ThumbnailContent: 198 | """Prepared method to receive thumbnail for workfile entity. 199 | 200 | Args: 201 | project_name (str): Project under which the entity is located. 202 | workfile_id (str): Worfile id for which thumbnail should be 203 | returned. 204 | thumbnail_id (Optional[str]): Prepared thumbnail id from entity. 205 | Used only to check if thumbnail was already cached. 206 | 207 | Returns: 208 | ThumbnailContent: Thumbnail content wrapper. Does not have to be 209 | valid. 210 | 211 | """ 212 | if thumbnail_id: 213 | warnings.warn( 214 | ( 215 | "Function 'get_workfile_thumbnail' got 'thumbnail_id'" 216 | " which is deprecated and will be removed in future" 217 | " version." 218 | ), 219 | DeprecationWarning 220 | ) 221 | return self.get_thumbnail( 222 | project_name, "workfile", workfile_id 223 | ) 224 | 225 | def create_thumbnail( 226 | self, 227 | project_name: str, 228 | src_filepath: str, 229 | thumbnail_id: Optional[str] = None, 230 | ) -> str: 231 | """Create new thumbnail on server from passed path. 232 | 233 | Args: 234 | project_name (str): Project where the thumbnail will be created 235 | and can be used. 236 | src_filepath (str): Filepath to thumbnail which should be uploaded. 237 | thumbnail_id (Optional[str]): Prepared if of thumbnail. 238 | 239 | Returns: 240 | str: Created thumbnail id. 241 | 242 | Raises: 243 | ValueError: When thumbnail source cannot be processed. 244 | 245 | """ 246 | if not os.path.exists(src_filepath): 247 | raise ValueError("Entered filepath does not exist.") 248 | 249 | if thumbnail_id: 250 | self.update_thumbnail( 251 | project_name, 252 | thumbnail_id, 253 | src_filepath 254 | ) 255 | return thumbnail_id 256 | 257 | mime_type = get_media_mime_type(src_filepath) 258 | response = self.upload_file( 259 | f"projects/{project_name}/thumbnails", 260 | src_filepath, 261 | request_type=RequestTypes.post, 262 | headers={"Content-Type": mime_type}, 263 | ) 264 | response.raise_for_status() 265 | return response.json()["id"] 266 | 267 | def create_thumbnail_with_stream( 268 | self, 269 | project_name: str, 270 | stream: StreamType, 271 | thumbnail_id: Optional[str] = None, 272 | ) -> str: 273 | """Create new thumbnail on server from byte stream. 274 | 275 | Args: 276 | project_name (str): Project where the thumbnail will be created 277 | and can be used. 278 | stream (StreamType): Thumbnail content stream. 279 | thumbnail_id (Optional[str]): Prepared if of thumbnail. 280 | 281 | Returns: 282 | str: Created thumbnail id. 283 | 284 | Raises: 285 | ValueError: When a thumbnail source cannot be processed. 286 | 287 | """ 288 | if thumbnail_id: 289 | self.update_thumbnail_from_stream( 290 | project_name, 291 | thumbnail_id, 292 | stream 293 | ) 294 | return thumbnail_id 295 | 296 | mime_type = get_media_mime_type_for_stream(stream) 297 | response = self.upload_file_from_stream( 298 | f"projects/{project_name}/thumbnails", 299 | stream, 300 | request_type=RequestTypes.post, 301 | headers={"Content-Type": mime_type}, 302 | ) 303 | response.raise_for_status() 304 | return response.json()["id"] 305 | 306 | def update_thumbnail( 307 | self, project_name: str, thumbnail_id: str, src_filepath: str 308 | ) -> None: 309 | """Change thumbnail content by id. 310 | 311 | Update can be also used to create new thumbnail. 312 | 313 | Args: 314 | project_name (str): Project where the thumbnail will be created 315 | and can be used. 316 | thumbnail_id (str): Thumbnail id to update. 317 | src_filepath (str): Filepath to thumbnail which should be uploaded. 318 | 319 | Raises: 320 | ValueError: When thumbnail source cannot be processed. 321 | 322 | """ 323 | if not os.path.exists(src_filepath): 324 | raise ValueError("Entered filepath does not exist.") 325 | 326 | mime_type = get_media_mime_type(src_filepath) 327 | response = self.upload_file( 328 | f"projects/{project_name}/thumbnails/{thumbnail_id}", 329 | src_filepath, 330 | request_type=RequestTypes.put, 331 | headers={"Content-Type": mime_type}, 332 | ) 333 | response.raise_for_status() 334 | 335 | def update_thumbnail_from_stream( 336 | self, 337 | project_name: str, 338 | thumbnail_id: str, 339 | stream: StreamType, 340 | ) -> None: 341 | """Change thumbnail content by id. 342 | 343 | Update can be also used to create new thumbnail. 344 | 345 | Args: 346 | project_name (str): Project where the thumbnail will be created 347 | and can be used. 348 | thumbnail_id (str): Thumbnail id to update. 349 | stream (StreamType): Thumbnail content stream. 350 | 351 | """ 352 | mime_type = get_media_mime_type_for_stream(stream) 353 | response = self.upload_file_from_stream( 354 | f"projects/{project_name}/thumbnails/{thumbnail_id}", 355 | stream, 356 | request_type=RequestTypes.put, 357 | headers={"Content-Type": mime_type}, 358 | ) 359 | response.raise_for_status() 360 | 361 | def _prepare_thumbnail_content( 362 | self, 363 | project_name: str, 364 | response: RestApiResponse, 365 | ) -> ThumbnailContent: 366 | content = None 367 | content_type = response.content_type 368 | 369 | # It is expected the response contains thumbnail id otherwise the 370 | # content cannot be cached and filepath returned 371 | thumbnail_id = response.headers.get("X-Thumbnail-Id") 372 | if thumbnail_id is not None: 373 | content = response.content 374 | 375 | return ThumbnailContent( 376 | project_name, thumbnail_id, content, content_type 377 | ) 378 | -------------------------------------------------------------------------------- /ayon_api/typing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import io 4 | from typing import ( 5 | Literal, 6 | Any, 7 | TypedDict, 8 | Union, 9 | Optional, 10 | BinaryIO, 11 | NotRequired, 12 | ) 13 | 14 | 15 | ServerVersion = tuple[int, int, int, str, str] 16 | 17 | ActivityType = Literal[ 18 | "comment", 19 | "watch", 20 | "reviewable", 21 | "status.change", 22 | "assignee.add", 23 | "assignee.remove", 24 | "version.publish" 25 | ] 26 | 27 | ActivityReferenceType = Literal[ 28 | "origin", 29 | "mention", 30 | "author", 31 | "relation", 32 | "watching", 33 | ] 34 | 35 | EntityListEntityType = Literal[ 36 | "folder", 37 | "product", 38 | "version", 39 | "representation", 40 | "task", 41 | "workfile", 42 | ] 43 | 44 | EntityListItemMode = Literal[ 45 | "replace", 46 | "merge", 47 | "delete", 48 | ] 49 | 50 | EventFilterValueType = Union[ 51 | None, 52 | str, int, float, 53 | list[str], list[int], list[float], 54 | ] 55 | 56 | 57 | IconType = Literal["material-symbols", "url"] 58 | 59 | 60 | class IconDefType(TypedDict): 61 | type: IconType 62 | name: Optional[str] 63 | color: Optional[str] 64 | icon: Optional[str] 65 | 66 | 67 | class EventFilterCondition(TypedDict): 68 | key: str 69 | value: EventFilterValueType 70 | operator: Literal[ 71 | "eq", 72 | "lt", 73 | "gt", 74 | "lte", 75 | "gte", 76 | "ne", 77 | "isnull", 78 | "notnull", 79 | "in", 80 | "notin", 81 | "contains", 82 | "excludes", 83 | "like", 84 | ] 85 | 86 | 87 | class EventFilter(TypedDict): 88 | conditions: list[EventFilterCondition] 89 | operator: Literal["and", "or"] 90 | 91 | 92 | class BackgroundOperationTask(TypedDict): 93 | id: str 94 | status: Literal["pending", "in_progress", "completed"] 95 | result: Optional[dict[str, Any]] 96 | 97 | 98 | AttributeScope = Literal[ 99 | "project", 100 | "folder", 101 | "task", 102 | "product", 103 | "version", 104 | "representation", 105 | "workfile", 106 | "user" 107 | ] 108 | 109 | AttributeType = Literal[ 110 | "string", 111 | "integer", 112 | "float", 113 | "boolean", 114 | "datetime", 115 | "list_of_strings", 116 | "list_of_integers", 117 | "list_of_any", 118 | "list_of_submodels", 119 | "dict", 120 | ] 121 | 122 | LinkDirection = Literal["in", "out"] 123 | 124 | 125 | class AttributeEnumItemDict(TypedDict): 126 | value: Union[str, int, float, bool] 127 | label: str 128 | icon: Union[str, None] 129 | color: Union[str, None] 130 | 131 | 132 | class AttributeSchemaDataDict(TypedDict): 133 | type: AttributeType 134 | inherit: bool 135 | title: str 136 | description: Optional[str] 137 | example: Optional[Any] 138 | default: Optional[Any] 139 | gt: Union[int, float, None] 140 | lt: Union[int, float, None] 141 | ge: Union[int, float, None] 142 | le: Union[int, float, None] 143 | minLength: Optional[int] 144 | maxLength: Optional[int] 145 | minItems: Optional[int] 146 | maxItems: Optional[int] 147 | regex: Optional[str] 148 | enum: Optional[list[AttributeEnumItemDict]] 149 | 150 | 151 | class AttributeSchemaDict(TypedDict): 152 | name: str 153 | position: int 154 | scope: list[AttributeScope] 155 | builtin: bool 156 | data: AttributeSchemaDataDict 157 | 158 | 159 | class AttributesSchemaDict(TypedDict): 160 | attributes: list[AttributeSchemaDict] 161 | 162 | 163 | class AddonVersionInfoDict(TypedDict): 164 | hasSettings: bool 165 | hasSiteSettings: bool 166 | frontendScopes: dict[str, Any] 167 | clientPyproject: dict[str, Any] 168 | clientSourceInfo: list[dict[str, Any]] 169 | isBroken: bool 170 | 171 | 172 | class AddonInfoDict(TypedDict): 173 | name: str 174 | title: str 175 | versions: dict[str, AddonVersionInfoDict] 176 | 177 | 178 | class AddonsInfoDict(TypedDict): 179 | addons: list[AddonInfoDict] 180 | 181 | 182 | class InstallerInfoDict(TypedDict): 183 | filename: str 184 | platform: str 185 | size: int 186 | checksum: str 187 | checksumAlgorithm: str 188 | sources: list[dict[str, Any]] 189 | version: str 190 | pythonVersion: str 191 | pythonModules: dict[str, str] 192 | runtimePythonModules: dict[str, str] 193 | 194 | 195 | class InstallersInfoDict(TypedDict): 196 | installers: list[InstallerInfoDict] 197 | 198 | 199 | class DependencyPackageDict(TypedDict): 200 | filename: str 201 | platform: str 202 | size: int 203 | checksum: str 204 | checksumAlgorithm: str 205 | sources: list[dict[str, Any]] 206 | installerVersion: str 207 | sourceAddons: dict[str, str] 208 | pythonModules: dict[str, str] 209 | 210 | 211 | class DependencyPackagesDict(TypedDict): 212 | packages: list[DependencyPackageDict] 213 | 214 | 215 | class DevBundleAddonInfoDict(TypedDict): 216 | enabled: bool 217 | path: str 218 | 219 | 220 | class BundleInfoDict(TypedDict): 221 | name: str 222 | createdAt: str 223 | addons: dict[str, str] 224 | installerVersion: str 225 | dependencyPackages: dict[str, str] 226 | addonDevelopment: dict[str, DevBundleAddonInfoDict] 227 | isProduction: bool 228 | isStaging: bool 229 | isArchived: bool 230 | isDev: bool 231 | activeUser: Optional[str] 232 | 233 | 234 | class BundlesInfoDict(TypedDict): 235 | bundles: list[BundleInfoDict] 236 | productionBundle: str 237 | devBundles: list[str] 238 | 239 | 240 | class AnatomyPresetInfoDict(TypedDict): 241 | name: str 242 | primary: bool 243 | version: str 244 | 245 | 246 | class AnatomyPresetRootDict(TypedDict): 247 | name: str 248 | windows: str 249 | linux: str 250 | darwin: str 251 | 252 | 253 | class AnatomyPresetTemplateDict(TypedDict): 254 | name: str 255 | directory: str 256 | file: str 257 | 258 | 259 | class AnatomyPresetTemplatesDict(TypedDict): 260 | version_padding: int 261 | version: str 262 | frame_padding: int 263 | frame: str 264 | work: list[AnatomyPresetTemplateDict] 265 | publish: list[AnatomyPresetTemplateDict] 266 | hero: list[AnatomyPresetTemplateDict] 267 | delivery: list[AnatomyPresetTemplateDict] 268 | staging: list[AnatomyPresetTemplateDict] 269 | others: list[AnatomyPresetTemplateDict] 270 | 271 | 272 | class AnatomyPresetSubtypeDict(TypedDict): 273 | name: str 274 | shortName: str 275 | icon: str 276 | original_name: str 277 | 278 | 279 | class AnatomyPresetLinkTypeDict(TypedDict): 280 | link_type: str 281 | input_type: str 282 | output_type: str 283 | color: str 284 | style: str 285 | 286 | 287 | StatusScope = Literal[ 288 | "folder", 289 | "task", 290 | "product", 291 | "version", 292 | "representation", 293 | "workfile" 294 | ] 295 | 296 | 297 | class AnatomyPresetStatusDict(TypedDict): 298 | name: str 299 | shortName: str 300 | state: str 301 | icon: str 302 | color: str 303 | scope: list[StatusScope] 304 | original_name: str 305 | 306 | 307 | class AnatomyPresetTagDict(TypedDict): 308 | name: str 309 | color: str 310 | original_name: str 311 | 312 | 313 | class AnatomyPresetDict(TypedDict): 314 | roots: list[AnatomyPresetRootDict] 315 | templates: AnatomyPresetTemplatesDict 316 | attributes: dict[str, Any] 317 | folder_types: list[AnatomyPresetSubtypeDict] 318 | task_types: list[AnatomyPresetSubtypeDict] 319 | link_types: list[AnatomyPresetLinkTypeDict] 320 | statuses: list[AnatomyPresetStatusDict] 321 | tags: list[AnatomyPresetTagDict] 322 | primary: bool 323 | name: str 324 | 325 | 326 | class SecretDict(TypedDict): 327 | name: str 328 | value: str 329 | 330 | 331 | class ProjectListDict(TypedDict): 332 | name: str 333 | code: str 334 | active: bool 335 | createdAt: str 336 | updatedAt: str 337 | 338 | 339 | ProjectDict = dict[str, Any] 340 | FolderDict = dict[str, Any] 341 | TaskDict = dict[str, Any] 342 | ProductDict = dict[str, Any] 343 | VersionDict = dict[str, Any] 344 | RepresentationDict = dict[str, Any] 345 | WorkfileInfoDict = dict[str, Any] 346 | EventDict = dict[str, Any] 347 | ActivityDict = dict[str, Any] 348 | AnyEntityDict = Union[ 349 | ProjectDict, 350 | FolderDict, 351 | TaskDict, 352 | ProductDict, 353 | VersionDict, 354 | RepresentationDict, 355 | WorkfileInfoDict, 356 | EventDict, 357 | ActivityDict, 358 | ] 359 | 360 | 361 | class NewFolderDict(TypedDict): 362 | id: str 363 | name: str 364 | folderType: str 365 | parentId: Optional[str] 366 | data: dict[str, Any] 367 | attrib: dict[str, Any] 368 | thumbnailId: Optional[str] 369 | status: NotRequired[str] 370 | tags: NotRequired[list[str]] 371 | 372 | 373 | class NewTaskDict(TypedDict): 374 | id: str 375 | name: str 376 | task_type: str 377 | folder_id: str 378 | label: NotRequired[str] 379 | assignees: NotRequired[list[str]] 380 | attrib: NotRequired[dict[str, Any]] 381 | data: NotRequired[dict[str, Any]] 382 | thumbnailId: NotRequired[str] 383 | active: NotRequired[bool] 384 | status: NotRequired[str] 385 | tags: NotRequired[list[str]] 386 | 387 | 388 | class NewProductDict(TypedDict): 389 | id: str 390 | name: str 391 | productType: str 392 | folderId: str 393 | data: dict[str, Any] 394 | attrib: dict[str, Any] 395 | status: NotRequired[str] 396 | tags: NotRequired[list[str]] 397 | 398 | 399 | class NewVersionDict(TypedDict): 400 | id: str 401 | version: int 402 | productId: str 403 | attrib: dict[str, Any] 404 | data: dict[str, Any] 405 | taskId: NotRequired[str] 406 | thumbnailId: NotRequired[str] 407 | author: NotRequired[str] 408 | status: NotRequired[str] 409 | tags: NotRequired[list[str]] 410 | 411 | 412 | class NewRepresentationDict(TypedDict): 413 | id: str 414 | versionId: str 415 | name: str 416 | data: dict[str, Any] 417 | attrib: dict[str, Any] 418 | files: list[dict[str, str]] 419 | traits: NotRequired[dict[str, Any]] 420 | status: NotRequired[str] 421 | tags: NotRequired[list[str]] 422 | 423 | 424 | class NewWorkfileDict(TypedDict): 425 | id: str 426 | taskId: str 427 | path: str 428 | data: dict[str, Any] 429 | attrib: dict[str, Any] 430 | status: NotRequired[str] 431 | tags: NotRequired[list[str]] 432 | 433 | 434 | EventStatus = Literal[ 435 | "pending", 436 | "in_progress", 437 | "finished", 438 | "failed", 439 | "aborted", 440 | "restarted", 441 | ] 442 | 443 | 444 | class EnrollEventData(TypedDict): 445 | id: str 446 | dependsOn: str 447 | hash: str 448 | status: EventStatus 449 | 450 | 451 | class FlatFolderDict(TypedDict): 452 | id: str 453 | parentId: Optional[str] 454 | path: str 455 | parents: list[str] 456 | name: str 457 | label: Optional[str] 458 | folderType: str 459 | hasTasks: bool 460 | hasChildren: bool 461 | taskNames: list[str] 462 | status: str 463 | attrib: dict[str, Any] 464 | ownAttrib: list[str] 465 | updatedAt: str 466 | 467 | 468 | class ProjectHierarchyItemDict(TypedDict): 469 | id: str 470 | name: str 471 | label: str 472 | status: str 473 | folderType: str 474 | hasTasks: bool 475 | taskNames: list[str] 476 | parents: list[str] 477 | parentId: Optional[str] 478 | children: list["ProjectHierarchyItemDict"] 479 | 480 | 481 | class ProjectHierarchyDict(TypedDict): 482 | hierarchy: list[ProjectHierarchyItemDict] 483 | 484 | 485 | class ProductTypeDict(TypedDict): 486 | name: str 487 | color: Optional[str] 488 | icon: Optional[str] 489 | 490 | 491 | ActionEntityTypes = Literal[ 492 | "project", 493 | "folder", 494 | "task", 495 | "product", 496 | "version", 497 | "representation", 498 | "workfile", 499 | "list", 500 | ] 501 | 502 | 503 | class ActionManifestDict(TypedDict): 504 | identifier: str 505 | label: str 506 | groupLabel: Optional[str] 507 | category: str 508 | order: int 509 | icon: Optional[IconDefType] 510 | adminOnly: bool 511 | managerOnly: bool 512 | configFields: list[dict[str, Any]] 513 | featured: bool 514 | addonName: str 515 | addonVersion: str 516 | variant: str 517 | 518 | 519 | ActionResponseType = Literal[ 520 | "form", 521 | "launcher", 522 | "navigate", 523 | "query", 524 | "redirect", 525 | "simple", 526 | ] 527 | 528 | ActionModeType = Literal["simple", "dynamic", "all"] 529 | 530 | 531 | class BaseActionPayload(TypedDict): 532 | extra_clipboard: str 533 | extra_download: str 534 | 535 | 536 | class ActionLauncherPayload(BaseActionPayload): 537 | uri: str 538 | 539 | 540 | class ActionNavigatePayload(BaseActionPayload): 541 | uri: str 542 | 543 | 544 | class ActionRedirectPayload(BaseActionPayload): 545 | uri: str 546 | new_tab: bool 547 | 548 | 549 | class ActionQueryPayload(BaseActionPayload): 550 | query: str 551 | 552 | 553 | class ActionFormPayload(BaseActionPayload): 554 | title: str 555 | fields: list[dict[str, Any]] 556 | submit_label: str 557 | submit_icon: str 558 | cancel_label: str 559 | cancel_icon: str 560 | show_cancel_button: bool 561 | show_submit_button: bool 562 | 563 | 564 | ActionPayload = Union[ 565 | ActionLauncherPayload, 566 | ActionNavigatePayload, 567 | ActionRedirectPayload, 568 | ActionQueryPayload, 569 | ActionFormPayload, 570 | ] 571 | 572 | class ActionTriggerResponse(TypedDict): 573 | type: ActionResponseType 574 | success: bool 575 | message: Optional[str] 576 | payload: Optional[ActionPayload] 577 | 578 | 579 | class ActionTakeResponse(TypedDict): 580 | eventId: str 581 | actionIdentifier: str 582 | args: list[str] 583 | context: dict[str, Any] 584 | addonName: str 585 | addonVersion: str 586 | variant: str 587 | userName: str 588 | 589 | 590 | class ActionConfigResponse(TypedDict): 591 | projectName: str 592 | entityType: str 593 | entitySubtypes: list[str] 594 | entityIds: list[str] 595 | formData: dict[str, Any] 596 | value: dict[str, Any] 597 | 598 | 599 | StreamType = Union[io.BytesIO, BinaryIO] 600 | 601 | 602 | class EntityListAttributeDefinitionDict(TypedDict): 603 | name: str 604 | data: dict[str, Any] 605 | -------------------------------------------------------------------------------- /ayon_api/_api_helpers/lists.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import typing 5 | from typing import Optional, Iterable, Any, Generator 6 | 7 | from ayon_api.utils import create_entity_id 8 | from ayon_api.graphql_queries import entity_lists_graphql_query 9 | 10 | from .base import BaseServerAPI 11 | 12 | if typing.TYPE_CHECKING: 13 | from ayon_api.typing import ( 14 | EntityListEntityType, 15 | EntityListAttributeDefinitionDict, 16 | EntityListItemMode, 17 | ) 18 | 19 | 20 | class ListsAPI(BaseServerAPI): 21 | def get_entity_lists( 22 | self, 23 | project_name: str, 24 | *, 25 | list_ids: Optional[Iterable[str]] = None, 26 | active: Optional[bool] = None, 27 | fields: Optional[Iterable[str]] = None, 28 | ) -> Generator[dict[str, Any], None, None]: 29 | """Fetch entity lists from server. 30 | 31 | Args: 32 | project_name (str): Project name where entity lists are. 33 | list_ids (Optional[Iterable[str]]): List of entity list ids to 34 | fetch. 35 | active (Optional[bool]): Filter by active state of entity lists. 36 | fields (Optional[Iterable[str]]): Fields to fetch from server. 37 | 38 | Returns: 39 | Generator[dict[str, Any], None, None]: Entity list entities 40 | matching defined filters. 41 | 42 | """ 43 | if fields is None: 44 | fields = self.get_default_fields_for_type("entityList") 45 | fields = set(fields) 46 | 47 | if active is not None: 48 | fields.add("active") 49 | 50 | filters: dict[str, Any] = {"projectName": project_name} 51 | if list_ids is not None: 52 | if not list_ids: 53 | return 54 | filters["listIds"] = list(set(list_ids)) 55 | 56 | query = entity_lists_graphql_query(fields) 57 | for attr, filter_value in filters.items(): 58 | query.set_variable_value(attr, filter_value) 59 | 60 | for parsed_data in query.continuous_query(self): 61 | for entity_list in parsed_data["project"]["entityLists"]: 62 | if active is not None and entity_list["active"] != active: 63 | continue 64 | 65 | attributes = entity_list.get("attributes") 66 | if isinstance(attributes, str): 67 | entity_list["attributes"] = json.loads(attributes) 68 | 69 | self._convert_entity_data(entity_list) 70 | 71 | yield entity_list 72 | 73 | def get_entity_list_rest( 74 | self, project_name: str, list_id: str 75 | ) -> Optional[dict[str, Any]]: 76 | """Get entity list by id using REST API. 77 | 78 | Args: 79 | project_name (str): Project name. 80 | list_id (str): Entity list id. 81 | 82 | Returns: 83 | Optional[dict[str, Any]]: Entity list data or None if not found. 84 | 85 | """ 86 | response = self.get(f"projects/{project_name}/lists/{list_id}") 87 | response.raise_for_status() 88 | return response.data 89 | 90 | def get_entity_list_by_id( 91 | self, 92 | project_name: str, 93 | list_id: str, 94 | fields: Optional[Iterable[str]] = None, 95 | ) -> Optional[dict[str, Any]]: 96 | """Get entity list by id using GraphQl. 97 | 98 | Args: 99 | project_name (str): Project name. 100 | list_id (str): Entity list id. 101 | fields (Optional[Iterable[str]]): Fields to fetch from server. 102 | 103 | Returns: 104 | Optional[dict[str, Any]]: Entity list data or None if not found. 105 | 106 | """ 107 | for entity_list in self.get_entity_lists( 108 | project_name, list_ids=[list_id], active=None, fields=fields 109 | ): 110 | return entity_list 111 | return None 112 | 113 | def create_entity_list( 114 | self, 115 | project_name: str, 116 | entity_type: EntityListEntityType, 117 | label: str, 118 | *, 119 | list_type: Optional[str] = None, 120 | access: Optional[dict[str, Any]] = None, 121 | attrib: Optional[list[dict[str, Any]]] = None, 122 | data: Optional[list[dict[str, Any]]] = None, 123 | tags: Optional[list[str]] = None, 124 | template: Optional[dict[str, Any]] = None, 125 | owner: Optional[str] = None, 126 | active: Optional[bool] = None, 127 | items: Optional[list[dict[str, Any]]] = None, 128 | list_id: Optional[str] = None, 129 | ) -> str: 130 | """Create entity list. 131 | 132 | Args: 133 | project_name (str): Project name where entity list lives. 134 | entity_type (EntityListEntityType): Which entity types can be 135 | used in list. 136 | label (str): Entity list label. 137 | list_type (Optional[str]): Entity list type. 138 | access (Optional[dict[str, Any]]): Access control for entity list. 139 | attrib (Optional[dict[str, Any]]): Attribute values of 140 | entity list. 141 | data (Optional[dict[str, Any]]): Custom data of entity list. 142 | tags (Optional[list[str]]): Entity list tags. 143 | template (Optional[dict[str, Any]]): Dynamic list template. 144 | owner (Optional[str]): New owner of the list. 145 | active (Optional[bool]): Change active state of entity list. 146 | items (Optional[list[dict[str, Any]]]): Initial items in 147 | entity list. 148 | list_id (Optional[str]): Entity list id. 149 | 150 | """ 151 | if list_id is None: 152 | list_id = create_entity_id() 153 | kwargs = { 154 | "id": list_id, 155 | "entityType": entity_type, 156 | "label": label, 157 | } 158 | for key, value in ( 159 | ("entityListType", list_type), 160 | ("access", access), 161 | ("attrib", attrib), 162 | ("template", template), 163 | ("tags", tags), 164 | ("owner", owner), 165 | ("data", data), 166 | ("active", active), 167 | ("items", items), 168 | ): 169 | if value is not None: 170 | kwargs[key] = value 171 | 172 | response = self.post( 173 | f"projects/{project_name}/lists/{list_id}/items", 174 | **kwargs 175 | 176 | ) 177 | response.raise_for_status() 178 | return list_id 179 | 180 | def update_entity_list( 181 | self, 182 | project_name: str, 183 | list_id: str, 184 | *, 185 | label: Optional[str] = None, 186 | access: Optional[dict[str, Any]] = None, 187 | attrib: Optional[list[dict[str, Any]]] = None, 188 | data: Optional[list[dict[str, Any]]] = None, 189 | tags: Optional[list[str]] = None, 190 | owner: Optional[str] = None, 191 | active: Optional[bool] = None, 192 | ) -> None: 193 | """Update entity list. 194 | 195 | Args: 196 | project_name (str): Project name where entity list lives. 197 | list_id (str): Entity list id that will be updated. 198 | label (Optional[str]): New label of entity list. 199 | access (Optional[dict[str, Any]]): Access control for entity list. 200 | attrib (Optional[dict[str, Any]]): Attribute values of 201 | entity list. 202 | data (Optional[dict[str, Any]]): Custom data of entity list. 203 | tags (Optional[list[str]]): Entity list tags. 204 | owner (Optional[str]): New owner of the list. 205 | active (Optional[bool]): Change active state of entity list. 206 | 207 | """ 208 | kwargs = { 209 | key: value 210 | for key, value in ( 211 | ("label", label), 212 | ("access", access), 213 | ("attrib", attrib), 214 | ("data", data), 215 | ("tags", tags), 216 | ("owner", owner), 217 | ("active", active), 218 | ) 219 | if value is not None 220 | } 221 | response = self.patch( 222 | f"projects/{project_name}/lists/{list_id}", 223 | **kwargs 224 | ) 225 | response.raise_for_status() 226 | 227 | def delete_entity_list(self, project_name: str, list_id: str) -> None: 228 | """Delete entity list from project. 229 | 230 | Args: 231 | project_name (str): Project name. 232 | list_id (str): Entity list id that will be removed. 233 | 234 | """ 235 | response = self.delete(f"projects/{project_name}/lists/{list_id}") 236 | response.raise_for_status() 237 | 238 | def get_entity_list_attribute_definitions( 239 | self, project_name: str, list_id: str 240 | ) -> list[EntityListAttributeDefinitionDict]: 241 | """Get attribute definitioins on entity list. 242 | 243 | Args: 244 | project_name (str): Project name. 245 | list_id (str): Entity list id. 246 | 247 | Returns: 248 | list[EntityListAttributeDefinitionDict]: List of attribute 249 | definitions. 250 | 251 | """ 252 | response = self.get( 253 | f"projects/{project_name}/lists/{list_id}/attributes" 254 | ) 255 | response.raise_for_status() 256 | return response.data 257 | 258 | def set_entity_list_attribute_definitions( 259 | self, 260 | project_name: str, 261 | list_id: str, 262 | attribute_definitions: list[EntityListAttributeDefinitionDict], 263 | ) -> None: 264 | """Set attribute definitioins on entity list. 265 | 266 | Args: 267 | project_name (str): Project name. 268 | list_id (str): Entity list id. 269 | attribute_definitions (list[EntityListAttributeDefinitionDict]): 270 | List of attribute definitions. 271 | 272 | """ 273 | response = self.raw_put( 274 | f"projects/{project_name}/lists/{list_id}/attributes", 275 | json=attribute_definitions, 276 | ) 277 | response.raise_for_status() 278 | 279 | def create_entity_list_item( 280 | self, 281 | project_name: str, 282 | list_id: str, 283 | *, 284 | position: Optional[int] = None, 285 | label: Optional[str] = None, 286 | attrib: Optional[dict[str, Any]] = None, 287 | data: Optional[dict[str, Any]] = None, 288 | tags: Optional[list[str]] = None, 289 | item_id: Optional[str] = None, 290 | ) -> str: 291 | """Create entity list item. 292 | 293 | Args: 294 | project_name (str): Project name where entity list lives. 295 | list_id (str): Entity list id where item will be added. 296 | position (Optional[int]): Position of item in entity list. 297 | label (Optional[str]): Label of item in entity list. 298 | attrib (Optional[dict[str, Any]]): Item attribute values. 299 | data (Optional[dict[str, Any]]): Item data. 300 | tags (Optional[list[str]]): Tags of item in entity list. 301 | item_id (Optional[str]): Id of item that will be created. 302 | 303 | Returns: 304 | str: Item id. 305 | 306 | """ 307 | if item_id is None: 308 | item_id = create_entity_id() 309 | kwargs = { 310 | "id": item_id, 311 | "entityId": list_id, 312 | } 313 | for key, value in ( 314 | ("position", position), 315 | ("label", label), 316 | ("attrib", attrib), 317 | ("data", data), 318 | ("tags", tags), 319 | ): 320 | if value is not None: 321 | kwargs[key] = value 322 | 323 | response = self.post( 324 | f"projects/{project_name}/lists/{list_id}/items", 325 | **kwargs 326 | ) 327 | response.raise_for_status() 328 | return item_id 329 | 330 | def update_entity_list_items( 331 | self, 332 | project_name: str, 333 | list_id: str, 334 | items: list[dict[str, Any]], 335 | mode: EntityListItemMode, 336 | ) -> None: 337 | """Update items in entity list. 338 | 339 | Args: 340 | project_name (str): Project name where entity list live. 341 | list_id (str): Entity list id. 342 | items (list[dict[str, Any]]): Entity list items. 343 | mode (EntityListItemMode): Mode of items update. 344 | 345 | """ 346 | response = self.post( 347 | f"projects/{project_name}/lists/{list_id}/items", 348 | items=items, 349 | mode=mode, 350 | ) 351 | response.raise_for_status() 352 | 353 | def update_entity_list_item( 354 | self, 355 | project_name: str, 356 | list_id: str, 357 | item_id: str, 358 | *, 359 | new_list_id: Optional[str], 360 | position: Optional[int] = None, 361 | label: Optional[str] = None, 362 | attrib: Optional[dict[str, Any]] = None, 363 | data: Optional[dict[str, Any]] = None, 364 | tags: Optional[list[str]] = None, 365 | ) -> None: 366 | """Update item in entity list. 367 | 368 | Args: 369 | project_name (str): Project name where entity list live. 370 | list_id (str): Entity list id where item lives. 371 | item_id (str): Item id that will be removed from entity list. 372 | new_list_id (Optional[str]): New entity list id where item will be 373 | added. 374 | position (Optional[int]): Position of item in entity list. 375 | label (Optional[str]): Label of item in entity list. 376 | attrib (Optional[dict[str, Any]]): Attributes of item in entity 377 | list. 378 | data (Optional[dict[str, Any]]): Custom data of item in 379 | entity list. 380 | tags (Optional[list[str]]): Tags of item in entity list. 381 | 382 | """ 383 | kwargs = {} 384 | for key, value in ( 385 | ("entityId", new_list_id), 386 | ("position", position), 387 | ("label", label), 388 | ("attrib", attrib), 389 | ("data", data), 390 | ("tags", tags), 391 | ): 392 | if value is not None: 393 | kwargs[key] = value 394 | response = self.patch( 395 | f"projects/{project_name}/lists/{list_id}/items/{item_id}", 396 | **kwargs, 397 | ) 398 | response.raise_for_status() 399 | 400 | def delete_entity_list_item( 401 | self, 402 | project_name: str, 403 | list_id: str, 404 | item_id: str, 405 | ) -> None: 406 | """Delete item from entity list. 407 | 408 | Args: 409 | project_name (str): Project name where entity list live. 410 | list_id (str): Entity list id from which item will be removed. 411 | item_id (str): Item id that will be removed from entity list. 412 | 413 | """ 414 | response = self.delete( 415 | f"projects/{project_name}/lists/{list_id}/items/{item_id}", 416 | ) 417 | response.raise_for_status() 418 | -------------------------------------------------------------------------------- /ayon_api/__init__.py: -------------------------------------------------------------------------------- 1 | from .version import __version__ 2 | from .utils import ( 3 | RequestTypes, 4 | TransferProgress, 5 | slugify_string, 6 | create_dependency_package_basename, 7 | get_user_by_token, 8 | is_token_valid, 9 | validate_url, 10 | login_to_server, 11 | take_web_action_event, 12 | abort_web_action_event, 13 | SortOrder, 14 | ) 15 | from .server_api import ( 16 | ServerAPI, 17 | ) 18 | 19 | from ._api import ( 20 | GlobalServerAPI, 21 | ServiceContext, 22 | init_service, 23 | get_service_addon_name, 24 | get_service_addon_version, 25 | get_service_name, 26 | get_service_addon_settings, 27 | is_connection_created, 28 | create_connection, 29 | close_connection, 30 | change_token, 31 | set_environments, 32 | get_server_api_connection, 33 | get_default_settings_variant, 34 | get_base_url, 35 | get_rest_url, 36 | get_ssl_verify, 37 | set_ssl_verify, 38 | get_cert, 39 | set_cert, 40 | get_timeout, 41 | set_timeout, 42 | get_max_retries, 43 | set_max_retries, 44 | is_service_user, 45 | get_site_id, 46 | set_site_id, 47 | get_client_version, 48 | set_client_version, 49 | set_default_settings_variant, 50 | get_sender, 51 | set_sender, 52 | get_sender_type, 53 | set_sender_type, 54 | get_info, 55 | get_server_version, 56 | get_server_version_tuple, 57 | is_product_base_type_supported, 58 | get_users, 59 | get_user_by_name, 60 | get_user, 61 | raw_post, 62 | raw_put, 63 | raw_patch, 64 | raw_get, 65 | raw_delete, 66 | post, 67 | put, 68 | patch, 69 | get, 70 | delete, 71 | download_file_to_stream, 72 | download_file, 73 | upload_file_from_stream, 74 | upload_file, 75 | upload_reviewable, 76 | trigger_server_restart, 77 | query_graphql, 78 | get_graphql_schema, 79 | get_server_schema, 80 | get_schemas, 81 | get_default_fields_for_type, 82 | get_rest_entity_by_id, 83 | send_batch_operations, 84 | send_background_batch_operations, 85 | get_background_operations_status, 86 | get_installers, 87 | create_installer, 88 | update_installer, 89 | delete_installer, 90 | download_installer, 91 | upload_installer, 92 | get_dependency_packages, 93 | create_dependency_package, 94 | update_dependency_package, 95 | delete_dependency_package, 96 | download_dependency_package, 97 | upload_dependency_package, 98 | get_secrets, 99 | get_secret, 100 | save_secret, 101 | delete_secret, 102 | get_actions, 103 | trigger_action, 104 | get_action_config, 105 | set_action_config, 106 | take_action, 107 | abort_action, 108 | get_activities, 109 | get_activity_by_id, 110 | create_activity, 111 | update_activity, 112 | delete_activity, 113 | send_activities_batch_operations, 114 | get_bundles, 115 | create_bundle, 116 | update_bundle, 117 | check_bundle_compatibility, 118 | delete_bundle, 119 | get_addon_endpoint, 120 | get_addons_info, 121 | get_addon_url, 122 | delete_addon, 123 | delete_addon_version, 124 | upload_addon_zip, 125 | download_addon_private_file, 126 | get_addon_settings_schema, 127 | get_addon_site_settings_schema, 128 | get_addon_studio_settings, 129 | get_addon_project_settings, 130 | get_addon_settings, 131 | get_addon_site_settings, 132 | get_bundle_settings, 133 | get_addons_studio_settings, 134 | get_addons_project_settings, 135 | get_addons_settings, 136 | get_event, 137 | get_events, 138 | update_event, 139 | dispatch_event, 140 | create_event, 141 | delete_event, 142 | enroll_event_job, 143 | get_attributes_schema, 144 | reset_attributes_schema, 145 | set_attribute_config, 146 | remove_attribute_config, 147 | get_attributes_for_type, 148 | get_attributes_fields_for_type, 149 | get_project_anatomy_presets, 150 | get_default_anatomy_preset_name, 151 | get_project_anatomy_preset, 152 | get_built_in_anatomy_preset, 153 | get_build_in_anatomy_preset, 154 | get_rest_project, 155 | get_rest_projects, 156 | get_rest_projects_list, 157 | get_project_names, 158 | get_projects, 159 | get_project, 160 | create_project, 161 | update_project, 162 | delete_project, 163 | get_project_root_overrides, 164 | get_project_roots_by_site, 165 | get_project_root_overrides_by_site_id, 166 | get_project_roots_for_site, 167 | get_project_roots_by_site_id, 168 | get_project_roots_by_platform, 169 | get_rest_folder, 170 | get_rest_folders, 171 | get_folders_hierarchy, 172 | get_folders_rest, 173 | get_folders, 174 | get_folder_by_id, 175 | get_folder_by_path, 176 | get_folder_by_name, 177 | get_folder_ids_with_products, 178 | create_folder, 179 | update_folder, 180 | delete_folder, 181 | get_rest_task, 182 | get_tasks, 183 | get_task_by_name, 184 | get_task_by_id, 185 | get_tasks_by_folder_paths, 186 | get_tasks_by_folder_path, 187 | get_task_by_folder_path, 188 | create_task, 189 | update_task, 190 | delete_task, 191 | get_rest_product, 192 | get_products, 193 | get_product_by_id, 194 | get_product_by_name, 195 | get_product_types, 196 | get_project_product_types, 197 | get_product_type_names, 198 | create_product, 199 | update_product, 200 | delete_product, 201 | get_rest_version, 202 | get_versions, 203 | get_version_by_id, 204 | get_version_by_name, 205 | get_hero_version_by_id, 206 | get_hero_version_by_product_id, 207 | get_hero_versions, 208 | get_last_versions, 209 | get_last_version_by_product_id, 210 | get_last_version_by_product_name, 211 | version_is_latest, 212 | create_version, 213 | update_version, 214 | delete_version, 215 | get_rest_representation, 216 | get_representations, 217 | get_representation_by_id, 218 | get_representation_by_name, 219 | get_representations_hierarchy, 220 | get_representation_hierarchy, 221 | get_representations_parents, 222 | get_representation_parents, 223 | get_repre_ids_by_context_filters, 224 | create_representation, 225 | update_representation, 226 | delete_representation, 227 | get_workfile_entities, 228 | get_workfile_entity, 229 | get_workfile_entity_by_id, 230 | create_workfile_entity, 231 | update_workfile_entity, 232 | delete_workfile_entity, 233 | get_workfiles_info, 234 | get_workfile_info, 235 | get_workfile_info_by_id, 236 | update_workfile_info, 237 | delete_workfile_info, 238 | get_full_link_type_name, 239 | get_link_types, 240 | get_link_type, 241 | create_link_type, 242 | delete_link_type, 243 | make_sure_link_type_exists, 244 | create_link, 245 | delete_link, 246 | get_entities_links, 247 | get_folders_links, 248 | get_folder_links, 249 | get_tasks_links, 250 | get_task_links, 251 | get_products_links, 252 | get_product_links, 253 | get_versions_links, 254 | get_version_links, 255 | get_representations_links, 256 | get_representation_links, 257 | get_entity_lists, 258 | get_entity_list_rest, 259 | get_entity_list_by_id, 260 | create_entity_list, 261 | update_entity_list, 262 | delete_entity_list, 263 | get_entity_list_attribute_definitions, 264 | set_entity_list_attribute_definitions, 265 | create_entity_list_item, 266 | update_entity_list_items, 267 | update_entity_list_item, 268 | delete_entity_list_item, 269 | get_thumbnail_by_id, 270 | get_thumbnail, 271 | get_folder_thumbnail, 272 | get_task_thumbnail, 273 | get_version_thumbnail, 274 | get_workfile_thumbnail, 275 | create_thumbnail, 276 | create_thumbnail_with_stream, 277 | update_thumbnail, 278 | update_thumbnail_from_stream, 279 | ) 280 | 281 | 282 | __all__ = ( 283 | "__version__", 284 | 285 | "RequestTypes", 286 | "TransferProgress", 287 | "slugify_string", 288 | "create_dependency_package_basename", 289 | "get_user_by_token", 290 | "is_token_valid", 291 | "validate_url", 292 | "login_to_server", 293 | "take_web_action_event", 294 | "abort_web_action_event", 295 | "SortOrder", 296 | 297 | "ServerAPI", 298 | 299 | "GlobalServerAPI", 300 | "ServiceContext", 301 | "init_service", 302 | "get_service_addon_name", 303 | "get_service_addon_version", 304 | "get_service_name", 305 | "get_service_addon_settings", 306 | "is_connection_created", 307 | "create_connection", 308 | "close_connection", 309 | "change_token", 310 | "set_environments", 311 | "get_server_api_connection", 312 | "get_default_settings_variant", 313 | "get_base_url", 314 | "get_rest_url", 315 | "get_ssl_verify", 316 | "set_ssl_verify", 317 | "get_cert", 318 | "set_cert", 319 | "get_timeout", 320 | "set_timeout", 321 | "get_max_retries", 322 | "set_max_retries", 323 | "is_service_user", 324 | "get_site_id", 325 | "set_site_id", 326 | "get_client_version", 327 | "set_client_version", 328 | "set_default_settings_variant", 329 | "get_sender", 330 | "set_sender", 331 | "get_sender_type", 332 | "set_sender_type", 333 | "get_info", 334 | "get_server_version", 335 | "get_server_version_tuple", 336 | "is_product_base_type_supported", 337 | "get_users", 338 | "get_user_by_name", 339 | "get_user", 340 | "raw_post", 341 | "raw_put", 342 | "raw_patch", 343 | "raw_get", 344 | "raw_delete", 345 | "post", 346 | "put", 347 | "patch", 348 | "get", 349 | "delete", 350 | "download_file_to_stream", 351 | "download_file", 352 | "upload_file_from_stream", 353 | "upload_file", 354 | "upload_reviewable", 355 | "trigger_server_restart", 356 | "query_graphql", 357 | "get_graphql_schema", 358 | "get_server_schema", 359 | "get_schemas", 360 | "get_default_fields_for_type", 361 | "get_rest_entity_by_id", 362 | "send_batch_operations", 363 | "send_background_batch_operations", 364 | "get_background_operations_status", 365 | "get_installers", 366 | "create_installer", 367 | "update_installer", 368 | "delete_installer", 369 | "download_installer", 370 | "upload_installer", 371 | "get_dependency_packages", 372 | "create_dependency_package", 373 | "update_dependency_package", 374 | "delete_dependency_package", 375 | "download_dependency_package", 376 | "upload_dependency_package", 377 | "get_secrets", 378 | "get_secret", 379 | "save_secret", 380 | "delete_secret", 381 | "get_actions", 382 | "trigger_action", 383 | "get_action_config", 384 | "set_action_config", 385 | "take_action", 386 | "abort_action", 387 | "get_activities", 388 | "get_activity_by_id", 389 | "create_activity", 390 | "update_activity", 391 | "delete_activity", 392 | "send_activities_batch_operations", 393 | "get_bundles", 394 | "create_bundle", 395 | "update_bundle", 396 | "check_bundle_compatibility", 397 | "delete_bundle", 398 | "get_addon_endpoint", 399 | "get_addons_info", 400 | "get_addon_url", 401 | "delete_addon", 402 | "delete_addon_version", 403 | "upload_addon_zip", 404 | "download_addon_private_file", 405 | "get_addon_settings_schema", 406 | "get_addon_site_settings_schema", 407 | "get_addon_studio_settings", 408 | "get_addon_project_settings", 409 | "get_addon_settings", 410 | "get_addon_site_settings", 411 | "get_bundle_settings", 412 | "get_addons_studio_settings", 413 | "get_addons_project_settings", 414 | "get_addons_settings", 415 | "get_event", 416 | "get_events", 417 | "update_event", 418 | "dispatch_event", 419 | "create_event", 420 | "delete_event", 421 | "enroll_event_job", 422 | "get_attributes_schema", 423 | "reset_attributes_schema", 424 | "set_attribute_config", 425 | "remove_attribute_config", 426 | "get_attributes_for_type", 427 | "get_attributes_fields_for_type", 428 | "get_project_anatomy_presets", 429 | "get_default_anatomy_preset_name", 430 | "get_project_anatomy_preset", 431 | "get_built_in_anatomy_preset", 432 | "get_build_in_anatomy_preset", 433 | "get_rest_project", 434 | "get_rest_projects", 435 | "get_rest_projects_list", 436 | "get_project_names", 437 | "get_projects", 438 | "get_project", 439 | "create_project", 440 | "update_project", 441 | "delete_project", 442 | "get_project_root_overrides", 443 | "get_project_roots_by_site", 444 | "get_project_root_overrides_by_site_id", 445 | "get_project_roots_for_site", 446 | "get_project_roots_by_site_id", 447 | "get_project_roots_by_platform", 448 | "get_rest_folder", 449 | "get_rest_folders", 450 | "get_folders_hierarchy", 451 | "get_folders_rest", 452 | "get_folders", 453 | "get_folder_by_id", 454 | "get_folder_by_path", 455 | "get_folder_by_name", 456 | "get_folder_ids_with_products", 457 | "create_folder", 458 | "update_folder", 459 | "delete_folder", 460 | "get_rest_task", 461 | "get_tasks", 462 | "get_task_by_name", 463 | "get_task_by_id", 464 | "get_tasks_by_folder_paths", 465 | "get_tasks_by_folder_path", 466 | "get_task_by_folder_path", 467 | "create_task", 468 | "update_task", 469 | "delete_task", 470 | "get_rest_product", 471 | "get_products", 472 | "get_product_by_id", 473 | "get_product_by_name", 474 | "get_product_types", 475 | "get_project_product_types", 476 | "get_product_type_names", 477 | "create_product", 478 | "update_product", 479 | "delete_product", 480 | "get_rest_version", 481 | "get_versions", 482 | "get_version_by_id", 483 | "get_version_by_name", 484 | "get_hero_version_by_id", 485 | "get_hero_version_by_product_id", 486 | "get_hero_versions", 487 | "get_last_versions", 488 | "get_last_version_by_product_id", 489 | "get_last_version_by_product_name", 490 | "version_is_latest", 491 | "create_version", 492 | "update_version", 493 | "delete_version", 494 | "get_rest_representation", 495 | "get_representations", 496 | "get_representation_by_id", 497 | "get_representation_by_name", 498 | "get_representations_hierarchy", 499 | "get_representation_hierarchy", 500 | "get_representations_parents", 501 | "get_representation_parents", 502 | "get_repre_ids_by_context_filters", 503 | "create_representation", 504 | "update_representation", 505 | "delete_representation", 506 | "get_workfile_entities", 507 | "get_workfile_entity", 508 | "get_workfile_entity_by_id", 509 | "create_workfile_entity", 510 | "update_workfile_entity", 511 | "delete_workfile_entity", 512 | "get_workfiles_info", 513 | "get_workfile_info", 514 | "get_workfile_info_by_id", 515 | "update_workfile_info", 516 | "delete_workfile_info", 517 | "get_full_link_type_name", 518 | "get_link_types", 519 | "get_link_type", 520 | "create_link_type", 521 | "delete_link_type", 522 | "make_sure_link_type_exists", 523 | "create_link", 524 | "delete_link", 525 | "get_entities_links", 526 | "get_folders_links", 527 | "get_folder_links", 528 | "get_tasks_links", 529 | "get_task_links", 530 | "get_products_links", 531 | "get_product_links", 532 | "get_versions_links", 533 | "get_version_links", 534 | "get_representations_links", 535 | "get_representation_links", 536 | "get_entity_lists", 537 | "get_entity_list_rest", 538 | "get_entity_list_by_id", 539 | "create_entity_list", 540 | "update_entity_list", 541 | "delete_entity_list", 542 | "get_entity_list_attribute_definitions", 543 | "set_entity_list_attribute_definitions", 544 | "create_entity_list_item", 545 | "update_entity_list_items", 546 | "update_entity_list_item", 547 | "delete_entity_list_item", 548 | "get_thumbnail_by_id", 549 | "get_thumbnail", 550 | "get_folder_thumbnail", 551 | "get_task_thumbnail", 552 | "get_version_thumbnail", 553 | "get_workfile_thumbnail", 554 | "create_thumbnail", 555 | "create_thumbnail_with_stream", 556 | "update_thumbnail", 557 | "update_thumbnail_from_stream", 558 | ) 559 | -------------------------------------------------------------------------------- /automated_api.py: -------------------------------------------------------------------------------- 1 | """Create public API functions based on ServerAPI methods. 2 | 3 | Public functions are created in '_api.py' file and imported in '__init_.py'. 4 | The script reads the 'ServerAPI' class and creates functions with the same 5 | signature and docstring in '_api.py' and '__init__.py' with new/removed 6 | functions. 7 | 8 | The script is executed by running 'python automated_api.py' in the terminal. 9 | 10 | TODOs: 11 | Use same signature in api functions as is used in 'ServerAPI' methods. 12 | Right now is used only '(*args, **kwargs)' signature. 13 | Prepare CI or pre-commit hook to run the script automatically. 14 | """ 15 | 16 | import os 17 | import sys 18 | import re 19 | import inspect 20 | import typing 21 | 22 | # Fake modules to avoid import errors 23 | requests = type(sys)("requests") 24 | requests.__dict__["Response"] = type( 25 | "Response", (), {"__module__": "requests"} 26 | ) 27 | 28 | sys.modules["requests"] = requests 29 | sys.modules["unidecode"] = type(sys)("unidecode") 30 | 31 | CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) 32 | 33 | EXCLUDED_METHODS = { 34 | "get_default_service_username", 35 | "get_default_settings_variant", 36 | "validate_token", 37 | "set_token", 38 | "reset_token", 39 | "create_session", 40 | "close_session", 41 | "as_username", 42 | "validate_server_availability", 43 | "get_headers", 44 | "login", 45 | "logout", 46 | "set_default_service_username", 47 | } 48 | EXCLUDED_IMPORT_NAMES = {"GlobalContext"} 49 | AUTOMATED_COMMENT = """ 50 | # ------------------------------------------------ 51 | # This content is generated automatically. 52 | # ------------------------------------------------ 53 | """.strip() 54 | 55 | 56 | # Read init file and remove ._api imports 57 | def prepare_init_without_api(init_filepath): 58 | with open(init_filepath, "r") as stream: 59 | content = stream.read() 60 | 61 | api_regex = re.compile(r"from \._api import \((?P[^\)]*)\)") 62 | api_imports = api_regex.search(content) 63 | start, end = api_imports.span() 64 | api_imports_text = content[start:end] 65 | functions_text = api_imports.group("functions") 66 | function_names = [ 67 | line.strip().rstrip(",") 68 | for line in functions_text.split("\n") 69 | if line.strip() 70 | ] 71 | function_names_q = { 72 | f'"{name}"' for name in function_names 73 | } 74 | 75 | all_regex = re.compile(r"__all__ = \([^\)]*\)") 76 | all_content = all_regex.search(content) 77 | start, end = all_content.span() 78 | all_content_text = content[start:end] 79 | filtered_lines = [] 80 | for line in content[start:end].split("\n"): 81 | found = False 82 | for name in function_names_q: 83 | if name in line: 84 | found = True 85 | break 86 | if not found: 87 | filtered_lines.append(line) 88 | new_all_content_text = ( 89 | "\n".join(filtered_lines).rstrip(") \n") + "\n\n{all_content}\n)" 90 | ) 91 | 92 | return ( 93 | content 94 | .replace(api_imports_text, "{api_imports}") 95 | .replace(all_content_text, new_all_content_text) 96 | ).rstrip("\n") 97 | 98 | 99 | # Creation of _api.py content 100 | def indent_lines(src_str, indent=1): 101 | new_lines = [] 102 | for line in src_str.split("\n"): 103 | if line: 104 | line = f"{' ' * indent}{line}" 105 | new_lines.append(line) 106 | return "\n".join(new_lines) 107 | 108 | 109 | def prepare_docstring(func): 110 | docstring = inspect.getdoc(func) 111 | if not docstring: 112 | return "" 113 | 114 | line_char = "" 115 | if "\n" in docstring: 116 | line_char = "\n" 117 | return f'"""{docstring}{line_char}\n"""' 118 | 119 | 120 | def _find_obj(obj_full, api_globals): 121 | parts = list(reversed(obj_full.split("."))) 122 | _name = None 123 | for part in parts: 124 | if _name is None: 125 | _name = part 126 | else: 127 | _name = f"{part}.{_name}" 128 | try: 129 | # Test if typehint is valid for known '_api' content 130 | exec(f"_: {_name} = None", api_globals) 131 | return _name 132 | except NameError: 133 | pass 134 | return None 135 | 136 | 137 | def _get_typehint(annotation, api_globals): 138 | if isinstance(annotation, str): 139 | annotation = annotation.replace("'", "") 140 | 141 | if inspect.isclass(annotation): 142 | module_name = str(annotation.__module__) 143 | full_name = annotation.__name__ 144 | if module_name: 145 | full_name = f"{module_name}.{full_name}" 146 | obj_name = _find_obj(full_name, api_globals) 147 | if obj_name is not None: 148 | return obj_name 149 | 150 | print("Unknown typehint:", full_name) 151 | return full_name 152 | 153 | typehint = ( 154 | str(annotation) 155 | .replace("NoneType", "None") 156 | ) 157 | full_path_regex = re.compile( 158 | r"(?P(?P[a-zA-Z0-9_\.]+))" 159 | ) 160 | 161 | for item in full_path_regex.finditer(str(typehint)): 162 | groups = item.groupdict() 163 | name = groups["name"] 164 | obj_name = _find_obj(name, api_globals) 165 | if obj_name: 166 | name = obj_name 167 | else: 168 | name = name.split(".")[-1] 169 | typehint = typehint.replace(groups["full"], name) 170 | 171 | forwardref_regex = re.compile( 172 | r"(?PForwardRef\('(?P[a-zA-Z0-9]+)'\))" 173 | ) 174 | for item in forwardref_regex.finditer(str(typehint)): 175 | groups = item.groupdict() 176 | name = groups["name"] 177 | obj_name = _find_obj(name, api_globals) 178 | if obj_name: 179 | name = obj_name 180 | else: 181 | name = name.split(".")[-1] 182 | typehint = typehint.replace(groups["full"], name) 183 | 184 | try: 185 | # Test if typehint is valid for known '_api' content 186 | exec(f"_: {typehint} = None", api_globals) 187 | return typehint 188 | except NameError: 189 | print("Unknown typehint:", typehint) 190 | 191 | _typehint = typehint 192 | _typehing_parents = [] 193 | while True: 194 | # Too hard to manage typehints with commas 195 | if "[" not in _typehint: 196 | break 197 | 198 | parts = _typehint.split("[") 199 | parent = parts.pop(0) 200 | 201 | try: 202 | # Test if typehint is valid for known '_api' content 203 | exec(f"_: {parent} = None", api_globals) 204 | except NameError: 205 | _typehint = parent 206 | break 207 | 208 | _typehint = "[".join(parts)[:-1] 209 | if "," in _typehint: 210 | _typing = parent 211 | break 212 | 213 | _typehing_parents.append(parent) 214 | 215 | if _typehing_parents: 216 | typehint = _typehint 217 | for parent in reversed(_typehing_parents): 218 | typehint = f"{parent}[{typehint}]" 219 | return typehint 220 | 221 | return typehint 222 | 223 | 224 | def _get_param_typehint(param, api_globals): 225 | if param.annotation is inspect.Parameter.empty: 226 | return None 227 | return _get_typehint(param.annotation, api_globals) 228 | 229 | 230 | def _add_typehint(param_name, param, api_globals): 231 | typehint = _get_param_typehint(param, api_globals) 232 | if not typehint: 233 | return param_name 234 | return f"{param_name}: {typehint}" 235 | 236 | 237 | def _kw_default_to_str(param_name, param, api_globals): 238 | from ayon_api._api_helpers.base import _PLACEHOLDER 239 | from ayon_api.utils import NOT_SET 240 | 241 | if param.default is inspect.Parameter.empty: 242 | return _add_typehint(param_name, param, api_globals) 243 | 244 | default = param.default 245 | if default is _PLACEHOLDER: 246 | default = "_PLACEHOLDER" 247 | elif default is NOT_SET: 248 | default = "NOT_SET" 249 | elif ( 250 | default is not None 251 | and not isinstance(default, (str, bool, int, float)) 252 | ): 253 | raise TypeError("Unknown default value type") 254 | else: 255 | default = repr(default) 256 | typehint = _get_param_typehint(param, api_globals) 257 | if typehint: 258 | return f"{param_name}: {typehint} = {default}" 259 | return f"{param_name}={default}" 260 | 261 | 262 | def sig_params_to_str(sig, param_names, api_globals, indent=0): 263 | pos_only = [] 264 | pos_or_kw = [] 265 | var_positional = None 266 | kw_only = [] 267 | var_keyword = None 268 | for param_name in param_names: 269 | param = sig.parameters[param_name] 270 | if param.kind == inspect.Parameter.POSITIONAL_ONLY: 271 | pos_only.append((param_name, param)) 272 | elif param.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD: 273 | pos_or_kw.append((param_name, param)) 274 | elif param.kind == inspect.Parameter.VAR_POSITIONAL: 275 | var_positional = param_name 276 | elif param.kind == inspect.Parameter.KEYWORD_ONLY: 277 | kw_only.append((param_name, param)) 278 | elif param.kind == inspect.Parameter.VAR_KEYWORD: 279 | var_keyword = param_name 280 | 281 | func_params = [] 282 | body_params = [] 283 | for param_name, param in pos_only: 284 | body_params.append(param_name) 285 | func_params.append(_add_typehint(param_name, param, api_globals)) 286 | 287 | if pos_only: 288 | func_params.append("/") 289 | 290 | for param_name, param in pos_or_kw: 291 | body_params.append(f"{param_name}={param_name}") 292 | func_params.append(_kw_default_to_str(param_name, param, api_globals)) 293 | 294 | if var_positional: 295 | body_params.append(f"*{var_positional}") 296 | func_params.append(f"*{var_positional}") 297 | 298 | elif kw_only: 299 | func_params.append("*") 300 | 301 | for param_name, param in kw_only: 302 | body_params.append(f"{param_name}={param_name}") 303 | func_params.append(_kw_default_to_str(param_name, param, api_globals)) 304 | 305 | if var_keyword is not None: 306 | body_params.append(f"**{var_keyword}") 307 | func_params.append(f"**{var_keyword}") 308 | 309 | base_indent_str = " " * indent 310 | param_indent_str = " " * (indent + 4) 311 | 312 | func_params_str = "()" 313 | if func_params: 314 | lines_str = "\n".join([ 315 | f"{param_indent_str}{line}," 316 | for line in func_params 317 | ]) 318 | func_params_str = f"(\n{lines_str}\n{base_indent_str})" 319 | 320 | if sig.return_annotation is not inspect.Signature.empty: 321 | return_typehint = _get_typehint(sig.return_annotation, api_globals) 322 | func_params_str += f" -> {return_typehint}" 323 | 324 | body_params_str = "()" 325 | if body_params: 326 | lines_str = "\n".join([ 327 | f"{param_indent_str}{line}," 328 | for line in body_params 329 | ]) 330 | body_params_str = f"(\n{lines_str}\n{base_indent_str})" 331 | 332 | return func_params_str, body_params_str 333 | 334 | 335 | def prepare_api_functions(api_globals): 336 | from ayon_api.server_api import ( # noqa: E402 337 | ServerAPI, 338 | InstallersAPI, 339 | DependencyPackagesAPI, 340 | SecretsAPI, 341 | BundlesAddonsAPI, 342 | EventsAPI, 343 | AttributesAPI, 344 | ProjectsAPI, 345 | FoldersAPI, 346 | TasksAPI, 347 | ProductsAPI, 348 | VersionsAPI, 349 | RepresentationsAPI, 350 | WorkfilesAPI, 351 | ThumbnailsAPI, 352 | ActivitiesAPI, 353 | ActionsAPI, 354 | LinksAPI, 355 | ListsAPI, 356 | ) 357 | 358 | functions = [] 359 | _items = list(ServerAPI.__dict__.items()) 360 | _items.extend(InstallersAPI.__dict__.items()) 361 | _items.extend(DependencyPackagesAPI.__dict__.items()) 362 | _items.extend(SecretsAPI.__dict__.items()) 363 | _items.extend(ActionsAPI.__dict__.items()) 364 | _items.extend(ActivitiesAPI.__dict__.items()) 365 | _items.extend(BundlesAddonsAPI.__dict__.items()) 366 | _items.extend(EventsAPI.__dict__.items()) 367 | _items.extend(AttributesAPI.__dict__.items()) 368 | _items.extend(ProjectsAPI.__dict__.items()) 369 | _items.extend(FoldersAPI.__dict__.items()) 370 | _items.extend(TasksAPI.__dict__.items()) 371 | _items.extend(ProductsAPI.__dict__.items()) 372 | _items.extend(VersionsAPI.__dict__.items()) 373 | _items.extend(RepresentationsAPI.__dict__.items()) 374 | _items.extend(WorkfilesAPI.__dict__.items()) 375 | _items.extend(LinksAPI.__dict__.items()) 376 | _items.extend(ListsAPI.__dict__.items()) 377 | _items.extend(ThumbnailsAPI.__dict__.items()) 378 | 379 | processed = set() 380 | for attr_name, attr in _items: 381 | if attr_name in processed: 382 | continue 383 | processed.add(attr_name) 384 | if ( 385 | attr_name.startswith("_") 386 | or attr_name in EXCLUDED_METHODS 387 | or not callable(attr) 388 | ): 389 | continue 390 | 391 | sig = inspect.signature(attr) 392 | param_names = list(sig.parameters) 393 | if inspect.isfunction(attr): 394 | param_names.pop(0) 395 | 396 | func_def_params, func_body_params = sig_params_to_str( 397 | sig, param_names, api_globals 398 | ) 399 | 400 | func_def = f"def {attr_name}{func_def_params}:\n" 401 | 402 | func_body_parts = [] 403 | docstring = prepare_docstring(attr) 404 | if docstring: 405 | func_body_parts.append(docstring) 406 | 407 | func_body_parts.extend([ 408 | "con = get_server_api_connection()", 409 | f"return con.{attr_name}{func_body_params}", 410 | ]) 411 | 412 | func_body = indent_lines("\n".join(func_body_parts)) 413 | full_def = func_def + func_body 414 | functions.append(full_def) 415 | return "\n\n\n".join(functions) 416 | 417 | 418 | def main(): 419 | print("Creating public API functions based on ServerAPI methods") 420 | # TODO order methods in some order 421 | ayon_api_root = os.path.join(CURRENT_DIR, "ayon_api") 422 | init_filepath = os.path.join(ayon_api_root, "__init__.py") 423 | api_filepath = os.path.join(ayon_api_root, "_api.py") 424 | 425 | print("(1/5) Reading current content of '_api.py'") 426 | with open(api_filepath, "r") as stream: 427 | old_content = stream.read() 428 | 429 | parts = old_content.split(AUTOMATED_COMMENT) 430 | if len(parts) == 1: 431 | raise RuntimeError( 432 | "Automated comment not found in '_api.py'" 433 | ) 434 | if len(parts) > 2: 435 | raise RuntimeError( 436 | "Automated comment found multiple times in '_api.py'" 437 | ) 438 | 439 | print("(2/5) Parsing current '__init__.py' content") 440 | formatting_init_content = prepare_init_without_api(init_filepath) 441 | 442 | # Read content of first part of `_api.py` to get global variables 443 | # - disable type checking so imports done only during typechecking are 444 | # not executed 445 | typing.TYPE_CHECKING = False 446 | api_globals = {"__name__": "ayon_api._api"} 447 | exec(parts[0], api_globals) 448 | 449 | for attr_name in dir(__builtins__): 450 | api_globals[attr_name] = getattr(__builtins__, attr_name) 451 | 452 | print("(3/5) Preparing functions body based on 'ServerAPI' class") 453 | result = prepare_api_functions(api_globals) 454 | 455 | print("(4/5) Store new functions body to '_api.py'") 456 | new_content = f"{parts[0]}{AUTOMATED_COMMENT}\n{result}" 457 | with open(api_filepath, "w") as stream: 458 | print(new_content, file=stream) 459 | 460 | # find all functions and classes available in '_api.py' 461 | func_regex = re.compile(r"^(def|class) (?P[^\(]*)(\(|:).*") 462 | func_names = [] 463 | for line in new_content.split("\n"): 464 | result = func_regex.search(line) 465 | if result: 466 | name = result.group("name") 467 | if name.startswith("_") or name in EXCLUDED_IMPORT_NAMES: 468 | continue 469 | func_names.append(name) 470 | 471 | print("(5/5) Updating imports in '__init__.py'") 472 | import_lines = ["from ._api import ("] 473 | for name in func_names: 474 | import_lines.append(f" {name},") 475 | import_lines.append(")") 476 | 477 | all_lines = [ 478 | f' "{name}",' 479 | for name in func_names 480 | ] 481 | new_init_content = formatting_init_content.format( 482 | api_imports="\n".join(import_lines), 483 | all_content="\n".join(all_lines), 484 | ) 485 | 486 | with open(init_filepath, "w") as stream: 487 | print(new_init_content, file=stream) 488 | 489 | print("Public API functions created successfully") 490 | 491 | 492 | if __name__ == "__main__": 493 | main() 494 | -------------------------------------------------------------------------------- /tests/resources/addon/create_package.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """Prepares server package from addon repo to upload to server. 4 | 5 | Requires Python 3.9. (Or at least 3.8+). 6 | 7 | This script should be called from cloned addon repo. 8 | 9 | It will produce 'package' subdirectory which could be pasted into server 10 | addon directory directly (eg. into `ayon-backend/addons`). 11 | 12 | Format of package folder: 13 | ADDON_REPO/package/{addon name}/{addon version} 14 | 15 | You can specify `--output_dir` in arguments to change output directory where 16 | package will be created. Existing package directory will always be purged if 17 | already present! This could be used to create package directly in server folder 18 | if available. 19 | 20 | Package contains server side files directly, 21 | client side code zipped in `private` subfolder. 22 | """ 23 | 24 | import os 25 | import sys 26 | import re 27 | import io 28 | import shutil 29 | import platform 30 | import argparse 31 | import logging 32 | import collections 33 | import zipfile 34 | import subprocess 35 | from typing import Optional, Iterable, Pattern, Union, List, Tuple 36 | 37 | import package 38 | 39 | FileMapping = Tuple[Union[str, io.BytesIO], str] 40 | ADDON_NAME: str = package.name 41 | ADDON_VERSION: str = package.version 42 | ADDON_CLIENT_DIR: Union[str, None] = getattr(package, "client_dir", None) 43 | 44 | CURRENT_ROOT: str = os.path.dirname(os.path.abspath(__file__)) 45 | SERVER_ROOT: str = os.path.join(CURRENT_ROOT, "server") 46 | FRONTEND_ROOT: str = os.path.join(CURRENT_ROOT, "frontend") 47 | FRONTEND_DIST_ROOT: str = os.path.join(FRONTEND_ROOT, "dist") 48 | DST_DIST_DIR: str = os.path.join("frontend", "dist") 49 | PRIVATE_ROOT: str = os.path.join(CURRENT_ROOT, "private") 50 | PUBLIC_ROOT: str = os.path.join(CURRENT_ROOT, "public") 51 | CLIENT_ROOT: str = os.path.join(CURRENT_ROOT, "client") 52 | 53 | VERSION_PY_CONTENT = f'''# -*- coding: utf-8 -*- 54 | """Package declaring AYON addon '{ADDON_NAME}' version.""" 55 | __version__ = "{ADDON_VERSION}" 56 | ''' 57 | 58 | # Patterns of directories to be skipped for server part of addon 59 | IGNORE_DIR_PATTERNS: List[Pattern] = [ 60 | re.compile(pattern) 61 | for pattern in { 62 | # Skip directories starting with '.' 63 | r"^\.", 64 | # Skip any pycache folders 65 | "^__pycache__$" 66 | } 67 | ] 68 | 69 | # Patterns of files to be skipped for server part of addon 70 | IGNORE_FILE_PATTERNS: List[Pattern] = [ 71 | re.compile(pattern) 72 | for pattern in { 73 | # Skip files starting with '.' 74 | # NOTE this could be an issue in some cases 75 | r"^\.", 76 | # Skip '.pyc' files 77 | r"\.pyc$" 78 | } 79 | ] 80 | 81 | 82 | class ZipFileLongPaths(zipfile.ZipFile): 83 | """Allows longer paths in zip files. 84 | 85 | Regular DOS paths are limited to MAX_PATH (260) characters, including 86 | the string's terminating NUL character. 87 | That limit can be exceeded by using an extended-length path that 88 | starts with the '\\?\' prefix. 89 | """ 90 | _is_windows = platform.system().lower() == "windows" 91 | 92 | def _extract_member(self, member, tpath, pwd): 93 | if self._is_windows: 94 | tpath = os.path.abspath(tpath) 95 | if tpath.startswith("\\\\"): 96 | tpath = "\\\\?\\UNC\\" + tpath[2:] 97 | else: 98 | tpath = "\\\\?\\" + tpath 99 | 100 | return super()._extract_member(member, tpath, pwd) 101 | 102 | 103 | def _get_yarn_executable() -> Union[str, None]: 104 | cmd = "which" 105 | if platform.system().lower() == "windows": 106 | cmd = "where" 107 | 108 | for line in subprocess.check_output( 109 | [cmd, "yarn"], encoding="utf-8" 110 | ).splitlines(): 111 | if not line or not os.path.exists(line): 112 | continue 113 | try: 114 | subprocess.call([line, "--version"]) 115 | return line 116 | except OSError: 117 | continue 118 | return None 119 | 120 | 121 | def safe_copy_file(src_path: str, dst_path: str): 122 | """Copy file and make sure destination directory exists. 123 | 124 | Ignore if destination already contains directories from source. 125 | 126 | Args: 127 | src_path (str): File path that will be copied. 128 | dst_path (str): Path to destination file. 129 | """ 130 | 131 | if src_path == dst_path: 132 | return 133 | 134 | dst_dir: str = os.path.dirname(dst_path) 135 | os.makedirs(dst_dir, exist_ok=True) 136 | 137 | shutil.copy2(src_path, dst_path) 138 | 139 | 140 | def _value_match_regexes(value: str, regexes: Iterable[Pattern]) -> bool: 141 | return any( 142 | regex.search(value) 143 | for regex in regexes 144 | ) 145 | 146 | 147 | def find_files_in_subdir( 148 | src_path: str, 149 | ignore_file_patterns: Optional[List[Pattern]] = None, 150 | ignore_dir_patterns: Optional[List[Pattern]] = None 151 | ) -> List[Tuple[str, str]]: 152 | """Find all files to copy in subdirectories of given path. 153 | 154 | All files that match any of the patterns in 'ignore_file_patterns' will 155 | be skipped and any directories that match any of the patterns in 156 | 'ignore_dir_patterns' will be skipped with all subfiles. 157 | 158 | Args: 159 | src_path (str): Path to directory to search in. 160 | ignore_file_patterns (Optional[list[Pattern]]): List of regexes 161 | to match files to ignore. 162 | ignore_dir_patterns (Optional[list[Pattern]]): List of regexes 163 | to match directories to ignore. 164 | 165 | Returns: 166 | list[tuple[str, str]]: List of tuples with path to file and parent 167 | directories relative to 'src_path'. 168 | """ 169 | 170 | if ignore_file_patterns is None: 171 | ignore_file_patterns = IGNORE_FILE_PATTERNS 172 | 173 | if ignore_dir_patterns is None: 174 | ignore_dir_patterns = IGNORE_DIR_PATTERNS 175 | output: List[Tuple[str, str]] = [] 176 | if not os.path.exists(src_path): 177 | return output 178 | 179 | hierarchy_queue: collections.deque = collections.deque() 180 | hierarchy_queue.append((src_path, [])) 181 | while hierarchy_queue: 182 | item: Tuple[str, str] = hierarchy_queue.popleft() 183 | dirpath, parents = item 184 | for name in os.listdir(dirpath): 185 | path: str = os.path.join(dirpath, name) 186 | if os.path.isfile(path): 187 | if not _value_match_regexes(name, ignore_file_patterns): 188 | items: List[str] = list(parents) 189 | items.append(name) 190 | output.append((path, os.path.sep.join(items))) 191 | continue 192 | 193 | if not _value_match_regexes(name, ignore_dir_patterns): 194 | items: List[str] = list(parents) 195 | items.append(name) 196 | hierarchy_queue.append((path, items)) 197 | 198 | return output 199 | 200 | 201 | def update_client_version(logger): 202 | """Update version in client code if version.py is present.""" 203 | if not ADDON_CLIENT_DIR: 204 | return 205 | 206 | version_path: str = os.path.join( 207 | CLIENT_ROOT, ADDON_CLIENT_DIR, "version.py" 208 | ) 209 | if not os.path.exists(version_path): 210 | logger.debug("Did not find version.py in client directory") 211 | return 212 | 213 | logger.info("Updating client version") 214 | with open(version_path, "w") as stream: 215 | stream.write(VERSION_PY_CONTENT) 216 | 217 | 218 | def build_frontend(): 219 | yarn_executable = _get_yarn_executable() 220 | if yarn_executable is None: 221 | raise RuntimeError("Yarn executable was not found.") 222 | 223 | subprocess.run([yarn_executable, "install"], cwd=FRONTEND_ROOT) 224 | subprocess.run([yarn_executable, "build"], cwd=FRONTEND_ROOT) 225 | if not os.path.exists(FRONTEND_DIST_ROOT): 226 | raise RuntimeError( 227 | "Frontend build failed. Did not find 'dist' folder." 228 | ) 229 | 230 | 231 | def get_client_files_mapping() -> List[Tuple[str, str]]: 232 | """Mapping of source client code files to destination paths. 233 | 234 | Example output: 235 | [ 236 | ( 237 | "C:/addons/MyAddon/version.py", 238 | "my_addon/version.py" 239 | ), 240 | ( 241 | "C:/addons/MyAddon/client/my_addon/__init__.py", 242 | "my_addon/__init__.py" 243 | ) 244 | ] 245 | 246 | Returns: 247 | list[tuple[str, str]]: List of path mappings to copy. The destination 248 | path is relative to expected output directory. 249 | 250 | """ 251 | # Add client code content to zip 252 | client_code_dir: str = os.path.join(CLIENT_ROOT, ADDON_CLIENT_DIR) 253 | mapping = [ 254 | (path, os.path.join(ADDON_CLIENT_DIR, sub_path)) 255 | for path, sub_path in find_files_in_subdir(client_code_dir) 256 | ] 257 | 258 | license_path = os.path.join(CURRENT_ROOT, "LICENSE") 259 | if os.path.exists(license_path): 260 | mapping.append((license_path, f"{ADDON_CLIENT_DIR}/LICENSE")) 261 | return mapping 262 | 263 | 264 | def get_client_zip_content(log) -> io.BytesIO: 265 | log.info("Preparing client code zip") 266 | files_mapping: List[Tuple[str, str]] = get_client_files_mapping() 267 | stream = io.BytesIO() 268 | with ZipFileLongPaths(stream, "w", zipfile.ZIP_DEFLATED) as zipf: 269 | for src_path, subpath in files_mapping: 270 | zipf.write(src_path, subpath) 271 | stream.seek(0) 272 | return stream 273 | 274 | 275 | def get_base_files_mapping() -> List[FileMapping]: 276 | filepaths_to_copy: List[FileMapping] = [ 277 | ( 278 | os.path.join(CURRENT_ROOT, "package.py"), 279 | "package.py" 280 | ) 281 | ] 282 | # Add license file to package if exists 283 | license_path = os.path.join(CURRENT_ROOT, "LICENSE") 284 | if os.path.exists(license_path): 285 | filepaths_to_copy.append((license_path, "LICENSE")) 286 | 287 | # Go through server, private and public directories and find all files 288 | for dirpath in (SERVER_ROOT, PRIVATE_ROOT, PUBLIC_ROOT): 289 | if not os.path.exists(dirpath): 290 | continue 291 | 292 | dirname = os.path.basename(dirpath) 293 | for src_file, subpath in find_files_in_subdir(dirpath): 294 | dst_subpath = os.path.join(dirname, subpath) 295 | filepaths_to_copy.append((src_file, dst_subpath)) 296 | 297 | if os.path.exists(FRONTEND_DIST_ROOT): 298 | for src_file, subpath in find_files_in_subdir(FRONTEND_DIST_ROOT): 299 | dst_subpath = os.path.join(DST_DIST_DIR, subpath) 300 | filepaths_to_copy.append((src_file, dst_subpath)) 301 | 302 | pyproject_toml = os.path.join(CLIENT_ROOT, "pyproject.toml") 303 | if os.path.exists(pyproject_toml): 304 | filepaths_to_copy.append( 305 | (pyproject_toml, "private/pyproject.toml") 306 | ) 307 | 308 | return filepaths_to_copy 309 | 310 | 311 | def copy_client_code(output_dir: str, log: logging.Logger): 312 | """Copies server side folders to 'addon_package_dir' 313 | 314 | Args: 315 | output_dir (str): Output directory path. 316 | log (logging.Logger) 317 | 318 | """ 319 | log.info(f"Copying client for {ADDON_NAME}-{ADDON_VERSION}") 320 | 321 | full_output_path = os.path.join( 322 | output_dir, f"{ADDON_NAME}_{ADDON_VERSION}" 323 | ) 324 | if os.path.exists(full_output_path): 325 | shutil.rmtree(full_output_path) 326 | os.makedirs(full_output_path, exist_ok=True) 327 | 328 | for src_path, dst_subpath in get_client_files_mapping(): 329 | dst_path = os.path.join(full_output_path, dst_subpath) 330 | safe_copy_file(src_path, dst_path) 331 | 332 | log.info("Client copy finished") 333 | 334 | 335 | def copy_addon_package( 336 | output_dir: str, 337 | files_mapping: List[FileMapping], 338 | log: logging.Logger 339 | ): 340 | """Copy client code to output directory. 341 | 342 | Args: 343 | output_dir (str): Directory path to output client code. 344 | files_mapping (List[FileMapping]): List of tuples with source file 345 | and destination subpath. 346 | log (logging.Logger): Logger object. 347 | 348 | """ 349 | log.info(f"Copying package for {ADDON_NAME}-{ADDON_VERSION}") 350 | 351 | # Add addon name and version to output directory 352 | addon_output_dir: str = os.path.join( 353 | output_dir, ADDON_NAME, ADDON_VERSION 354 | ) 355 | if os.path.isdir(addon_output_dir): 356 | log.info(f"Purging {addon_output_dir}") 357 | shutil.rmtree(addon_output_dir) 358 | 359 | os.makedirs(addon_output_dir, exist_ok=True) 360 | 361 | # Copy server content 362 | for src_file, dst_subpath in files_mapping: 363 | dst_path: str = os.path.join(addon_output_dir, dst_subpath) 364 | dst_dir: str = os.path.dirname(dst_path) 365 | os.makedirs(dst_dir, exist_ok=True) 366 | if isinstance(src_file, io.BytesIO): 367 | with open(dst_path, "wb") as stream: 368 | stream.write(src_file.getvalue()) 369 | else: 370 | safe_copy_file(src_file, dst_path) 371 | 372 | log.info("Package copy finished") 373 | 374 | 375 | def create_addon_package( 376 | output_dir: str, 377 | files_mapping: List[FileMapping], 378 | log: logging.Logger 379 | ): 380 | log.info(f"Creating package for {ADDON_NAME}-{ADDON_VERSION}") 381 | 382 | os.makedirs(output_dir, exist_ok=True) 383 | output_path = os.path.join( 384 | output_dir, f"{ADDON_NAME}-{ADDON_VERSION}.zip" 385 | ) 386 | 387 | with ZipFileLongPaths(output_path, "w", zipfile.ZIP_DEFLATED) as zipf: 388 | # Copy server content 389 | for src_file, dst_subpath in files_mapping: 390 | if isinstance(src_file, io.BytesIO): 391 | zipf.writestr(dst_subpath, src_file.getvalue()) 392 | else: 393 | zipf.write(src_file, dst_subpath) 394 | 395 | log.info("Package created") 396 | 397 | 398 | def main( 399 | output_dir: Optional[str] = None, 400 | skip_zip: Optional[bool] = False, 401 | only_client: Optional[bool] = False 402 | ): 403 | log: logging.Logger = logging.getLogger("create_package") 404 | log.info("Package creation started") 405 | 406 | if not output_dir: 407 | output_dir = os.path.join(CURRENT_ROOT, "package") 408 | 409 | has_client_code = bool(ADDON_CLIENT_DIR) 410 | if has_client_code: 411 | client_dir: str = os.path.join(CLIENT_ROOT, ADDON_CLIENT_DIR) 412 | if not os.path.exists(client_dir): 413 | raise RuntimeError( 414 | f"Client directory was not found '{client_dir}'." 415 | " Please check 'client_dir' in 'package.py'." 416 | ) 417 | update_client_version(log) 418 | 419 | if only_client: 420 | if not has_client_code: 421 | raise RuntimeError("Client code is not available. Skipping") 422 | 423 | copy_client_code(output_dir, log) 424 | return 425 | 426 | log.info(f"Preparing package for {ADDON_NAME}-{ADDON_VERSION}") 427 | 428 | if os.path.exists(FRONTEND_ROOT): 429 | build_frontend() 430 | 431 | files_mapping: List[FileMapping] = [] 432 | files_mapping.extend(get_base_files_mapping()) 433 | 434 | if has_client_code: 435 | files_mapping.append( 436 | (get_client_zip_content(log), "private/client.zip") 437 | ) 438 | 439 | # Skip server zipping 440 | if skip_zip: 441 | copy_addon_package(output_dir, files_mapping, log) 442 | else: 443 | create_addon_package(output_dir, files_mapping, log) 444 | 445 | log.info("Package creation finished") 446 | 447 | 448 | if __name__ == "__main__": 449 | parser = argparse.ArgumentParser() 450 | parser.add_argument( 451 | "--skip-zip", 452 | dest="skip_zip", 453 | action="store_true", 454 | help=( 455 | "Skip zipping server package and create only" 456 | " server folder structure." 457 | ) 458 | ) 459 | parser.add_argument( 460 | "-o", "--output", 461 | dest="output_dir", 462 | default=None, 463 | help=( 464 | "Directory path where package will be created" 465 | " (Will be purged if already exists!)" 466 | ) 467 | ) 468 | parser.add_argument( 469 | "--only-client", 470 | dest="only_client", 471 | action="store_true", 472 | help=( 473 | "Extract only client code. This is useful for development." 474 | " Requires '-o', '--output' argument to be filled." 475 | ) 476 | ) 477 | parser.add_argument( 478 | "--debug", 479 | dest="debug", 480 | action="store_true", 481 | help="Debug log messages." 482 | ) 483 | 484 | args = parser.parse_args(sys.argv[1:]) 485 | level = logging.INFO 486 | if args.debug: 487 | level = logging.DEBUG 488 | logging.basicConfig(level=level) 489 | main(args.output_dir, args.skip_zip, args.only_client) 490 | --------------------------------------------------------------------------------