diff --git a/.circleci/config.yml b/.circleci/config.yml index 1d805407..1a563f5f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,8 +1,7 @@ version: 2.1 orbs: - python: circleci/python@0.2.1 - win: circleci/windows@2.2.0 + python: circleci/python@2.0.3 workflows: version: 2 @@ -23,7 +22,7 @@ workflows: - gh-pages matrix: parameters: - python-version: ["3.6.5", "3.7.7", "3.8.6", "3.9.3", "latest"] + python-version: ["3.6.9", "3.7.7", "3.8.6", "3.9.3", "3.9.9", "3.9.10", "latest"] - hold: type: approval @@ -40,37 +39,20 @@ workflows: requires: - hold - # - docs: - # requires: - # - deploy - # - build - - # upload_test: - # triggers: - # - schedule: - # cron: "0,30 * * * *" - # filters: - # branches: - # only: - # - jh/use-xxhash-for-integration-test - # jobs: - # - build - # - upload_test_job: - # requires: - # - build - jobs: build: - docker: - - image: circleci/python:latest + executor: python/default steps: - checkout: name: Checkout Git + - python/install-packages: + pkg-manager: + poetry - run: name: Build Package command: | echo -e "Running sdist" - python setup.py sdist + poetry build - persist_to_workspace: root: /home/circleci/project/ paths: @@ -82,72 +64,37 @@ jobs: python-version: type: string docker: - - image: circleci/python:<< parameters.python-version >> + - image: cimg/python:<< parameters.python-version >> steps: - attach_workspace: at: /tmp/artifact name: Attach build artifact + - python/install-packages: + pkg-manager: + poetry - run: - name: Install package command: | - pip install --user '/tmp/artifact' + pip install poetry -U - run: - name: Run integration test + name: Install package command: | - python /tmp/artifact/tests/integration.py - - upload_test_job: - description: Upload test - docker: - - image: circleci/python:latest - steps: - - attach_workspace: - at: /tmp/artifact - name: Attach build artifact + cd /tmp/artifact + poetry install --extras docs - run: - name: Upload to pypi + name: Run integration test command: | - cd /tmp/artifact - twine upload dist/* - - # docs: - # docker: - # - image: circleci/python:latest - - # steps: - # - attach_workspace: - # at: /tmp/artifact - # name: Attach build artifact - - # - run: - # name: Install dependencies - # command: | - # cd /tmp/artifact/docs - # pip install -r requirements.txt - - # - run: - # name: Build autodocs - # command: | - # cd /tmp/artifact/docs - # make jekyll - - # - run: - # name: Publish autodocs - # command: | - # cd /tmp/artifact/docs - # python publish.py + python /tmp/artifact/tests/test_integration.py deploy: docker: - - image: circleci/python:latest + - image: cimg/python:3.10.2 steps: - attach_workspace: at: /tmp/artifact name: Attach build artifact - - run: - name: Install dependencies - command: | - pip install setuptools wheel twine + - python/install-packages: + pkg-manager: + poetry - run: name: init .pypirc command: | @@ -159,4 +106,4 @@ jobs: name: Upload to pypi command: | cd /tmp/artifact - twine upload dist/* + poetry publish diff --git a/.gitignore b/.gitignore index 37bc36ef..768ce79c 100644 --- a/.gitignore +++ b/.gitignore @@ -110,4 +110,5 @@ Pipfile.lock .vscode/launch.json .vscode/settings.json -pyproject.toml \ No newline at end of file +pyproject.toml +.vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json index de288e1e..b276816b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,8 @@ { - "python.formatting.provider": "black" + "python.formatting.provider": "black", + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true } \ No newline at end of file diff --git a/LICENSE b/LICENSE index bad78c3c..47981373 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2018 Frame.io +Copyright (c) 2022 Frame.io, an Adobe company. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile index c6126f0e..e0f0b07a 100644 --- a/Makefile +++ b/Makefile @@ -17,10 +17,13 @@ clean: find . -name "*.pyc" -exec rm -f {} \; test: - cd tests && pipenv run python integration.py + cd tests && poetry run python test_integration.py package: - pipenv run python3 setup.py sdist bdist_wheel + poetry build + +publish: + poetry publish build-docker: docker build . -t benchmark @@ -32,7 +35,7 @@ format: black frameioclient view-docs: - cd docs && pip install -r requirements.txt && make dev + cd docs && poetry run make dev publish-docs: - cd docs && pip install -r requirements.txt && make jekyll && make publish + cd docs && poetry run make jekyll diff --git a/README.md b/README.md index d8c4dd94..a2e2caf2 100644 --- a/README.md +++ b/README.md @@ -13,22 +13,15 @@ Frame.io is a cloud-based collaboration hub that allows video professionals to s ### Installation -via Pip -``` +via `pip` +```sh $ pip install frameioclient ``` -via Source -``` -$ git clone https://github.com/frameio/python-frameio-client -$ pip install . -``` - -### Developing -Install the package into your development environment and link to it by running the following: - +from source ```sh -pipenv install -e . -pre +$ git clone https://github.com/frameio/python-frameio-client +$ pip install -e . ``` ## Documentation @@ -36,11 +29,11 @@ pipenv install -e . -pre [Frame.io API Documentation](https://developer.frame.io/docs) ### Use CLI -When you install this package, a cli tool called `fioctl` will also be installed to your environment. +When you install this package, a cli tool called `fioctfioclil` will also be installed to your environment. **To upload a file or folder** ```sh -fioctl \ +fiocli \ --token fio-u-YOUR_TOKEN_HERE \ --destination "YOUR TARGET FRAME.IO PROJECT OR FOLDER" \ --target "YOUR LOCAL SYSTEM DIRECTORY" \ @@ -49,33 +42,13 @@ fioctl \ **To download a file, project, or folder** ```sh -fioctl \ +fiocli \ --token fio-u-YOUR_TOKEN_HERE \ --destination "YOUR LOCAL SYSTEM DIRECTORY" \ --target "YOUR TARGET FRAME.IO PROJECT OR FOLDER" \ --threads 2 ``` -### Links - -**Sphinx Documentation** -- https://pythonhosted.org/sphinxcontrib-restbuilder/ -- https://www.npmjs.com/package/rst-selector-parser -- https://sphinx-themes.org/sample-sites/furo/_sources/index.rst.txt -- https://developer.mantidproject.org/Standards/DocumentationGuideForDevs.html -- https://sublime-and-sphinx-guide.readthedocs.io/en/latest/code_blocks.html -- https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html -- https://stackoverflow.com/questions/64451966/python-sphinx-how-to-embed-code-into-a-docstring -- https://pythonhosted.org/an_example_pypi_project/sphinx.html - -**Decorators** -- https://docs.python.org/3.7/library/functools.html -- https://realpython.com/primer-on-python-decorators/ -- https://www.sphinx-doc.org/en/master/usage/quickstart.html -- https://www.geeksforgeeks.org/decorators-with-parameters-in-python/ -- https://stackoverflow.com/questions/43544954/why-does-sphinx-autodoc-output-a-decorators-docstring-when-there-are-two-decora - - ## Usage _Note: A valid token is required to make requests to Frame.io. Go to our [Developer Portal](https://developer.frame.io/) to get a token!_ @@ -87,9 +60,13 @@ In addition to the snippets below, examples are included in [/examples](/example Get basic info on the authenticated user. ```python +import os from frameioclient import FrameioClient -client = FrameioClient("TOKEN") +# We always recommend passing the token you'll be using via an environment variable and accessing it using os.getenv("FRAMEIO_TOKEN") +FRAMEIO_TOKEN = os.getenv("FRAMEIO_TOKEN") +client = FrameioClient(FRAMEIO_TOKEN) + me = client.users.get_me() print(me['id']) ``` @@ -102,12 +79,14 @@ Create a new asset and upload a file. For `parent_asset_id` you must have the ro import os from frameioclient import FrameioClient -client = FrameioClient("TOKEN") +# We always recommend passing the token you'll be using via an environment variable and accessing it using os.getenv("FRAMEIO_TOKEN") +FRAMEIO_TOKEN = os.getenv("FRAMEIO_TOKEN") +client = FrameioClient(FRAMEIO_TOKEN) # Create a new asset manually -asset = client.assets.create( - parent_asset_id="1234abcd", +client.assets.create( + parent_asset_id="0d98e024-d738-4d9a-ae89-19f02839116d", name="MyVideo.mp4", type="file", filetype="video/mp4", @@ -115,12 +94,54 @@ asset = client.assets.create( ) # Create a new folder -client.assets.create( - parent_asset_id="", +client.assets.create_folder( + parent_asset_id="63bfd7cc-8517-4a61-b655-0a59f5dec630", name="Folder name", - type="folder" # this kwarg is what makes it a folder ) # Upload a file client.assets.upload(destination_id, "video.mp4") ``` + +## Contributing +Install the package into your development environment using Poetry. This should auto-link it within the current virtual-env that gets created during installation. + +```sh +poetry install +``` + +### Publishing to PyPI + +```sh +# Start by versioning +poetry version prerelease + +# Then build +poetry build + +# Now you can publish the new version +poetry publish --username=__token__ --password=INSERT_TOKEN_FROM_PYPI_OR_PASS_VIA_ENV_VARIABLE + +# You can also build and publish in one go with +poetry publish --build --username=__token__ --password=INSERT_TOKEN_FROM_PYPI_OR_PASS_VIA_ENV_VARIABLE +``` + +### Ancillary links + +**Sphinx Documentation** +- https://pythonhosted.org/sphinxcontrib-restbuilder/ +- https://www.npmjs.com/package/rst-selector-parser +- https://sphinx-themes.org/sample-sites/furo/_sources/index.rst.txt +- https://developer.mantidproject.org/Standards/DocumentationGuideForDevs.html +- https://sublime-and-sphinx-guide.readthedocs.io/en/latest/code_blocks.html +- https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html +- https://stackoverflow.com/questions/64451966/python-sphinx-how-to-embed-code-into-a-docstring +- https://pythonhosted.org/an_example_pypi_project/sphinx.html + +**Decorators** +- https://docs.python.org/3.7/library/functools.html +- https://realpython.com/primer-on-python-decorators/ +- https://www.sphinx-doc.org/en/master/usage/quickstart.html +- https://www.geeksforgeeks.org/decorators-with-parameters-in-python/ +- https://stackoverflow.com/questions/43544954/why-does-sphinx-autodoc-output-a-decorators-docstring-when-there-are-two-decora + diff --git a/docs/conf.py b/docs/conf.py index 73cc4d39..f1f7b075 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -6,7 +6,7 @@ PACKAGE_TITLE = 'Frame.io Python SDK' PACKAGE_NAME = 'frameioclient' -PACKAGE_DIR = '../frameioclient' +PACKAGE_DIR = '../' AUTHOR_NAME = 'Frame.io' try: diff --git a/docs/index.rst b/docs/index.rst index 9da0551d..ea0bf6b4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,4 +1,4 @@ -Welcome to Frame.io's Python SDK documentation! +Welcome to the documentation for `frameioclient` ! =============================================== .. toctree:: diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 2119f510..00000000 --- a/docs/make.bat +++ /dev/null @@ -1,35 +0,0 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set SOURCEDIR=. -set BUILDDIR=_build - -if "%1" == "" goto help - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% -goto end - -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% - -:end -popd diff --git a/docs/requirements.txt b/docs/requirements.txt index 63a029d6..fd7cde4a 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,13 +1,13 @@ -sphinx -sphinx-jekyll-builder -sphinxcontrib-restbuilder -contentful_management -python-frontmatter -frameioclient -xxhash -furo -analytics-python -token-bucket -speedtest-cli -sphinx-autobuild -sphinx-autodoc-typehints \ No newline at end of file +"sphinx", +"sphinx-jekyll-builder", +"sphinxcontrib-restbuilder", +"contentful_management", +"python-frontmatter", +"frameioclient", +"xxhash", +"furo", +"analytics-python", +"token-bucket", +"speedtest-cli", +"sphinx-autobuild", +"sphinx-autodoc-typehints", \ No newline at end of file diff --git a/examples/account_info/crawler.py b/examples/account_info/crawler.py new file mode 100644 index 00000000..699e5c12 --- /dev/null +++ b/examples/account_info/crawler.py @@ -0,0 +1,175 @@ +import json +import os +from pathlib import Path +from time import time + +from dotenv import find_dotenv, load_dotenv +from frameioclient import FrameioClient + + +class AccountScraper: + def __init__(self, token=None, minimal=False): + self.token = token + self.client = FrameioClient(self.token) + + self.minimal = minimal # Only fetch 2x teams + self.final_data = None # Store final data + + def get_team_ids(self): + account_id = self.client.me["account_id"] + teams_info = self.client.teams.list(account_id=account_id) + + # Slice the list and only return the data from the first two teams + if self.minimal == True: + teams_info = teams_info[0:2] + + team_id_list = list() + for team in teams_info: + team_id_list.append( + { + "id": team["id"], + "name": team["name"], + "projects": team["project_count"], + "members": team["member_count"], + "storage": team["storage"], + "storage_limit": team["storage_limit"], + "date_created": team["inserted_at"], + } + ) + + return team_id_list + + def custom_get_members(self, team_id): + team_members = self.client.teams.get_members(team_id=team_id) + clean_team_members = list() + + for member in team_members: + user_object = member["user"] + id = user_object["id"] + name = user_object["name"] + email = user_object["email"] + + clean_team_members.append({"id": id, "name": name, "email": email}) + + return clean_team_members + + def custom_get_project_members(self, project_name, project_id): + print( + "Adding team members for project: {}, ID: {}".format( + project_name, project_id + ) + ) + project_members = self.client.projects.get_collaborators(project_id=project_id) + + results = [] + + for member in project_members: + user_object = member["user"] + id = user_object["id"] + name = user_object["name"] + email = user_object["email"] + + try: + joined_via = user_object["joined_via"] + if joined_via == "account_member": + role = "Collaborator" + elif joined_via == "organic": + role = "Team Member" + else: + role = None + + except KeyError: + role = None + + results.append({"id": id, "name": name, "email": email, "role": role}) + + return results + + def persist_state(self): + path = Path('.').joinpath("account_state.json") + with open(path.as_posix(), "w") as account_data: + json.dump(self.final_data, account_data) + + return True + + @staticmethod + def clean_data(data=None): + for team in data: + # Grab the list of team members so we can then remove them from project membership + team_members_list = team["members"] + team_member_ids = [] + for team_member in team_members_list: + team_member_ids.append(team_member["id"]) + + for project in team["projects"]: + project_members_list = project["collaborators"] + for count, project_member in enumerate(project_members_list): + if project_member["id"] in team_member_ids: + project_members_list.pop(count) + return data + + def do_it_all(self): + """ + 1. Get all team ids + 2. Get members list for all teams + 3. Get all projects + 4. Get all collaborators on each project + 5. Exclude TEAM MEMBERS, and then you have a list of people who are just collaborators + """ + + teams_list = self.get_team_ids() + + team_member_list = [] # reset blank list + for count, team in enumerate(teams_list, start=1): + print("Team {}/{}".format(count, len(teams_list))) + membership_info = self.custom_get_members(team["id"]) # get membership info + team[ + "members" + ] = membership_info # add member info to the team before passing it to the list + + print( + "Adding projects for team: {}, ID: {}".format(team["name"], team["id"]) + ) + project_info = [] + projects_list = self.client.teams.list_projects(team_id=team["id"]) + + for count, project in enumerate(projects_list, start=1): + print("Project {}/{}".format(count, len(projects_list))) + + # Add the project info + project_info.append( + { + "id": project["id"], + "date_created": project["inserted_at"], + "name": project["name"], + "owner_id": project["owner_id"], + "root_asset_id": project["root_asset"]["id"], + "file_count": project["file_count"], + "folder_count": project["folder_count"], + "storage": project["storage"], + "collaborators": self.custom_get_project_members( + project["name"], project["id"] + ), + } + ) + + print("Appended project info...") + + # Inject project info into the main JSON + team["projects"] = project_info + team_member_list.append(team) + + # Clean data (remove team members from members list in projects) + cleaned_data = self.clean_data(team_member_list) + + self.final_data = cleaned_data + self.persist_state() + + return True + + +if __name__ == "__main__": + dotenv_file = find_dotenv('.env', True, False) + load_dotenv(dotenv_file) + token = os.getenv("FRAMEIO_TOKEN") + AccountScraper(token=token, minimal=True).do_it_all() diff --git a/examples/assets/asset_scraper.py b/examples/assets/asset_scraper.py index 082d8595..faef181c 100644 --- a/examples/assets/asset_scraper.py +++ b/examples/assets/asset_scraper.py @@ -8,10 +8,8 @@ import csv -from functools import lru_cache import os -import time -from itertools import chain +from functools import lru_cache from typing import Dict, List from frameioclient import FrameioClient diff --git a/examples/assets/simple_recursive_upload.py b/examples/assets/simple_recursive_upload.py new file mode 100644 index 00000000..0850b15c --- /dev/null +++ b/examples/assets/simple_recursive_upload.py @@ -0,0 +1,21 @@ +import os +from frameioclient import FrameioClient + +from dotenv import find_dotenv, load_dotenv +load_dotenv(find_dotenv()) + + +token = os.getenv("FRAMEIO_TOKEN") # Your Frame.io token +destination_folder_id = "d986681a-e460-4cc6-8db6-bbfbebdb88c7" + +def simple_recursive_upload(destination_folder_id: str, source_folder: str): + client = FrameioClient(token) + + print("Starting upload...") + client.assets.upload_folder(source_folder, destination_folder_id) + + +if __name__ == "__main__": + source_directory = '/Users/jeff/Movies/Assets/images/RAW Images/nikon' + + simple_recursive_upload("d986681a-e460-4cc6-8db6-bbfbebdb88c7", source_directory) diff --git a/examples/audit_logs/crawl.py b/examples/audit_logs/crawl.py new file mode 100644 index 00000000..d37a1699 --- /dev/null +++ b/examples/audit_logs/crawl.py @@ -0,0 +1,39 @@ +import os +import json +from pprint import pprint +import time +from typing import Optional +from dotenv import find_dotenv, load_dotenv +from frameioclient import FrameioClient, Utils + +# load_dotenv(find_dotenv()) +load_dotenv('/Users/jeff/Code/developer-relations/python-frameio-client/.env') +token = os.getenv("FRAMEIO_TOKEN") + +def get_audit_logs(account_id: str, event_type: Optional[str] = None): + logs = list() + + start_time = time.time() + client = FrameioClient(token) + endpoint = f"/accounts/{account_id}/audit_logs" + + if event_type != None: + endpoint += f'?filter[action]={event_type}' + + for log_page in Utils.stream_results(endpoint, client=client): + logs.append(log_page) + print(f"{len(logs)} logs fetched") + + # Write the file + with open("audit_logs.json", "w") as out_file: + json.dump(logs, out_file) + + duration = time.time() - start_time + + print(f"Took {duration} seconds to complete.") + +if __name__ == "__main__": + account_id = 'f6365640-575c-42e5-8a7f-cd9e2d6b9273' + get_audit_logs(account_id, 'AssetCreated') + + diff --git a/examples/audit_logs/crawler.py b/examples/audit_logs/crawler.py new file mode 100644 index 00000000..e8e6c1b7 --- /dev/null +++ b/examples/audit_logs/crawler.py @@ -0,0 +1,31 @@ +import os + +from dotenv import load_dotenv +from frameioclient import FrameioClient, Utils + +load_dotenv("/Users/jeff/Code/developer-relations/python-frameio-client/.env") + +client = FrameioClient(os.getenv("FRAMEIO_TOKEN")) + +def get_audit_logs(account_id: str): + logs = [] + + for chunk in Utils.stream_results( + f"/accounts/{account_id}/audit_logs", client=client + ): + logs.append(chunk) + + try: + print(chunk.keys()) + except Exception as e: + print(e) + + print(len(logs)) + + for log in logs: + print(log.keys()) + + +if __name__ == "__main__": + my_account_id = client.me['account_id'] + get_audit_logs(my_account_id) diff --git a/examples/comments/random_comments.py b/examples/comments/random_comments.py new file mode 100644 index 00000000..9ee2171f --- /dev/null +++ b/examples/comments/random_comments.py @@ -0,0 +1,21 @@ +import os +from frameioclient import FrameioClient + + +token = os.getenv('FRAMEIO_TOKEN') + +def create_comments(asset_id: str, count=7449) -> bool: + client = FrameioClient(token) + + try: + for i in range(count): + client.comments.create(asset_id=asset_id, text="Test comment") + + return True + except Exception as e: + print(e) + return False + +if __name__ == "__main__": + asset_id = "5efaf3c3-e0fe-4742-bce1-1ce57f87c4bb" + create_comments(asset_id) diff --git a/examples/projects/batch_operations.py b/examples/projects/batch_operations.py new file mode 100644 index 00000000..4dbc65c0 --- /dev/null +++ b/examples/projects/batch_operations.py @@ -0,0 +1,166 @@ +import csv +import time +from itertools import islice +from pathlib import Path +from re import split +from typing import Dict, List, Tuple + +from dotenv import dotenv_values, find_dotenv +from frameioclient import FrameioClient +from requests.exceptions import HTTPError +from tqdm import tqdm + +dotenv_path = find_dotenv() +env = dotenv_values(Path(dotenv_path)) + +FRAMEIO_TOKEN = env["FRAMEIO_TOKEN"] +fio_client = FrameioClient(FRAMEIO_TOKEN) + + +def split_seq(iterable, size): + it = iter(iterable) + item = list(islice(it, size)) + while item: + yield item + + +def purge_projects(team_id): + # Get projects + projects_list = fio_client.teams.list_projects(team_id) + for project in projects_list: + fio_client._api_call("DELETE", f"/projects/{project['id']}") + + +def purge_projects_from_csv(csv_path: str): + with open(csv_path, "r") as project_list: + for row in csv.reader(project_list): + if row[0] == "id": + continue + try: + fio_client._api_call("DELETE", f"/projects/{row[0]}") + print(f"Deleted: {row[1]}") + except Exception as e: + print(e) + + # Sleeping so as not to run amuck of rate limits + time.sleep(1) + + return True + + +def batch_disable_presentations(csv_path): + fio_client = FrameioClient(FRAMEIO_TOKEN) + with open(csv_path, "r") as project_list: + for row in csv.reader(project_list): + if row[0] == "id": + continue + try: + fio_client._api_call("DELETE", f"/presentations/{row[0]}") + print(f"Deleted: {row[1]}") + except Exception as e: + print(e) + + # Sleeping so as not to run amuck of rate limits + time.sleep(3) + + return True + + +def batch_update_labels(asset_list: List, new_label: str) -> Dict: + """Updates the labels for a list of assets using the batch update endpont + + Args: + asset_list (List): List of Frame.io Asset IDs + new_label (str): New Label to be applied + + Returns: + bool: True or false for whether or not they all updated correctly + """ + fio_client = FrameioClient(FRAMEIO_TOKEN) + batch_ops = list() + all_successful = True + + for batch in split_seq( + asset_list, 50 + ): # Split into batches of 50 because that's the limit + for asset_id in batch: # Iterate over the items in each batch + batch_ops.append({"id": asset_id, "label": new_label}) + + update_payload = {"batch": batch_ops} + + try: + fio_client._api_call("POST", f"/batch/assets/label", payload=update_payload) + except Exception as e: + all_successful = False + + return all_successful + + +def batch_delete_assets(csv_path, token: str): + fio_client = FrameioClient(token) + + # Write header row + csv_columns = ["timestamp", "asset_id", "error"] + with open("deletion_results.csv", "w") as outfile: + writer = csv.DictWriter(outfile, fieldnames=csv_columns) + writer.writeheader() + + asset_ids = [] # Real list rather than CSV + start_pos = 798 # Start position in CSV + + # Read from CSV into a list so that we can slice the list if needed + with open(csv_path, "r") as asset_list: + for row in csv.reader(asset_list): + asset_ids.append(row[0]) + + # Iterate over asset_ids + for row in asset_ids: + print(row) + try: + fio_client.assets.delete(row) + print(f"Deleted: {row}") + except HTTPError as e: + if e.response.status_code == 403 or e.response.status_code == 400: + data = { + "timestamp": time.time(), + "asset_id": row, + "error": e.response.status_code, + } + with open("deletion_results.csv", "a") as outfile: + csv_writer = csv.DictWriter(outfile, fieldnames=csv_columns) + csv_writer.writerow(data) + + # Sleeping so as not to run amuck of rate limits + time.sleep(1.25) + + elif e.response.status_code == 404: + print(f"Asset: {row} already deleted") + continue + + else: + continue + + return True + + +def batch_remove_users(team_id: str, csv_path: str): + users = [] # Real list rather than CSV + start_pos = 798 # Start position in CSV + + # Read from CSV into a list so that we can slice the list if needed + with open(csv_path, "r") as asset_list: + for row in csv.reader(asset_list): + users.append(row[0]) + + # Iterate over asset_ids + for row in split_seq(users): + fio_client.teams.remove_members(team_id, row) + + +if __name__ == "__main__": + # batch_update_labels(test_list, "in_progress") + # batch_remove_users("csv_file_1.csv") + # batch_delete_assets("csv_file_2.csv") + + # csv_path_1 = "/Users/jeff/Downloads/csv_file_1.csv" + # csv_path_2 = "/Users/jeff/Downloads/csv_file_2.csv" diff --git a/examples/projects/download_project.py b/examples/projects/download_project.py index fd492244..375a6057 100644 --- a/examples/projects/download_project.py +++ b/examples/projects/download_project.py @@ -1,13 +1,13 @@ -from frameioclient.lib.utils import FormatTypes, Utils import os -from pathlib import Path +from time import time -import pdb -from time import time,sleep -from pprint import pprint +from dotenv import find_dotenv, load_dotenv from frameioclient import FrameioClient +from frameioclient.lib.utils import FormatTypes, Utils + +load_dotenv(find_dotenv()) -def get_folder_size(path='.'): +def get_folder_size(path: str = '.'): total = 0 for entry in os.scandir(path): if entry.is_file(): @@ -16,32 +16,26 @@ def get_folder_size(path='.'): total += get_folder_size(entry.path) return total -def demo_project_download(project_id): - TOKEN = os.getenv("FRAMEIO_TOKEN") - client = FrameioClient(TOKEN) +def demo_project_download(project_id: str, download_dir: str): + TOKEN = os.getenv("FRAMEIO_TOKEN") + client = FrameioClient(TOKEN) - start_time = time() - download_dir = '/Volumes/Jeff-EXT/Python Transfer Test' - item_count = client.projects.download(project_id, destination_directory=download_dir) + project_info = client.projects.get(project_id) - # item_count = client.projects.download(project_id, destination_directory='/Users/jeff/Temp/Transfer vs Python SDK/Python SDK') + start_time = time() + downloaded_item_count = client.projects.download(project_id, destination_directory=download_dir) - end_time = time() - elapsed = round((end_time - start_time), 2) + end_time = time() + elapsed = round((end_time - start_time), 2) - - folder_size = get_folder_size(download_dir) - # pdb.set_trace() + folder_size = get_folder_size(download_dir) - print(f"Found {item_count} items") - print(f"Took {elapsed} second to download {Utils.format_value(folder_size, type=FormatTypes.SIZE)} for project: {client.projects.get(project_id)['name']}") - print("\n") + print(f"Found {downloaded_item_count} items to download in the in the {project_info['name']} project") + print(f"Took {elapsed} second to download {Utils.format_value(folder_size, type=FormatTypes.SIZE)} for project: {project_info['name']}") + print("\n") if __name__ == "__main__": - # project_id = '2dfb6ce6-90d8-4994-881f-f02cd94b1c81' - # project_id='e2845993-7330-54c6-8b77-eafbd5144eac' - # project_id = '5d3ff176-ab1f-4c0b-a027-abe3d2a960e3' - project_id = 'ba1791e8-bf1e-46cb-bcad-5e4bb6431a08' - demo_project_download(project_id) + project_id = 'bb4d6293-514b-4097-a9aa-792d91916414' -# Took 443.84 second to download 12.43 GB to USB HDD for project: HersheyPark Summer Campaign using Python SDK \ No newline at end of file + destination_dir = '/Users/jeff/Movies/Assets/temp/Python SDK Test' + demo_project_download(project_id, destination_dir) \ No newline at end of file diff --git a/examples/projects/project_tree.py b/examples/projects/project_tree.py index 0f4f6450..71a1058a 100644 --- a/examples/projects/project_tree.py +++ b/examples/projects/project_tree.py @@ -3,25 +3,32 @@ from time import time from pprint import pprint from frameioclient import FrameioClient +from pathlib import Path + +from dotenv import load_dotenv, find_dotenv + +dotenv_path = find_dotenv() +load_dotenv(dotenv_path) def demo_folder_tree(project_id): - TOKEN = os.getenv("FRAMEIO_TOKEN") - client = FrameioClient(TOKEN) + TOKEN = os.getenv("FRAMEIO_TOKEN") + client = FrameioClient(TOKEN) - start_time = time() - tree = client.helpers.build_project_tree(project_id, slim=True) + start_time = time() + tree = client.helpers.build_project_tree(project_id, slim=True) - end_time = time() - elapsed = round((end_time - start_time), 2) + end_time = time() + elapsed = round((end_time - start_time), 2) - item_count = len(tree) - pprint(tree) + item_count = len(tree) + pprint(tree) - print(f"Found {item_count} items") - print(f"Took {elapsed} second to fetch the slim payload for project: {project_id}") - print("\n") + print(f"Found {item_count} items") + print(f"Took {elapsed} second to fetch the slim payload for project: {project_id}") + print("\n") if __name__ == "__main__": - # project_id = 'ba1791e8-bf1e-46cb-bcad-5e4bb6431a08' - project_id = '2dfb6ce6-90d8-4994-881f-f02cd94b1c81' - demo_folder_tree(project_id) + # project_id = 'ba1791e8-bf1e-46cb-bcad-5e4bb6431a08' + # project_id = 'f37bc51c-fec1-438d-8ccf-6a88ebd82146' + project_id = 'ceb229b1-bd23-4543-832e-afa0b2151000' + demo_folder_tree(project_id) diff --git a/examples/sharing/batch_updates.py b/examples/sharing/batch_updates.py new file mode 100644 index 00000000..1a7e7cc6 --- /dev/null +++ b/examples/sharing/batch_updates.py @@ -0,0 +1,99 @@ +import csv +import time +import logging +from pathlib import Path +from typing import Dict, List, Tuple + +from dotenv import dotenv_values, find_dotenv +from tqdm import tqdm + +from frameioclient import FrameioClient + +dotenv_path = find_dotenv('/Users/jeff/Code/devrel/python-frameio-client/.env') +env = dotenv_values(Path(dotenv_path)) + +FRAMEIO_TOKEN = env["FRAMEIO_TOKEN"] +fio_client = FrameioClient(FRAMEIO_TOKEN) + +# Gets or creates a logger +logger = logging.getLogger(__name__) + +# set log level +logger.setLevel(logging.INFO) + +# define file handler and set formatter +file_handler = logging.FileHandler('log_file.log') +formatter = logging.Formatter('%(asctime)s : %(levelname)s : %(name)s : %(message)s') +file_handler.setFormatter(formatter) + +# add file handler to logger +logger.addHandler(file_handler) + + +def grant_access_to_all_teams(): + # 1. Get accounts list + # 2. Iterate over accounts + # 3. Fetch all teams in account + # 4. Add user to each team as a team member + + accounts = fio_client._api_call('GET', '/accounts') + for account in accounts: + teams = fio_client.teams.list(account['id']) + for team in teams: + fio_client.teams.add_members(team['id'], ['user@frame.io']) + + pass + + +def batch_disable_presentation_links(csv_path): + with open(csv_path, "r") as project_list: + for row in csv.reader(project_list): + if row[0] == "id": + continue + try: + fio_client.presentation_links.update(row[0], enabled=False) + logger.info(f"Disabled presentation link: {row[0]}") + except Exception as e: + print(e) + logger.error(f"Failed to disable presentation link: {row[0]}") + + return True + + +def batch_disable_review_links(csv_path): + with open(csv_path, "r") as review_link_list: + for row in csv.reader(review_link_list): + if row[0] == "id": + continue + try: + fio_client.review_links.update_settings(row[0], is_active=False) + logger.info(f"Disabled review link: {row[0]}") + except Exception as e: + print(e) + logger.error(f"Failed to disable review link: {row[0]}") + time.sleep(.05) + + return True + +def batch_delete_review_links(csv_path): + with open(csv_path, "r") as review_link_list: + for row in csv.reader(review_link_list): + if row[0] == "id": + continue + try: + fio_client._api_call("DELETE", f"/review_links/{row['id']}") + except Exception as e: + print(e) + logger.error(f"Failed to disable review link: {row[0]}") + time.sleep(.05) + + return True + +if __name__ == '__main__': + presentation_links_csv_path = '/Users/jeff/Code/examples/presentation_links_to_delete.csv' + review_links_csv_path = '/Users/jeff/Code/devrel/python-frameio-client/fio fio_review_links 2022-12-13T1244.csv' + + batch_disable_presentation_links(presentation_links_csv_path) + batch_disable_review_links(review_links_csv_path) + + grant_access_to_all_teams() diff --git a/frameioclient/__init__.py b/frameioclient/__init__.py index 04bcc1e2..186d7407 100644 --- a/frameioclient/__init__.py +++ b/frameioclient/__init__.py @@ -1,3 +1,3 @@ from .lib import * -from .services import * +from .resources import * from .client import FrameioClient diff --git a/frameioclient/client.py b/frameioclient/client.py index fefbbe40..aa734b36 100644 --- a/frameioclient/client.py +++ b/frameioclient/client.py @@ -7,8 +7,7 @@ from .config import Config from .lib import APIClient, ClientVersion, FrameioDownloader -# from .lib import Telemetry -from .services import * +from .resources import * class FrameioClient(APIClient): @@ -25,9 +24,6 @@ def __init__( def me(self): return self.users.get_me() - # def telemetry(self): - # return Telemetry(self) - def _auth(self): return self.token @@ -39,54 +35,54 @@ def _download(self): @property def users(self): - from .services import User + from .resources import User return User(self) @property def assets(self): - from .services import Asset + from .resources import Asset return Asset(self) @property def comments(self): - from .services import Comment + from .resources import Comment return Comment(self) @property def logs(self): - from .services import AuditLogs + from .resources import AuditLogs return AuditLogs(self) @property def review_links(self): - from .services import ReviewLink + from .resources import ReviewLink return ReviewLink(self) @property def presentation_links(self): - from .services import PresentationLink + from .resources import PresentationLink return PresentationLink(self) @property def projects(self): - from .services import Project + from .resources import Project return Project(self) @property def teams(self): - from .services import Team + from .resources import Team return Team(self) @property def helpers(self): - from .services import FrameioHelpers + from .resources import FrameioHelpers return FrameioHelpers(self) diff --git a/frameioclient/lib/bandwidth.py b/frameioclient/lib/bandwidth.py deleted file mode 100644 index b1991d53..00000000 --- a/frameioclient/lib/bandwidth.py +++ /dev/null @@ -1,57 +0,0 @@ -import speedtest - - -class NetworkBandwidth: - # Test the network bandwidth any time we have a new IP address - # Persist this information to a config.json file - - def __init__(self): - self.results = dict() - - def load_stats(self): - # Force an update on these stats before starting download/upload - pass - - def persist_stats(self): - pass - - def run(self): - self.results = self.speed_test() - - @staticmethod - def speedtest(): - """ - Run a speedtest using Speedtest.net in order to get a 'control' for \ - bandwidth optimization. - - Example:: - NetworkBandwidth.speedtest() - """ - - st = speedtest.Speedtest() - download_speed = round(st.download(threads=10) * (1.192 * 10 ** -7), 2) - upload_speed = round(st.upload(threads=10) * (1.192 * 10 ** -7), 2) - servernames = [] - server_names = st.get_servers(servernames) - ping = st.results.ping - - return { - "ping": ping, - "download_speed": download_speed, - "upload_speed": upload_speed, - } - - def __repr__(self): - self.results - - -class DiskBandwidth: - # Test the disk speed and write to a config.json file for re-use - # Worth re-checking the disk every time a new one is detected (base route) - - def __init__(self, volume): - self.volume = volume - self.results = dict() - - def __repr__(self): - self.results diff --git a/frameioclient/lib/constants.py b/frameioclient/lib/constants.py index 4b59e029..c206632a 100644 --- a/frameioclient/lib/constants.py +++ b/frameioclient/lib/constants.py @@ -20,4 +20,5 @@ default_thread_count = 5 -retryable_statuses = [400, 429, 500, 503] +retryable_statuses = [400, 429, 500, 502, 503] +retryable_methods = ['GET', 'PUT', 'POST', 'DELETE'] diff --git a/frameioclient/lib/download.py b/frameioclient/lib/download.py index a96dca46..be3dfb27 100644 --- a/frameioclient/lib/download.py +++ b/frameioclient/lib/download.py @@ -1,21 +1,15 @@ -import os import math +import os from typing import Dict -from .utils import Utils - from .logger import SDKLogger from .transfer import AWSClient - -# from .telemetry import Event, ComparisonTest +from .utils import Utils logger = SDKLogger("downloads") -from .exceptions import ( - DownloadException, - WatermarkIDDownloadException, - AssetNotFullyUploaded, -) +from .exceptions import (AssetNotFullyUploaded, DownloadException, + WatermarkIDDownloadException) class FrameioDownloader(object): diff --git a/frameioclient/lib/service.py b/frameioclient/lib/service.py index bd5e455c..7ad2632d 100644 --- a/frameioclient/lib/service.py +++ b/frameioclient/lib/service.py @@ -1,18 +1,15 @@ from ..client import FrameioClient -from ..lib.bandwidth import NetworkBandwidth class Service(object): def __init__(self, client: FrameioClient): self.client = client self.concurrency = 10 - self.bandwidth = NetworkBandwidth() - # Auto-configure afterwards + # Auto-configure SDK self.autoconfigure() def autoconfigure(self): - # self.bandwidth = SpeedTest.speedtest() return def save_config(self): diff --git a/frameioclient/lib/telemetry.py b/frameioclient/lib/telemetry.py index 51248bd7..309e89fd 100644 --- a/frameioclient/lib/telemetry.py +++ b/frameioclient/lib/telemetry.py @@ -13,11 +13,10 @@ class Telemetry(object): def __init__(self, user_id): self.user_id = user_id - self.speedtest = None self.identity = None self.context = None self.integrations = {"all": False, "Amplitude": True} - self.logger = SDKLogger("telemetry") + self.logger = SDKLogger("frameioclient.telemetry") self.build_context() @@ -84,7 +83,6 @@ def track_transfer(self): # self.logger.info(pprint(chunk)) # Collect info to build message - # Build payload for transfer tracking # stats_payload = self._build_transfer_stats_payload() diff --git a/frameioclient/lib/transfer.py b/frameioclient/lib/transfer.py index f698e519..7b40d1ff 100644 --- a/frameioclient/lib/transfer.py +++ b/frameioclient/lib/transfer.py @@ -11,20 +11,15 @@ from .exceptions import ( AssetChecksumMismatch, AssetChecksumNotPresent, - DownloadException, -) -from .logger import SDKLogger -from .utils import FormatTypes, Utils - -logger = SDKLogger("downloads") - -from .bandwidth import DiskBandwidth, NetworkBandwidth -from .exceptions import ( AssetNotFullyUploaded, DownloadException, WatermarkIDDownloadException, ) +from .logger import SDKLogger from .transport import HTTPClient +from .utils import FormatTypes, Utils + +logger = SDKLogger("frameioclient.transfer") class FrameioDownloader(object): @@ -207,8 +202,6 @@ def __init__(self, downloader: FrameioDownloader, concurrency=None, progress=Tru # Ensure this is a valid number before assigning if concurrency is not None and type(concurrency) == int and concurrency > 0: self.concurrency = concurrency - # else: - # self.concurrency = self._optimize_concurrency() @staticmethod def check_cdn(url): @@ -237,25 +230,6 @@ def _create_file_stub(self): raise e return True - def _optimize_concurrency(self): - """ - This method looks as the net_stats and disk_stats that we've run on \ - the current environment in order to suggest the best optimized \ - number of concurrent TCP connections. - - Example:: - AWSClient._optimize_concurrency() - """ - - net_stats = NetworkBandwidth - disk_stats = DiskBandwidth - - # Algorithm ensues - # - # - - return 5 - def _get_byte_range( self, url: str, start_byte: Optional[int] = 0, end_byte: Optional[int] = 2048 ): @@ -460,27 +434,3 @@ def multi_thread_download(self): return dl_info else: return self.destination - - -class TransferJob(AWSClient): - # These will be used to track the job and then push telemetry - def __init__(self, job_info): - self.job_info = job_info # < - convert to JobInfo class - self.cdn = "S3" # or 'CF' - use check_cdn to confirm - self.progress_manager = None - - -class DownloadJob(TransferJob): - def __init__(self): - self.asset_type = "review_link" # we should use a dataclass here - # Need to create a re-usable job schema - # Think URL -> output_path - pass - - -class UploadJob(TransferJob): - def __init__(self, destination): - self.destination = destination - # Need to create a re-usable job schema - # Think local_file path and remote Frame.io destination - pass diff --git a/frameioclient/lib/transport.py b/frameioclient/lib/transport.py index 6dda6e16..89bc5fc5 100644 --- a/frameioclient/lib/transport.py +++ b/frameioclient/lib/transport.py @@ -1,7 +1,7 @@ import concurrent.futures import threading import time -from typing import Dict, Optional +from typing import Union, Dict, Iterable, Optional import requests from requests.adapters import HTTPAdapter @@ -43,6 +43,7 @@ def __init__(self, threads: Optional[int] = default_thread_count): self.thread_local = None self.client_version = ClientVersion.version() self.shared_headers = {"x-frameio-client": f"python/{self.client_version}"} + self.rate_limit_bypass_header = {"x-client-type": "Socket Service v2"} # Configure retry strategy (very broad right now) self.retry_strategy = Retry( @@ -52,10 +53,7 @@ def __init__(self, threads: Optional[int] = default_thread_count): method_whitelist=["GET", "POST", "PUT", "GET", "DELETE"], ) - # Create real thread - self._initialize_thread() - - def _initialize_thread(self): + # Initialize thread self.thread_local = threading.local() def _get_session(self): @@ -88,7 +86,6 @@ def __init__(self, token: str, host: str, threads: int, progress: bool): self.token = token self.threads = threads self.progress = progress - self._initialize_thread() self.session = self._get_session() self.auth_header = {"Authorization": f"Bearer {self.token}"} @@ -97,8 +94,8 @@ def _format_api_call(self, endpoint: str): def _api_call( self, method, endpoint: str, payload: Dict = {}, limit: Optional[int] = None - ): - headers = {**self.shared_headers, **self.auth_header} + ) -> Union[Dict, PaginatedResponse, None]: + headers = {**self.shared_headers, **self.auth_header, **self.rate_limit_bypass_header} r = self.session.request( method, self._format_api_call(endpoint), headers=headers, json=payload @@ -126,6 +123,12 @@ def _api_call( if r.status_code == 422 and "presentation" in endpoint: raise PresentationException + + if r.status_code == 500 and 'audit' in endpoint: + print(f"Hit a 500 on page: {r.headers.get('page-number')}, url: {r.url}") + return [] + + return r.raise_for_status() @@ -142,14 +145,15 @@ def get_specific_page( page (int): What page to get """ if method == HTTPMethods.GET: - endpoint = "{endpoint}?page={page}" + endpoint = f"{endpoint}?page={page}" return self._api_call(method, endpoint) if method == HTTPMethods.POST: payload["page"] = page + return self._api_call(method, endpoint, payload=payload) - def exec_stream(callable, iterable, sync=lambda _: False, capacity=10, rate=10): + def exec_stream(callable, iterable: Iterable, sync=lambda _: False, capacity=10, rate=10): """ Executes a stream according to a defined rate limit. """ diff --git a/frameioclient/lib/upload.py b/frameioclient/lib/upload.py index 50128245..46ecd633 100644 --- a/frameioclient/lib/upload.py +++ b/frameioclient/lib/upload.py @@ -108,7 +108,7 @@ def upload(self): except Exception as exc: print(exc) - def file_counter(self, folder): + def count_files(self, folder): matches = [] for root, dirnames, filenames in os.walk(folder): for filename in filenames: @@ -118,13 +118,13 @@ def file_counter(self, folder): return matches - def recursive_upload(self, client, folder, parent_asset_id): + def upload_recursive(self, client, folder, parent_asset_id): # Seperate files and folders: file_list = list() folder_list = list() if self.file_count == 0: - self.file_counter(folder) + self.count_files(folder) for item in os.listdir(folder): if item == ".DS_Store": # Ignore .DS_Store files on Mac @@ -159,4 +159,4 @@ def recursive_upload(self, client, folder, parent_asset_id): parent_asset_id=parent_asset_id, name=folder_name, type="folder" )["id"] - self.recursive_upload(client, new_folder, new_parent_asset_id) + self.upload_recursive(client, new_folder, new_parent_asset_id) diff --git a/frameioclient/lib/utils.py b/frameioclient/lib/utils.py index 3d18b670..320ac5d9 100644 --- a/frameioclient/lib/utils.py +++ b/frameioclient/lib/utils.py @@ -2,9 +2,10 @@ import os import re import sys -from typing import Any, Dict, Optional +from typing import Any, Dict, Generator, Optional import xxhash +from furl import furl KB = 1024 MB = KB * KB @@ -31,27 +32,42 @@ class FormatTypes(enum.Enum): class Utils: @staticmethod - def stream(func, page=1, page_size=20): + def stream(func, page=1, page_size=50): """ Accepts a lambda of a call to a client list method, and streams the results until \ the list has been exhausted. - Args: - fun (function): A 1-arity function to apply during the stream + Args: + fun (function): A 1-arity function to apply during the stream - Example:: - - stream(lambda pagination: client.get_collaborators(project_id, **pagination)) - """ + Example:: + + stream(lambda pagination: client.get_collaborators(project_id, **pagination)) + """ total_pages = page while page <= total_pages: result_list = func(page=page, page_size=page_size) - total_pages = result_list.total_pages - for res in result_list: + if type(result_list) == PaginatedResponse: + total_pages = result_list.total_pages + for res in result_list: + yield res + else: yield res page += 1 + @staticmethod + def stream_results( + endpoint, page=1, page_size=50, client=None, **_kwargs + ) -> Generator: + def fetch_page(page=1, page_size=50): + return client._api_call( + "get", furl(endpoint).add({"page": page, "page_size": page_size}).url + ) + + for result in Utils.stream(fetch_page, page=page, page_size=page_size): + yield result + @staticmethod def format_value(value: int, type: FormatTypes = FormatTypes.SIZE) -> str: """ diff --git a/frameioclient/services/__init__.py b/frameioclient/resources/__init__.py similarity index 100% rename from frameioclient/services/__init__.py rename to frameioclient/resources/__init__.py diff --git a/frameioclient/services/assets.py b/frameioclient/resources/assets.py similarity index 98% rename from frameioclient/services/assets.py rename to frameioclient/resources/assets.py index 08a2ac08..21d74344 100644 --- a/frameioclient/services/assets.py +++ b/frameioclient/resources/assets.py @@ -141,7 +141,7 @@ def create( } endpoint = "/assets/{}/children".format(parent_asset_id) - return self.client._api_call("post", endpoint, payload=kwargs) + return self.client._api_call("post", endpoint, payload={**kwargs}) @ApiReference(operation="#createAsset") def create_folder(self, parent_asset_id: str, name: str = "New Folder"): @@ -259,6 +259,7 @@ def bulk_copy( endpoint = "/batch/assets/{}/copy".format(destination_folder_id) return self.client._api_call("post", endpoint, payload) + @ApiReference(operation="#addVersionToAsset") def add_version( self, target_asset_id: Union[str, UUID], new_version_id: Union[str, UUID] ): @@ -378,9 +379,11 @@ def download( client.assets.download(asset, "~./Downloads") """ + downloader = FrameioDownloader( asset, download_folder, prefix, multi_part, replace ) + return AWSClient(downloader, concurrency=5).multi_thread_download() def upload_folder(self, source_path: str, destination_id: Union[str, UUID]): @@ -407,6 +410,6 @@ def upload_folder(self, source_path: str, destination_id: Union[str, UUID]): # Then try to grab it as a project folder_id = Project(self.client).get(destination_id)["root_asset_id"] finally: - return FrameioUploader().recursive_upload( + return FrameioUploader().upload_recursive( self.client, source_path, folder_id ) diff --git a/frameioclient/services/comments.py b/frameioclient/resources/comments.py similarity index 98% rename from frameioclient/services/comments.py rename to frameioclient/resources/comments.py index 5d797183..4376f82e 100644 --- a/frameioclient/services/comments.py +++ b/frameioclient/resources/comments.py @@ -104,7 +104,7 @@ def delete(self, comment_id: Union[str, UUID]): return self.client._api_call("delete", endpoint) @ApiReference(operation="#createReply") - def reply(self, comment_id, **kwargs): + def reply(self, comment_id: Union[str, UUID], **kwargs): """ Reply to an existing comment. diff --git a/frameioclient/services/helpers.py b/frameioclient/resources/helpers.py similarity index 100% rename from frameioclient/services/helpers.py rename to frameioclient/resources/helpers.py diff --git a/frameioclient/services/links.py b/frameioclient/resources/links.py similarity index 75% rename from frameioclient/services/links.py rename to frameioclient/resources/links.py index 731f02c9..f5b63848 100644 --- a/frameioclient/services/links.py +++ b/frameioclient/resources/links.py @@ -1,10 +1,12 @@ +from typing import Union +from uuid import UUID from ..lib.utils import ApiReference from ..lib.service import Service class ReviewLink(Service): @ApiReference(operation="#reviewLinkCreate") - def create(self, project_id, **kwargs): + def create(self, project_id: Union[str, UUID], **kwargs): """ Create a review link. @@ -26,7 +28,7 @@ def create(self, project_id, **kwargs): return self.client._api_call("post", endpoint, payload=kwargs) @ApiReference(operation="#reviewLinksList") - def list(self, project_id): + def list(self, project_id: Union[str, UUID]): """ Get the review links of a project @@ -37,7 +39,7 @@ def list(self, project_id): return self.client._api_call("get", endpoint) @ApiReference(operation="#reviewLinkGet") - def get(self, link_id, **kwargs): + def get(self, link_id: Union[str, UUID], **kwargs): """ Get a single review link @@ -48,7 +50,7 @@ def get(self, link_id, **kwargs): return self.client._api_call("get", endpoint, payload=kwargs) @ApiReference(operation="#reviewLinkItemsList") - def get_assets(self, link_id): + def get_assets(self, link_id: Union[str, UUID]): """ Get items from a single review link. @@ -65,7 +67,7 @@ def get_assets(self, link_id): return self.client._api_call("get", endpoint) @ApiReference(operation="#reviewLinkItemsUpdate") - def update_assets(self, link_id, **kwargs): + def update_assets(self, link_id: Union[str, UUID], **kwargs): """ Add or update assets for a review link. @@ -86,7 +88,7 @@ def update_assets(self, link_id, **kwargs): return self.client._api_call("post", endpoint, payload=kwargs) @ApiReference(operation="#reviewLinkUpdate") - def update_settings(self, link_id, **kwargs): + def update_settings(self, link_id: Union[str, UUID], **kwargs): """ Updates review link settings. @@ -112,7 +114,7 @@ def update_settings(self, link_id, **kwargs): class PresentationLink(Service): @ApiReference(operation="#createPresentation") - def create(self, asset_id, **kwargs): + def create(self, asset_id: Union[str, UUID], **kwargs): """ Create a presentation link. @@ -132,3 +134,24 @@ def create(self, asset_id, **kwargs): """ endpoint = "/assets/{}/presentations".format(asset_id) return self.client._api_call("post", endpoint, payload=kwargs) + + def update(self, presentation_id: Union[str, UUID], **kwargs): + """ + Update a presentation link. + + Args: + presentation_id (string): The presentation id. + + :Keyword Arguments: + kwargs: additional request parameters. + + Example:: + + client.presentation_links.update( + presentation_id="9cee7966-4066-b326-7db1-f9e6f5e929e4", + name="My fresh presentation", + enabled=False + ) + """ + endpoint = "/presentations/{}".format(presentation_id) + return self.client._api_call("put", endpoint, payload=kwargs) diff --git a/frameioclient/services/logs.py b/frameioclient/resources/logs.py similarity index 100% rename from frameioclient/services/logs.py rename to frameioclient/resources/logs.py diff --git a/frameioclient/services/projects.py b/frameioclient/resources/projects.py similarity index 100% rename from frameioclient/services/projects.py rename to frameioclient/resources/projects.py diff --git a/frameioclient/services/search.py b/frameioclient/resources/search.py similarity index 82% rename from frameioclient/services/search.py rename to frameioclient/resources/search.py index 31067f09..96c54c0b 100644 --- a/frameioclient/services/search.py +++ b/frameioclient/resources/search.py @@ -22,8 +22,6 @@ def library( Search for assets using the library search endpoint, documented at https://developer.frame.io/docs/workflows-assets/search-for-assets. For more information check out https://developer.frame.io/api/reference/operation/librarySearchPost/. - # TODO, confirm that account_id is required or not, could we use self.me? - :param query: The search keyword you want to search with. :param account_id: The frame.io account want you to contrain your search to (you may only have one, but some users have 20+ that they have acces to). :param type: The type of frame.io asset you want to search: [file, folder, review_link, presentation]. @@ -73,3 +71,21 @@ def library( endpoint = "/search/library" return self.client._api_call("post", endpoint, payload=payload) + + def users(self, account_id: str, query: str): + """Search for users within a given account + + Args: + account_id (str): UUID for the account you want to search within, must be one you have access to + query (str): The query string you want to seach with, usually an email or a name + + Returns: + List[Dict]: List of user resources found via your search + """ + + endpoint = "/search/users" + payload = { + "account_id": account_id, + "q": query + } + return self.client._api_call("post", endpoint, payload=payload) diff --git a/frameioclient/services/teams.py b/frameioclient/resources/teams.py similarity index 97% rename from frameioclient/services/teams.py rename to frameioclient/resources/teams.py index 6c3ee306..ee875356 100644 --- a/frameioclient/services/teams.py +++ b/frameioclient/resources/teams.py @@ -98,6 +98,8 @@ def remove_members(self, team_id, emails): emails (list): The e-mails you want to add. """ + # TODO: Implement pagination here since the batch size is 20? + payload = dict() payload["batch"] = list(map(lambda email: {"email": email}, emails)) diff --git a/frameioclient/services/users.py b/frameioclient/resources/users.py similarity index 100% rename from frameioclient/services/users.py rename to frameioclient/resources/users.py diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 00000000..fac37138 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,847 @@ +[[package]] +name = "alabaster" +version = "0.7.12" +description = "A configurable sidebar-enabled Sphinx theme" +category = "dev" +optional = true +python-versions = "*" + +[[package]] +name = "analytics-python" +version = "1.4.0" +description = "The hassle-free way to integrate analytics into any python application." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +backoff = "1.10.0" +monotonic = ">=1.5" +python-dateutil = ">2.1" +requests = ">=2.7,<3.0" +six = ">=1.5" + +[package.extras] +test = ["flake8 (==3.7.9)", "pylint (==1.9.3)", "mock (==2.0.0)"] + +[[package]] +name = "ansicon" +version = "1.89.0" +description = "Python wrapper for loading Jason Hood's ANSICON" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "babel" +version = "2.9.1" +description = "Internationalization utilities" +category = "dev" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.dependencies] +pytz = ">=2015.7" + +[[package]] +name = "backoff" +version = "1.10.0" +description = "Function decoration for backoff and retry" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "beautifulsoup4" +version = "4.10.0" +description = "Screen-scraping library" +category = "dev" +optional = true +python-versions = ">3.0.0" + +[package.dependencies] +soupsieve = ">1.2" + +[package.extras] +html5lib = ["html5lib"] +lxml = ["lxml"] + +[[package]] +name = "blessed" +version = "1.19.1" +description = "Easy, practical library for making terminal apps, by providing an elegant, well-documented interface to Colors, Keyboard input, and screen Positioning capabilities." +category = "main" +optional = false +python-versions = ">=2.7" + +[package.dependencies] +jinxed = {version = ">=1.1.0", markers = "platform_system == \"Windows\""} +six = ">=1.9.0" +wcwidth = ">=0.1.4" + +[[package]] +name = "bump2version" +version = "1.0.1" +description = "Version-bump your software with a single command!" +category = "dev" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "certifi" +version = "2021.10.8" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "chardet" +version = "4.0.0" +description = "Universal encoding detector for Python 2 and 3" +category = "dev" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "charset-normalizer" +version = "2.0.12" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" +optional = false +python-versions = ">=3.5.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + +[[package]] +name = "colorama" +version = "0.4.4" +description = "Cross-platform colored terminal text." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "contentful-management" +version = "2.11.0" +description = "Contentful Management API Client" +category = "dev" +optional = true +python-versions = "*" + +[package.dependencies] +python-dateutil = "*" +requests = ">=2.20.0,<3.0" + +[[package]] +name = "docutils" +version = "0.17.1" +description = "Docutils -- Python Documentation Utilities" +category = "dev" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "enlighten" +version = "1.10.2" +description = "Enlighten Progress Bar" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +blessed = ">=1.17.7" +prefixed = ">=0.3.2" + +[[package]] +name = "furl" +version = "2.1.3" +description = "URL manipulation made simple." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +orderedmultidict = ">=1.0.1" +six = ">=1.8.0" + +[[package]] +name = "furo" +version = "2022.3.4" +description = "A clean customisable Sphinx documentation theme." +category = "dev" +optional = true +python-versions = ">=3.6" + +[package.dependencies] +beautifulsoup4 = "*" +pygments = ">=2.7,<3.0" +sphinx = ">=4.0,<5.0" + +[[package]] +name = "html2text" +version = "2020.1.16" +description = "Turn HTML into equivalent Markdown-structured text." +category = "dev" +optional = true +python-versions = ">=3.5" + +[[package]] +name = "idna" +version = "3.3" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "imagesize" +version = "1.3.0" +description = "Getting image size from png/jpeg/jpeg2000/gif file" +category = "dev" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "importlib-metadata" +version = "4.11.3" +description = "Read metadata from Python packages" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.dependencies] +typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} +zipp = ">=0.5" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] +perf = ["ipython"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] + +[[package]] +name = "jinja2" +version = "3.0.3" +description = "A very fast and expressive template engine." +category = "dev" +optional = true +python-versions = ">=3.6" + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "jinxed" +version = "1.1.0" +description = "Jinxed Terminal Library" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +ansicon = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "livereload" +version = "2.6.3" +description = "Python LiveReload is an awesome tool for web developers" +category = "dev" +optional = true +python-versions = "*" + +[package.dependencies] +six = "*" +tornado = {version = "*", markers = "python_version > \"2.7\""} + +[[package]] +name = "markupsafe" +version = "2.1.1" +description = "Safely add untrusted strings to HTML/XML markup." +category = "dev" +optional = true +python-versions = ">=3.7" + +[[package]] +name = "monotonic" +version = "1.6" +description = "An implementation of time.monotonic() for Python 2 & < 3.3" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "munch" +version = "2.5.0" +description = "A dot-accessible dictionary (a la JavaScript objects)" +category = "dev" +optional = true +python-versions = "*" + +[package.dependencies] +six = "*" + +[package.extras] +testing = ["pytest", "coverage", "astroid (>=1.5.3,<1.6.0)", "pylint (>=1.7.2,<1.8.0)", "astroid (>=2.0)", "pylint (>=2.3.1,<2.4.0)"] +yaml = ["PyYAML (>=5.1.0)"] + +[[package]] +name = "orderedmultidict" +version = "1.0.1" +description = "Ordered Multivalue Dictionary" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +six = ">=1.8.0" + +[[package]] +name = "packaging" +version = "21.3" +description = "Core utilities for Python packages" +category = "dev" +optional = true +python-versions = ">=3.6" + +[package.dependencies] +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" + +[[package]] +name = "prefixed" +version = "0.3.2" +description = "Prefixed alternative numeric library" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "pydash" +version = "5.1.0" +description = "The kitchen sink of Python utility libraries for doing \"stuff\" in a functional way. Based on the Lo-Dash Javascript library." +category = "dev" +optional = true +python-versions = ">=3.6" + +[package.extras] +dev = ["black", "coverage", "docformatter", "flake8", "flake8-black", "flake8-bugbear", "flake8-isort", "invoke", "isort", "pylint", "pytest", "pytest-cov", "pytest-flake8", "pytest-pylint", "sphinx", "sphinx-rtd-theme", "tox", "twine", "wheel"] + +[[package]] +name = "pygments" +version = "2.11.2" +description = "Pygments is a syntax highlighting package written in Python." +category = "dev" +optional = true +python-versions = ">=3.5" + +[[package]] +name = "pyparsing" +version = "3.0.7" +description = "Python parsing module" +category = "dev" +optional = true +python-versions = ">=3.6" + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] + +[[package]] +name = "python-dateutil" +version = "2.8.2" +description = "Extensions to the standard Python datetime module" +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "python-dotenv" +version = "0.19.2" +description = "Read key-value pairs from a .env file and set them as environment variables" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "python-frontmatter" +version = "1.0.0" +description = "Parse and manage posts with YAML (or other) frontmatter" +category = "dev" +optional = true +python-versions = "*" + +[package.dependencies] +PyYAML = "*" + +[package.extras] +test = ["pyaml", "toml", "pytest"] +docs = ["sphinx"] + +[[package]] +name = "pytz" +version = "2022.1" +description = "World timezone definitions, modern and historical" +category = "dev" +optional = true +python-versions = "*" + +[[package]] +name = "pyyaml" +version = "6.0" +description = "YAML parser and emitter for Python" +category = "dev" +optional = true +python-versions = ">=3.6" + +[[package]] +name = "requests" +version = "2.27.1" +description = "Python HTTP for Humans." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} +idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "snowballstemmer" +version = "2.2.0" +description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." +category = "dev" +optional = true +python-versions = "*" + +[[package]] +name = "soupsieve" +version = "2.3.1" +description = "A modern CSS selector implementation for Beautiful Soup." +category = "dev" +optional = true +python-versions = ">=3.6" + +[[package]] +name = "sphinx" +version = "4.4.0" +description = "Python documentation generator" +category = "dev" +optional = true +python-versions = ">=3.6" + +[package.dependencies] +alabaster = ">=0.7,<0.8" +babel = ">=1.3" +colorama = {version = ">=0.3.5", markers = "sys_platform == \"win32\""} +docutils = ">=0.14,<0.18" +imagesize = "*" +importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} +Jinja2 = ">=2.3" +packaging = "*" +Pygments = ">=2.0" +requests = ">=2.5.0" +snowballstemmer = ">=1.1" +sphinxcontrib-applehelp = "*" +sphinxcontrib-devhelp = "*" +sphinxcontrib-htmlhelp = ">=2.0.0" +sphinxcontrib-jsmath = "*" +sphinxcontrib-qthelp = "*" +sphinxcontrib-serializinghtml = ">=1.1.5" + +[package.extras] +docs = ["sphinxcontrib-websupport"] +lint = ["flake8 (>=3.5.0)", "isort", "mypy (>=0.931)", "docutils-stubs", "types-typed-ast", "types-requests"] +test = ["pytest", "pytest-cov", "html5lib", "cython", "typed-ast"] + +[[package]] +name = "sphinx-autobuild" +version = "2021.3.14" +description = "Rebuild Sphinx documentation on changes, with live-reload in the browser." +category = "dev" +optional = true +python-versions = ">=3.6" + +[package.dependencies] +colorama = "*" +livereload = "*" +sphinx = "*" + +[package.extras] +test = ["pytest", "pytest-cov"] + +[[package]] +name = "sphinx-autodoc-typehints" +version = "1.17.0" +description = "Type hints (PEP 484) support for the Sphinx autodoc extension" +category = "dev" +optional = true +python-versions = ">=3.7" + +[package.dependencies] +Sphinx = ">=4" + +[package.extras] +testing = ["covdefaults (>=2)", "coverage (>=6)", "diff-cover (>=6.4)", "nptyping (>=1)", "pytest (>=6)", "pytest-cov (>=3)", "sphobjinv (>=2)", "typing-extensions (>=3.5)"] +type_comments = ["typed-ast (>=1.4.0)"] + +[[package]] +name = "sphinx-jekyll-builder" +version = "0.3.0" +description = "sphinx builder that outputs jekyll compatible markdown files with frontmatter" +category = "dev" +optional = true +python-versions = "*" + +[package.dependencies] +alabaster = ">=0.7.12" +Babel = ">=2.6.0" +certifi = ">=2018.11.29" +chardet = ">=3.0.4" +docutils = ">=0.14" +html2text = ">=2018.1.9" +idna = ">=2.8" +imagesize = ">=1.1.0" +Jinja2 = ">=2.10.1" +MarkupSafe = ">=1.1.0" +munch = ">=2.3.2" +packaging = ">=19.0" +pydash = ">=4.7.4" +Pygments = ">=2.3.1" +pyparsing = ">=2.3.1" +pytz = ">=2018.9" +PyYAML = ">=5.1" +requests = ">=2.21.0" +six = ">=1.12.0" +snowballstemmer = ">=1.2.1" +Sphinx = ">=1.8.3" +sphinx-markdown-builder = ">=0.5.3" +sphinxcontrib-websupport = ">=1.1.0" +typing = ">=3.6.6" +urllib3 = ">=1.24.2" + +[[package]] +name = "sphinx-markdown-builder" +version = "0.5.5" +description = "sphinx builder that outputs markdown files" +category = "dev" +optional = true +python-versions = "*" + +[package.dependencies] +html2text = "*" +pydash = "*" +sphinx = "*" +unify = "*" +yapf = "*" + +[[package]] +name = "sphinxcontrib-applehelp" +version = "1.0.2" +description = "sphinxcontrib-applehelp is a sphinx extension which outputs Apple help books" +category = "dev" +optional = true +python-versions = ">=3.5" + +[package.extras] +test = ["pytest"] +lint = ["docutils-stubs", "mypy", "flake8"] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "1.0.2" +description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." +category = "dev" +optional = true +python-versions = ">=3.5" + +[package.extras] +test = ["pytest"] +lint = ["docutils-stubs", "mypy", "flake8"] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.0.0" +description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" +category = "dev" +optional = true +python-versions = ">=3.6" + +[package.extras] +test = ["html5lib", "pytest"] +lint = ["docutils-stubs", "mypy", "flake8"] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +description = "A sphinx extension which renders display math in HTML via JavaScript" +category = "dev" +optional = true +python-versions = ">=3.5" + +[package.extras] +test = ["mypy", "flake8", "pytest"] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "1.0.3" +description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." +category = "dev" +optional = true +python-versions = ">=3.5" + +[package.extras] +test = ["pytest"] +lint = ["docutils-stubs", "mypy", "flake8"] + +[[package]] +name = "sphinxcontrib-restbuilder" +version = "0.3" +description = "Sphinx extension to output reST files." +category = "dev" +optional = true +python-versions = ">=2.7, !=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" + +[package.dependencies] +Sphinx = ">=1.4" + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "1.1.5" +description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." +category = "dev" +optional = true +python-versions = ">=3.5" + +[package.extras] +lint = ["flake8", "mypy", "docutils-stubs"] +test = ["pytest"] + +[[package]] +name = "sphinxcontrib-websupport" +version = "1.2.4" +description = "Sphinx API for Web Apps" +category = "dev" +optional = true +python-versions = ">=3.5" + +[package.dependencies] +sphinxcontrib-serializinghtml = "*" + +[package.extras] +lint = ["flake8"] +test = ["pytest", "sqlalchemy", "whoosh", "sphinx"] + +[[package]] +name = "token-bucket" +version = "0.3.0" +description = "Very fast implementation of the token bucket algorithm." +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "tornado" +version = "6.1" +description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." +category = "dev" +optional = true +python-versions = ">= 3.5" + +[[package]] +name = "tqdm" +version = "4.64.1" +description = "Fast, Extensible Progress Meter" +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +dev = ["py-make (>=0.1.0)", "twine", "wheel"] +notebook = ["ipywidgets (>=6)"] +slack = ["slack-sdk"] +telegram = ["requests"] + +[[package]] +name = "typing" +version = "3.7.4.3" +description = "Type Hints for Python" +category = "dev" +optional = true +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "typing-extensions" +version = "4.1.1" +description = "Backported and Experimental Type Hints for Python 3.6+" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "unify" +version = "0.5" +description = "Modifies strings to all use the same (single/double) quote where possible." +category = "dev" +optional = true +python-versions = "*" + +[package.dependencies] +untokenize = "*" + +[[package]] +name = "untokenize" +version = "0.1.1" +description = "Transforms tokens into original source code (while preserving whitespace)." +category = "dev" +optional = true +python-versions = "*" + +[[package]] +name = "urllib3" +version = "1.26.9" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" + +[package.extras] +brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[[package]] +name = "wcwidth" +version = "0.2.5" +description = "Measures the displayed width of unicode strings in a terminal" +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "xxhash" +version = "3.0.0" +description = "Python binding for xxHash" +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "yapf" +version = "0.32.0" +description = "A formatter for Python code." +category = "dev" +optional = true +python-versions = "*" + +[[package]] +name = "zipp" +version = "3.7.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +category = "main" +optional = false +python-versions = ">=3.7" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] + +[extras] +docs = [] + +[metadata] +lock-version = "1.1" +python-versions = "^3.7" +content-hash = "bfcf23e232bd808e73977444b10762e036d0013bff23c6f1cebacce81c7e6fc6" + +[metadata.files] +alabaster = [] +analytics-python = [] +ansicon = [] +babel = [] +backoff = [] +beautifulsoup4 = [] +blessed = [] +bump2version = [] +certifi = [] +chardet = [] +charset-normalizer = [] +colorama = [] +contentful-management = [] +docutils = [] +enlighten = [] +furl = [] +furo = [] +html2text = [] +idna = [] +imagesize = [] +importlib-metadata = [] +jinja2 = [] +jinxed = [] +livereload = [] +markupsafe = [] +monotonic = [] +munch = [] +orderedmultidict = [] +packaging = [] +prefixed = [] +pydash = [] +pygments = [] +pyparsing = [] +python-dateutil = [] +python-dotenv = [] +python-frontmatter = [] +pytz = [] +pyyaml = [] +requests = [] +six = [] +snowballstemmer = [] +soupsieve = [] +sphinx = [] +sphinx-autobuild = [] +sphinx-autodoc-typehints = [] +sphinx-jekyll-builder = [] +sphinx-markdown-builder = [] +sphinxcontrib-applehelp = [] +sphinxcontrib-devhelp = [] +sphinxcontrib-htmlhelp = [] +sphinxcontrib-jsmath = [] +sphinxcontrib-qthelp = [] +sphinxcontrib-restbuilder = [] +sphinxcontrib-serializinghtml = [] +sphinxcontrib-websupport = [] +token-bucket = [] +tornado = [] +typing = [] +typing-extensions = [] +unify = [] +untokenize = [] +urllib3 = [] +wcwidth = [] +xxhash = [] +yapf = [] +zipp = [] diff --git a/pyproject.toml b/pyproject.toml index b0471b7f..a4f2b913 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,64 @@ +[tool.poetry] +name = "frameioclient" +version = "2.0.1a5" +description='Client library for the Frame.io API' +readme = "README.md" +license='MIT' +homepage = "https://github.com/Frameio/python-frameio-client" +authors = ["Frame.io DevRel "] +classifiers = [ + 'Development Status :: 5 - Production/Stable', + 'Intended Audience :: Developers', + 'Topic :: Multimedia :: Video', + 'Topic :: Software Development :: Libraries', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9' +] + +[tool.poetry.dependencies] +python = "^3.7" +analytics-python = "^1.4.0" +enlighten = "^1.10.2" +importlib-metadata = "^4.11.3" +requests = "^2.27.1" +token-bucket = "^0.3.0" +urllib3 = "^1.26.9" +xxhash = "^3.0.0" +furl = "^2.1.3" +tqdm = "^4.64.1" + +[tool.poetry.dev-dependencies] +bump2version = "^1.0.1" + +# Optional dependencies +Sphinx = { version = "^4.4.0", optional = true } +sphinx-jekyll-builder = { version = "^0.3.0", optional = true } +sphinxcontrib-restbuilder = { version = "^0.3", optional = true } +sphinx-autobuild = { version = "^2021.3.14", optional = true } +contentful_management = { version = "^2.11.0", optional = true } +python-frontmatter = { version = "^1.0.0", optional = true } +sphinx-autodoc-typehints = { version = "^1.17.0", optional = true } +furo = { version = "^2022.3.4", optional = true } +python-dotenv = "^0.19.2" + +[tool.poetry.extras] +docs = [ + "sphinx", + "sphinx-jekyll-builder", + "sphinxcontrib-restbuilder", + "sphinx-autobuild", + "contentful_management", + "python-frontmatter", + "sphinx-autodoc-typehints", + "furo" +] + +[tool.poetry.scripts] +fiocli = 'frameioclient.fiocli:main' + [build-system] -requires = ["setuptools", "wheel"] -build-backend = "setuptools.build_meta:__legacy__" \ No newline at end of file +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/setup.py b/setup.py index b39899c4..02601345 100644 --- a/setup.py +++ b/setup.py @@ -27,10 +27,10 @@ def run(self): install_requires=[ 'analytics-python', 'enlighten', + 'furl', 'importlib-metadata ~= 1.0 ; python_version < "3.8"', 'requests', 'token-bucket', - 'speedtest-cli', 'urllib3', 'xxhash', ], diff --git a/tests/test_frameiodownloader.py b/tests/test_frameiodownloader.py index f09cf379..400cb2e8 100644 --- a/tests/test_frameiodownloader.py +++ b/tests/test_frameiodownloader.py @@ -5,14 +5,17 @@ regular_asset = { "is_hls_required": False, "is_session_watermarked": False, - "downloads": { - "h264_720": "some-720-url", - "h264_1080_best": "some-1080-url" - }, + "filesize": 1000, + "name": "Demo file.mov", + "duration": "100.00", + "filetype": 'quicktime/mov', + "type": "file", + "_type": "file", + "downloads": {"h264_720": "some-720-url", "h264_1080_best": "some-1080-url"}, "h264_720": "some-720-url", "h264_1080_best": "some-1080-url", "original": "some-original-url", - "hls_manifest": "some-hls-url" + "hls_manifest": "some-hls-url", } watermarked_asset_download_allowed = { @@ -20,36 +23,43 @@ "is_session_watermarked": True, "downloads": { "h264_720": "download-stream-service-url", - "h264_1080_best": "download-stream-service-url" + "h264_1080_best": "download-stream-service-url", }, - "hls_manifest": "hls-url" + "hls_manifest": "hls-url", } watermarked_asset_no_download = { "is_hls_required": True, "is_session_watermarked": True, - "hls_manifest": "hls-url" + "hls_manifest": "hls-url", } no_download_allowed = { "is_hls_required": True, "is_session_watermarked": False, - "hls_manifest": "hls-url" + "hls_manifest": "hls-url", } + def test_get_download_key_returns_original(): - url = FrameioDownloader(regular_asset, './').get_download_key() - assert url == regular_asset['original'] + url = FrameioDownloader( + regular_asset, + download_folder="./", + prefix="", + ).get_download_key() + assert url == regular_asset["original"] + def test_get_download_key_returns_watermarked_download(): - url = FrameioDownloader(watermarked_asset_download_allowed, './').get_download_key() - assert url == watermarked_asset_download_allowed['downloads']['h264_1080_best'] + url = FrameioDownloader(watermarked_asset_download_allowed, "./").get_download_key() + assert url == watermarked_asset_download_allowed["downloads"]["h264_1080_best"] + def test_get_download_key_fails_gracefully_on_watermarked_asset(): with pytest.raises(DownloadException): - FrameioDownloader(watermarked_asset_no_download, './').get_download_key() + FrameioDownloader(watermarked_asset_no_download, "./").get_download_key() + def test_get_download_key_fails_gracefully_when_downloads_disallowed(): with pytest.raises(DownloadException): - FrameioDownloader(no_download_allowed, './').get_download_key() - + FrameioDownloader(no_download_allowed, "./").get_download_key() diff --git a/tests/py3_integration.py b/tests/test_integration.py similarity index 95% rename from tests/py3_integration.py rename to tests/test_integration.py index c4564f11..40820e49 100644 --- a/tests/py3_integration.py +++ b/tests/test_integration.py @@ -13,6 +13,10 @@ from frameioclient import FrameioClient, Utils, KB, MB from frameioclient.lib.utils import FormatTypes +from dotenv import find_dotenv, load_dotenv +load_dotenv(find_dotenv()) + + token = os.getenv("FRAMEIO_TOKEN") # Your Frame.io token project_id = os.getenv("PROJECT_ID") # Project you want to upload files back into download_asset_id = os.getenv("DOWNLOAD_FOLDER_ID") # Source folder on Frame.io (to then verify against) @@ -122,18 +126,19 @@ def test_download(client: FrameioClient, override=False): return True -# Test upload functionality +# Test upload functionality def test_upload(client: FrameioClient): print("Beginning upload test") # Create new parent asset project_info = client.projects.get(project_id) root_asset_id = project_info['root_asset_id'] - print("Creating new folder to upload to") - new_folder = client.assets.create( - parent_asset_id=root_asset_id, - name="{}_{}_Py{}_{}".format(socket.gethostname(), platform.system(), platform.python_version(), datetime.now().strftime("%B-%d-%Y")), - type="folder", + print(f"Creating new folder to upload to in project {project_id}") + test_run_name = "{}_{}_Py{}_{}".format(socket.gethostname(), platform.system(), platform.python_version(), datetime.now().strftime('%B-%d-%Y')) + print(f"Folder name: {test_run_name}") + new_folder = client.assets.create_folder( + parent_asset_id=root_asset_id, + name=test_run_name, ) new_parent_id = new_folder['id']