├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── AUTHORS.md ├── CHANGES.md ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs ├── DOCUMENTATION.md └── examples │ ├── config.json │ ├── config.toml │ ├── config.yaml │ ├── example_auth-from-file.py │ ├── example_auth-inline.py │ ├── example_context-manager.py │ └── example_upload_file.py ├── pyproject.toml ├── requirements-dev.txt ├── requirements.txt ├── setup.cfg ├── setup.py ├── src └── graph_onedrive │ ├── __init__.py │ ├── __main__.py │ ├── _cli.py │ ├── _config.py │ ├── _decorators.py │ ├── _main.py │ ├── _manager.py │ ├── _onedrive.py │ └── py.typed └── tests ├── __init__.py ├── _config_test.py ├── _decorators_test.py ├── _manager_test.py ├── _onedrive_test.py ├── conftest.py └── mock_responses.json /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # Test the revised code on a push into the main branch 2 | # https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#running-tests-with-tox 3 | 4 | name: Tests 5 | 6 | on: 7 | pull_request: 8 | branches: 9 | - main 10 | paths-ignore: 11 | - "docs/**" 12 | push: 13 | branches: 14 | - main 15 | 16 | jobs: 17 | test: 18 | runs-on: ${{ matrix.os }} 19 | strategy: 20 | matrix: 21 | os: [ubuntu-latest] 22 | python-version: ["3.9", "3.10", "3.11", "3.12"] 23 | include: 24 | - os: windows-latest 25 | python-version: "3.9" 26 | - os: macos-latest 27 | python-version: "3.9" 28 | 29 | steps: 30 | - name: Check out repository code 31 | uses: actions/checkout@v2 32 | - name: Set up Python ${{ matrix.python-version }} 33 | uses: actions/setup-python@v2 34 | with: 35 | python-version: ${{ matrix.python-version }} 36 | - name: Install dependencies 37 | run: | 38 | python -m pip install --upgrade pip virtualenv setuptools wheel 39 | pip install tox 40 | - name: Test using tox 41 | run: tox -e py 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.DS_Store 2 | *.egg-info 3 | *.env 4 | *.gz 5 | *.pyc 6 | *.pyo 7 | *.vscode 8 | *.whl 9 | /.coverage 10 | /.mypy_cache 11 | /.pytest_cache 12 | /.python-version 13 | /.tox 14 | /__pycache__ 15 | /build 16 | /config.json 17 | /dist 18 | /docs/_build 19 | /venv 20 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autofix_commit_msg: "[pre-commit.ci] auto fixes from hooks" 3 | autoupdate_schedule: monthly 4 | repos: 5 | - repo: https://github.com/asottile/setup-cfg-fmt 6 | rev: v2.8.0 7 | hooks: 8 | - id: setup-cfg-fmt 9 | # Disable reorder-python-imports due to incompatability with Black 24 (Issue #57) 10 | # - repo: https://github.com/asottile/reorder-python-imports 11 | # rev: v3.12.0 12 | # hooks: 13 | # - id: reorder-python-imports 14 | # name: Reorder Python imports (src, tests) 15 | # args: ["--application-directories", "src"] 16 | - repo: https://github.com/asottile/pyupgrade 17 | rev: v3.20.0 18 | hooks: 19 | - id: pyupgrade 20 | args: ["--py39-plus"] 21 | - repo: https://github.com/psf/black 22 | rev: 25.1.0 23 | hooks: 24 | - id: black 25 | - repo: https://github.com/asottile/blacken-docs 26 | rev: 1.19.1 27 | hooks: 28 | - id: blacken-docs 29 | - repo: https://github.com/pre-commit/pre-commit-hooks 30 | rev: v5.0.0 31 | hooks: 32 | - id: check-added-large-files 33 | - id: check-ast 34 | - id: check-case-conflict 35 | - id: check-docstring-first 36 | - id: check-yaml 37 | - id: check-json 38 | - id: check-merge-conflict 39 | - id: check-toml 40 | - id: debug-statements 41 | - id: detect-private-key 42 | - id: name-tests-test 43 | - id: pretty-format-json 44 | args: ["--autofix", "--no-sort-keys"] 45 | - id: requirements-txt-fixer 46 | - id: trailing-whitespace 47 | - id: fix-byte-order-marker 48 | - id: end-of-file-fixer 49 | - repo: https://github.com/pre-commit/mirrors-mypy 50 | rev: v1.16.0 51 | hooks: 52 | - id: mypy 53 | additional_dependencies: [types-aiofiles, types-PyYAML, types-toml] 54 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Author 2 | 3 | * [Dario Bauer](https://github.com/dariobauer) 4 | 5 | # Significant Contributors 6 | 7 | These people provided significant contributions to the package including new features: 8 | 9 | * [Matteo Tenca](https://github.com/Shub77) - async file downloads 10 | 11 | # Contributors 12 | 13 | Refer GitHub's [contributors page for Graph-OneDrive](https://github.com/dariobauer/graph-onedrive/graphs/contibutors). 14 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Unreleased 4 | 5 | * Python 3.7 and 3.8 have reached end of life so support was removed and the code upgraded 6 | 7 | ## Released 8 | 9 | ### Version 0.4.0 10 | 11 | * Add destination argument to download_file method (Issue [#30](https://github.com/dariobauer/graph-onedrive/issues/30)) 12 | * Fixed bug in download_file method when verbose was set to false (Issue [#29](https://github.com/dariobauer/graph-onedrive/issues/29)) 13 | * Removed depreciated class constructors & deconstructors `graph_onedrive.create()`, `graph_onedrive.create_from_config_file()`, `graph_onedrive.save_to_config_file()`, `OneDrive.from_json()`, `OneDrive.to_json()`, `OneDrive.from_yaml()`, `OneDrive.to_yaml()`, `OneDrive.from_toml()`, `OneDrive.to_toml()`. Alternative methods are available, refer to the documentation 14 | 15 | ### Version 0.3.0 16 | 17 | Released 2022-01-08 18 | 19 | * Added search method to OneDrive class and CLI 20 | * Added optional yaml config files support with optional PyYAML dependency 21 | * Added optional toml config files support with optional TOML dependency 22 | * Improved logging 23 | * `from_json` and `to_json` are now pending depreciation, use `from_file` and `to_file` instead 24 | 25 | ### Version 0.2.0 26 | 27 | Released 2021-11-30 28 | 29 | * Added py.typed for mypy typing support 30 | * Added ability to create sharing links (Issue [#16](https://github.com/dariobauer/graph-onedrive/issues/16)) 31 | * Improved upload to attempt to retain file creation and modified metadata (Issue [#13](https://github.com/dariobauer/graph-onedrive/issues/13)) 32 | * Improved developer experience by adding tests, testing automation (tox, GitHub Actions), requirements files, pre-commit improvements 33 | * Input type checks added and error messaging improved 34 | * Fixed bug in sharing links part of the CLI 35 | * Listing a directory now gets all items, even if there are over 200 36 | * New method detail_item_path details an item by providing a drive path instead of id 37 | * New `OneDriveManager` context manager added 38 | * `create` depreciated, use the OneDrive class directly 39 | * `create_from_config_file`, use `OneDrive.from_json` or the `OneDriveManager` context manager 40 | * `save_to_config_file`, use `OneDrive.to_json` or the `OneDriveManager` context manager 41 | * Added basic logging 42 | * Docs, examples, and tests updated to reflect above changes 43 | 44 | ### Version 0.1.0 45 | 46 | Released 2021-10-29 47 | 48 | * Improved file download to asynchronously download using multiple connections 49 | * Added HTTPX and aiofiles packages as dependencies 50 | * Replaced Requests package with HTTPX (Issue [#11](https://github.com/dariobauer/graph-onedrive/issues/11)) 51 | * Added verbose keyword arguments to download and upload functions 52 | * Improved error handling 53 | * item_type, is_file, is_folder methods added 54 | * Fixed a bug in the copy_item method 55 | * Set the copy_item method to confirm the copy by default 56 | * Documentation updates 57 | * Updates to authors.md to include significant contributor (Shub77) 58 | 59 | ### Version 0.0.1a10 60 | 61 | Released 2021-10-21 62 | 63 | * Major improvements to the cli, now uses argparse, docs updated 64 | * Removed access token validation (Issue [#4](https://github.com/dariobauer/graph-onedrive/issues/4)) 65 | * Allowed access token response to continue when no refresh token provided (Issue [#6](https://github.com/dariobauer/graph-onedrive/issues/6)) 66 | * Various dictionary value lookups improved to account for missing keys (Issue [#7](https://github.com/dariobauer/graph-onedrive/issues/7)) 67 | 68 | ### Version 0.0.1a9 69 | 70 | Released 2021-10-20 71 | 72 | * Improved code typing 73 | * Updated code formatting 74 | * Improved validation of authorization codes and access tokens (Issues [#3](https://github.com/dariobauer/graph-onedrive/issues/3) & [#4](https://github.com/dariobauer/graph-onedrive/issues/4)) 75 | 76 | ### Version 0.0.1a8 77 | 78 | Released 2021-10-17 79 | 80 | * Updated exits from cli 81 | * Moved OneDrive class from _main.py to _onedrive.py 82 | * Moved testing and debugging decorators to /tests 83 | * Updated some formatting using pre-commit hooks 84 | * Docs improved including syntax highlighting 85 | * Improved auth function to not require a session state (Pull Request #1) 86 | 87 | ### Version 0.0.1a7 88 | 89 | Released 2021-10-13 90 | 91 | * Minor doc fix 92 | 93 | ### Version 0.0.1a6 94 | 95 | Released 2021-10-13 96 | 97 | * Minor doc fix 98 | 99 | ### Version 0.0.1a5 100 | 101 | Released 2021-10-13 102 | 103 | * Added safeties in cli to not overwrite existing configs 104 | * Minor doc updates and fixes 105 | 106 | ### Version 0.0.1a4 107 | 108 | Released 2021-10-11 109 | 110 | * json import bug fixed within cli 111 | 112 | ### Version 0.0.1a3 113 | 114 | Released 2021-10-11 115 | 116 | * Minor bug fix to cli 117 | 118 | ### Version 0.0.1a2 119 | 120 | Released 2021-10-11 121 | 122 | * Split cli auth to config and auth 123 | * Corrected spelling 'Onedrive' to 'OneDrive' 124 | * Moved documentation from README to /docs 125 | * Updated documentation 126 | * Added pre-commit file for Black 127 | * Reformatted all code using Black 128 | * Updated CONTRIBUTING patch section 129 | * Updated README 130 | 131 | ### Version 0.0.1a1 132 | 133 | Released 2021-10-10 134 | 135 | * Initial alpha commit 136 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute to the Graph-Onedrive package 2 | 3 | Thank you for considering contributing to the package! 4 | 5 | 6 | ## Support questions 7 | 8 | Please don't use the issue tracker for this. The issue tracker is a tool to address bugs and feature requests in the code source itself. 9 | 10 | Support is not provided for this package. 11 | 12 | 13 | ## Reporting issues 14 | 15 | When [reporting an issue][1], please include the following information: 16 | 17 | * Describe what you expected to happen; 18 | * If possible, include a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) to help identify the issue. This also helps check that the issue is not with your own code; 19 | * Describe what actually happened. Include the full trace-back if there was an exception; 20 | * List your Python and package versions. If possible, check if this issue is already fixed in the latest releases or the latest code in the repository. 21 | 22 | 23 | ## Feature requests and feedback 24 | 25 | The best way to send feedback is to [file an issue][1]. 26 | 27 | If you are proposing a feature: 28 | 29 | * Explain in detail how it would work; 30 | * Keep the scope as narrow as possible, to make it easier to implement; 31 | * Remember that this is a volunteer-driven project, and that code contributions are welcome. 32 | 33 | 34 | ## Documentation improvements 35 | 36 | Documentation can always be improved, whether as part of the official docs or in doc-strings. 37 | Please first discuss any deviations from the current documentation formats already in use in the package. 38 | 39 | 40 | ## Submitting patches 41 | 42 | If there is not an open issue for what you want to submit, the preference is for you to open one for discussion before working on a pull request. You can work on any issue that doesn't have an open PR linked to it or a maintainer assigned to it. These show up in the sidebar. There is no need to ask if you can work on an issue that interests you. 43 | It is suggested that you fork to a branch named after the issue (ie not `main`) to simplify merge/rebase. 44 | 45 | When submitting your patch, please ensure that you: 46 | 47 | 1. Test your code; 48 | 2. Include type annotations; 49 | 3. Update any relevant docs pages and doc-strings; 50 | 4. Add an entry in `CHANGES.md`; 51 | 5. Ideally run [pre-commit](https://pre-commit.com), and correct any issues. 52 | 53 | 54 | 55 | [1]: "GitHub issues" 56 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2021, Dario Bauer 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include CHANGES.md 3 | include LICENSE 4 | include src/graph_onedrive/py.typed 5 | global-exclude *.pyc 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Tests](https://github.com/dariobauer/graph-onedrive/actions/workflows/tests.yml/badge.svg)](https://github.com/dariobauer/graph-onedrive/actions/workflows/tests.yml) [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/dariobauer/graph-onedrive/main.svg)](https://results.pre-commit.ci/latest/github/dariobauer/graph-onedrive/main) [![PyPI version](https://img.shields.io/pypi/v/graph-onedrive)][pypi] [![Supported Python versions](https://img.shields.io/pypi/pyversions/graph-onedrive)][pypi] 2 | 3 | # Graph-OneDrive 4 | 5 | Interact with Microsoft's OneDrive service using the Graph API. 6 | 7 | The Graph-OneDrive package facilitates the creation of OneDrive class instances which are objects that you can use to interact with OneDrive sessions. Thus multiple OneDrives can be connected to in parallel. 8 | 9 | Functions include: 10 | 11 | * listing directories 12 | * moving, copying, and renaming files and folders 13 | * uploading and asynchronously downloading files 14 | * getting file and drive metadata including usage 15 | * getting links to files and creating sharing links 16 | 17 | ## Azure app requirement 18 | 19 | For the package to connect to the Graph API, you need to have an app registered in the Microsoft Azure Portal. The [documentation][docs] provides basic guidance on how to register an app. 20 | 21 | Note that some Microsoft work and school accounts will not allow apps to connect with them without admin consent. 22 | 23 | ## Installation 24 | 25 | The package currently requires Python 3.9 or greater. 26 | The last version to support Python 3.7 and 3.8 was release 0.4.0 which can still be installed. 27 | 28 | Install and update using [pip](https://pip.pypa.io/en/stable/getting-started/) which will use the releases hosted on [PyPI][pypi]. Further options in the docs. 29 | 30 | ```console 31 | pip install -U graph-onedrive 32 | ``` 33 | 34 | ## Documentation 35 | 36 | Documentation and examples are [provided on GitHub in the docs folder][docs]. 37 | 38 | ### A simple example 39 | 40 | *This is a simple example using a config file. Refer to the documentation for other instance constructors including inline options.* 41 | 42 | Run this command in the terminal after installation which will create a config file in the current working directory. 43 | 44 | ```console 45 | graph-onedrive --configure --authenticate -f "config.json" -k "onedrive" 46 | ``` 47 | 48 | Save the following in a .py file in the same folder. 49 | 50 | ```python 51 | from graph_onedrive import OneDriveManager 52 | 53 | # Use a context manager to manage the session 54 | with OneDriveManager(config_path="config.json", config_key="onedrive") as my_drive: 55 | # Print the OneDrive usage 56 | my_drive.get_usage(verbose=True) 57 | 58 | # Upload a file to the root directory 59 | new_file_id = my_drive.upload_file("my-photo.jpg", verbose=True) 60 | ``` 61 | 62 | ## License and Terms of Use 63 | 64 | This project itself is subject to BSD 3-Clause License detailed in [LICENSE][license]. 65 | 66 | The Graph API is provided by Microsoft Corporation and subject to their [terms of use](https://docs.microsoft.com/en-us/legal/microsoft-apis/terms-of-use). 67 | 68 | ## Links 69 | 70 | * [Documentation][docs] 71 | * [License][license] 72 | * [Change Log](https://github.com/dariobauer/graph-onedrive/blob/main/CHANGES.md) 73 | * [PyPI][pypi] 74 | * [PyPI Release History][releases] 75 | * [Source Code](https://github.com/dariobauer/graph-onedrive/) 76 | * [Contributing](https://github.com/dariobauer/graph-onedrive/blob/main/CONTRIBUTING.md) 77 | * [Issue Tracker](https://github.com/dariobauer/graph-onedrive/issues) 78 | 79 | [docs]: "Graph-OneDrive Documentation" 80 | [license]: "Graph-OneDrive License" 81 | [releases]: "History of Graph-OneDrive releases on PyPI" 82 | [pypi]: "Graph-OneDrive on PyPI" 83 | -------------------------------------------------------------------------------- /docs/DOCUMENTATION.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | ## General 4 | 5 | ### Azure app creation 6 | 7 | Before the package can interact with the Graph API, it must first create an authenticated session with the Microsoft identity platform. 8 | 9 | The Graph API operates securely through each request having an accompanying bearer token containing an "access token". These tokens are used to verify a session has been authenticated. 10 | The package generates these access tokens on your behalf, acting as a native OAuth client. 11 | 12 | Note that some Microsoft work and school accounts will not allow apps to connect with them without admin consent. 13 | 14 | #### Step 1: Create an Azure app 15 | 16 | To interact with the Graph API, an app needs to be registered through the [Azure portal](https://portal.azure.com/). Detailed documentation on how to do this is [available directly from Microsoft](https://docs.microsoft.com/en-us/graph/auth-register-app-v2?context=graph%2Fapi%2F1.0&view=graph-rest-1.0). 17 | 18 | | Setup Option | Description | 19 | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | 20 | | Supported account types | This is related to the tenant described in the next section. Essentially the more restrictive, the easier it is to get the app registered. | 21 | | Redirect URI | It is recommended this is left as `Web` to `http://localhost`. | 22 | 23 | #### Step 2: Obtain authentication details 24 | 25 | You need to obtain your registered app's authentication details from the [Azure portal](https://portal.azure.com/) to use in the package for authentication. 26 | 27 | | Parameter | Location within Azure Portal | Description | 28 | | ----------------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 29 | | Directory (tenant) ID | App registrations > *your app name* > Overview | The tenant is used to restrict an app to certain accounts. `common` allows both personal Microsoft accounts as well as work/school accounts to use the app. `organizations`, and `consumers` each only allow these account types. All of these types are known as multi-tenant and require more security processes be passed to ensure that you are a legitimate developer. On the contrary, single-tenant apps restrict the app to only one work/school account (i.e. typically one company) and therefore have far fewer security requirements. Single tenants are either a GUID or domain. Refer to the [Azure docs](https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-v2-protocols#endpoints) for details. | 30 | | Application (client) ID | App registrations > *your app name* > Overview | The application ID that's assigned to your app. You can find this information in the portal where you registered your app. Note that this is not the client secret id but the id of the app itself. | 31 | | Client secret value | App registrations > *your app name* > Certificates & secrets | The client secret that you generated for your app in the app registration portal. Note that this allows you to set an expiry and should be checked if your app stops working. The client secret is hidden after the initial generation so it is important to copy it and keep it secure. | 32 | 33 | WARNING: The client secret presents a security risk if exposed. It is recommended to revoke the client secret immediately if it becomes exposed. 34 | 35 | ### Configuration 36 | 37 | Graph-OneDrive requires the Azure app credentials and a few other items of configuration to operate. While these parameters can be input during instance creation, it is instead recommended to use a configuration file. 38 | 39 | The configuration details required are below displayed in the typical configuration file format (json). 40 | 41 | ```json 42 | { 43 | "onedrive": { 44 | "tenant_id": "", 45 | "client_id": "", 46 | "client_secret_value": "", 47 | "redirect_url": "http://localhost:8080", 48 | "refresh_token": null 49 | } 50 | } 51 | ``` 52 | 53 | Equivalent YAML and TOML formats can also be used when the corresponding optional dependency is installed. 54 | 55 | Note that the `onedrive` dictionary key can be any string and could facilitate multiple instances running from the same configuration file with different keys. 56 | 57 | The `redirect_url` and `refresh_token` values are default so these lines can be removed if you are using the defaults. To learn about the refresh token, refer to the corresponding section in the docs below. 58 | 59 | ### Installation 60 | 61 | #### Requirements 62 | 63 | The package currently requires Python 3.9 or greater. 64 | The last version to support Python 3.7 and 3.8 was release 0.4.0 which can still be installed. 65 | 66 | #### Install 67 | 68 | Install and update using [pip](https://pip.pypa.io/en/stable/getting-started/) which will use the releases hosted on [PyPI](https://pypi.org/project/graph-onedrive/#history). 69 | Depending on your installation you may need to use `pip3` instead. 70 | 71 | ```console 72 | pip install -U graph-onedrive 73 | ``` 74 | 75 | If you plan to use YAML and/or TOML formatted config files, then the optional install dependencies can be installed (yaml, toml, or both): 76 | 77 | ```console 78 | pip install -U 'graph-onedrive[yaml,toml]' 79 | ``` 80 | 81 | You can also install the in-development version: 82 | 83 | ```console 84 | pip install https://github.com/dariobauer/graph-onedrive/archive/main.zip 85 | ``` 86 | 87 | #### Dependencies 88 | 89 | ##### Required Dependencies 90 | 91 | These dependencies provide critical functions for the package to run and will typically be installed automatically if using pip as described above. 92 | 93 | * [HTTPX](https://pypi.org/project/httpx/) - used for http requests 94 | * [aiofiles](https://pypi.org/project/aiofiles/) - used when downloading files 95 | 96 | ##### Optional Dependencies 97 | 98 | Optional dependencies provide secondary features that are not part of the core package functionality and will not typically be installed automatically. These can be installed manually or as a package extra as described in [installation](#installation). 99 | 100 | * [PyYAML](https://pypi.org/project/PyYAML/) - enables .yaml config files 101 | * [TOML](https://pypi.org/project/TOML/) - enables .toml config files 102 | 103 | ## Command-line interface 104 | 105 | A command-line interface tool is provided with the typical installation. 106 | 107 | You can run the cli using ```graph-onedrive``` or ```python3 -m graph-onedrive```. 108 | 109 | ```console 110 | graph-onedrive [-h] [-c] [-a] [-i] [-V] [-f PATH] [-k KEY] 111 | ``` 112 | 113 | ### Main actions 114 | 115 | One of the main actions must be given as an input. 116 | 117 | | Action argument | Description | 118 | | ------------------ | ------------------------------------------------------------------------------------------ | 119 | | -c, --configure | Create a new configuration file, or add to an existing one with a different dictionary key | 120 | | -a, --authenticate | Authenticate a configuration file | 121 | | -i, --instance | Interact with OneDrive to test your config and perform simple tasks | 122 | 123 | You can combine these to run multiple tasks in succession, with a common one being `graph-onedrive -cai` which will create a config file, authenticate it, and then run an instance. 124 | 125 | ### Options to input configuration file path and key 126 | 127 | You can use flags to specify the config file path and/or dictionary key. 128 | 129 | | Optional argument | Description | 130 | | ----------------- | ----------------------------------- | 131 | | -f, --file PATH | Optional path to config file | 132 | | -k, --key KEY | Optional config file dictionary key | 133 | 134 | Use these flags by using the flag followed by the input, for example: 135 | 136 | ```console 137 | graph-onedrive -cai -f "config.json" -k "onedrive" 138 | ``` 139 | 140 | ### Other commands 141 | 142 | | Other arguments | Description | 143 | | --------------- | ----------------------------------------------- | 144 | | -V, --version | Returns the version of Graph-Onedrive installed | 145 | | -h, --help | Displays help, including a list of attributes | 146 | 147 | ## Limitations 148 | 149 | ### Personal drives only 150 | 151 | The Graph API provides hooks to interact with a number of drives connected to an account, including SharePoint. 152 | 153 | However the Graph-OneDrive package currently only connects to a user's personal OneDrive, including a user's work personal, or school personal OneDrive. 154 | 155 | ### Work and school accounts may limit apps 156 | 157 | Some Microsoft work and school accounts will not allow apps to connect with them without admin consent. 158 | 159 | ### Saving refresh tokens for multiple users 160 | 161 | The package currently has the option to save refresh tokens for a user. While it is possible to create multiple instances with a different user for each instance, it is not possible to use a single config file for multiple users. 162 | 163 | It is however possible to use one config file to host multiple configurations within itself by using the dictionary key described in the config file section. 164 | 165 | ### Throttling limits 166 | 167 | TLDR; for file transfer methods keep max_connections ≤ 16 168 | 169 | Depending on your internet connection and the sizes of the files that you and transferring may see increased transfer speeds with more connections than the default. Graph-OneDrive currently will create a new connection for every 1MiB if not limited by the max_connections. If you have a really fast connection having many connections may slow the overall performance. 170 | 171 | A word of caution on too many connections - the Graph API may throttle requests. This can be a hard throttle where all existing connections are cut and a cool-down period is enforced. 172 | Throttling can be applied at the user level or the whole organization. [Details on throttling](https://docs.microsoft.com/en-us/graph/throttling) are available, however the [exact limits are not provided](https://docs.microsoft.com/en-us/graph/throttling). 173 | 174 | We recommended to not exceed 16 connections for performance and to avoid throttling. 175 | 176 | ## Package use 177 | 178 | ### Package import 179 | 180 | ```python 181 | import graph_onedrive 182 | ``` 183 | 184 | ### Creating an OneDrive instance 185 | 186 | Graph-OneDrive is an object-orientated package that uses an OneDrive class allowing you to create multiple instances of that class. 187 | 188 | To create an instance, you need to provide the configuration. 189 | 190 | #### a) Using a config file and the context manager (recommended) 191 | 192 | The context manager is recommend as it will save the configuration back to file including the tokens. 193 | Refer above section on configuration to learn about config files. 194 | 195 | ```python 196 | from graph_onedrive import OneDriveManager 197 | 198 | config_path = "config.json" # path to config file 199 | config_key = "onedrive" # config file dictionary key (default = "onedrive") 200 | with OneDriveManager(config_path, config_key) as onedrive: 201 | pass # do stuff 202 | ``` 203 | 204 | #### b) Using a config file 205 | 206 | Useful if wanting to use a config file but don't want to save the tokens back to file. 207 | Refer above section on configuration to learn about config files. 208 | 209 | ```python 210 | from graph_onedrive import OneDrive 211 | 212 | config_path = "config.json" # path to config file 213 | config_key = "onedrive" # config file dictionary key (default = "onedrive") 214 | my_instance = OneDrive.from_file(config_path, config_key) 215 | # do stuff 216 | my_instance.to_file(config_path, config_key) # optionally dump the config 217 | ``` 218 | 219 | #### c) Using in-line configuration parameters 220 | 221 | This solution is slightly easier but could be a security issue, especially if sharing code. 222 | 223 | ```python 224 | from graph_onedrive import OneDrive 225 | 226 | client_id = "" 227 | client_secret_value = "" 228 | tenant = "" 229 | redirect_url = "http://localhost:8080" 230 | my_instance = OneDrive(client_id, client_secret_value, tenant, redirect_url) 231 | # do stuff 232 | my_instance.to_file(config_path, config_key) # optionally dump the config 233 | ``` 234 | 235 | ### Authenticating the instance 236 | 237 | The instance must be authenticated by a user giving their delegated permission for your project to interact with the Graph API. 238 | 239 | #### a) Authenticate at the time of instance initialization 240 | 241 | The easiest option is to just create an instance as per above methods. A prompt will appear in the command line for you to copy a url to a web browser, authenticate, and then copy the redirected url back into the terminal. 242 | 243 | #### b) Authenticate the config file in the command-line 244 | 245 | You can use a config file to authenticate in the command line ahead of instance creation which is useful if your production code will not be run in a terminal. This will save a refresh token (described in the next section) to your config file. 246 | 247 | ```console 248 | graph-onedrive -a 249 | ``` 250 | 251 | WARNING: This configuration assumes that your use of the configuration file serves one user only. If using the same code to serve multiple users then the refresh token must be stored independently of the configuration file. 252 | 253 | ### Using refresh tokens 254 | 255 | Access tokens used to authenticate each request directly by the Graph API, without having to re-authenticate each time with the authentication server which speeds up requests. However due to security, these tokens typically expire after one hour. 256 | To avoid having to have the user re-authorize the app with their account, refresh tokens are used instead. This process of refreshing access tokens in managed automatically by the package, however if the script ends, then the instance information is lost. 257 | 258 | To use the refresh token to create a new instance (e.g. after running the script again), you can save the refresh token and provide it at the time of instance initiation. 259 | 260 | WARNING: Saving the refresh token presents a security risk as it could be used by anyone with it exchange it for an access token. If a refresh token is exposed, it is recommended that the app client secret it revoked. 261 | 262 | #### Obtaining the refresh token 263 | 264 | ##### a) Saving to a config file 265 | 266 | If using a configuration file then it is suggested that you use the `OneDriveManager` context manager which will save the refresh token to your configuration. 267 | Alternatively you can manually save to a file (suggested after your last API request to ensure the latest tokens are used). 268 | 269 | ```python 270 | my_instance.to_file(config_path="config.json", config_key="onedrive") 271 | ``` 272 | 273 | Then when creating the instance again using your config file, the refresh token will be used. 274 | 275 | WARNING: This configuration assumes that your use of the configuration file serves one user only. If using the same code to serve multiple users then the refresh token must be stored independently of the configuration file. 276 | 277 | ##### b) Saving the token manually 278 | 279 | You can get the refresh token from your instance: 280 | 281 | ```python 282 | refresh_token = my_instance.refresh_token 283 | ``` 284 | 285 | Then when creating an instance later, provide the refresh token to : 286 | 287 | ```python 288 | my_instance = graph_onedrive.OneDrive(..., refresh_token=refresh_token) 289 | ``` 290 | 291 | ### Context manager 292 | 293 | #### OneDriveManager 294 | 295 | Create an instance of the OneDrive class using a context manager (generator). 296 | Uses from_file() on entry and to_file() on exit. 297 | 298 | ```python 299 | with graph_onedrive.OneDriveManager( 300 | config_path="config.json", config_key="onedrive" 301 | ) as onedrive: 302 | pass 303 | ``` 304 | 305 | Keyword arguments: 306 | 307 | * config_path (str|Path) -- path to configuration file (default = "config.json") 308 | * config_key (str) -- key of the item storing the configuration (default = "onedrive") 309 | 310 | Yields: 311 | 312 | * onedrive_instance (OneDrive) -- OneDrive object instance 313 | 314 | ### Class methods 315 | 316 | Module class constructors and deconstructors/exporters. 317 | 318 | #### OneDrive() 319 | 320 | Create an instance of the OneDrive class for arguments, and assist in creating and saving OneDrive class objects. 321 | 322 | ```python 323 | onedrive_instance = graph_onedrive.OneDrive( 324 | client_id, 325 | client_secret, 326 | tenant="common", 327 | redirect_url="http://localhost:8080", 328 | refresh_token=None, 329 | ) 330 | ``` 331 | 332 | Positional arguments: 333 | 334 | * client_id (str) -- Azure app client id 335 | * client_secret (str) -- Azure app client secret 336 | 337 | Keyword arguments: 338 | 339 | * tenant (str) -- Azure app org tenant id number, use default if multi-tenant (default = "common") 340 | * redirect_url (str) -- Authentication redirection url (default = "http://localhost:8080") 341 | * refresh_token (str) -- optional token from previous session (default = None) 342 | 343 | Returns: 344 | 345 | * onedrive_instance (OneDrive) -- OneDrive object instance 346 | 347 | #### from_file 348 | 349 | Create an instance of the OneDrive class from a configuration file. 350 | 351 | To use yaml and toml config files the corresponding [optional dependencies](#dependencies) are required. 352 | 353 | ```python 354 | onedrive_instance = graph_onedrive.OneDrive.from_file( 355 | config_path="config.json", config_key="onedrive", save_refresh_token=False 356 | ) 357 | ``` 358 | 359 | Keyword arguments: 360 | 361 | * config_path (str|Path) -- path to configuration file (default = "config.json") 362 | * config_key (str) -- key of the item storing the configuration (default = "onedrive") 363 | * save_refresh_token (bool) -- save the refresh token back to the config file during instance initiation (default = False) 364 | 365 | Returns: 366 | 367 | * onedrive_instance (OneDrive) -- OneDrive object instance 368 | 369 | #### to_file 370 | 371 | Save the configuration to a configuration file. 372 | 373 | To use yaml or toml config files the corresponding [optional dependencies](#dependencies) are required. 374 | 375 | ```python 376 | onedrive_instance.to_file(config_path="config.json", config_key="onedrive") 377 | ``` 378 | 379 | Keyword arguments: 380 | 381 | * config_path (str|Path) -- path to configuration file (default = "config.json") 382 | * config_key (str) -- key of the item storing the configuration (default = "onedrive") 383 | 384 | Returns: 385 | 386 | * None 387 | 388 | ### Instance methods 389 | 390 | The requests to the Graph API are made using the instance of the OneDrive class that you have created. 391 | 392 | #### get_usage 393 | 394 | Get the current usage and capacity of the connected OneDrive. 395 | 396 | ```python 397 | used, capacity, units = my_instance.get_usage(unit="gb", refresh=False, verbose=False) 398 | ``` 399 | 400 | Keyword arguments: 401 | 402 | * unit (str) -- unit to return value, either "b", "kb", "mb", "gb" (default = "gb") 403 | * refresh (bool) -- refresh the usage data (default = False) 404 | * verbose (bool) -- print the usage (default = False) 405 | 406 | Returns: 407 | 408 | * used (float) -- storage used in unit requested 409 | * capacity (float) -- storage capacity in unit requested 410 | * units (str) -- unit of usage 411 | 412 | #### list_directory 413 | 414 | List the files and folders within the input folder/root of the connected OneDrive. 415 | 416 | ```python 417 | items = my_instance.list_directory(folder_id=None, verbose=False) 418 | ``` 419 | 420 | Keyword arguments: 421 | 422 | * folder_id (str) -- the item id of the folder to look into, None being the root directory (default = None) 423 | * verbose (bool) -- print the items along with their ids (default = False) 424 | 425 | Returns: 426 | 427 | * items (dict) -- details of all the items within the requested directory 428 | 429 | #### search 430 | 431 | List files and folders matching a search query. 432 | 433 | ```python 434 | items = my_instance.search(query, top=-1, verbose=False) 435 | ``` 436 | 437 | Positional arguments: 438 | 439 | * query (str) -- search query string 440 | 441 | Keyword arguments: 442 | 443 | * top (int) -- limits the results list length, use -1 to not limit (default = -1) 444 | * verbose (bool) -- print the items along with their ids (default = False) 445 | 446 | Returns: 447 | 448 | * items (dict) -- details of items matching the search query 449 | 450 | #### detail_item 451 | 452 | Retrieves the metadata for an item by id. 453 | 454 | ```python 455 | item_details = my_instance.detail_item(item_id, verbose=False) 456 | ``` 457 | 458 | Positional arguments: 459 | 460 | * item_id (str) -- item id of the folder or file 461 | 462 | Keyword arguments: 463 | 464 | * verbose (bool) -- print the main parts of the item metadata (default = False) 465 | 466 | Returns: 467 | 468 | * items (dict) -- metadata of the requested item 469 | 470 | #### detail_item_path 471 | 472 | Retrieves the metadata for an item by path. 473 | 474 | ```python 475 | item_details = my_instance.detail_item_path(item_path, verbose=False) 476 | ``` 477 | 478 | Positional arguments: 479 | 480 | * item_path (str) -- drive root path to the folder or file (e.g. "/pictures/Holiday 01.jpg") 481 | 482 | Keyword arguments: 483 | 484 | * verbose (bool) -- print the main parts of the item metadata (default = False) 485 | 486 | Returns: 487 | 488 | * items (dict) -- metadata of the requested item 489 | 490 | #### item_type 491 | 492 | Returns the item type in str format. 493 | 494 | ```python 495 | item_type = my_instance.item_type(item_id) 496 | ``` 497 | 498 | Positional arguments: 499 | 500 | * item_id (str) -- item id of the folder or file 501 | 502 | Returns: 503 | 504 | * type (str) -- "folder" or "file" 505 | 506 | #### is_folder 507 | 508 | Checks if an item is a folder. 509 | 510 | ```python 511 | item_type = my_instance.is_folder(item_id) 512 | ``` 513 | 514 | Positional arguments: 515 | 516 | * item_id (str) -- item id of the folder or file 517 | 518 | Returns: 519 | 520 | * folder (bool) -- True if folder, else false. 521 | 522 | #### is_file 523 | 524 | Checks if an item is a file. 525 | 526 | ```python 527 | item_type = my_instance.is_file(item_id) 528 | ``` 529 | 530 | Positional arguments: 531 | 532 | * item_id (str) -- item id of the folder or file 533 | 534 | Returns: 535 | 536 | * file (bool) -- True if file, else false. 537 | 538 | #### create_share_link 539 | 540 | Creates a basic sharing link for an item. 541 | 542 | ```python 543 | link = my_instance.create_share_link( 544 | item_id, link_type="view", password=None, expiration=None, scope="anonymous" 545 | ) 546 | ``` 547 | 548 | Positional arguments: 549 | 550 | * item_id (str) -- item id of the folder or file 551 | 552 | Keyword arguments: 553 | 554 | * link_type (str) -- type of sharing link to create, either "view", "edit", or ("embed" for OneDrive personal only) (default = "view") 555 | * password (str) -- password for the sharing link (OneDrive personal only) (default = None) 556 | * expiration (datetime) -- expiration of the sharing link, computer local timezone assumed for 'native' datetime objects (default = None) 557 | * scope (str) -- "anonymous" for anyone with the link, or ("organization" to limit to the tenant for OneDrive Business) Note businesses may choose to disable anonymous links which will result in an error (default = "anonymous") 558 | 559 | Returns: 560 | 561 | * link (str) -- typically a web link, html iframe if link_type="embed" 562 | 563 | #### make_folder 564 | 565 | Creates a new folder within the input folder/root of the connected OneDrive. 566 | 567 | ```python 568 | folder_id = my_instance.make_folder( 569 | folder_name, parent_folder_id=None, check_existing=True, if_exists="rename" 570 | ) 571 | ``` 572 | 573 | Positional arguments: 574 | 575 | * folder_name (str) -- the name of the new folder 576 | 577 | Keyword arguments: 578 | 579 | * parent_folder_id (str) -- the item id of the parent folder, None being the root directory (default = None) 580 | * check_existing (bool) -- checks parent and returns folder_id if a matching folder already exists (default = True) 581 | * if_exists (str) -- if check_existing is set to False; action to take if the new folder already exists, either "fail", "replace", "rename" (default = "rename") 582 | 583 | Returns: 584 | 585 | * folder_id (str) -- newly created folder item id 586 | 587 | #### move_item 588 | 589 | Moves an item (folder/file) within the connected OneDrive. Optionally rename an item at the same time. 590 | 591 | ```python 592 | item_id, folder_id = my_instance.move_item(item_id, new_folder_id, new_name=None) 593 | ``` 594 | 595 | Positional arguments: 596 | 597 | * item_id (str) -- item id of the folder or file to move 598 | * new_folder_id (str) -- item id of the folder to shift the item to 599 | 600 | Keyword arguments: 601 | 602 | * new_name (str) -- optional new item name with extension (default = None) 603 | 604 | Returns: 605 | 606 | * item_id (str) -- item id of the folder or file that was moved, should match input item id 607 | * folder_id (str) -- item id of the new parent folder, should match input folder id 608 | 609 | #### copy_item 610 | 611 | Copies an item (folder/file) within the connected OneDrive server-side. 612 | 613 | ```python 614 | item_id = my_instance.copy_item( 615 | item_id, new_folder_id, new_name=None, confirm_complete=True, verbose=False 616 | ) 617 | ``` 618 | 619 | Positional arguments: 620 | 621 | * item_id (str) -- item id of the folder or file to copy 622 | * new_folder_id (str) -- item id of the folder to copy the item to 623 | 624 | Keyword arguments: 625 | 626 | * new_name (str) -- optional new item name with extension (default = None) 627 | * confirm_complete (bool) -- waits for the copy operation to finish before returning (default = True) 628 | * verbose (bool) -- prints status message during the download process (default = False) 629 | 630 | Returns: 631 | 632 | * item_id (str | None) -- item id of the new item (None returned if confirm_complete set to False) 633 | 634 | #### rename_item 635 | 636 | Renames an item (folder/file) without moving it within the connected OneDrive. 637 | 638 | ```python 639 | item_name = my_instance.rename_item(item_id, new_name) 640 | ``` 641 | 642 | Positional arguments: 643 | 644 | * item_id (str) -- item id of the folder or file to rename 645 | * new_name (str) -- new item name with extension 646 | 647 | Returns: 648 | 649 | * item_name (str) -- new name of the folder or file that was renamed 650 | 651 | #### delete_item 652 | 653 | Deletes an item (folder/file) within the connected OneDrive. Potentially recoverable in the OneDrive web browser client. 654 | 655 | ```python 656 | confirmation = my_instance.delete_item(item_id, pre_confirm=False) 657 | ``` 658 | 659 | Positional arguments: 660 | 661 | * item_id (str) -- item id of the folder or file to be deleted 662 | 663 | Keyword arguments: 664 | 665 | * pre_confirm (bool) -- confirm that you want to delete the file and not show the warning (default = False) 666 | 667 | Returns: 668 | 669 | * confirmation (bool) -- True if item was deleted successfully 670 | 671 | #### download_file 672 | 673 | Downloads a file to the current working directory asynchronously with multiple concurrent http requests for files larger than 1mb. 674 | Note folders cannot be downloaded, you need to implement a loop instead. 675 | 676 | ```python 677 | file_path = my_instance.download_file( 678 | item_id, max_connections=8, dest_dir=None, verbose=False 679 | ) 680 | ``` 681 | 682 | Positional arguments: 683 | 684 | * item_id (str) -- item id of the file to be deleted 685 | 686 | Keyword arguments: 687 | 688 | * max_connections (int) -- max concurrent open http requests, refer [throttling limits](#throttling-limits) 689 | * dest_dir (str | Path) -- destination directory for the downloaded file, default is current working directory (default = None) 690 | * verbose (bool) -- prints the download progress (default = False) 691 | 692 | Returns: 693 | 694 | * file_path (Path) -- returns the path to the downloaded file including extension 695 | 696 | #### upload_file 697 | 698 | Uploads a file to a particular folder with a provided file name. 699 | 700 | ```python 701 | item_id = my_instance.upload_file( 702 | file_path, 703 | new_file_name=None, 704 | parent_folder_id=None, 705 | if_exists="rename", 706 | verbose=False, 707 | ) 708 | ``` 709 | 710 | Positional arguments: 711 | 712 | * file_path (str|Path) -- path of the local source file to upload (path to the file on your computer which you are wanting to upload) 713 | 714 | Keyword arguments: 715 | 716 | * new_file_name (str) -- new name of the file as it should appear on OneDrive, without extension (default = None) 717 | * parent_folder_id (str) -- item id of the folder to put the file within, if None then root (default = None) 718 | * if_exists (str) -- action to take if the new folder already exists, either "fail", "replace", "rename" (default = "rename") 719 | * verbose (bool) -- prints the upload progress (default = False) 720 | 721 | Returns: 722 | 723 | * item_id (str) -- item id of the newly uploaded file 724 | 725 | ## Examples 726 | 727 | Examples are provided to aid in development: 728 | 729 | ## Support and issues 730 | 731 | * Support info, feature requests: 732 | * Issue Tracker: 733 | -------------------------------------------------------------------------------- /docs/examples/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "onedrive": { 3 | "tenant_id": "", 4 | "client_id": "", 5 | "client_secret_value": "", 6 | "redirect_url": "http://localhost:8080", 7 | "refresh_token": null 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /docs/examples/config.toml: -------------------------------------------------------------------------------- 1 | # Note that using toml config files requires the optional dependency TOML. 2 | [onedrive] 3 | tenant_id = "" # required 4 | client_id = "" # required 5 | client_secret_value = "" # required 6 | redirect_url = "http://localhost:8080" # optional, remove line if unused 7 | refresh_token = "" # optional, remove line if unused 8 | -------------------------------------------------------------------------------- /docs/examples/config.yaml: -------------------------------------------------------------------------------- 1 | # Note that using yaml config files requires the optional dependency PyYAML. 2 | onedrive: 3 | tenant_id: # required 4 | client_id: # required 5 | client_secret_value: # required 6 | redirect_url: http://localhost:8080 # optional, line can be removed 7 | refresh_token: null # optional, line can be removed 8 | -------------------------------------------------------------------------------- /docs/examples/example_auth-from-file.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | 3 | from graph_onedrive import OneDrive 4 | 5 | 6 | def main() -> None: 7 | """Load my OneDrive from a config file and print the drive usage.""" 8 | 9 | # Set config path 10 | config_file_name = "config.json" 11 | directory_of_this_file = path.dirname(path.abspath(__file__)) 12 | config_path = path.join(directory_of_this_file, config_file_name) 13 | 14 | # Set config dictionary key 15 | config_key = "onedrive" 16 | 17 | # Create session instance 18 | my_drive = OneDrive.from_file(config_path, config_key) 19 | 20 | # Complete tasks using the instance. For this example we will just display the usage 21 | my_drive.get_usage(verbose=True) 22 | 23 | # OPTIONAL: save back to the config file to retain the refresh token which can be used to bypass authentication. 24 | # If you are doing this then it is reccommended that you use the context manager instead. 25 | my_drive.to_file(config_path, config_key) 26 | 27 | 28 | if __name__ == "__main__": 29 | main() 30 | -------------------------------------------------------------------------------- /docs/examples/example_auth-inline.py: -------------------------------------------------------------------------------- 1 | from graph_onedrive import OneDrive 2 | 3 | 4 | def main() -> None: 5 | """Print the usage of my OneDrive.""" 6 | 7 | # Set config 8 | client_id = "" 9 | client_secret = "" 10 | tenant = "common" # Optional, default set 11 | redirect_url = "http://localhost:8080" # Optional, default set 12 | refresh_token = None # Optional: from last session 13 | 14 | # Create session instance 15 | my_drive = OneDrive(client_id, client_secret, tenant, redirect_url, refresh_token) 16 | 17 | # Complete tasks using the instance. For this example we will just display the usage 18 | my_drive.get_usage(verbose=True) 19 | 20 | # OPTIONAL: Get refresh token that can be saved somewhere to recreate session 21 | # Suggested you use a configuration file and the context manager 22 | refresh_token = my_drive.refresh_token 23 | 24 | 25 | if __name__ == "__main__": 26 | main() 27 | -------------------------------------------------------------------------------- /docs/examples/example_context-manager.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | 3 | from graph_onedrive import OneDriveManager 4 | 5 | 6 | def main() -> None: 7 | """Load my OneDrive from a config file and print the drive usage.""" 8 | 9 | # Set config path 10 | config_file_name = "config.json" 11 | directory_of_this_file = path.dirname(path.abspath(__file__)) 12 | config_path = path.join(directory_of_this_file, config_file_name) 13 | 14 | # Set config dictionary key 15 | config_key = "onedrive" 16 | 17 | # Use the context manager to manage a session instance 18 | with OneDriveManager(config_path, config_key) as my_drive: 19 | # Complete tasks using the instance. For this example we will just display the usage 20 | my_drive.get_usage(verbose=True) 21 | 22 | 23 | if __name__ == "__main__": 24 | main() 25 | -------------------------------------------------------------------------------- /docs/examples/example_upload_file.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | 3 | from graph_onedrive import OneDriveManager 4 | 5 | 6 | def main() -> None: 7 | """Create a OneDrive instance using a config file, and then upload 8 | a file to a folder in the top level of my OneDrive.""" 9 | 10 | # Set the location of the file to upload 11 | my_file = "my-photo.jpg" # Can be a string or a pathlib Path object 12 | 13 | # Say we have a folder in our OneDrive root level (top level) called "My Photos" 14 | dest_folder_name = "My Photos" 15 | 16 | # Use the context manager to manage a session instance 17 | with OneDriveManager(config_path="config.json", config_key="onedrive") as my_drive: 18 | # Get the details of all the items in the root directory 19 | items = my_drive.list_directory() 20 | 21 | # Search through the root directory to find the file 22 | dest_folder_id = None 23 | for item in items: 24 | if "folder" in item and item.get("name") == dest_folder_name: 25 | dest_folder_id = item["id"] 26 | break 27 | 28 | # Raise an error if the folder is not found 29 | if dest_folder_id is None: 30 | raise Exception( 31 | f"Could not find a folder named {dest_folder_name} in the root of the OneDrive" 32 | ) 33 | 34 | # Upload the file 35 | new_file_id = my_drive.upload_file( 36 | file_path=my_file, parent_folder_id=dest_folder_id, verbose=True 37 | ) 38 | 39 | print( 40 | f"{my_file} uploaded to OneDrive folder {dest_folder_name}, and now has the id {new_file_id}." 41 | ) 42 | 43 | 44 | if __name__ == "__main__": 45 | main() 46 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=54", 4 | "wheel" 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | 8 | [tool.black] 9 | target-version = ["py39", "py310", "py311", "py312"] 10 | 11 | [tool.coverage.run] 12 | plugins = ["covdefaults",] 13 | source = ["graph_onedrive"] 14 | 15 | [tool.pytest.ini_options] 16 | minversion = "6.0" 17 | testpaths = ["tests",] 18 | 19 | [tool.mypy] 20 | check_untyped_defs = true 21 | disallow_any_generics = true 22 | disallow_incomplete_defs = true 23 | disallow_untyped_defs = true 24 | disallow_subclassing_any = true 25 | no_implicit_optional = true 26 | strict_equality = true 27 | warn_redundant_casts = true 28 | warn_unused_configs = true 29 | warn_unused_ignores = true 30 | namespace_packages = false 31 | 32 | [[tool.mypy.overrides]] 33 | module = [ 34 | "testing.*", 35 | "tests.*", 36 | "docs.*" 37 | ] 38 | disallow_untyped_defs = false 39 | 40 | [tool.tox] 41 | legacy_tox_ini = """ 42 | [tox] 43 | envlist = 44 | py{38,39,310} 45 | style-and-typing 46 | skip_missing_interpreters = true 47 | 48 | [testenv] 49 | setenv = 50 | PYTHONPATH = {toxinidir} 51 | deps = -r{toxinidir}/requirements-dev.txt 52 | commands = 53 | coverage erase 54 | coverage run -m pytest {posargs:tests} 55 | coverage report --fail-under 55 56 | 57 | [testenv:style-and-typing] 58 | skip_install = true 59 | deps = pre-commit 60 | commands = pre-commit run --all-files 61 | """ 62 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | covdefaults 2 | coverage[toml] 3 | pytest 4 | pytest-asyncio 5 | pyyaml 6 | respx 7 | toml 8 | tox 9 | types-aiofiles 10 | types-PyYAML 11 | types-toml 12 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiofiles 2 | httpx 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = Graph_OneDrive 3 | version = attr: graph_onedrive.__version__ 4 | description = Perform simple tasks on OneDrive through the Graph API. 5 | long_description = file: README.md 6 | long_description_content_type = text/markdown 7 | url = https://github.com/dariobauer/graph-onedrive 8 | author = Dario Bauer 9 | author_email = dariobauer@duck.com 10 | license = BSD-3-Clause 11 | license_files = LICENSE 12 | classifiers = 13 | Development Status :: 3 - Alpha 14 | Intended Audience :: Developers 15 | Operating System :: OS Independent 16 | Programming Language :: Python 17 | Programming Language :: Python :: 3 18 | Programming Language :: Python :: 3 :: Only 19 | keywords = onedrive, graph, microsoft 20 | project_urls = 21 | Documentation = https://github.com/dariobauer/graph-onedrive/blob/main/docs/DOCUMENTATION.md 22 | Issue Tracker = https://github.com/dariobauer/graph-onedrive/issues 23 | Changes = https://github.com/dariobauer/graph-onedrive/blob/main/CHANGES.md 24 | Source Code = https://github.com/dariobauer/graph-onedrive 25 | 26 | [options] 27 | packages = find: 28 | install_requires = 29 | aiofiles 30 | httpx 31 | python_requires = >=3.9 32 | include_package_data = true 33 | package_dir = = src 34 | 35 | [options.packages.find] 36 | where = 37 | src 38 | exclude = 39 | tests* 40 | testing* 41 | docs* 42 | 43 | [options.entry_points] 44 | console_scripts = 45 | graph-onedrive = graph_onedrive._cli:main 46 | 47 | [options.extras_require] 48 | toml = 49 | toml 50 | yaml = 51 | pyyaml 52 | 53 | [options.package_data] 54 | graph_onedrive = py.typed 55 | 56 | [bdist_wheel] 57 | universal = True 58 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | # Refer setup.cfg 4 | setup(name="graph_onedrive") 5 | -------------------------------------------------------------------------------- /src/graph_onedrive/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.4.0" 2 | 3 | import logging 4 | 5 | from graph_onedrive._main import * 6 | 7 | # Set default logging handler to avoid "No handler found" warnings 8 | logging.getLogger(__name__).addHandler(logging.NullHandler()) 9 | -------------------------------------------------------------------------------- /src/graph_onedrive/__main__.py: -------------------------------------------------------------------------------- 1 | """Entrypoint module redirects to command line interface, in case of use of `python -m graphonedrive`.""" 2 | 3 | from graph_onedrive._cli import main 4 | 5 | if __name__ == "__main__": 6 | raise SystemExit(main()) 7 | -------------------------------------------------------------------------------- /src/graph_onedrive/_cli.py: -------------------------------------------------------------------------------- 1 | """Command line interface tools for the Python package Graph-OneDrive. 2 | Run terminal command 'graph-onedrive --help' or 'python -m graph-onedrive --help' for details. 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import argparse 8 | import logging 9 | import os 10 | from datetime import datetime 11 | from pathlib import Path 12 | from collections.abc import Sequence 13 | 14 | from graph_onedrive.__init__ import __version__ 15 | from graph_onedrive._config import CONFIG_EXTS 16 | from graph_onedrive._config import dump_config 17 | from graph_onedrive._config import load_config 18 | from graph_onedrive._main import OneDrive 19 | 20 | 21 | CONFIG_DEFAULT_FILENAME = "config" 22 | CONFIG_DEFAULT_KEY = "onedrive" 23 | 24 | 25 | # Set logger 26 | logger = logging.getLogger(__name__) 27 | package_logger = logging.getLogger(__package__) 28 | package_logger.propagate = True 29 | handler = logging.StreamHandler() 30 | formatter = logging.Formatter("[%(levelname)s] %(message)s") 31 | handler.setFormatter(formatter) 32 | package_logger.addHandler(handler) 33 | 34 | 35 | def main(argv: Sequence[str] | None = None) -> int: 36 | """Command line interface tools for the Python package Graph OneDrive. 37 | Use command --help for details. 38 | """ 39 | # Create the argument parser 40 | cli_description = "Graph-OneDrive helper functions to create and authenticate configs and interact with OneDrive to test your configuration." 41 | cli_epilog = "Note: use of the Graph API is subject to the Microsoft terms of use available at https://docs.microsoft.com/en-us/legal/microsoft-apis/terms-of-use" 42 | parser = argparse.ArgumentParser(description=cli_description, epilog=cli_epilog) 43 | # Add arguments 44 | parser_actions = parser.add_argument_group("actions") 45 | parser_actions.add_argument( 46 | "-c", "--configure", action="store_true", help="create a new configuration file" 47 | ) 48 | parser_actions.add_argument( 49 | "-a", 50 | "--authenticate", 51 | action="store_true", 52 | help="authenticate a configuration file", 53 | ) 54 | parser_actions.add_argument( 55 | "-i", 56 | "--instance", 57 | action="store_true", 58 | help="interact with OneDrive to test your config and perform simple tasks", 59 | ) 60 | parser.add_argument( 61 | "-V", 62 | "--version", 63 | action="version", 64 | version=__version__, 65 | help="Graph-OneDrive version number", 66 | ) 67 | parser.add_argument( 68 | "-f", 69 | "--file", 70 | action="store", 71 | type=str, 72 | metavar="PATH", 73 | help="optional path to config json or yaml file", 74 | ) 75 | parser.add_argument( 76 | "-k", 77 | "--key", 78 | action="store", 79 | type=str, 80 | help="optional config file dictionary key", 81 | ) 82 | parser.add_argument( 83 | "-l", 84 | "--log", 85 | action="count", 86 | default=0, 87 | help="displays INFO logs, use -ll for DEBUG logs", 88 | ) 89 | # Parse arguments, using function input args when given for tests 90 | args = parser.parse_args(argv) 91 | 92 | # Validate arguments 93 | if not (args.configure or args.authenticate or args.instance): 94 | parser.error("No action provided, use --help for details") 95 | 96 | if args.file and not args.file.endswith(CONFIG_EXTS): 97 | if args.file.endswith((".yaml",)): 98 | parser.error(f"--file path was to yaml file but PyYAML is not installed") 99 | elif args.file.endswith((".toml",)): 100 | parser.error(f"--file path was to toml file but TOML is not installed") 101 | else: 102 | parser.error( 103 | f"--file path must have {' or '.join([i for i in CONFIG_EXTS])} extension" 104 | ) 105 | 106 | if args.key and args.key == "": 107 | parser.error("--key provided can not be blank") 108 | 109 | # Configure logger level 110 | if args.log == 1: 111 | package_logger.setLevel(logging.INFO) 112 | logger.info("Logging enabled with level=INFO") 113 | elif args.log == 2: 114 | package_logger.setLevel(logging.DEBUG) 115 | logger.info("Logging enabled with level=DEBUG") 116 | 117 | # Call action functions 118 | if args.configure: 119 | config(args.file, args.key) 120 | if args.authenticate: 121 | authenticate(args.file, args.key) 122 | if args.instance: 123 | instance(args.file, args.key) 124 | 125 | # Returning 0 to the terminal to indicate success 126 | return 0 127 | 128 | 129 | def config(config_path: str | None = None, config_key: str | None = None) -> None: 130 | """Create a configuration file.""" 131 | 132 | if config_path and not config_path.endswith(CONFIG_EXTS): 133 | raise ValueError( 134 | f"config_path expected extension {' or '.join([i for i in CONFIG_EXTS])}" 135 | ) 136 | 137 | # Set the export directory when not set as an argument 138 | if not config_path: 139 | if ( 140 | input( 141 | f"Save as {CONFIG_DEFAULT_FILENAME}.json in current working directory? [Y/n]: " 142 | ) 143 | .strip() 144 | .lower() 145 | != "n" 146 | ): 147 | config_path = os.path.join(os.getcwd(), CONFIG_DEFAULT_FILENAME + ".json") 148 | 149 | else: 150 | config_path = "" 151 | while not config_path.endswith(CONFIG_EXTS): 152 | config_path = input( 153 | f"Path to config file (with extension {' or '.join([i for i in CONFIG_EXTS])}): " 154 | ).strip() 155 | 156 | # Set config dictionary key 157 | if not config_key: 158 | if ( 159 | input(f"Use config dictionary key default '{CONFIG_DEFAULT_KEY}' [Y/n]: ") 160 | .strip() 161 | .lower() 162 | == "n" 163 | ): 164 | config_key = input("Config dictionary key to use: ").strip() 165 | else: 166 | config_key = CONFIG_DEFAULT_KEY 167 | 168 | # Load the current file if it exists, otherwise create dictionary 169 | if os.path.isfile(config_path): 170 | config = load_config(config_path, config_key) 171 | # For safety do not overwrite existing configs 172 | if config: 173 | logger.error( 174 | f"'{config_key}' already exists within {Path(config_path).name}" 175 | ) 176 | raise SystemExit() 177 | else: 178 | config = {} 179 | 180 | # Set basic app credentials 181 | tenant_id = input("tenant: ").strip() 182 | client_id = input("client id: ").strip() 183 | client_secret_value = input("client secret value: ").strip() 184 | 185 | # Set redirect url 186 | if ( 187 | input("Use redirect url default 'http://localhost:8080' [Y/n]: ") 188 | .strip() 189 | .lower() 190 | == "n" 191 | ): 192 | redirect_url = input("Redirect url to use: ").strip() 193 | else: 194 | redirect_url = "http://localhost:8080" 195 | 196 | # Format the config into the dictionary 197 | config["tenant_id"] = tenant_id 198 | config["client_id"] = client_id 199 | config["client_secret_value"] = client_secret_value 200 | config["redirect_url"] = redirect_url 201 | config["refresh_token"] = None 202 | 203 | # Save the configuration to config file 204 | dump_config(config, config_path, config_key) 205 | print(f"Configuration saved to: {config_path}") 206 | 207 | 208 | def authenticate(config_path: str | None = None, config_key: str | None = None) -> None: 209 | """Authenticate with OneDrive and then save the configuration to file.""" 210 | # Get the config file path 211 | config_path, config_key = _get_config_file(config_path, config_key) 212 | 213 | # Create the instance and save the config 214 | OneDrive.from_file(config_path, config_key, save_refresh_token=True) 215 | print(f"Refresh token saved to configuration: {config_path}") 216 | 217 | 218 | def _get_config_file( 219 | config_path: str | None = None, config_key: str | None = None 220 | ) -> tuple[str, str]: 221 | """Sets a config path and key by searching the cwd with assistance from from user.""" 222 | 223 | if not config_path: 224 | # Look for json files in the current working directory and confirm with user 225 | cwd_path = os.getcwd() 226 | count = 0 227 | for root, dirs, files in os.walk(cwd_path): 228 | for file_name in files: 229 | if file_name.endswith(CONFIG_EXTS): 230 | if ( 231 | input(f"Found: {file_name} Use this? [Y/n]: ").strip().lower() 232 | != "n" 233 | ): 234 | config_path = os.path.join(root, file_name) 235 | break 236 | # Limit number of suggested files to top 5 237 | count += 1 238 | if count >= 5: 239 | break 240 | else: 241 | continue 242 | break 243 | # Get the file path from the user 244 | if not config_path: 245 | while True: 246 | config_path = input("Path to config file: ").strip() 247 | if os.path.isfile(config_path) and config_path.endswith(CONFIG_EXTS): 248 | break 249 | print("Path could not be validated, please try again.") 250 | print("Ensure that the path includes the filename and extension.") 251 | print(f"Acceptable file types: {CONFIG_EXTS}") 252 | 253 | # Open the config file 254 | if config_key: 255 | config = load_config(config_path, config_key) 256 | else: 257 | try: 258 | config_key = CONFIG_DEFAULT_KEY 259 | config = load_config(config_path, config_key) 260 | if ( 261 | input( 262 | f"Config dictionary key '{CONFIG_DEFAULT_KEY}' found. Use this key? [Y/n]: " 263 | ) 264 | .strip() 265 | .lower() 266 | == "n" 267 | ): 268 | raise KeyError() 269 | except KeyError: 270 | config_key = input("Config dictionary key to use: ").strip() 271 | config = load_config(config_path, config_key) 272 | 273 | return config_path, config_key 274 | 275 | 276 | def instance(config_path: str | None = None, config_key: str | None = None) -> None: 277 | """Interact directly with OneDrive in the command line to perform simple tasks and test the configuration.""" 278 | 279 | # Check with user if using config file 280 | if config_path or config_key: 281 | use_config_file = True 282 | elif input("Load an existing config file [Y/n]: ").strip().lower() != "n": 283 | use_config_file = True 284 | else: 285 | use_config_file = False 286 | 287 | # Create the instance 288 | if use_config_file: 289 | # Get the config details 290 | config_path_verified, config_key_verified = _get_config_file( 291 | config_path, config_key 292 | ) 293 | 294 | # Create session 295 | onedrive = OneDrive.from_file(config_path_verified, config_key_verified, True) 296 | 297 | else: 298 | print("Manual configuration entry:") 299 | client_id = input("client_id: ").strip() 300 | client_secret = input("client_secret: ").strip() 301 | tenant = input("tenant (leave blank to use 'common': ").strip() 302 | if tenant == "": 303 | tenant = "common" 304 | refresh_token = input("refresh_token (leave blank to reauthenticate): ").strip() 305 | onedrive = OneDrive(client_id, client_secret, tenant, refresh_token) 306 | 307 | # Command menu for the user 308 | help_info = """ 309 | Graph-OneDrive cli instance actions: 310 | u / usage : prints the OneDrive usage 311 | od / onedrive : print the OneDrive details 312 | ls / list : list the contents of a folder 313 | se / search : list items matching a query 314 | de / detail : print metadata of an item 315 | sl / link : create a sharing link for an item 316 | md / mkdir : make a new folder 317 | mv / move : move an item 318 | cp / copy : copy an item 319 | rn / rename : rename an item 320 | rm / remove : remove an item 321 | dl / download : download a file 322 | ul / upload : upload a file 323 | exit / quit : exit the menu\n""" 324 | print(help_info) 325 | 326 | # Ask for input and trigger commands 327 | try: 328 | while True: 329 | command = input("Please enter command: ").strip().lower() 330 | 331 | if command == "help": 332 | print(help_info) 333 | 334 | elif command in ("u", "usage"): 335 | onedrive.get_usage(verbose=True) 336 | 337 | elif command in ("ls", "li", "list"): 338 | folder_id: str | None = input( 339 | "Folder id to look into (enter nothing for root): " 340 | ).strip() 341 | if folder_id == "": 342 | folder_id = None 343 | elif not onedrive.is_folder(str(folder_id)): 344 | print("The item id is not a folder.") 345 | continue 346 | onedrive.list_directory(folder_id, verbose=True) 347 | 348 | elif command in ("se", "search"): 349 | query = input("Search query: ").strip() 350 | top = input("Max number of results: ").strip() 351 | try: 352 | top_n = int(top) 353 | except ValueError: 354 | top_n = 10 355 | onedrive.search(query, top_n, verbose=True) 356 | 357 | elif command in ("de", "detail"): 358 | item_id = input("Item id or root path starting with /: ").strip() 359 | if item_id[0] == "/": 360 | onedrive.detail_item_path(item_id, verbose=True) 361 | else: 362 | onedrive.detail_item(item_id, verbose=True) 363 | 364 | elif command in ["sl", "link"]: 365 | item_id = input("Item id to create a link for: ").strip() 366 | link_type = "" 367 | while link_type not in ("view", "edit", "embed"): 368 | link_type = input("Type of link (view/edit/embed): ").strip() 369 | password = None 370 | if ( 371 | onedrive._drive_type == "personal" 372 | and input("Set password [y/N]: ").strip().lower() == "y" 373 | ): 374 | password = input("Password: ").strip() 375 | expiration = None 376 | if input("Set expiry [y/N]: ").strip().lower() == "y": 377 | while True: 378 | date = input("Set expiry date in format YYYY-MM-DD: ").strip() 379 | try: 380 | expiration = datetime.strptime(date, "%Y-%m-%d") 381 | except ValueError: 382 | print("Not in correct format, try again or use ^c to exit.") 383 | continue 384 | if ( 385 | input( 386 | f"{expiration.strftime('%e %B %Y')} - is this correct? [Y/n]: " 387 | ) 388 | .strip() 389 | .lower() 390 | != "n" 391 | ): 392 | break 393 | scope = "anonymous" 394 | if onedrive._drive_type == "business": 395 | if ( 396 | input("Limit to your organization [Y/n]: ").strip().lower() 397 | != "n" 398 | ): 399 | scope = "organization" 400 | response = onedrive.create_share_link( 401 | item_id, link_type, password, expiration, scope 402 | ) 403 | print(response) 404 | 405 | elif command in ("md", "mkdir"): 406 | parent_folder_id: str | None = input( 407 | "Parent folder id (enter nothing for root): " 408 | ).strip() 409 | if parent_folder_id == "": 410 | parent_folder_id = None 411 | elif not onedrive.is_folder(str(parent_folder_id)): 412 | print("The item id is not a folder.") 413 | continue 414 | folder_name = input("Name of the new folder: ").strip() 415 | response = onedrive.make_folder(folder_name, parent_folder_id) 416 | print(response) 417 | 418 | elif command in ("mv", "move"): 419 | item_id = input("Item id of the file/folder to move: ").strip() 420 | new_folder_id = input("New parent folder id: ").strip() 421 | if input("Specify a new file name? [y/N]: ").strip().lower() == "y": 422 | new_file_name: str | None = input( 423 | "New file name (with extension): " 424 | ).strip() 425 | else: 426 | new_file_name = None 427 | response = onedrive.move_item(item_id, new_folder_id, new_file_name) 428 | print(f"Item {item_id} was moved to {new_folder_id}") 429 | 430 | elif command in ("cp", "copy"): 431 | item_id = input("Item id of the file/folder to copy: ").strip() 432 | new_folder_id = input("New parent folder id: ").strip() 433 | if input("Specify a new name? [y/N]: ").strip().lower() == "y": 434 | new_file_name = input("New file name (with extension): ").strip() 435 | else: 436 | new_file_name = None 437 | response = onedrive.copy_item( 438 | item_id, 439 | new_folder_id, 440 | new_file_name, 441 | confirm_complete=True, 442 | verbose=True, 443 | ) 444 | print( 445 | f"Item was copied to folder {new_folder_id} with new item id {response}" 446 | ) 447 | 448 | elif command in ("rn", "rename"): 449 | item_id = input("Item id of the file/folder to rename: ").strip() 450 | new_file_name = input("New file name (with extension): ").strip() 451 | response = onedrive.rename_item(item_id, new_file_name) 452 | print("Item was renamed.") 453 | 454 | elif command in ("rm", "remove", "delete"): 455 | item_id = input("Item id of the file/folder to remove: ").strip() 456 | response = onedrive.delete_item(item_id) 457 | if response == True: 458 | print(f"Item {item_id} was successfully removed.") 459 | 460 | elif command in ("dl", "download"): 461 | item_id = input("File item id to download: ").strip() 462 | if onedrive.is_folder(item_id): 463 | print("Item id is a folder. Folders cannot be downloaded.") 464 | continue 465 | print("Downloading") 466 | response = onedrive.download_file(item_id, verbose=True) 467 | print( 468 | f"Item was downloaded in the current working directory as {response}" 469 | ) 470 | 471 | elif command in ("ul", "upload"): 472 | file_path = input("Provide full file path: ").strip() 473 | if input("Rename file? [y/N]: ").strip().lower() == "y": 474 | new_file_name = input( 475 | "Upload as file name (with extension): " 476 | ).strip() 477 | else: 478 | new_file_name = None 479 | parent_folder_id = input( 480 | "Folder id to upload within (enter nothing for root): " 481 | ).strip() 482 | if parent_folder_id == "": 483 | parent_folder_id = None 484 | elif not onedrive.is_folder(str(parent_folder_id)): 485 | print("The item id is not a folder.") 486 | continue 487 | item_id = onedrive.upload_file( 488 | file_path, new_file_name, parent_folder_id, verbose=True 489 | ) 490 | print(f"New file item id: {item_id}") 491 | 492 | elif command in ("od", CONFIG_DEFAULT_KEY, "drive"): 493 | print("Drive id: ", onedrive._drive_id) 494 | print("Drive name: ", onedrive._drive_name) 495 | print("Drive type: ", onedrive._drive_type) 496 | print("Owner id: ", onedrive._owner_id) 497 | print("Owner email: ", onedrive._owner_email) 498 | print("Owner name: ", onedrive._owner_name) 499 | print( 500 | "Quota used: ", 501 | round(onedrive._quota_used / (1024 * 1024), 2), 502 | "mb", 503 | ) 504 | print( 505 | "Quota remain:", 506 | round(onedrive._quota_remaining / (1024 * 1024), 2), 507 | "mb", 508 | ) 509 | print( 510 | "Quota total: ", 511 | round(onedrive._quota_total / (1024 * 1024), 2), 512 | "mb", 513 | ) 514 | 515 | elif command == "_access": 516 | print(onedrive._access_token) 517 | 518 | elif command == "_refresh": 519 | print(onedrive.refresh_token) 520 | 521 | elif command in ("exit", "exit()", "quit", "q", "end"): 522 | break 523 | 524 | else: 525 | print("Command not recognized. Use 'help' for info or 'exit' to quit.") 526 | 527 | finally: 528 | if use_config_file: 529 | onedrive.to_file( 530 | config_path_verified, 531 | config_key_verified, 532 | ) 533 | 534 | 535 | if __name__ == "__main__": 536 | raise SystemExit(main()) 537 | -------------------------------------------------------------------------------- /src/graph_onedrive/_config.py: -------------------------------------------------------------------------------- 1 | """Configuration file related functions.""" 2 | 3 | from __future__ import annotations 4 | 5 | import json 6 | import logging 7 | from pathlib import Path 8 | from typing import Any 9 | from typing import Optional 10 | 11 | # Set logger 12 | logger = logging.getLogger(__name__) 13 | 14 | # import the PyYAML optional dependency 15 | try: 16 | import yaml 17 | 18 | optionals_yaml = True 19 | logger.debug("yaml imported successfully, YAML config files supported") 20 | except ImportError: 21 | optionals_yaml = False 22 | logger.debug("yaml could not be imported, YAML config files not supported") 23 | 24 | # import the TOML optional dependency 25 | try: 26 | import toml 27 | 28 | optionals_toml = True 29 | logger.debug("toml imported successfully, TOML config files supported") 30 | except ImportError: 31 | optionals_toml = False 32 | logger.debug("toml could not be imported, TOML config files not supported") 33 | 34 | # Create a tuple of acceptable config file extensions, typically used for str.endswith() 35 | if optionals_yaml and optionals_toml: 36 | CONFIG_EXTS: tuple[str, ...] = (".json", ".yaml", ".toml") 37 | elif optionals_yaml: 38 | CONFIG_EXTS = (".json", ".yaml") 39 | elif optionals_toml: 40 | CONFIG_EXTS = (".json", ".toml") 41 | else: 42 | CONFIG_EXTS = (".json",) 43 | 44 | 45 | def load_config( 46 | config_path: str | Path, config_key: str | None = None 47 | ) -> dict[str, Any]: 48 | """INTERNAL: Loads a config dictionary object from a file. 49 | Positional arguments: 50 | config_path (str|Path) -- path to configuration file 51 | config_key (str) -- key of the item storing the configuration (default = None) 52 | Returns: 53 | config (dict) -- returns the decoded dictionary contents 54 | """ 55 | # check the file type 56 | _check_file_type(config_path) 57 | 58 | # read the config file 59 | with open(config_path) as config_file: 60 | if str(config_path).endswith(".json"): 61 | logger.debug(f"loading {config_path} as a json file") 62 | config = json.load(config_file) 63 | elif str(config_path).endswith(".yaml"): 64 | logger.debug(f"loading {config_path} as a yaml file") 65 | config = yaml.safe_load(config_file) 66 | elif str(config_path).endswith(".toml"): 67 | logger.debug(f"loading {config_path} as a toml file") 68 | config = toml.load(config_file) 69 | else: 70 | raise NotImplementedError("config file type not supported") 71 | 72 | # return raw data if option set 73 | if config_key is None: 74 | logger.debug(f"returning the raw data without checking the contents") 75 | return config 76 | 77 | # return the configuration after checking that the config key 78 | try: 79 | return config[config_key] 80 | except KeyError: 81 | raise KeyError( 82 | f"config_key '{config_key}' not found in '{Path(config_path).name}'" 83 | ) 84 | 85 | 86 | def dump_config( 87 | config: dict[str, str], config_path: str | Path, config_key: str 88 | ) -> None: 89 | """INTERNAL: Dumps a config dictionary object to a file. 90 | Positional arguments: 91 | config (dict) -- dictionary of key value pairs 92 | config_path (str|Path) -- path to configuration file 93 | config_key (str) -- key of the item storing the configuration 94 | """ 95 | # Load existing file 96 | try: 97 | main_config = load_config(config_path) 98 | logger.debug(f"{config_path} file already exists, data loaded") 99 | except FileNotFoundError: 100 | main_config = {} 101 | logger.debug(f"{config_path} does not yet exist, new file will be created") 102 | 103 | # Update values 104 | main_config.update({config_key: config}) 105 | 106 | # Dump to file 107 | with open(config_path, "w") as config_file: 108 | if str(config_path).endswith(".json"): 109 | logger.debug(f"dumping data to {config_path} as a json file") 110 | json.dump(main_config, config_file, indent=4) 111 | elif str(config_path).endswith(".yaml"): 112 | logger.debug(f"dumping data to {config_path} as a yaml file") 113 | yaml.safe_dump(main_config, config_file) 114 | elif str(config_path).endswith(".toml"): 115 | logger.debug(f"dumping data to {config_path} as a toml file") 116 | toml.dump(main_config, config_file) 117 | else: 118 | raise NotImplementedError("config file type not supported") 119 | 120 | 121 | def _check_file_type( 122 | file_path: str | Path, accepted_formats: tuple[str, ...] = CONFIG_EXTS 123 | ) -> bool: 124 | """INTERNAL: Checks a file extension type compared to a tuple of acceptable types. 125 | Positional arguments: 126 | file_path (str|Path) -- a path to a file 127 | Keyword arguments: 128 | accepted_formats (tuple) -- tuple of acceptable file extensions (default = (".json", ".yaml", ".toml")) 129 | Returns: 130 | (bool) -- True if acceptable, otherwise raises TypeError 131 | """ 132 | # Return true if the file has an acceptable extension 133 | if str(file_path).endswith(accepted_formats): 134 | return True 135 | 136 | # Raise TypeErrors but provide hints for toml and yaml files 137 | if str(file_path).endswith(".yaml"): 138 | raise TypeError( 139 | f"file path was to yaml file but PyYAML is not installed, Hint: 'pip install pyyaml'" 140 | ) 141 | elif str(file_path).endswith(".toml"): 142 | raise TypeError( 143 | f"file path was to toml file but TOML is not installed, Hint: 'pip install toml'" 144 | ) 145 | else: 146 | raise TypeError( 147 | f"file path must have {' or '.join([i for i in accepted_formats])} extension" 148 | ) 149 | -------------------------------------------------------------------------------- /src/graph_onedrive/_decorators.py: -------------------------------------------------------------------------------- 1 | """Decorators to wrap Graph-OneDrive package methods and functions.""" 2 | 3 | import logging 4 | from datetime import datetime 5 | from functools import wraps 6 | from typing import no_type_check 7 | 8 | 9 | # Set logger 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | @no_type_check 14 | def token_required(func): 15 | """INTERNAL: Graph-OneDrive decorator to check for and refresh the access token when calling methods.""" 16 | 17 | @wraps(func) 18 | def wrapper_token(*args, **kwargs): 19 | # Set self from args to get instance expires attribute 20 | onedrive_instance = args[0] 21 | expires = onedrive_instance._access_expires 22 | # Get the current timestamp for comparison 23 | now = datetime.timestamp(datetime.now()) 24 | # Refresh access token if expires does not exist or has expired 25 | if expires <= now: 26 | logger.info( 27 | "access token expired, redeeming refresh token for new access token" 28 | ) 29 | onedrive_instance._get_token() 30 | onedrive_instance._create_headers() 31 | # Function that is wrapped runs here 32 | wrapped_func = func(*args, **kwargs) 33 | # After run do nothing 34 | # Return the wrapped function 35 | return wrapped_func 36 | 37 | return wrapper_token 38 | -------------------------------------------------------------------------------- /src/graph_onedrive/_main.py: -------------------------------------------------------------------------------- 1 | """OneDrive Class and Context Manager.""" 2 | 3 | from graph_onedrive._manager import OneDriveManager 4 | from graph_onedrive._onedrive import OneDrive 5 | -------------------------------------------------------------------------------- /src/graph_onedrive/_manager.py: -------------------------------------------------------------------------------- 1 | """OneDrive context manager.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from contextlib import contextmanager 7 | from pathlib import Path 8 | from collections.abc import Generator 9 | 10 | from graph_onedrive._onedrive import OneDrive 11 | 12 | 13 | # Set logger 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | @contextmanager 18 | def OneDriveManager( 19 | config_path: str | Path, config_key: str = "onedrive" 20 | ) -> Generator[OneDrive]: 21 | """Context manager for the OneDrive class, only use this if you want to save and read from a file. 22 | Positional arguments: 23 | config_path (str|Path) -- path to configuration file 24 | Keyword arguments: 25 | config_key (str) -- key of the item storing the configuration (default = "onedrive") 26 | Returns: 27 | onedrive_instance (OneDrive) -- OneDrive object instance 28 | """ 29 | logger.info("OneDriveManager creating instance") 30 | onedrive_instance = OneDrive.from_file(config_path, config_key) 31 | yield onedrive_instance 32 | logger.info("OneDriveManager saving instance configuration to file") 33 | onedrive_instance.to_file(config_path, config_key) 34 | -------------------------------------------------------------------------------- /src/graph_onedrive/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dariobauer/graph-onedrive/28d37d4a64d253e26a9bb44c9c7d99f822743355/src/graph_onedrive/py.typed -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dariobauer/graph-onedrive/28d37d4a64d253e26a9bb44c9c7d99f822743355/tests/__init__.py -------------------------------------------------------------------------------- /tests/_config_test.py: -------------------------------------------------------------------------------- 1 | """Tests the config functions using pytest.""" 2 | 3 | import json 4 | import sys 5 | from pathlib import Path 6 | from typing import Any 7 | from typing import Dict 8 | from unittest import mock 9 | 10 | import pytest 11 | import toml 12 | import yaml 13 | 14 | from .conftest import ACCESS_TOKEN 15 | from .conftest import AUTH_CODE 16 | from .conftest import CLIENT_ID 17 | from .conftest import CLIENT_SECRET 18 | from .conftest import REDIRECT 19 | from .conftest import REFRESH_TOKEN 20 | from .conftest import SCOPE 21 | from .conftest import TENANT 22 | from .conftest import TESTS_DIR 23 | from graph_onedrive._config import _check_file_type 24 | from graph_onedrive._config import dump_config 25 | from graph_onedrive._config import load_config 26 | 27 | 28 | class TestLoad: 29 | """Tests the load_config function.""" 30 | 31 | @pytest.mark.parametrize( 32 | "config_path, config_key", 33 | [ 34 | ("config.json", "onedrive"), 35 | ("test.yaml", "test_2"), 36 | ("unknown.txt.toml", "one more test"), 37 | ], 38 | ) 39 | def test_load_config(self, tmp_path, config_path, config_key): 40 | # Make a temporary config file 41 | config = { 42 | config_key: { 43 | "tenant_id": TENANT, 44 | "client_id": CLIENT_ID, 45 | "client_secret_value": CLIENT_SECRET, 46 | "redirect_url": REDIRECT, 47 | "refresh_token": REFRESH_TOKEN, 48 | } 49 | } 50 | temp_dir = Path(tmp_path, "temp_config") 51 | temp_dir.mkdir() 52 | config_path = Path(temp_dir, config_path) 53 | with open(config_path, "w") as fw: 54 | if str(config_path).endswith(".json"): 55 | json.dump(config, fw) 56 | elif str(config_path).endswith(".yaml"): 57 | yaml.safe_dump(config, fw) 58 | elif str(config_path).endswith(".toml"): 59 | toml.dump(config, fw) 60 | # Test load 61 | data = load_config(config_path, config_key) 62 | assert data == config[config_key] 63 | 64 | def test_load_config_raw(self, tmp_path): 65 | config_key = "onedrive" 66 | # Make a temporary config file 67 | config = { 68 | config_key: { 69 | "tenant_id": TENANT, 70 | "client_id": CLIENT_ID, 71 | "client_secret_value": CLIENT_SECRET, 72 | "redirect_url": REDIRECT, 73 | "refresh_token": REFRESH_TOKEN, 74 | }, 75 | "other data": 1234, 76 | } 77 | temp_dir = Path(tmp_path, "temp_config") 78 | temp_dir.mkdir() 79 | config_path = Path(temp_dir, "raw.yaml") 80 | with open(config_path, "w") as fw: 81 | if str(config_path).endswith(".json"): 82 | json.dump(config, fw) 83 | elif str(config_path).endswith(".yaml"): 84 | yaml.safe_dump(config, fw) 85 | elif str(config_path).endswith(".toml"): 86 | toml.dump(config, fw) 87 | # Test load 88 | data = load_config(config_path) 89 | assert data == config 90 | 91 | def test_load_config_failure(self): ... 92 | 93 | 94 | class TestDump: 95 | """Tests the dump_config function.""" 96 | 97 | @pytest.mark.parametrize( 98 | "config_path", 99 | ["config.json", "test.yaml", "unknown.txt.toml"], 100 | ) 101 | def test_dump_config(self, tmp_path, config_path): 102 | config_key: str = "onedrive" 103 | initial_file: dict[str, Any] = { 104 | config_key: { 105 | "tenant_id": TENANT, 106 | "client_id": CLIENT_ID, 107 | "client_secret_value": CLIENT_SECRET, 108 | "redirect_url": REDIRECT, 109 | "refresh_token": REFRESH_TOKEN, 110 | }, 111 | "other data": 1234, 112 | } 113 | temp_dir = Path(tmp_path, "temp_config") 114 | temp_dir.mkdir() 115 | config_path = Path(temp_dir, config_path) 116 | with open(config_path, "w") as fw: 117 | if str(config_path).endswith(".json"): 118 | json.dump(initial_file, fw) 119 | elif str(config_path).endswith(".yaml"): 120 | yaml.safe_dump(initial_file, fw) 121 | elif str(config_path).endswith(".toml"): 122 | toml.dump(initial_file, fw) 123 | # Test load 124 | new_config = { 125 | "tenant_id": TENANT, 126 | "client_id": CLIENT_ID, 127 | "client_secret_value": CLIENT_SECRET, 128 | "redirect_url": REDIRECT, 129 | "refresh_token": "new", 130 | } 131 | dump_config(new_config, config_path, config_key) 132 | # Verify data 133 | with open(config_path) as fr: 134 | if str(config_path).endswith(".json"): 135 | read_data = json.load(fr) 136 | elif str(config_path).endswith(".yaml"): 137 | read_data = yaml.safe_load(fr) 138 | elif str(config_path).endswith(".toml"): 139 | read_data = toml.load(fr) 140 | initial_file[config_key]["refresh_token"] = "new" 141 | assert read_data == initial_file 142 | 143 | def test_dump_config_failure(self): ... 144 | 145 | 146 | class TestFileTypeCheck: 147 | """Tests the _check_file_type function.""" 148 | 149 | @pytest.mark.parametrize( 150 | "file_path, accepted_formats", 151 | [ 152 | ("config.json", (".json",)), 153 | ("test.yaml", (".json", ".yaml")), 154 | ("unknown.txt.toml", (".json", ".yaml", ".toml")), 155 | ], 156 | ) 157 | def test_check_file_type(self, file_path, accepted_formats): 158 | result = _check_file_type(file_path, accepted_formats) 159 | assert result == True 160 | 161 | @pytest.mark.parametrize( 162 | "file_path, accepted_formats, exp_msg", 163 | [ 164 | ("config.json", (".txt",), "file path must have .txt extension"), 165 | ( 166 | "test.yaml", 167 | (".json",), 168 | "file path was to yaml file but PyYAML is not installed, Hint: 'pip install pyyaml'", 169 | ), 170 | ( 171 | "unknown.txt.toml", 172 | (".json", ".yaml"), 173 | "file path was to toml file but TOML is not installed, Hint: 'pip install toml'", 174 | ), 175 | ], 176 | ) 177 | def test_check_file_type_failure(self, file_path, accepted_formats, exp_msg): 178 | with pytest.raises(TypeError) as excinfo: 179 | _check_file_type(file_path, accepted_formats) 180 | (msg,) = excinfo.value.args 181 | assert msg == exp_msg 182 | -------------------------------------------------------------------------------- /tests/_decorators_test.py: -------------------------------------------------------------------------------- 1 | """Tests the OneDrive decorators pytest.""" 2 | 3 | from datetime import datetime 4 | from datetime import timedelta 5 | 6 | import pytest 7 | import respx 8 | 9 | 10 | class TestTokenRequiredDecorator: 11 | """Tests the @token_required decorator.""" 12 | 13 | def test_token_required(self, onedrive): 14 | # Make a call to a mathod that uses the decorator 15 | onedrive.get_usage() 16 | # No errors is not the best way of asserting but is expected 17 | assert True 18 | 19 | def test_token_required_expired(self, temp_onedrive): 20 | # Set the access token time as expired by 2 seconds 21 | temp_onedrive._access_expires = datetime.timestamp( 22 | datetime.now() - timedelta(seconds=2) 23 | ) 24 | # Make a call to a mathod that uses the decorator 25 | temp_onedrive.get_usage() 26 | # Check to ensure that the _access_expires is now in the future 27 | assert temp_onedrive._access_expires > datetime.timestamp(datetime.now()) + 30 28 | -------------------------------------------------------------------------------- /tests/_manager_test.py: -------------------------------------------------------------------------------- 1 | """Tests the OneDrive context manager using pytest.""" 2 | 3 | import json 4 | from pathlib import Path 5 | 6 | import pytest 7 | 8 | import graph_onedrive 9 | from .conftest import ACCESS_TOKEN 10 | from .conftest import AUTH_CODE 11 | from .conftest import CLIENT_ID 12 | from .conftest import CLIENT_SECRET 13 | from .conftest import REDIRECT 14 | from .conftest import REFRESH_TOKEN 15 | from .conftest import SCOPE 16 | from .conftest import TENANT 17 | from .conftest import TESTS_DIR 18 | from graph_onedrive._onedrive import GraphAPIError 19 | from graph_onedrive._onedrive import OneDrive 20 | 21 | 22 | class TestManager: 23 | """Tests the context manager.""" 24 | 25 | @pytest.mark.parametrize( 26 | "redirect_url, refresh_token", 27 | [ 28 | (REDIRECT, REFRESH_TOKEN), 29 | (False, REFRESH_TOKEN), 30 | (REDIRECT, False), 31 | (False, False), 32 | ], 33 | ) 34 | def test_manager( 35 | self, 36 | tmp_path, 37 | monkeypatch, 38 | mock_graph_api, 39 | mock_auth_api, 40 | redirect_url, 41 | refresh_token, 42 | ): 43 | # Make a temporary config file 44 | config_key = "onedrive" 45 | config = { 46 | config_key: { 47 | "tenant_id": TENANT, 48 | "client_id": CLIENT_ID, 49 | "client_secret_value": CLIENT_SECRET, 50 | } 51 | } 52 | if redirect_url: 53 | config[config_key]["redirect_url"] = redirect_url 54 | if refresh_token: 55 | config[config_key]["refresh_token"] = refresh_token 56 | temp_dir = Path(tmp_path, "temp_config") 57 | temp_dir.mkdir() 58 | config_path = Path(temp_dir, "config.json") 59 | with open(config_path, "w") as fw: 60 | json.dump(config, fw) 61 | # If no refresh token provided then monkeypatch the auth input 62 | if not refresh_token: 63 | input_url = REDIRECT + "?code=" + AUTH_CODE 64 | monkeypatch.setattr("builtins.input", lambda _: input_url) 65 | # Run the test 66 | with graph_onedrive.OneDriveManager(config_path) as onedrive: 67 | assert isinstance(onedrive, OneDrive) 68 | 69 | @pytest.mark.parametrize( 70 | "config_path, config_key, exp_msg", 71 | [ 72 | (123, None, "config_path expected 'str' or 'Path', got 'int'"), 73 | ("str", 4.1, "config_key expected 'str', got 'float'"), 74 | ], 75 | ) 76 | def test_manager_failure_type(self, config_path, config_key, exp_msg): 77 | with pytest.raises(TypeError) as excinfo: 78 | with graph_onedrive.OneDriveManager(config_path, config_key) as onedrive: 79 | pass 80 | (msg,) = excinfo.value.args 81 | assert msg == exp_msg 82 | 83 | @pytest.mark.parametrize( 84 | "config_key_input, config, exp_msg", 85 | [ 86 | ( 87 | "bad-key", 88 | {"tenant_id": "blah"}, 89 | "config_key 'bad-key' not found in 'config.json'", 90 | ), 91 | ( 92 | "onedrive", 93 | {"tenant_id": "blah"}, 94 | "expected client_id in first level of dictionary", 95 | ), 96 | ], 97 | ) 98 | def test_manager_failure_key(self, tmp_path, config_key_input, config, exp_msg): 99 | # Make a temporary config file 100 | config = {"onedrive": config} 101 | temp_dir = Path(tmp_path, "temp_config") 102 | temp_dir.mkdir() 103 | config_path = Path(temp_dir, "config.json") 104 | with open(config_path, "w") as fw: 105 | json.dump(config, fw) 106 | # Run the test 107 | with pytest.raises(KeyError) as excinfo: 108 | with graph_onedrive.OneDriveManager( 109 | config_path, config_key_input 110 | ) as onedrive: 111 | pass 112 | (msg,) = excinfo.value.args 113 | assert msg == exp_msg 114 | -------------------------------------------------------------------------------- /tests/_onedrive_test.py: -------------------------------------------------------------------------------- 1 | """Tests the OneDrive class using pytest.""" 2 | 3 | import json 4 | import logging 5 | import os 6 | import re 7 | from pathlib import Path 8 | 9 | import httpx 10 | import pytest 11 | import yaml 12 | 13 | from .conftest import ACCESS_TOKEN 14 | from .conftest import AUTH_CODE 15 | from .conftest import CLIENT_ID 16 | from .conftest import CLIENT_SECRET 17 | from .conftest import REDIRECT 18 | from .conftest import REFRESH_TOKEN 19 | from .conftest import SCOPE 20 | from .conftest import TENANT 21 | from .conftest import TESTS_DIR 22 | from graph_onedrive._onedrive import GraphAPIError 23 | from graph_onedrive._onedrive import OneDrive 24 | 25 | 26 | class TestDunders: 27 | """Tests the instance double under methods.""" 28 | 29 | # __init__ 30 | @pytest.mark.parametrize( 31 | "refresh_token", 32 | [REFRESH_TOKEN, None], 33 | ) 34 | def test_init(self, mock_graph_api, mock_auth_api, monkeypatch, refresh_token): 35 | # monkeypatch the Authorization step when no refresh token provided 36 | input_url = REDIRECT + "?code=" + AUTH_CODE 37 | monkeypatch.setattr("builtins.input", lambda _: input_url) 38 | # make the request 39 | assert OneDrive(CLIENT_ID, CLIENT_SECRET, TENANT, REDIRECT, refresh_token) 40 | 41 | @pytest.mark.parametrize( 42 | "client_id, client_secret, tenant, redirect, refresh_token, exp_msg", 43 | [ 44 | ( 45 | 123, 46 | CLIENT_SECRET, 47 | TENANT, 48 | REDIRECT, 49 | REFRESH_TOKEN, 50 | "client_id expected 'str', got 'int'", 51 | ), 52 | ( 53 | CLIENT_ID, 54 | 4.5, 55 | TENANT, 56 | REDIRECT, 57 | REFRESH_TOKEN, 58 | "client_secret expected 'str', got 'float'", 59 | ), 60 | ( 61 | CLIENT_ID, 62 | CLIENT_SECRET, 63 | {}, 64 | REDIRECT, 65 | REFRESH_TOKEN, 66 | "tenant expected 'str', got 'dict'", 67 | ), 68 | ( 69 | CLIENT_ID, 70 | CLIENT_SECRET, 71 | TENANT, 72 | b"https://", 73 | REFRESH_TOKEN, 74 | "redirect_url expected 'str', got 'bytes'", 75 | ), 76 | ( 77 | CLIENT_ID, 78 | CLIENT_SECRET, 79 | TENANT, 80 | REDIRECT, 81 | 99, 82 | "refresh_token expected 'str', got 'int'", 83 | ), 84 | ], 85 | ) 86 | def test_init_failure_type( 87 | self, 88 | mock_graph_api, 89 | mock_auth_api, 90 | client_id, 91 | client_secret, 92 | tenant, 93 | redirect, 94 | refresh_token, 95 | exp_msg, 96 | ): 97 | with pytest.raises(TypeError) as excinfo: 98 | OneDrive(client_id, client_secret, tenant, redirect, refresh_token) 99 | (msg,) = excinfo.value.args 100 | assert msg == exp_msg 101 | 102 | # __repr__ 103 | def test_repr(self, onedrive): 104 | assert ( 105 | repr(onedrive) 106 | == f"" 107 | ) 108 | 109 | 110 | class TestConstructors: 111 | """Tests the from_dict, from_file, from_json, from_yaml methods.""" 112 | 113 | @pytest.mark.parametrize( 114 | "redirect_url, refresh_token, save_back, file_ext", 115 | [ 116 | (REDIRECT, REFRESH_TOKEN, False, "json"), 117 | (False, REFRESH_TOKEN, True, "json"), 118 | (REDIRECT, False, True, "json"), 119 | (False, False, True, "json"), 120 | (REDIRECT, REFRESH_TOKEN, False, "yaml"), 121 | (False, REFRESH_TOKEN, True, "yaml"), 122 | (REDIRECT, False, True, "yaml"), 123 | (False, False, True, "yaml"), 124 | ], 125 | ) 126 | def test_from_file( 127 | self, 128 | tmp_path, 129 | monkeypatch, 130 | mock_graph_api, 131 | mock_auth_api, 132 | redirect_url, 133 | refresh_token, 134 | save_back, 135 | file_ext, 136 | ): 137 | # Make a temporary config file 138 | config_key = "onedrive" 139 | config = { 140 | config_key: { 141 | "tenant_id": TENANT, 142 | "client_id": CLIENT_ID, 143 | "client_secret_value": CLIENT_SECRET, 144 | } 145 | } 146 | if redirect_url: 147 | config[config_key]["redirect_url"] = redirect_url 148 | if refresh_token: 149 | config[config_key]["refresh_token"] = refresh_token 150 | temp_dir = Path(tmp_path, "temp_config") 151 | temp_dir.mkdir() 152 | config_path = Path(temp_dir, f"config.{file_ext}") 153 | with open(config_path, "w") as fw: 154 | if file_ext == "json": 155 | json.dump(config, fw) 156 | elif file_ext == "yaml": 157 | yaml.safe_dump(config, fw) 158 | else: 159 | raise NotImplementedError("testing extension not implemented") 160 | # If no refresh token provided then monkeypatch the auth input 161 | if not refresh_token: 162 | input_url = REDIRECT + "?code=" + AUTH_CODE 163 | monkeypatch.setattr("builtins.input", lambda _: input_url) 164 | # Run the test 165 | onedrive_instance = OneDrive.from_file( 166 | config_path, config_key, save_refresh_token=save_back 167 | ) 168 | assert isinstance(onedrive_instance, OneDrive) 169 | 170 | @pytest.mark.parametrize( 171 | "config_path, config_key, exp_msg", 172 | [ 173 | (123, None, "config_path expected 'str' or 'Path', got 'int'"), 174 | ("str", 4.1, "config_key expected 'str', got 'float'"), 175 | ], 176 | ) 177 | def test_from_file_failure_type(self, config_path, config_key, exp_msg): 178 | with pytest.raises(TypeError) as excinfo: 179 | OneDrive.from_file(config_path, config_key) 180 | (msg,) = excinfo.value.args 181 | assert msg == exp_msg 182 | 183 | @pytest.mark.parametrize( 184 | "config_key_input, config, exp_msg", 185 | [ 186 | ( 187 | "bad-key", 188 | {"tenant_id": "blah"}, 189 | "config_key 'bad-key' not found in 'config.json'", 190 | ), 191 | ( 192 | "onedrive", 193 | {"tenant_id": "blah", "client_secret_value": "test"}, 194 | "expected client_id in first level of dictionary", 195 | ), 196 | ( 197 | "onedrive", 198 | {"client_id": "blah", "client_secret_value": "test"}, 199 | "expected tenant_id in first level of dictionary", 200 | ), 201 | ( 202 | "onedrive", 203 | {"client_id": "blah", "tenant_id": "blah"}, 204 | "expected client_secret_value in first level of dictionary", 205 | ), 206 | ], 207 | ) 208 | def test_from_file_failure_key(self, tmp_path, config_key_input, config, exp_msg): 209 | # Make a temporary config file 210 | config = {"onedrive": config} 211 | temp_dir = Path(tmp_path, "temp_config") 212 | temp_dir.mkdir() 213 | config_path = Path(temp_dir, "config.json") 214 | with open(config_path, "w") as fw: 215 | json.dump(config, fw) 216 | # Run the test 217 | with pytest.raises(KeyError) as excinfo: 218 | OneDrive.from_file(config_path, config_key_input) 219 | (msg,) = excinfo.value.args 220 | assert msg == exp_msg 221 | 222 | 223 | class TestDeconstructors: 224 | """Tests the to_file, to_json, to_yaml methods.""" 225 | 226 | @pytest.mark.parametrize( 227 | "config_key, file_ext", 228 | [ 229 | (None, "json"), 230 | ("onedrive", "json"), 231 | ("my random key", "json"), 232 | (None, "yaml"), 233 | ("onedrive", "yaml"), 234 | ("my random key", "yaml"), 235 | ], 236 | ) 237 | def test_to_file(self, onedrive, tmp_path, config_key, file_ext): 238 | temp_dir = Path(tmp_path, "temp_config") 239 | temp_dir.mkdir() 240 | config_path = Path(temp_dir, f"config-{config_key}.{file_ext}") 241 | # Run the test 242 | if config_key: 243 | onedrive.to_file(config_path, config_key) 244 | else: 245 | onedrive.to_file(config_path) 246 | assert os.path.isfile(config_path) 247 | with open(config_path) as fr: 248 | if file_ext == "json": 249 | config = json.load(fr) 250 | elif file_ext == "yaml": 251 | config = yaml.safe_load(fr) 252 | else: 253 | config = {} 254 | if config_key is None: 255 | config_key = "onedrive" 256 | assert config_key in config 257 | assert config[config_key]["tenant_id"] == onedrive._tenant_id 258 | assert config[config_key]["client_id"] == onedrive._client_id 259 | assert config[config_key]["client_secret_value"] == onedrive._client_secret 260 | assert config[config_key]["redirect_url"] == onedrive._redirect 261 | assert config[config_key]["refresh_token"] == onedrive.refresh_token 262 | 263 | def test_to_file_existing(self, onedrive, tmp_path): 264 | # Create an existing json file 265 | temp_dir = Path(tmp_path, "temp_config") 266 | temp_dir.mkdir() 267 | config_path = Path(temp_dir, "config.json") 268 | with open(config_path, "w") as fw: 269 | json.dump({"other": 100}, fw) 270 | # Run the test 271 | config_key = "new_config" 272 | onedrive.to_file(config_path, config_key) 273 | with open(config_path) as fr: 274 | config = json.load(fr) 275 | assert config_key in config 276 | assert config["other"] == 100 277 | 278 | @pytest.mark.parametrize( 279 | "config_path, config_key, exp_msg", 280 | [ 281 | ( 282 | 123, 283 | "str", 284 | "file_path expected 'str' or 'Path', got 'int'", 285 | ), 286 | ("str", 4.1, "config_key expected 'str', got 'float'"), 287 | ], 288 | ) 289 | def test_to_file_failure_type(self, onedrive, config_path, config_key, exp_msg): 290 | with pytest.raises(TypeError) as excinfo: 291 | onedrive.to_file(config_path, config_key) 292 | (msg,) = excinfo.value.args 293 | assert msg == exp_msg 294 | 295 | 296 | class TestResponseChecks: 297 | """Tests the _raise_unexpected_response method.""" 298 | 299 | @pytest.mark.parametrize( 300 | "resp_code, check_code, message, has_json", 301 | [ 302 | (200, 200, "", False), 303 | (400, [302, 400], "123", False), 304 | (500, ["blah", 500], "just a string", True), 305 | ], 306 | ) 307 | def test_raise_unexpected_response( 308 | self, onedrive, resp_code, check_code, message, has_json 309 | ): 310 | response = httpx.Response(status_code=resp_code, json={"content": "nothing"}) 311 | onedrive._raise_unexpected_response( 312 | response, check_code, message, has_json=has_json 313 | ) 314 | assert True 315 | 316 | @pytest.mark.parametrize( 317 | "resp_code, resp_json, check_code, message, has_json, exp_msg", 318 | [ 319 | ( 320 | 200, 321 | {"no": "content"}, 322 | 201, 323 | "just a test", 324 | False, 325 | "just a test (no error message returned)", 326 | ), 327 | ( 328 | 204, 329 | None, 330 | 204, 331 | "test 123", 332 | True, 333 | "test 123 (response did not contain json)", 334 | ), 335 | ( 336 | 400, 337 | {"error": {"message": "Invalid request"}}, 338 | 204, 339 | "could not delete link", 340 | True, 341 | "could not delete link (Invalid request)", 342 | ), 343 | ( 344 | 500, 345 | {"error_description": "Unauthorized"}, 346 | 201, 347 | "could not get headers", 348 | True, 349 | "could not get headers (Unauthorized)", 350 | ), 351 | ], 352 | ) 353 | def test_raise_unexpected_response_failure( 354 | self, onedrive, resp_code, check_code, message, resp_json, has_json, exp_msg 355 | ): 356 | response = httpx.Response(status_code=resp_code, json=resp_json) 357 | with pytest.raises(GraphAPIError) as excinfo: 358 | onedrive._raise_unexpected_response( 359 | response, check_code, message, has_json=has_json 360 | ) 361 | (msg,) = excinfo.value.args 362 | assert msg == exp_msg 363 | 364 | 365 | class TestGetTokens: 366 | """Tests the _get_token method.""" 367 | 368 | def test_get_tokens_using_auth_code(self, temp_onedrive, monkeypatch): 369 | # monkeypatch the response url typically input by user 370 | input_url = REDIRECT + "?code=" + AUTH_CODE 371 | monkeypatch.setattr("builtins.input", lambda _: input_url) 372 | # set the refresh token as empty 373 | temp_onedrive.refresh_token = "" 374 | # make the request 375 | temp_onedrive._get_token() 376 | assert temp_onedrive.refresh_token == REFRESH_TOKEN 377 | assert temp_onedrive._access_token == ACCESS_TOKEN 378 | 379 | def test_get_tokens_using_refresh_token(self, temp_onedrive): 380 | temp_onedrive._get_token() 381 | assert temp_onedrive.refresh_token == REFRESH_TOKEN 382 | assert temp_onedrive._access_token == ACCESS_TOKEN 383 | 384 | def test_get_token_failure_bad_request_token(self, temp_onedrive): 385 | temp_onedrive.refresh_token = "badtoken" 386 | with pytest.raises(GraphAPIError) as excinfo: 387 | temp_onedrive._get_token() 388 | (msg,) = excinfo.value.args 389 | assert msg == "could not get access token (Invalid request)" 390 | 391 | def test_get_token_failure_bad_return_access_token( 392 | self, temp_onedrive, mock_auth_api 393 | ): 394 | mock_auth_api.routes["access_token"].snapshot() 395 | mock_auth_api.routes["access_token"].side_effect = None 396 | mock_auth_api.routes["access_token"].return_value = httpx.Response( 397 | 200, json={"access_token": None, "refresh_token": REFRESH_TOKEN} 398 | ) 399 | with pytest.raises(GraphAPIError) as excinfo: 400 | temp_onedrive._get_token() 401 | (msg,) = excinfo.value.args 402 | assert msg == "response did not return an access token" 403 | mock_auth_api.routes["access_token"].rollback() 404 | 405 | def test_get_token_failure_bad_return_refresh_token( 406 | self, temp_onedrive, mock_auth_api, caplog 407 | ): 408 | mock_auth_api.routes["access_token"].snapshot() 409 | mock_auth_api.routes["access_token"].side_effect = None 410 | mock_auth_api.routes["access_token"].return_value = httpx.Response( 411 | 200, json={"access_token": ACCESS_TOKEN, "refresh_token": None} 412 | ) 413 | with caplog.at_level(logging.WARNING, logger="graph_onedrive"): 414 | temp_onedrive._get_token() 415 | assert len(caplog.records) == 1 416 | assert ( 417 | str(caplog.records[0].message) 418 | == "token request did not return a refresh token, existing config not updated" 419 | ) 420 | # old refresh token should still be set 421 | assert temp_onedrive.refresh_token == REFRESH_TOKEN 422 | mock_auth_api.routes["access_token"].rollback() 423 | 424 | def test_get_token_failure_unknown(self, temp_onedrive, mock_auth_api): 425 | mock_auth_api.routes["access_token"].snapshot() 426 | mock_auth_api.routes["access_token"].side_effect = None 427 | mock_auth_api.routes["access_token"].return_value = httpx.Response(400) 428 | with pytest.raises(GraphAPIError) as excinfo: 429 | temp_onedrive._get_token() 430 | (msg,) = excinfo.value.args 431 | assert msg == "could not get access token (no error message returned)" 432 | mock_auth_api.routes["access_token"].rollback() 433 | 434 | 435 | class TestAuthorization: 436 | """Tests the _get_authorization method.""" 437 | 438 | def test_get_authorization(self, temp_onedrive, monkeypatch): 439 | # monkeypatch the response url typically input by user 440 | input_url = REDIRECT + "?code=" + AUTH_CODE 441 | monkeypatch.setattr("builtins.input", lambda _: input_url) 442 | # make the request 443 | auth_code = temp_onedrive._get_authorization() 444 | assert auth_code == AUTH_CODE 445 | 446 | @pytest.mark.parametrize( 447 | "input_url, exp_msg", 448 | [ 449 | ( 450 | REDIRECT + "?code=&123&state=nomatch", 451 | "response 'state' not for this request, occurs when reusing an old authorization url", 452 | ), 453 | (REDIRECT + "?code=&", "response did not contain an authorization code"), 454 | ( 455 | REDIRECT + "?code=123&state=blah", 456 | "response 'state' not for this request, occurs when reusing an old authorization url", 457 | ), 458 | ], 459 | ) 460 | def test_get_authorization_failure( 461 | self, temp_onedrive, monkeypatch, input_url, exp_msg 462 | ): 463 | # monkeypatch the response url typically input by user 464 | monkeypatch.setattr("builtins.input", lambda _: input_url) 465 | # make the request 466 | with pytest.raises(GraphAPIError) as excinfo: 467 | temp_onedrive._get_authorization() 468 | (msg,) = excinfo.value.args 469 | assert msg == exp_msg 470 | 471 | def test_get_authorization_warn(self, temp_onedrive, monkeypatch, caplog): 472 | # monkeypatch the response url typically input by user 473 | input_url = REDIRECT + "?code=" + AUTH_CODE 474 | monkeypatch.setattr("builtins.input", lambda _: input_url) 475 | # make the request 476 | with caplog.at_level(logging.WARNING, logger="graph_onedrive"): 477 | auth_code = temp_onedrive._get_authorization() 478 | assert len(caplog.records) == 1 479 | assert ( 480 | str(caplog.records[0].message) 481 | == "response 'state' was not in returned url, response not confirmed" 482 | ) 483 | assert auth_code == AUTH_CODE 484 | 485 | 486 | class TestHeaders: 487 | """Tests the _create_headers method.""" 488 | 489 | def test_create_headers(self, temp_onedrive): 490 | temp_onedrive._headers = {} 491 | temp_onedrive._create_headers() 492 | exp_headers = {"Accept": "*/*", "Authorization": "Bearer " + ACCESS_TOKEN} 493 | assert temp_onedrive._headers == exp_headers 494 | 495 | def test_create_headers_failure_value(self, temp_onedrive): 496 | temp_onedrive._headers = {} 497 | temp_onedrive._access_token = "" 498 | with pytest.raises(ValueError) as excinfo: 499 | temp_onedrive._create_headers() 500 | (msg,) = excinfo.value.args 501 | assert msg == "expected self._access_token to be set, got empty string" 502 | 503 | 504 | class TestDriveDetails: 505 | """Tests the _get_drive_details, get_usage methods.""" 506 | 507 | # get_drive_details 508 | def test_get_drive_details(self, onedrive): 509 | onedrive._get_drive_details() 510 | assert ( 511 | onedrive._drive_id 512 | == "b!-RIj2DuyvEyV1T4NlOaMHk8XkS_I8MdFlUCq1BlcjgmhRfAj3-Z8RY2VpuvV_tpd" 513 | ) 514 | assert onedrive._drive_name == "OneDrive" 515 | assert onedrive._drive_type == "business" 516 | assert onedrive._owner_id == "48d31887-5fad-4d73-a9f5-3c356e68a038" 517 | assert onedrive._owner_email == "MeganB@M365x214355.onmicrosoft.com" 518 | assert onedrive._owner_name == "Megan Bowen" 519 | assert onedrive._quota_used == 106330475 520 | assert onedrive._quota_remaining == 1099217263127 521 | assert onedrive._quota_total == 1099511627776 522 | 523 | @pytest.mark.parametrize( 524 | "json_returned, exp_msg", 525 | [ 526 | ( 527 | {"error": {"code": "invalidRequest", "message": "Invalid request"}}, 528 | "could not get drive details (Invalid request)", 529 | ), 530 | (None, "could not get drive details (no error message returned)"), 531 | ], 532 | ) 533 | def test_get_drive_details_failure( 534 | self, temp_onedrive, mock_graph_api, json_returned, exp_msg 535 | ): 536 | mock_graph_api.routes["drive_details"].snapshot() 537 | mock_graph_api.routes["drive_details"].side_effect = None 538 | mock_graph_api.routes["drive_details"].return_value = httpx.Response( 539 | 400, json=json_returned 540 | ) 541 | with pytest.raises(GraphAPIError) as excinfo: 542 | temp_onedrive._get_drive_details() 543 | (msg,) = excinfo.value.args 544 | assert msg == exp_msg 545 | mock_graph_api.routes["drive_details"].rollback() 546 | 547 | # get_usage 548 | @pytest.mark.parametrize( 549 | "unit, exp_used, exp_capacity, exp_unit", 550 | [ 551 | ("b", 106330475, 1099511627776, "b"), 552 | ("kb", 103838.4, 1073741824, "kb"), 553 | ("MB", 101.4, 1048576, "mb"), 554 | ("gb", 0.1, 1024, "gb"), 555 | (None, 0.1, 1024, "gb"), 556 | ], 557 | ) 558 | def test_get_usage(self, onedrive, unit, exp_used, exp_capacity, exp_unit): 559 | if unit == None: 560 | used, capacity, unit = onedrive.get_usage(refresh=True) 561 | else: 562 | used, capacity, unit = onedrive.get_usage(unit=unit) 563 | assert round(used, 1) == round(exp_used, 1) 564 | assert round(capacity, 1) == round(exp_capacity, 1) 565 | assert unit == exp_unit 566 | 567 | def test_get_usage_verbose(self, onedrive, capsys): 568 | onedrive.get_usage(verbose=True) 569 | stdout, sterr = capsys.readouterr() 570 | assert stdout == "Using 0.1 gb (0.01%) of total 1024.0 gb.\n" 571 | 572 | def test_get_usage_failure_value(self, onedrive): 573 | with pytest.raises(ValueError) as excinfo: 574 | onedrive.get_usage(unit="TB") 575 | (msg,) = excinfo.value.args 576 | assert msg == "'tb' is not a supported unit" 577 | 578 | @pytest.mark.parametrize( 579 | "unit, exp_msg", 580 | [ 581 | (1, "unit expected 'str', got 'int'"), 582 | (None, "unit expected 'str', got 'NoneType'"), 583 | ], 584 | ) 585 | def test_get_usage_failure_type(self, onedrive, unit, exp_msg): 586 | with pytest.raises(TypeError) as excinfo: 587 | onedrive.get_usage(unit=unit) 588 | (msg,) = excinfo.value.args 589 | assert msg == exp_msg 590 | 591 | 592 | class TestListingDirectories: 593 | """Tests the list_directory method.""" 594 | 595 | def test_list_directory_root(self, onedrive): 596 | items = onedrive.list_directory() 597 | assert items[0].get("id") == "01BYE5RZ6QN3ZWBTUFOFD3GSPGOHDJD36K" 598 | 599 | def test_list_directory_folder(self, onedrive): 600 | item_id = "01BYE5RZYFPM65IDVARFELFLNTXR4ZKABD" 601 | items = onedrive.list_directory(item_id) 602 | assert items[0].get("id") == "01BYE5RZZWSN2ASHUEBJH2XJJ25WSEBUJ3" 603 | 604 | @pytest.mark.skip(reason="not implemented") 605 | def test_list_directory_failure(self, onedrive): ... 606 | 607 | def test_list_directory_failure_type(self, onedrive): 608 | with pytest.raises(TypeError) as excinfo: 609 | onedrive.list_directory(123) 610 | (msg,) = excinfo.value.args 611 | assert msg == "folder_id expected 'str', got 'int'" 612 | 613 | 614 | class TestSearch: 615 | """Tests the search method.""" 616 | 617 | @pytest.mark.parametrize( 618 | "query, top, exp_len", 619 | [ 620 | ("Contoso", 4, 4), 621 | ("Sales", 200, 6), 622 | ("does not exist", 150, 0), 623 | ], 624 | ) 625 | def test_search(self, onedrive, query, top, exp_len): 626 | items = onedrive.search(query, top=top) 627 | assert len(items) == exp_len 628 | 629 | @pytest.mark.skip(reason="not implemented") 630 | def test_search_failure(self, onedrive): ... 631 | 632 | @pytest.mark.parametrize( 633 | "query, top, exp_msg", 634 | [ 635 | (123, 4, "query expected 'str', got 'int'"), 636 | ("123", 4.0, "top expected 'int', got 'float'"), 637 | ], 638 | ) 639 | def test_search_failure_type(self, onedrive, query, top, exp_msg): 640 | with pytest.raises(TypeError) as excinfo: 641 | onedrive.search(query, top) 642 | (msg,) = excinfo.value.args 643 | assert msg == exp_msg 644 | 645 | @pytest.mark.parametrize( 646 | "query", 647 | [ 648 | "", 649 | " ", 650 | "%20", 651 | ], 652 | ) 653 | def test_search_failure_value(self, onedrive, query): 654 | with pytest.raises(ValueError) as excinfo: 655 | onedrive.search(query) 656 | (msg,) = excinfo.value.args 657 | assert ( 658 | msg 659 | == "cannot search for blank string. Did you mean list_directory(folder_id=None)?" 660 | ) 661 | 662 | 663 | class TestItemDetails: 664 | """Tests the detail_item, item_type, is_folder, is_file methods.""" 665 | 666 | # detail_item 667 | @pytest.mark.parametrize( 668 | "item_id, exp_name", 669 | [ 670 | ("01BYE5RZ2XXKUBPDYT7JGLPHYXALBIXKEL", "Contoso Patent Template.docx"), 671 | ("01BYE5RZ6TAJHXA5GMWZB2HDLD7SNEXFFU", "CR-227 Project"), 672 | ], 673 | ) 674 | def test_detail_item(self, onedrive, item_id, exp_name): 675 | item_details = onedrive.detail_item(item_id) 676 | assert item_details.get("name") == exp_name 677 | 678 | @pytest.mark.parametrize( 679 | "item_id, exp_stout", 680 | [ 681 | ( 682 | "01BYE5RZ2XXKUBPDYT7JGLPHYXALBIXKEL", 683 | "item id: 01BYE5RZ2XXKUBPDYT7JGLPHYXALBIXKEL\n" 684 | "name: Contoso Patent Template.docx\n" 685 | "type: file\n" 686 | "created: 2017-08-07T16:03:47Z by: Megan Bowen\n" 687 | "last modified: 2017-08-10T17:06:12Z by: Megan Bowen\n" 688 | "size: 85596\n" 689 | "web url: https://m365x214355-my.sharepoint.com/personal/meganb_m365x214355_onmicrosoft_com/_layouts/15/Doc.aspx?sourcedoc=%7B17A8BA57-138F-4CFA-B79F-1702C28BA88B%7D&file=Contoso%20Patent%20Template.docx&action=default&mobileredirect=true\n" 690 | "file system created: 2017-08-07T16:03:47Z\n" 691 | "file system last modified: 2017-08-10T17:06:12Z\n" 692 | "file quickXor hash: jWK86kNVvULlV/oFKuGvDKybt+I=\n", 693 | ), 694 | ( 695 | "01BYE5RZ6TAJHXA5GMWZB2HDLD7SNEXFFU", 696 | "item id: 01BYE5RZ6TAJHXA5GMWZB2HDLD7SNEXFFU\n" 697 | "name: CR-227 Project\n" 698 | "type: folder\n" 699 | "created: 2017-08-07T16:17:40Z by: Megan Bowen\n" 700 | "last modified: 2017-08-07T16:17:40Z by: Megan Bowen\n" 701 | "size: 6934759\n" 702 | "web url: https://m365x214355-my.sharepoint.com/personal/meganb_m365x214355_onmicrosoft_com/Documents/CR-227%20Project\n" 703 | "file system created: 2017-08-07T16:17:40Z\n" 704 | "file system last modified: 2017-08-07T16:17:40Z\n" 705 | "child count: 5\n", 706 | ), 707 | ], 708 | ) 709 | def test_detail_item_verbose(self, onedrive, capsys, item_id, exp_stout): 710 | item_details = onedrive.detail_item(item_id, verbose=True) 711 | stdout, sterr = capsys.readouterr() 712 | assert stdout == exp_stout 713 | 714 | @pytest.mark.skip(reason="not implemented") 715 | def test_detail_item_failure(self): ... 716 | 717 | # detail_item_path 718 | def test_detail_item_path(self, onedrive): 719 | item_path = "Contoso Electronics/Contoso Electronics Sales Presentation.pptx" 720 | item_details = onedrive.detail_item_path(item_path) 721 | assert item_details.get("name") == "Contoso Electronics Sales Presentation.pptx" 722 | 723 | # item_type 724 | @pytest.mark.parametrize( 725 | "item_id, exp_type", 726 | [ 727 | ("01BYE5RZ6KU4MREZDFEVGKWRBC7OK4ET3J", "file"), 728 | ("01BYE5RZ5YOS4CWLFWORAJ4U63SCA3JT5P", "folder"), 729 | ], 730 | ) 731 | def test_item_type(self, onedrive, item_id, exp_type): 732 | item_type = onedrive.item_type(item_id) 733 | assert item_type == exp_type 734 | 735 | @pytest.mark.skip(reason="not implemented") 736 | def test_item_type_failure(self): ... 737 | 738 | # is_folder 739 | @pytest.mark.parametrize( 740 | "item_id, exp_bool", 741 | [ 742 | ("01BYE5RZ6KU4MREZDFEVGKWRBC7OK4ET3J", False), 743 | ("01BYE5RZ5YOS4CWLFWORAJ4U63SCA3JT5P", True), 744 | ], 745 | ) 746 | def test_is_folder(self, onedrive, item_id, exp_bool): 747 | is_folder = onedrive.is_folder(item_id) 748 | assert is_folder == exp_bool 749 | 750 | @pytest.mark.skip(reason="not implemented") 751 | def test_is_folder_failure(self): ... 752 | 753 | # is_file 754 | @pytest.mark.parametrize( 755 | "item_id, exp_bool", 756 | [ 757 | ("01BYE5RZ6KU4MREZDFEVGKWRBC7OK4ET3J", True), 758 | ("01BYE5RZ5YOS4CWLFWORAJ4U63SCA3JT5P", False), 759 | ], 760 | ) 761 | def test_is_file(self, onedrive, item_id, exp_bool): 762 | is_file = onedrive.is_file(item_id) 763 | assert is_file == exp_bool 764 | 765 | @pytest.mark.skip(reason="not implemented") 766 | def test_is_file_failure(self): ... 767 | 768 | 769 | class TestSharingLink: 770 | """Tests the create_share_link method.""" 771 | 772 | @pytest.mark.parametrize( 773 | "item_id, link_type, password, expiration, scope, exp_link", 774 | [ 775 | ( 776 | "01BYE5RZ6KU4MREZDFEVGKWRBC7OK4ET3J", 777 | "view", 778 | None, 779 | None, 780 | "anonymous", 781 | "https://onedrive.com/fakelink", 782 | ), 783 | ( 784 | "01BYE5RZ6KU4MREZDFEVGKWRBC7OK4ET3J", 785 | "edit", 786 | None, 787 | None, 788 | "anonymous", 789 | "https://onedrive.com/fakelink", 790 | ), 791 | ( 792 | "01BYE5RZ6KU4MREZDFEVGKWRBC7OK4ET3J", 793 | "edit", 794 | None, 795 | None, 796 | "organization", 797 | "https://onedrive.com/fakelink", 798 | ), 799 | ( 800 | "01BYE5RZ6KU4MREZDFEVGKWRBC7OK4ET3J", 801 | "view", 802 | None, 803 | None, 804 | "anonymous", 805 | "https://onedrive.com/fakelink", 806 | ), 807 | ( 808 | "01BYE5RZ6KU4MREZDFEVGKWRBC7OK4ET3J", 809 | "edit", 810 | None, 811 | None, 812 | "anonymous", 813 | "https://onedrive.com/fakelink", 814 | ), 815 | ( 816 | "01BYE5RZ6KU4MREZDFEVGKWRBC7OK4ET3J", 817 | "edit", 818 | None, 819 | None, 820 | "organization", 821 | "https://onedrive.com/fakelink", 822 | ), 823 | ( 824 | "01BYE5RZ5YOS4CWLFWORAJ4U63SCA3JT5P", 825 | "edit", 826 | None, 827 | None, 828 | "organization", 829 | "https://onedrive.com/fakelink", 830 | ), 831 | ], 832 | ) 833 | def test_create_share_link( 834 | self, onedrive, item_id, link_type, password, expiration, scope, exp_link 835 | ): 836 | link = onedrive.create_share_link( 837 | item_id, link_type, password, expiration, scope 838 | ) 839 | assert link == exp_link 840 | 841 | @pytest.mark.parametrize( 842 | "item_id, link_type, password, expiration, scope, exp_msg", 843 | [ 844 | ( 845 | "999", 846 | "view", 847 | None, 848 | None, 849 | "anonymous", 850 | "share link could not be created (Invalid request)", 851 | ), 852 | ], 853 | ) 854 | def test_create_share_link_failure_graph( 855 | self, onedrive, item_id, link_type, password, expiration, scope, exp_msg 856 | ): 857 | with pytest.raises(GraphAPIError) as excinfo: 858 | onedrive.create_share_link(item_id, link_type, password, expiration, scope) 859 | (msg,) = excinfo.value.args 860 | assert msg == exp_msg 861 | 862 | @pytest.mark.parametrize( 863 | "item_id, link_type, password, expiration, scope, exp_msg", 864 | [ 865 | ( 866 | "999", 867 | {"view"}, 868 | None, 869 | None, 870 | "anonymous", 871 | "link_type expected 'str', got 'set'", 872 | ), 873 | ], 874 | ) 875 | def test_create_share_link_failure_type( 876 | self, onedrive, item_id, link_type, password, expiration, scope, exp_msg 877 | ): 878 | with pytest.raises(TypeError) as excinfo: 879 | onedrive.create_share_link(item_id, link_type, password, expiration, scope) 880 | (msg,) = excinfo.value.args 881 | assert msg == exp_msg 882 | 883 | @pytest.mark.parametrize( 884 | "item_id, link_type, password, expiration, scope, exp_msg", 885 | [ 886 | ( 887 | "999", 888 | "view ", 889 | None, 890 | None, 891 | "anonymous", 892 | "link_type expected 'view', 'edit', or 'embed', got 'view '", 893 | ), 894 | ( 895 | "999", 896 | "embed", 897 | None, 898 | None, 899 | "anonymous", 900 | "link_type='embed' is not available for business OneDrive accounts", 901 | ), 902 | ( 903 | "999", 904 | "view", 905 | "password", 906 | None, 907 | "anonymous", 908 | "password is not available for business OneDrive accounts", 909 | ), 910 | ], 911 | ) 912 | def test_create_share_link_failure_value( 913 | self, onedrive, item_id, link_type, password, expiration, scope, exp_msg 914 | ): 915 | with pytest.raises(ValueError) as excinfo: 916 | onedrive.create_share_link(item_id, link_type, password, expiration, scope) 917 | (msg,) = excinfo.value.args 918 | assert msg == exp_msg 919 | 920 | 921 | class TestMakeFolder: 922 | """Tests the make_folder method.""" 923 | 924 | @pytest.mark.parametrize( 925 | "folder_name, parent_folder_id, check_existing, exp_str", 926 | [ 927 | ("tesy 1", "01BYE5RZ4CPC5XBOTZCFD2CT7SZFNICEYC", True, "ACEA49D1-144"), 928 | ("tesy 1", "01BYE5RZ4CPC5XBOTZCFD2CT7SZFNICEYC", False, "ACEA49D1-144"), 929 | ("tesy 1", None, True, "ACEA49D1-144"), 930 | ("tesy 1", None, False, "ACEA49D1-144"), 931 | # To-do: allow for other folder names by using a side effect in the mock route 932 | ], 933 | ) 934 | def test_make_folder( 935 | self, onedrive, folder_name, parent_folder_id, check_existing, exp_str 936 | ): 937 | item_id = onedrive.make_folder(folder_name, parent_folder_id, check_existing) 938 | assert item_id == exp_str 939 | 940 | @pytest.mark.skip(reason="not implemented") 941 | def test_make_folder_failure(self): ... 942 | 943 | 944 | class TestMove: 945 | """Tests the move_item method.""" 946 | 947 | @pytest.mark.parametrize( 948 | "item_id, new_folder_id, new_name", 949 | [ 950 | ( 951 | "01BYE5RZ53CPZEMSJFTZDJ6AEFVZP3C3BG", 952 | "01BYE5RZ5YOS4CWLFWORAJ4U63SCA3JT5P", 953 | "new-item-name.txt", 954 | ), 955 | ( 956 | "01BYE5RZ53CPZEMSJFTZDJ6AEFVZP3C3BG", 957 | "01BYE5RZ5YOS4CWLFWORAJ4U63SCA3JT5P", 958 | None, 959 | ), 960 | ], 961 | ) 962 | def test_move_item(self, onedrive, item_id, new_folder_id, new_name): 963 | returned_item_id, folder_id = onedrive.move_item( 964 | item_id, new_folder_id, new_name 965 | ) 966 | assert returned_item_id == item_id 967 | assert folder_id == new_folder_id 968 | 969 | @pytest.mark.parametrize( 970 | "item_id, new_folder_id", 971 | [ 972 | ("123", "01BYE5RZ5YOS4CWLFWORAJ4U63SCA3JT5P"), 973 | ("01BYE5RZ53CPZEMSJFTZDJ6AEFVZP3C3BG", "456"), 974 | ], 975 | ) 976 | def test_move_item_failure(self, onedrive, item_id, new_folder_id): 977 | with pytest.raises(GraphAPIError) as excinfo: 978 | onedrive.move_item(item_id, new_folder_id) 979 | (msg,) = excinfo.value.args 980 | assert msg == "item not moved (Invalid request)" 981 | 982 | @pytest.mark.parametrize( 983 | "item_id, new_folder_id, new_name, exp_msg", 984 | [ 985 | (123, "new_folder_id", None, "item_id expected 'str', got 'int'"), 986 | ("item_id", None, None, "new_folder_id expected 'str', got 'NoneType'"), 987 | ( 988 | "item_id", 989 | "new_folder_id", 990 | b"name", 991 | "new_name expected 'str', got 'bytes'", 992 | ), 993 | ], 994 | ) 995 | def test_move_item_failure_type( 996 | self, onedrive, item_id, new_folder_id, new_name, exp_msg 997 | ): 998 | with pytest.raises(TypeError) as excinfo: 999 | onedrive.move_item(item_id, new_folder_id, new_name) 1000 | (msg,) = excinfo.value.args 1001 | assert msg == exp_msg 1002 | 1003 | 1004 | class TestCopy: 1005 | """Tests the copy_item method.""" 1006 | 1007 | @pytest.mark.parametrize( 1008 | "new_name, confirm_complete, exp_return", 1009 | [ 1010 | ("new-item-name.txt", True, "01MOWKYVJML57KN2ANMBA3JZJS2MBGC7KM"), 1011 | ("new-item-name.txt", False, None), 1012 | (None, True, "01MOWKYVJML57KN2ANMBA3JZJS2MBGC7KM"), 1013 | ], 1014 | ) 1015 | def test_copy_item(self, onedrive, new_name, confirm_complete, exp_return): 1016 | item_id = "01BYE5RZ53CPZEMSJFTZDJ6AEFVZP3C3BG" 1017 | new_folder_id = "01BYE5RZ5YOS4CWLFWORAJ4U63SCA3JT5P" 1018 | returned_item_id = onedrive.copy_item( 1019 | item_id, new_folder_id, new_name, confirm_complete 1020 | ) 1021 | assert returned_item_id != item_id 1022 | assert returned_item_id != new_folder_id 1023 | assert returned_item_id == exp_return 1024 | 1025 | @pytest.mark.parametrize( 1026 | "confirm_complete, exp_return, exp_stdout", 1027 | [ 1028 | ( 1029 | True, 1030 | "01MOWKYVJML57KN2ANMBA3JZJS2MBGC7KM", 1031 | "Copy request sent.\nWaiting 1s before checking progress\nPercentage complete = 96.7%\nWaiting 1s before checking progress\nCopy confirmed complete.\n", 1032 | ), 1033 | (False, None, "Copy request sent.\n"), 1034 | ], 1035 | ) 1036 | def test_copy_item_verbose( 1037 | self, onedrive, capsys, confirm_complete, exp_return, exp_stdout 1038 | ): 1039 | item_id = "01BYE5RZ53CPZEMSJFTZDJ6AEFVZP3C3BG" 1040 | new_folder_id = "01BYE5RZ5YOS4CWLFWORAJ4U63SCA3JT5P" 1041 | returned_item_id = onedrive.copy_item( 1042 | item_id, new_folder_id, "new-item-name.txt", confirm_complete, True 1043 | ) 1044 | assert returned_item_id == exp_return 1045 | stdout, sterr = capsys.readouterr() 1046 | # Note that the stout alternates between partically complete and complete bassed on call count 1047 | # You may need to rerun all the tests if there is an issue to reset the call count 1048 | assert stdout == exp_stdout 1049 | 1050 | @pytest.mark.parametrize( 1051 | "item_id, new_folder_id", 1052 | [ 1053 | ("123", "01BYE5RZ5YOS4CWLFWORAJ4U63SCA3JT5P"), 1054 | ("01BYE5RZ53CPZEMSJFTZDJ6AEFVZP3C3BG", "456"), 1055 | ], 1056 | ) 1057 | def test_copy_item_failure(self, onedrive, item_id, new_folder_id): 1058 | with pytest.raises(GraphAPIError) as excinfo: 1059 | onedrive.copy_item(item_id, new_folder_id) 1060 | (msg,) = excinfo.value.args 1061 | assert msg == "item not copied (Invalid request)" 1062 | 1063 | @pytest.mark.parametrize( 1064 | "item_id, new_folder_id, new_name, exp_msg", 1065 | [ 1066 | (123, "new_folder_id", None, "item_id expected 'str', got 'int'"), 1067 | ("item_id", None, None, "new_folder_id expected 'str', got 'NoneType'"), 1068 | ( 1069 | "item_id", 1070 | "new_folder_id", 1071 | b"name", 1072 | "new_name expected 'str', got 'bytes'", 1073 | ), 1074 | ], 1075 | ) 1076 | def test_copy_item_failure_type( 1077 | self, onedrive, item_id, new_folder_id, new_name, exp_msg 1078 | ): 1079 | with pytest.raises(TypeError) as excinfo: 1080 | onedrive.copy_item(item_id, new_folder_id, new_name) 1081 | (msg,) = excinfo.value.args 1082 | assert msg == exp_msg 1083 | 1084 | 1085 | class TestRename: 1086 | """Tests the rename_item method.""" 1087 | 1088 | def test_rename_item(self, onedrive): 1089 | item_id = "01BYE5RZ53CPZEMSJFTZDJ6AEFVZP3C3BG" 1090 | new_name = "new-item-name.txt" 1091 | returned_name = onedrive.rename_item(item_id, new_name) 1092 | assert returned_name == new_name 1093 | 1094 | @pytest.mark.skip(reason="not implemented") 1095 | def test_rename_item_failure(self): ... 1096 | 1097 | 1098 | class TestDelete: 1099 | """Tests the delete_item method.""" 1100 | 1101 | def test_delete_item(self, onedrive): 1102 | response = onedrive.delete_item( 1103 | "01BYE5RZ53CPZEMSJFTZDJ6AEFVZP3C3BG", pre_confirm=True 1104 | ) 1105 | assert isinstance(response, bool) 1106 | assert response == True 1107 | 1108 | @pytest.mark.parametrize( 1109 | "input_str, exp_bool", 1110 | [("delete", True), ("DeLeTe ", True), ("D elete", False)], 1111 | ) 1112 | def test_delete_item_manual_confirm( 1113 | self, onedrive, monkeypatch, input_str, exp_bool 1114 | ): 1115 | monkeypatch.setattr("builtins.input", lambda _: input_str) 1116 | response = onedrive.delete_item("01BYE5RZ53CPZEMSJFTZDJ6AEFVZP3C3BG") 1117 | assert isinstance(response, bool) 1118 | assert response == exp_bool 1119 | 1120 | def test_delete_item_failure(self, onedrive): 1121 | with pytest.raises(GraphAPIError) as excinfo: 1122 | onedrive.delete_item("999", pre_confirm=True) 1123 | (msg,) = excinfo.value.args 1124 | assert msg == "item not deleted (Invalid request)" 1125 | 1126 | @pytest.mark.parametrize( 1127 | "item_id, pre_confirm, exp_msg", 1128 | [ 1129 | (999, False, "item_id expected 'str', got 'int'"), 1130 | ("999", "true", "pre_confirm expected 'bool', got 'str'"), 1131 | ], 1132 | ) 1133 | def test_delete_item_failure_type(self, onedrive, item_id, pre_confirm, exp_msg): 1134 | with pytest.raises(TypeError) as excinfo: 1135 | onedrive.delete_item(item_id, pre_confirm) 1136 | (msg,) = excinfo.value.args 1137 | assert msg == exp_msg 1138 | 1139 | 1140 | class TestDownload: 1141 | """Tests the download_file, _download_async, _download_async_part methods.""" 1142 | 1143 | # download_file 1144 | @pytest.mark.skip(reason="not implemented") 1145 | def test_download_file(self): ... 1146 | 1147 | @pytest.mark.skip(reason="not implemented") 1148 | def test_download_file_failure(self): ... 1149 | 1150 | """# _download_async 1151 | @pytest.mark.skip(reason="not implemented") 1152 | async def test_download_async(self): 1153 | ... 1154 | 1155 | @pytest.mark.skip(reason="not implemented") 1156 | async def test_download_async_failure(self): 1157 | ... 1158 | 1159 | # _download_async_part 1160 | @pytest.mark.skip(reason="not implemented") 1161 | async def test_download_async_part(self): 1162 | ... 1163 | 1164 | @pytest.mark.skip(reason="not implemented") 1165 | async def test_download_async_part_failure(self): 1166 | ... 1167 | """ 1168 | 1169 | 1170 | class TestUpload: 1171 | """Tests the upload_file, _upload_large_file methods.""" 1172 | 1173 | # upload_file 1174 | @pytest.mark.parametrize( 1175 | "new_file_name, parent_folder_id, if_exists, size", 1176 | [ 1177 | (None, None, "rename", 1024), 1178 | (None, None, "fail", 1000), 1179 | (None, None, "replace", 25446), 1180 | ("hello hello_there-01", None, "rename", 87486), 1181 | ("large_archive.zip", None, "rename", 5791757), 1182 | ("my_movie.mov", "01BYE5RZ5MYLM2SMX75ZBIPQZIHT6OAYPB", "rename", 485), 1183 | ], 1184 | ) 1185 | def test_upload_file( 1186 | self, onedrive, tmp_path, new_file_name, parent_folder_id, if_exists, size 1187 | ): 1188 | # Make a temporary file, at least one case should be larger than the upload chunk size (5MiB) 1189 | temp_dir = Path(tmp_path, "temp_upload") 1190 | temp_dir.mkdir() 1191 | file_path = Path(temp_dir, "temp_file.txt") 1192 | file_path.write_bytes(os.urandom(size)) 1193 | # Make the request 1194 | item_id = onedrive.upload_file( 1195 | file_path, new_file_name, parent_folder_id, if_exists 1196 | ) 1197 | assert item_id == "91231001" 1198 | 1199 | def test_upload_file_verbose(self, onedrive, tmp_path, capsys): 1200 | # Make a temporary file, at least one case should be larger than the upload chunk size (5MiB) 1201 | temp_dir = Path(tmp_path, "temp_upload") 1202 | temp_dir.mkdir() 1203 | file_path = Path(temp_dir, "temp_file.txt") 1204 | file_path.write_bytes(os.urandom(5791757)) 1205 | onedrive.upload_file(file_path, verbose=True) 1206 | stdout, sterr = capsys.readouterr() 1207 | assert ( 1208 | stdout == "Requesting upload session\n" 1209 | "File temp_file.txt will be uploaded in 2 segments\n" 1210 | "Loading file\n" 1211 | "Uploading segment 1/2\n" 1212 | "Uploading segment 2/2 (~50% complete)\n" 1213 | "Upload complete\n" 1214 | ) 1215 | 1216 | def test_upload_file_failure(self, onedrive, tmp_path): 1217 | # Make a temporary file, at least one case should be larger than the upload chunk size (5MiB) 1218 | temp_dir = Path(tmp_path, "temp_upload") 1219 | temp_dir.mkdir() 1220 | file_path = Path(temp_dir, "temp_file.txt") 1221 | file_path.write_bytes(os.urandom(100)) 1222 | with pytest.raises(GraphAPIError) as excinfo: 1223 | onedrive.upload_file(file_path, parent_folder_id="not-valid-id") 1224 | (msg,) = excinfo.value.args 1225 | assert msg == "upload session could not be created (Invalid request)" 1226 | 1227 | @pytest.mark.parametrize( 1228 | "file_path, new_file_name, parent_folder_id, exp_msg", 1229 | [ 1230 | (123, "str", "str", "file_path expected 'str' or 'Path', got 'int'"), 1231 | ("str", 4.0, "str", "new_file_name expected 'str', got 'float'"), 1232 | ("str", "str", 123, "parent_folder_id expected 'str', got 'int'"), 1233 | ], 1234 | ) 1235 | def test_upload_file_failure_type( 1236 | self, onedrive, file_path, new_file_name, parent_folder_id, exp_msg 1237 | ): 1238 | with pytest.raises(TypeError) as excinfo: 1239 | onedrive.upload_file(file_path, new_file_name, parent_folder_id) 1240 | (msg,) = excinfo.value.args 1241 | assert msg == exp_msg 1242 | 1243 | def test_upload_file_failure_value(self, onedrive): 1244 | with pytest.raises(ValueError) as excinfo: 1245 | onedrive.upload_file("file_path", if_exists="delete") 1246 | (msg,) = excinfo.value.args 1247 | assert msg == "if_exists expected 'fail', 'replace', or 'rename', got 'delete'" 1248 | 1249 | def test_upload_file_failure_bad_path(self, onedrive): 1250 | with pytest.raises(ValueError) as excinfo: 1251 | onedrive.upload_file("non-existing") 1252 | (msg,) = excinfo.value.args 1253 | assert ( 1254 | msg == "file_path expected a path to an existing file, got 'non-existing'" 1255 | ) 1256 | 1257 | # _get_local_file_metadata 1258 | def test_get_local_file_metadata(self, onedrive): 1259 | file_path = os.path.join(TESTS_DIR, "__init__.py") 1260 | ( 1261 | file_size, 1262 | file_created_str, 1263 | file_modified_str, 1264 | ) = onedrive._get_local_file_metadata(file_path) 1265 | assert file_size == 0 1266 | timestamp_format = "^(?:20|19)[0-9]{2}-(?:0[1-9]|1[012])-(?:[0-2][0-9]|3[01])T(?:[01][0-9]|2[0-3])(?::[0-5][0-9]){2}Z$" 1267 | assert re.search(timestamp_format, file_created_str) 1268 | assert re.search(timestamp_format, file_modified_str) 1269 | # These timestamp asserts are buggy on some platforms, take care if updating 1270 | # assert file_created_str == "2021-11-07T06:46:23Z" 1271 | # assert file_modified_str == "2021-11-07T06:46:23Z" 1272 | 1273 | def test_get_local_file_metadata_failure_type(self, onedrive): 1274 | with pytest.raises(TypeError) as excinfo: 1275 | onedrive._get_local_file_metadata(123) 1276 | (msg,) = excinfo.value.args 1277 | assert msg == "file_path expected 'str' or 'Path', got 'int'" 1278 | 1279 | def test_get_local_file_metadata_failure_bad_path(self, onedrive): 1280 | with pytest.raises(ValueError) as excinfo: 1281 | onedrive._get_local_file_metadata("non-existing-file") 1282 | (msg,) = excinfo.value.args 1283 | assert ( 1284 | msg 1285 | == "file_path expected a path to an existing file, got 'non-existing-file'" 1286 | ) 1287 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Sets-up and tears-down configuration used for testing.""" 2 | 3 | import json 4 | import os 5 | import re 6 | import secrets 7 | import urllib.parse 8 | from typing import List 9 | from typing import Tuple 10 | 11 | import httpx 12 | import pytest 13 | import respx 14 | 15 | import graph_onedrive 16 | 17 | 18 | # Set the variables used to create the OneDrive instances in tests 19 | # Warning: certain tests require these to match the assertions in the tests 20 | CLIENT_ID = "1a2B3" 21 | CLIENT_SECRET = "4c5D6" 22 | AUTH_CODE = "7e8F9" 23 | REFRESH_TOKEN = "10g11H" 24 | ACCESS_TOKEN = "12i13J" 25 | TENANT = "test" 26 | SCOPE = "offline_access files.readwrite" 27 | REDIRECT = "http://localhost:8080" 28 | 29 | # Get the absolute file path of the tests directory by locating this file 30 | TESTS_DIR = os.path.dirname(os.path.abspath(__file__)) 31 | 32 | # Read mocked response content 33 | with open(os.path.join(TESTS_DIR, "mock_responses.json")) as file: 34 | MOCKED_RESPONSE_DATA = json.load(file) 35 | MOCKED_ITEMS_ROOT = MOCKED_RESPONSE_DATA["list-root"]["value"] 36 | MOCKED_ITEMS_DIR = MOCKED_RESPONSE_DATA["list-directory"]["value"] 37 | MOCKED_ITEMS_ALL = MOCKED_ITEMS_ROOT + MOCKED_ITEMS_DIR 38 | 39 | 40 | @pytest.fixture(scope="module") 41 | def mock_graph_api(): 42 | """Mock the Graph api for testing.""" 43 | 44 | # Set the api expected route host and header 45 | api_url = "https://graph.microsoft.com/v1.0/" 46 | headers = {"Accept": "*/*", "Authorization": "Bearer " + ACCESS_TOKEN} 47 | 48 | # Create the mocked routes 49 | # IMPORTANT: routes ordered by most specific top as respx it will use first match 50 | with respx.mock(base_url=api_url, assert_all_called=False) as respx_mock: 51 | # Make folder 52 | make_folder_route = respx_mock.post( 53 | path__regex=r"me/drive/(?:root|items/[0-9a-zA-Z-]+)/children$", 54 | headers=headers, 55 | json__folder={}, 56 | name="make_folder", 57 | ).mock(side_effect=side_effect_make_folder) 58 | 59 | # List directory 60 | list_directory_route = respx_mock.get( 61 | path__regex=r"me/drive/(?:root|items/[0-9a-zA-Z-]+)/children$", 62 | headers=headers, 63 | name="list_directory", 64 | ).mock(side_effect=side_effect_list_dir) 65 | 66 | # Search 67 | search_route = respx_mock.get( 68 | path__regex=r"me/drive/root/search\(q='[0-9a-zA-Z% -]+'\)(?:\?\$top=[0-9]+)?(?:&\$skiptoken=s![0-9a-zA-Z-]+)?$", 69 | headers=headers, 70 | name="search", 71 | ).mock(side_effect=side_effect_search) 72 | 73 | # Sharing Link 74 | share_link_route = respx_mock.post( 75 | path__regex=r"me/drive/items/[0-9a-zA-Z-]+/createLink$", 76 | headers=headers, 77 | name="create_share_link", 78 | ).mock(side_effect=side_effect_sharing_link) 79 | 80 | # Patch Item - Move, Rename 81 | patch_item_route = respx_mock.patch( 82 | path__regex=r"me/drive/items/[0-9a-zA-Z-]+$", 83 | headers=headers, 84 | name="patch_item", 85 | ).mock(side_effect=side_effect_patch_item) 86 | 87 | # Copy Item 88 | copy_item_route = respx_mock.post( 89 | path__regex=r"me/drive/items/[0-9a-zA-Z-]+/copy$", 90 | headers=headers, 91 | name="copy_item", 92 | ).mock(side_effect=side_effect_copy_item) 93 | # note monitor route is in non base url context manager 94 | 95 | # Copy Item Monitor 96 | # host specified as not using base_url 97 | copy_item_monitor_route = respx_mock.get( 98 | host__regex=r"[0-9a-zA-Z-]+.sharepoint.com", 99 | path__regex=r"/_api/v2.0/monitor/[0-9a-zA-Z-]+$", 100 | name="copy_item_monitor", 101 | ).mock(side_effect=side_effect_copy_item_monitor) 102 | 103 | # Delete Item 104 | delete_item_route = respx_mock.delete( 105 | path__regex=r"me/drive/items/[0-9a-zA-Z-]+$", 106 | headers=headers, 107 | name="delete_item", 108 | ).mock(side_effect=side_effect_delete_item) 109 | 110 | # Download File 111 | 112 | # Upload File Session 113 | # Note this doesn't match non-standard characters as encoding is decoded by respx 114 | upload_session_route = respx_mock.post( 115 | path__regex=r"me/drive/(?:root|items/[0-9a-zA-Z-]+):/[0-9a-zA-Z-_.%+ ]+:/createUploadSession$", 116 | headers=headers, 117 | name="upload_session", 118 | ).mock(side_effect=side_effect_upload_session) 119 | 120 | # Upload File Delete Session 121 | # host specified as not using base_url 122 | upload_session_delete_route = respx_mock.delete( 123 | host__regex=r"[0-9a-zA-Z-]+.up.1drv.com", 124 | path__regex=r"/up/[0-9a-zA-Z]+$", 125 | name="delete_upload_session", 126 | ).mock(return_value=httpx.Response(204)) 127 | 128 | # Upload File 129 | # host specified as not using base_url 130 | upload_item_route = respx_mock.put( 131 | host__regex=r"[0-9a-zA-Z-]+.up.1drv.com", 132 | path__regex=r"/up/[0-9a-zA-Z]+$", 133 | name="upload_item", 134 | ).mock(side_effect=side_effect_upload_item) 135 | 136 | # Detail item 137 | detail_item_route = respx_mock.get( 138 | path__regex=r"me/drive/items/[0-9a-zA-Z-]+$", 139 | headers=headers, 140 | name="detail_item", 141 | ).mock(side_effect=side_effect_detail_item) 142 | 143 | # Detail item from path 144 | detail_item_path_route = respx_mock.get( 145 | path__regex=r"me/drive/root:/[0-9a-zA-Z/-_.%+ ]+", 146 | headers=headers, 147 | name="detail_item_path", 148 | ).mock(side_effect=side_effect_detail_item_path) 149 | 150 | # Drive details 151 | drive_details_route = respx_mock.get( 152 | path="me/drive/", headers=headers, name="drive_details" 153 | ).mock(side_effect=side_effect_drive_details) 154 | 155 | yield respx_mock 156 | 157 | 158 | def side_effect_detail_item(request): 159 | item_id_match = re.search("items/([0-9a-zA-Z-]+)", request.url.path) 160 | if not item_id_match: 161 | return httpx.Response(400, json=MOCKED_RESPONSE_DATA["invalid-request"]) 162 | # Check if the item id provided corresponds to an item in the mocked items list (root only) 163 | matching_item_list = [ 164 | item for item in MOCKED_ITEMS_ROOT if item.get("id") == item_id_match.group(1) 165 | ] 166 | if not matching_item_list: 167 | return httpx.Response(400, json=MOCKED_RESPONSE_DATA["invalid-request"]) 168 | return httpx.Response(200, json=matching_item_list[0]) 169 | 170 | 171 | def side_effect_detail_item_path(request): 172 | path_match = re.search("root:/([0-9a-zA-Z/-_.%+ ]+)", request.url.path) 173 | if not path_match: 174 | return httpx.Response(400, json=MOCKED_RESPONSE_DATA["invalid-request"]) 175 | # Check items for a matching path 176 | path_request = "/drive/root:/" + path_match.group(1) 177 | for item in MOCKED_ITEMS_ALL: 178 | item_path = item.get("parentReference", {}).get("path") + "/" + item.get("name") 179 | if item_path == path_request: 180 | matching_item = item 181 | break 182 | else: 183 | return httpx.Response(400, json=MOCKED_RESPONSE_DATA["invalid-request"]) 184 | return httpx.Response(200, json=matching_item) 185 | 186 | 187 | def side_effect_drive_details(request): 188 | return httpx.Response(200, json=MOCKED_RESPONSE_DATA["drive-details"]) 189 | 190 | 191 | def side_effect_list_dir(request): 192 | # return early if root 193 | if "root" in request.url.path: 194 | return httpx.Response(200, json=MOCKED_RESPONSE_DATA["list-root"]) 195 | # Extract the item id 196 | item_id_match = re.search("items/([0-9a-zA-Z-]+)", request.url.path) 197 | if not item_id_match: 198 | return httpx.Response(400, json=MOCKED_RESPONSE_DATA["invalid-request"]) 199 | # Check if the item id provided corresponds to an item in the mocked items list (root only) 200 | matching_item_list = [ 201 | item for item in MOCKED_ITEMS_ROOT if (item.get("id") == item_id_match.group(1)) 202 | ] 203 | if not matching_item_list: 204 | return httpx.Response(400, json=MOCKED_RESPONSE_DATA["invalid-request"]) 205 | elif "folder" not in matching_item_list[0]: 206 | # If item is not a folder then return an empty list of children 207 | return httpx.Response(200, json=MOCKED_RESPONSE_DATA["list-directory-empty"]) 208 | # Prepare and return the response 209 | return httpx.Response(200, json=MOCKED_RESPONSE_DATA["list-directory"]) 210 | 211 | 212 | def side_effect_search(request): 213 | # Extract the item id 214 | search_match = re.search(r"search\(q='([0-9a-zA-Z% -]+)'\)", request.url.path) 215 | if not search_match: 216 | return httpx.Response(400, json=MOCKED_RESPONSE_DATA["invalid-request"]) 217 | query = search_match.group(1) 218 | params = dict(request.url.params.items()) 219 | try: 220 | top = int(params["$top"]) 221 | if top > 100: 222 | top = 100 223 | except (KeyError, TypeError): 224 | top = 100 225 | skip_token = params.get("$skiptoken", None) 226 | # Create list of matching items 227 | matching_item_list = [ 228 | item for item in MOCKED_ITEMS_ALL if (re.search(query, item.get("name"))) 229 | ] 230 | # Trim the list 231 | if len(matching_item_list) > top: 232 | if skip_token: 233 | matching_item_list = matching_item_list[top : (top * 2)] 234 | next = False # pretending that there are no more results 235 | else: 236 | matching_item_list = matching_item_list[0:top] 237 | next = True 238 | else: 239 | next = False 240 | # Prepare and return the response 241 | response_json = { 242 | "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#Collection(driveItem)", 243 | "value": matching_item_list, 244 | } 245 | if next: 246 | alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" # = string.ascii_letters + string.digits 247 | skip_token = "s!" + "".join(secrets.choice(alphabet) for _ in range(10)) 248 | response_json["@odata.nextLink"] = ( 249 | f"https://graph.microsoft.com/v1.0/me/drive/root/search(q='{query}')?$top={top}&$skiptoken={skip_token}" 250 | ) 251 | return httpx.Response(200, json=response_json) 252 | 253 | 254 | def side_effect_make_folder(request): 255 | # Get the parent folder 256 | if "root" in request.url.path: 257 | parent_id = None 258 | else: 259 | parent_id_re = re.search("items/([0-9a-zA-Z-]+)", request.url.path) 260 | if parent_id_re: 261 | parent_id = parent_id_re.group(1) 262 | # Load the body 263 | try: 264 | body = json.loads(request.content) 265 | new_folder_name = body["name"] 266 | except: 267 | return httpx.Response(400, json=MOCKED_RESPONSE_DATA["invalid-request"]) 268 | # Check the conflict behavior 269 | conflict_behavior = body.get("@microsoft.graph.conflictBehavior", "rename") 270 | if conflict_behavior not in ("fail", "replace", "rename"): 271 | return httpx.Response(400, json=MOCKED_RESPONSE_DATA["invalid-request"]) 272 | # Load mocked items from file and loop through them 273 | parent_id_valid = False 274 | for item in MOCKED_ITEMS_ALL: 275 | # Check new folder does not conflict 276 | if ( 277 | "folder" in item 278 | and conflict_behavior != "replace" 279 | and item["name"] == new_folder_name 280 | ): 281 | if (parent_id and item["parentReference"]["id"] == parent_id) or ( 282 | parent_id is None and item["parentReference"]["path"] == "/drive/root:" 283 | ): 284 | if conflict_behavior == "fail": 285 | return httpx.Response( 286 | 400, json=MOCKED_RESPONSE_DATA["invalid-request"] 287 | ) 288 | else: 289 | new_folder_name += "-1" 290 | # Check parent exists 291 | if parent_id and parent_id == item["id"]: 292 | if "folder" in item: 293 | parent_id_valid = True 294 | else: 295 | return httpx.Response(400, json=MOCKED_RESPONSE_DATA["invalid-request"]) 296 | if "root" not in request.url.path and not parent_id_valid: 297 | return httpx.Response(400, json=MOCKED_RESPONSE_DATA["invalid-request"]) 298 | # Prepare and return the response 299 | response_json = MOCKED_RESPONSE_DATA["create-folder"] 300 | response_json["name"] = new_folder_name 301 | return httpx.Response(201, json=response_json) 302 | 303 | 304 | def side_effect_sharing_link(request): 305 | item_id_re = re.search("items/([0-9a-zA-Z-]+)", request.url.path) 306 | if item_id_re: 307 | item_id = item_id_re.group(1) 308 | # Load the body 309 | try: 310 | body = json.loads(request.content) 311 | link_type = body["type"] 312 | scope = body["scope"] 313 | except: 314 | return httpx.Response(400, json=MOCKED_RESPONSE_DATA["invalid-request"]) 315 | password = body.get("password") 316 | expiration = body.get("expirationDateTime") 317 | matching_item_list = [ 318 | item for item in MOCKED_ITEMS_ROOT if item.get("id") == item_id 319 | ] 320 | if not matching_item_list: 321 | return httpx.Response(400, json=MOCKED_RESPONSE_DATA["invalid-request"]) 322 | response_json = {"link": {"webUrl": "https://onedrive.com/fakelink"}} 323 | if link_type == "embed": 324 | response_json["link"]["webHtml"] = "" 325 | return httpx.Response(201, json=response_json) 326 | 327 | 328 | def side_effect_patch_item(request): 329 | # If a parent folder is specified, check it exists 330 | item_id_re = re.search("items/([0-9a-zA-Z-]+)", request.url.path) 331 | if item_id_re: 332 | matching_item_list = [ 333 | item for item in MOCKED_ITEMS_ROOT if item.get("id") == item_id_re.group(1) 334 | ] 335 | if not matching_item_list: 336 | return httpx.Response(400, json=MOCKED_RESPONSE_DATA["invalid-request"]) 337 | # Load the body 338 | try: 339 | body = json.loads(request.content) 340 | except: 341 | return httpx.Response(400, json=MOCKED_RESPONSE_DATA["invalid-request"]) 342 | # Prepare and return the response 343 | response_json = MOCKED_RESPONSE_DATA["patch-item"] 344 | new_folder_id = body.get("parentReference", {}).get("id") 345 | if new_folder_id: 346 | if not [ 347 | item 348 | for item in MOCKED_ITEMS_ROOT 349 | if item.get("id") == new_folder_id and "folder" in item 350 | ]: 351 | return httpx.Response(400, json=MOCKED_RESPONSE_DATA["invalid-request"]) 352 | response_json["parentReference"]["id"] = new_folder_id 353 | new_name = body.get("name") 354 | if new_name: 355 | response_json["name"] = new_name 356 | return httpx.Response(200, json=response_json) 357 | 358 | 359 | def side_effect_copy_item(request): 360 | # If a parent folder is specified, check it exists 361 | item_id_re = re.search("items/([0-9a-zA-Z-]+)", request.url.path) 362 | if item_id_re: 363 | item_id = item_id_re.group(1) 364 | matching_item_list = [ 365 | item for item in MOCKED_ITEMS_ROOT if item.get("id") == item_id 366 | ] 367 | else: 368 | matching_item_list = [] 369 | if not matching_item_list: 370 | return httpx.Response(400, json=MOCKED_RESPONSE_DATA["invalid-request"]) 371 | # Load the body 372 | try: 373 | body = json.loads(request.content) 374 | new_folder_id = body["parentReference"]["id"] 375 | new_name = body.get("name") 376 | except Exception: 377 | return httpx.Response(400, json=MOCKED_RESPONSE_DATA["invalid-request"]) 378 | if not [ 379 | item 380 | for item in MOCKED_ITEMS_ROOT 381 | if item.get("id") == new_folder_id and "folder" in item 382 | ]: 383 | return httpx.Response(400, json=MOCKED_RESPONSE_DATA["invalid-request"]) 384 | # Return the monitor url 385 | headers = { 386 | "Location": "https://m365x214355-my.sharepoint.com/_api/v2.0/monitor/4A3407B5-88FC-4504-8B21-0AABD3412717" 387 | } 388 | return httpx.Response(202, headers=headers) 389 | 390 | 391 | def side_effect_copy_item_monitor(request, route): 392 | # If this is the first call return progress otherwise return the finished response 393 | if (route.call_count + 1) % 2 == 0: 394 | return httpx.Response(202, json=MOCKED_RESPONSE_DATA["copy-item-progress"]) 395 | else: 396 | return httpx.Response(202, json=MOCKED_RESPONSE_DATA["copy-item-complete"]) 397 | 398 | 399 | def side_effect_delete_item(request): 400 | # Check item exists 401 | item_id_re = re.search("items/([0-9a-zA-Z-]+)", request.url.path) 402 | if not item_id_re or not [ 403 | item for item in MOCKED_ITEMS_ROOT if item.get("id") == item_id_re.group(1) 404 | ]: 405 | return httpx.Response(400, json=MOCKED_RESPONSE_DATA["invalid-request"]) 406 | return httpx.Response(204) 407 | 408 | 409 | def side_effect_upload_session(request): 410 | # If a parent folder is specified, check it exists 411 | parent_id_re = re.search("items/([0-9a-zA-Z-]+)", request.url.path) 412 | if parent_id_re: 413 | item_id = parent_id_re.group(1) 414 | matching_item_list = [ 415 | item 416 | for item in MOCKED_ITEMS_ROOT 417 | if item.get("id") == item_id and "folder" in item 418 | ] 419 | if not matching_item_list: 420 | return httpx.Response(400, json=MOCKED_RESPONSE_DATA["invalid-request"]) 421 | # Check the file name 422 | file_name_raw = re.search( 423 | ":/([0-9a-zA-Z-_.%+]+):/createUploadSession", str(request.url.raw_path) 424 | ) 425 | if not file_name_raw: 426 | return httpx.Response(400, json=MOCKED_RESPONSE_DATA["invalid-request"]) 427 | # Load and give the body a few simple checks 428 | if request.content: 429 | try: 430 | body = json.loads(request.content)["item"] 431 | except Exception: 432 | return httpx.Response(400, json=MOCKED_RESPONSE_DATA["invalid-request"]) 433 | conflict_behavior = body.get("@microsoft.graph.conflictBehavior", "rename") 434 | if conflict_behavior not in ("rename", "replace", "fail"): 435 | return httpx.Response(400, json=MOCKED_RESPONSE_DATA["invalid-request"]) 436 | if file_name_raw.group(1) != urllib.parse.quote(body.get("name")): 437 | return httpx.Response(400, json=MOCKED_RESPONSE_DATA["invalid-request"]) 438 | # Returns an upload url 439 | return httpx.Response(200, json=MOCKED_RESPONSE_DATA["upload-session"]) 440 | 441 | 442 | def side_effect_upload_item(request): 443 | # Load headers 444 | try: 445 | content_range = request.headers["Content-Range"] 446 | byte_re = re.search("^bytes ([0-9]+)-([0-9]+)/([0-9]+)$", content_range) 447 | if byte_re: 448 | content_range_start = int(byte_re.group(1)) 449 | content_range_end = int(byte_re.group(2)) 450 | file_size = int(byte_re.group(3)) 451 | except Exception: 452 | return httpx.Response(400, json=MOCKED_RESPONSE_DATA["invalid-request"]) 453 | if request.headers.get("Authorization"): 454 | return httpx.Response(401, json=MOCKED_RESPONSE_DATA["invalid-request"]) 455 | if ( 456 | content_range_start >= content_range_end 457 | or content_range_start >= file_size 458 | or content_range_end >= file_size 459 | ): 460 | return httpx.Response(416, json=MOCKED_RESPONSE_DATA["invalid-request"]) 461 | # Check if this is the last chunk 462 | if content_range_end != (file_size - 1): 463 | response_json = { 464 | "expirationDateTime": "2015-01-29T09:21:55.523Z", 465 | "nextExpectedRanges": [f"{content_range_end+1}-"], 466 | } 467 | return httpx.Response(202, json=response_json) 468 | # Upload finished 469 | return httpx.Response(201, json=MOCKED_RESPONSE_DATA["upload-complete"]) 470 | 471 | 472 | @pytest.fixture(scope="module") 473 | def mock_auth_api(): 474 | """Mock the Identity Platform api for testing.""" 475 | 476 | # Set the auth rul 477 | auth_base_url = "https://login.microsoftonline.com/" 478 | 479 | with respx.mock(base_url=auth_base_url) as respx_mock: 480 | # Authorization Code and Refresh Token 481 | token_route = respx_mock.post( 482 | path__regex=r"[0-9a-zA-Z-]+/oauth2/v2.0/token$", 483 | name="access_token", 484 | ).mock(side_effect=side_effect_access_token) 485 | 486 | yield respx_mock 487 | 488 | 489 | def side_effect_access_token(request): 490 | # Parse and decode the request content, typed to help mypy 491 | body_encoded: list[tuple[bytes, bytes]] = urllib.parse.parse_qsl(request.content) 492 | body = {key.decode(): value.decode() for (key, value) in body_encoded} 493 | # Check the content is as expected 494 | grant_type = body["grant_type"] 495 | error = {"error_description": "Invalid request"} 496 | if grant_type not in ("authorization_code", "refresh_token"): 497 | return httpx.Response(400, json=error) 498 | elif ( 499 | body["client_id"] != CLIENT_ID 500 | or body["client_secret"] != CLIENT_SECRET 501 | or body["scope"] != SCOPE 502 | or body["redirect_uri"] != REDIRECT 503 | ): 504 | return httpx.Response(400, json=error) 505 | elif grant_type == "refresh_token": 506 | if ( 507 | body.get("refresh_token") is None 508 | or body.get("refresh_token") != REFRESH_TOKEN 509 | ): 510 | return httpx.Response(400, json=error) 511 | elif grant_type == "authorization_code": 512 | if body.get("code") is None or body.get("code") != AUTH_CODE: 513 | return httpx.Response(400, json=error) 514 | # Return the tokens 515 | response_json = { 516 | "access_token": ACCESS_TOKEN, 517 | "refresh_token": REFRESH_TOKEN, 518 | "expires_in": 100, 519 | } 520 | return httpx.Response(200, json=response_json) 521 | 522 | 523 | @pytest.fixture(scope="module") 524 | def onedrive(mock_graph_api, mock_auth_api): 525 | """Creates a OneDrive instance, scope for whole module so is shared for efficiency. 526 | Use temp_onedrive instead if intending to edit the instance attributes.""" 527 | onedrive = graph_onedrive.OneDrive( 528 | CLIENT_ID, CLIENT_SECRET, TENANT, REDIRECT, REFRESH_TOKEN 529 | ) 530 | yield onedrive 531 | 532 | 533 | @pytest.fixture(scope="function") 534 | def temp_onedrive(mock_graph_api, mock_auth_api): 535 | """Creates a OneDrive instance, scope limited to the function, so can be negatively altered.""" 536 | onedrive = graph_onedrive.OneDrive( 537 | CLIENT_ID, CLIENT_SECRET, TENANT, REDIRECT, REFRESH_TOKEN 538 | ) 539 | yield onedrive 540 | --------------------------------------------------------------------------------